Compare commits
145 Commits
v0.4.13
...
fix_db_ini
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634c3fbb3e | ||
|
|
849aabb575 | ||
|
|
dd33df2033 | ||
|
|
94eb2820fd | ||
|
|
e49708770c | ||
|
|
5eafa01843 | ||
|
|
0c1477e087 | ||
|
|
ef73d98873 | ||
|
|
ec92c0ea6d | ||
|
|
ee14b89868 | ||
|
|
be68160cd9 | ||
|
|
dbc15b1bc3 | ||
|
|
dc6ce16e5a | ||
|
|
0c9d4ad6e2 | ||
|
|
13d57b77d4 | ||
|
|
f9e5bd5bf0 | ||
|
|
adf2b06efa | ||
|
|
80e3966e4e | ||
|
|
3662a057dc | ||
|
|
bdf336f9bf | ||
|
|
f6c310fa22 | ||
|
|
d658894b7f | ||
|
|
783744b497 | ||
|
|
de9ac41f76 | ||
|
|
060202e557 | ||
|
|
8d45ac14cc | ||
|
|
47ee2247c8 | ||
|
|
c16c8d54db | ||
|
|
3e669a0739 | ||
|
|
02e175c8a0 | ||
|
|
b4e98e7624 | ||
|
|
2392529092 | ||
|
|
f9f5772d92 | ||
|
|
4267d7340e | ||
|
|
dcf923551b | ||
|
|
69a5ac3a56 | ||
|
|
7b8c1ebdf1 | ||
|
|
580b623939 | ||
|
|
ac21fbb181 | ||
|
|
588ae65dfd | ||
|
|
30acba39a5 | ||
|
|
3a5bb3dc45 | ||
|
|
f42c0d956e | ||
|
|
0ed13fcf0f | ||
|
|
afc87910e6 | ||
|
|
b97eca9620 | ||
|
|
f4aa8661c4 | ||
|
|
8f0ae3a341 | ||
|
|
b5450bd221 | ||
|
|
88dbe4ea85 | ||
|
|
f0b5956b54 | ||
|
|
e5000246b3 | ||
|
|
9dacf1e530 | ||
|
|
f248ed2875 | ||
|
|
4e6295885b | ||
|
|
2357232cae | ||
|
|
39d8115dda | ||
|
|
bd71b04a9d | ||
|
|
c0b03cd832 | ||
|
|
9b7c740145 | ||
|
|
4f929fb8da | ||
|
|
24ee87d14e | ||
|
|
55862628fb | ||
|
|
fbd731f020 | ||
|
|
a8b750ad75 | ||
|
|
1054b6d2f5 | ||
|
|
669ce41c2e | ||
|
|
7c4683012f | ||
|
|
cfcdc1e342 | ||
|
|
07cf03a408 | ||
|
|
dd17d2cbec | ||
|
|
f3d14c6746 | ||
|
|
447332e558 | ||
|
|
9bbc19ae44 | ||
|
|
5564ae0393 | ||
|
|
93d7842f6c | ||
|
|
84c02048bc | ||
|
|
66a3bb3203 | ||
|
|
0da802be42 | ||
|
|
5bc3933d11 | ||
|
|
1c6d1ac120 | ||
|
|
ba1e6478d7 | ||
|
|
e3af248456 | ||
|
|
43bafb610f | ||
|
|
8e22568efb | ||
|
|
6bb9ed5182 | ||
|
|
b6c3954f98 | ||
|
|
f73b303172 | ||
|
|
50d066669e | ||
|
|
68541c0046 | ||
|
|
644222e958 | ||
|
|
31a5fd97d4 | ||
|
|
b54fbf15f6 | ||
|
|
a787e60e7c | ||
|
|
1e250306dc | ||
|
|
d64a296ebe | ||
|
|
691b27c924 | ||
|
|
dbc591aa63 | ||
|
|
5ea6828f8c | ||
|
|
3dabacd055 | ||
|
|
e8ee829577 | ||
|
|
aebc8a6171 | ||
|
|
c5db169441 | ||
|
|
bef5bef875 | ||
|
|
3a4f86942f | ||
|
|
94eb772467 | ||
|
|
3a2a1b2cd6 | ||
|
|
69c10b05ac | ||
|
|
7833d5d408 | ||
|
|
e0baa79d6b | ||
|
|
737c9c94f3 | ||
|
|
c57586acae | ||
|
|
74030b5806 | ||
|
|
cc276ddff3 | ||
|
|
375c551a3a | ||
|
|
e3e4556f83 | ||
|
|
7fa132e09c | ||
|
|
1a1dbe6975 | ||
|
|
1a5881c935 | ||
|
|
2d7176914e | ||
|
|
987ac3da1b | ||
|
|
03e31d66a7 | ||
|
|
7547dff67d | ||
|
|
1945b14694 | ||
|
|
ec23600861 | ||
|
|
41a9c0ae11 | ||
|
|
c266c4cb3c | ||
|
|
b5bce88398 | ||
|
|
48cf86a449 | ||
|
|
d40aeb6c82 | ||
|
|
9c759ba99b | ||
|
|
f467b9ad7b | ||
|
|
7fe2a8b453 | ||
|
|
5274737ab8 | ||
|
|
40805f39f7 | ||
|
|
f9af7536d0 | ||
|
|
0d39a9bbd0 | ||
|
|
66f8a84260 | ||
|
|
2a9921a4e1 | ||
|
|
50f657ba00 | ||
|
|
5d5eba72de | ||
|
|
577b96518e | ||
|
|
c6c27271d6 | ||
|
|
72c0246d8c | ||
|
|
06d4786e0a |
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [22.x]
|
node-version: [24.x]
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
data/settings.db
|
data/settings.db
|
||||||
|
|
||||||
|
# prisma generated client
|
||||||
|
/prisma/generated/
|
||||||
|
|
||||||
# ssh keys (sensitive)
|
# ssh keys (sensitive)
|
||||||
data/ssh-keys/
|
data/ssh-keys/
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ apt install -y nodejs
|
|||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://github.com/community-scripts/ProxmoxVE-Local.git /opt/PVESciptslocal
|
git clone https://github.com/community-scripts/ProxmoxVE-Local.git /opt/PVESciptslocal
|
||||||
cd PVESciptslocal
|
cd /opt/PVESciptslocal
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
npm install
|
npm install
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import eslintPluginNext from "@next/eslint-plugin-next";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
import reactPlugin from "eslint-plugin-react";
|
||||||
const compat = new FlatCompat({
|
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||||
baseDirectory: import.meta.dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"@next/next": eslintPluginNext,
|
||||||
|
"react": reactPlugin,
|
||||||
|
"react-hooks": reactHooksPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintPluginNext.configs.recommended.rules,
|
||||||
|
...eslintPluginNext.configs["core-web-vitals"].rules,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...compat.extends("next/core-web-vitals"),
|
|
||||||
{
|
{
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -18,31 +18,25 @@ const config = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Allow cross-origin requests from local network ranges
|
// Allow cross-origin requests from local network in dev mode
|
||||||
allowedDevOrigins: [
|
// Note: In Next.js 16, we disable this check entirely for dev
|
||||||
'http://localhost:3000',
|
async headers() {
|
||||||
'http://127.0.0.1:3000',
|
return [
|
||||||
'http://[::1]:3000',
|
{
|
||||||
'http://10.*',
|
source: '/:path*',
|
||||||
'http://172.16.*',
|
headers: [
|
||||||
'http://172.17.*',
|
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||||
'http://172.18.*',
|
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
|
||||||
'http://172.19.*',
|
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
|
||||||
'http://172.20.*',
|
|
||||||
'http://172.21.*',
|
|
||||||
'http://172.22.*',
|
|
||||||
'http://172.23.*',
|
|
||||||
'http://172.24.*',
|
|
||||||
'http://172.25.*',
|
|
||||||
'http://172.26.*',
|
|
||||||
'http://172.27.*',
|
|
||||||
'http://172.28.*',
|
|
||||||
'http://172.29.*',
|
|
||||||
'http://172.30.*',
|
|
||||||
'http://172.31.*',
|
|
||||||
'http://192.168.*',
|
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
turbopack: {
|
||||||
|
// Disable Turbopack and use Webpack instead for compatibility
|
||||||
|
// This is necessary for server-side code that uses child_process
|
||||||
|
},
|
||||||
webpack: (config, { dev, isServer }) => {
|
webpack: (config, { dev, isServer }) => {
|
||||||
if (dev && !isServer) {
|
if (dev && !isServer) {
|
||||||
config.watchOptions = {
|
config.watchOptions = {
|
||||||
@@ -50,15 +44,18 @@ const config = {
|
|||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Handle server-side modules
|
||||||
|
if (isServer) {
|
||||||
|
config.externals = config.externals || [];
|
||||||
|
if (!config.externals.includes('child_process')) {
|
||||||
|
config.externals.push('child_process');
|
||||||
|
}
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
// Ignore ESLint errors during build (they can be fixed separately)
|
// TypeScript errors will fail the build
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
4277
package-lock.json
generated
4277
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
103
package.json
103
package.json
@@ -4,17 +4,20 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "prisma generate && next build --webpack",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"dev:server": "node server.js",
|
"dev:server": "node --import tsx server.js",
|
||||||
"dev:next": "next dev",
|
"dev:next": "next dev --webpack",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next lint",
|
"generate": "prisma generate",
|
||||||
"lint:fix": "next lint --fix",
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint --fix .",
|
||||||
|
"migrate": "prisma migrate dev",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "node server.js",
|
"postinstall": "prisma generate",
|
||||||
|
"start": "node --import tsx server.js",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
@@ -22,76 +25,82 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.18.0",
|
"@prisma/adapter-better-sqlite3": "^7.1.0",
|
||||||
|
"@prisma/client": "^7.1.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.8.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.8.1",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.8.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cron-validator": "^1.2.0",
|
"cron-validator": "^1.4.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "^15.1.6",
|
"next": "^16.0.10",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^4.2.1",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"refractor": "^5.0.0",
|
"refractor": "^5.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"superjson": "^2.2.3",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.4",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19.2.4",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^4.0.14",
|
||||||
|
"baseline-browser-mapping": "^2.9.3",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^15.1.6",
|
"eslint-config-next": "^16.1.0",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.3.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^7.1.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.8.2",
|
"tsx": "^4.21.0",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^3.2.4"
|
"typescript-eslint": "^8.48.1",
|
||||||
|
"vitest": "^4.0.14"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"packageManager": "npm@10.9.3",
|
"packageManager": "npm@10.9.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"prismjs": "^1.30.0"
|
"prismjs": "^1.30.0"
|
||||||
}
|
}
|
||||||
|
|||||||
20
prisma.config.ts
Normal file
20
prisma.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import path from 'path'
|
||||||
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
|
// Resolve database path
|
||||||
|
const dbPath = process.env.DATABASE_URL ?? `file:${path.join(process.cwd(), 'data', 'pve-scripts.db')}`
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: dbPath,
|
||||||
|
},
|
||||||
|
// @ts-expect-error - Prisma 7 config types are incomplete
|
||||||
|
studio: {
|
||||||
|
adapter: async () => {
|
||||||
|
const { PrismaBetterSqlite3 } = await import('@prisma/adapter-better-sqlite3')
|
||||||
|
return new PrismaBetterSqlite3({ url: dbPath })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client"
|
||||||
|
output = "./generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model InstalledScript {
|
model InstalledScript {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) 2021-2025 community-scripts ORG
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
# Author: tteck (tteckster)
|
# Author: tteck (tteckster)
|
||||||
# Co-Author: MickLesk
|
# Co-Author: MickLesk
|
||||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
@@ -6,33 +6,65 @@
|
|||||||
if ! command -v curl >/dev/null 2>&1; then
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
apk update && apk add curl >/dev/null 2>&1
|
apk update && apk add curl >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
|
||||||
load_functions
|
load_functions
|
||||||
|
catch_errors
|
||||||
|
|
||||||
# This function enables IPv6 if it's not disabled and sets verbose mode
|
# This function enables IPv6 if it's not disabled and sets verbose mode
|
||||||
verb_ip6() {
|
verb_ip6() {
|
||||||
set_std_mode # Set STD mode based on VERBOSE
|
set_std_mode # Set STD mode based on VERBOSE
|
||||||
|
|
||||||
if [ "$DISABLEIPV6" == "yes" ]; then
|
if [ "${IPV6_METHOD:-}" = "disable" ]; then
|
||||||
|
msg_info "Disabling IPv6 (this may affect some services)"
|
||||||
$STD sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
$STD sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||||
echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
|
$STD sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||||
|
$STD sysctl -w net.ipv6.conf.lo.disable_ipv6=1
|
||||||
|
mkdir -p /etc/sysctl.d
|
||||||
|
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
|
||||||
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.lo.disable_ipv6 = 1
|
||||||
|
EOF
|
||||||
$STD rc-update add sysctl default
|
$STD rc-update add sysctl default
|
||||||
|
msg_ok "Disabled IPv6"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function catches errors and handles them with the error handler function
|
|
||||||
catch_errors() {
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
trap 'error_handler $? $LINENO "$BASH_COMMAND"' ERR
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap on_interrupt INT
|
||||||
|
trap on_terminate TERM
|
||||||
|
|
||||||
|
error_handler() {
|
||||||
|
local exit_code="$1"
|
||||||
|
local line_number="$2"
|
||||||
|
local command="$3"
|
||||||
|
|
||||||
|
if [[ "$exit_code" -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\e[?25h"
|
||||||
|
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
|
||||||
|
exit "$exit_code"
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function handles errors
|
on_exit() {
|
||||||
error_handler() {
|
|
||||||
local exit_code="$?"
|
local exit_code="$?"
|
||||||
local line_number="$1"
|
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
|
||||||
local command="$2"
|
exit "$exit_code"
|
||||||
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
|
}
|
||||||
echo -e "\n$error_message\n"
|
|
||||||
|
on_interrupt() {
|
||||||
|
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
|
||||||
|
exit 130
|
||||||
|
}
|
||||||
|
|
||||||
|
on_terminate() {
|
||||||
|
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
|
||||||
|
exit 143
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
|
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
|
||||||
@@ -61,10 +93,10 @@ network_check() {
|
|||||||
set +e
|
set +e
|
||||||
trap - ERR
|
trap - ERR
|
||||||
if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then
|
if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then
|
||||||
msg_ok "Internet Connected"
|
ipv4_status="${GN}✔${CL} IPv4"
|
||||||
else
|
else
|
||||||
msg_error "Internet NOT Connected"
|
ipv4_status="${RD}✖${CL} IPv4"
|
||||||
read -r -p "Would you like to continue anyway? <y/N> " prompt
|
read -r -p "Internet NOT connected. Continue anyway? <y/N> " prompt
|
||||||
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
|
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
|
||||||
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
|
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
|
||||||
else
|
else
|
||||||
@@ -73,7 +105,11 @@ network_check() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
RESOLVEDIP=$(getent hosts github.com | awk '{ print $1 }')
|
RESOLVEDIP=$(getent hosts github.com | awk '{ print $1 }')
|
||||||
if [[ -z "$RESOLVEDIP" ]]; then msg_error "DNS Lookup Failure"; else msg_ok "DNS Resolved github.com to ${BL}$RESOLVEDIP${CL}"; fi
|
if [[ -z "$RESOLVEDIP" ]]; then
|
||||||
|
msg_error "Internet: ${ipv4_status} DNS Failed"
|
||||||
|
else
|
||||||
|
msg_ok "Internet: ${ipv4_status} DNS: ${BL}${RESOLVEDIP}${CL}"
|
||||||
|
fi
|
||||||
set -e
|
set -e
|
||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
||||||
}
|
}
|
||||||
@@ -82,7 +118,7 @@ network_check() {
|
|||||||
update_os() {
|
update_os() {
|
||||||
msg_info "Updating Container OS"
|
msg_info "Updating Container OS"
|
||||||
$STD apk -U upgrade
|
$STD apk -U upgrade
|
||||||
#source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
|
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
|
||||||
msg_ok "Updated Container OS"
|
msg_ok "Updated Container OS"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,10 +190,4 @@ EOF
|
|||||||
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update
|
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update
|
||||||
chmod +x /usr/bin/update
|
chmod +x /usr/bin/update
|
||||||
|
|
||||||
if [[ -n "${SSH_AUTHORIZED_KEY}" ]]; then
|
|
||||||
mkdir -p /root/.ssh
|
|
||||||
echo "${SSH_AUTHORIZED_KEY}" >/root/.ssh/authorized_keys
|
|
||||||
chmod 700 /root/.ssh
|
|
||||||
chmod 600 /root/.ssh/authorized_keys
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
507
scripts/core/alpine-tools.func
Normal file
507
scripts/core/alpine-tools.func
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
#!/bin/ash
|
||||||
|
# shellcheck shell=ash
|
||||||
|
|
||||||
|
# Expects existing msg_* functions and optional $STD from the framework.
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# helpers
|
||||||
|
# ------------------------------
|
||||||
|
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
||||||
|
has() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
need_tool() {
|
||||||
|
# usage: need_tool curl jq unzip ...
|
||||||
|
# setup missing tools via apk
|
||||||
|
local missing=0 t
|
||||||
|
for t in "$@"; do
|
||||||
|
if ! has "$t"; then missing=1; fi
|
||||||
|
done
|
||||||
|
if [ "$missing" -eq 1 ]; then
|
||||||
|
msg_info "Installing tools: $*"
|
||||||
|
apk add --no-cache "$@" >/dev/null 2>&1 || {
|
||||||
|
msg_error "apk add failed for: $*"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
msg_ok "Tools ready: $*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
net_resolves() {
|
||||||
|
# better handling for missing getent on Alpine
|
||||||
|
# usage: net_resolves api.github.com
|
||||||
|
local host="$1"
|
||||||
|
ping -c1 -W1 "$host" >/dev/null 2>&1 || nslookup "$host" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_usr_local_bin_persist() {
|
||||||
|
local PROFILE_FILE="/etc/profile.d/10-localbin.sh"
|
||||||
|
if [ ! -f "$PROFILE_FILE" ]; then
|
||||||
|
echo 'case ":$PATH:" in *:/usr/local/bin:*) ;; *) export PATH="/usr/local/bin:$PATH";; esac' >"$PROFILE_FILE"
|
||||||
|
chmod +x "$PROFILE_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
download_with_progress() {
|
||||||
|
# $1 url, $2 dest
|
||||||
|
local url="$1" out="$2" cl
|
||||||
|
need_tool curl pv || return 1
|
||||||
|
cl=$(curl -fsSLI "$url" 2>/dev/null | awk 'tolower($0) ~ /^content-length:/ {print $2}' | tr -d '\r')
|
||||||
|
if [ -n "$cl" ]; then
|
||||||
|
curl -fsSL "$url" | pv -s "$cl" >"$out" || {
|
||||||
|
msg_error "Download failed: $url"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
curl -fL# -o "$out" "$url" || {
|
||||||
|
msg_error "Download failed: $url"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# GitHub: check Release
|
||||||
|
# ------------------------------
|
||||||
|
check_for_gh_release() {
|
||||||
|
# app, repo, [pinned]
|
||||||
|
local app="$1" source="$2" pinned="${3:-}"
|
||||||
|
local app_lc
|
||||||
|
app_lc="$(lower "$app" | tr -d ' ')"
|
||||||
|
local current_file="$HOME/.${app_lc}"
|
||||||
|
local current="" release tag
|
||||||
|
|
||||||
|
msg_info "Check for update: $app"
|
||||||
|
|
||||||
|
net_resolves api.github.com || {
|
||||||
|
msg_error "DNS/network error: api.github.com"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
need_tool curl jq || return 1
|
||||||
|
|
||||||
|
tag=$(curl -fsSL "https://api.github.com/repos/${source}/releases/latest" | jq -r '.tag_name // empty')
|
||||||
|
[ -z "$tag" ] && {
|
||||||
|
msg_error "Unable to fetch latest tag for $app"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
release="${tag#v}"
|
||||||
|
|
||||||
|
[ -f "$current_file" ] && current="$(cat "$current_file")"
|
||||||
|
|
||||||
|
if [ -n "$pinned" ]; then
|
||||||
|
if [ "$pinned" = "$release" ]; then
|
||||||
|
msg_ok "$app pinned to v$pinned (no update)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$current" = "$pinned" ]; then
|
||||||
|
msg_ok "$app pinned v$pinned installed (upstream v$release)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
msg_info "$app pinned v$pinned (upstream v$release) → update/downgrade"
|
||||||
|
CHECK_UPDATE_RELEASE="$pinned"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$release" != "$current" ] || [ ! -f "$current_file" ]; then
|
||||||
|
CHECK_UPDATE_RELEASE="$release"
|
||||||
|
msg_info "New release available: v$release (current: v${current:-none})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
msg_ok "$app is up to date (v$release)"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# GitHub: get Release & deploy (Alpine)
|
||||||
|
# modes: tarball | prebuild | singlefile
|
||||||
|
# ------------------------------
|
||||||
|
fetch_and_deploy_gh() {
|
||||||
|
# $1 app, $2 repo, [$3 mode], [$4 version], [$5 target], [$6 asset_pattern
|
||||||
|
local app="$1" repo="$2" mode="${3:-tarball}" version="${4:-latest}" target="${5:-/opt/$1}" pattern="${6:-}"
|
||||||
|
local app_lc
|
||||||
|
app_lc="$(lower "$app" | tr -d ' ')"
|
||||||
|
local vfile="$HOME/.${app_lc}"
|
||||||
|
local json url filename tmpd unpack
|
||||||
|
|
||||||
|
net_resolves api.github.com || {
|
||||||
|
msg_error "DNS/network error"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
need_tool curl jq tar || return 1
|
||||||
|
[ "$mode" = "prebuild" ] || [ "$mode" = "singlefile" ] && need_tool unzip >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
tmpd="$(mktemp -d)" || return 1
|
||||||
|
mkdir -p "$target"
|
||||||
|
|
||||||
|
# Release JSON
|
||||||
|
if [ "$version" = "latest" ]; then
|
||||||
|
json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest")" || {
|
||||||
|
msg_error "GitHub API failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/tags/$version")" || {
|
||||||
|
msg_error "GitHub API failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# correct Version
|
||||||
|
version="$(printf '%s' "$json" | jq -r '.tag_name // empty')"
|
||||||
|
version="${version#v}"
|
||||||
|
|
||||||
|
[ -z "$version" ] && {
|
||||||
|
msg_error "No tag in release json"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
tarball | source)
|
||||||
|
url="$(printf '%s' "$json" | jq -r '.tarball_url // empty')"
|
||||||
|
[ -z "$url" ] && url="https://github.com/$repo/archive/refs/tags/v$version.tar.gz"
|
||||||
|
filename="${app_lc}-${version}.tar.gz"
|
||||||
|
download_with_progress "$url" "$tmpd/$filename" || {
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
tar -xzf "$tmpd/$filename" -C "$tmpd" || {
|
||||||
|
msg_error "tar extract failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
unpack="$(find "$tmpd" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
|
# copy content of unpack to target
|
||||||
|
(cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||||||
|
msg_error "copy failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
;;
|
||||||
|
prebuild)
|
||||||
|
[ -n "$pattern" ] || {
|
||||||
|
msg_error "prebuild requires asset pattern"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" '
|
||||||
|
BEGIN{IGNORECASE=1}
|
||||||
|
$0 ~ p {print; exit}
|
||||||
|
')"
|
||||||
|
[ -z "$url" ] && {
|
||||||
|
msg_error "asset not found for pattern: $pattern"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
filename="${url##*/}"
|
||||||
|
download_with_progress "$url" "$tmpd/$filename" || {
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
# unpack archive (Zip or tarball)
|
||||||
|
case "$filename" in
|
||||||
|
*.zip)
|
||||||
|
need_tool unzip || {
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mkdir -p "$tmpd/unp"
|
||||||
|
unzip -q "$tmpd/$filename" -d "$tmpd/unp"
|
||||||
|
;;
|
||||||
|
*.tar.gz | *.tgz | *.tar.xz | *.tar.zst | *.tar.bz2)
|
||||||
|
mkdir -p "$tmpd/unp"
|
||||||
|
tar -xf "$tmpd/$filename" -C "$tmpd/unp"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
msg_error "unsupported archive: $filename"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# top-level folder strippen
|
||||||
|
if [ "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1 ] && [ -z "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type f | head -n1)" ]; then
|
||||||
|
unpack="$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
(cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||||||
|
msg_error "copy failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
(cd "$tmpd/unp" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||||||
|
msg_error "copy failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
singlefile)
|
||||||
|
[ -n "$pattern" ] || {
|
||||||
|
msg_error "singlefile requires asset pattern"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" '
|
||||||
|
BEGIN{IGNORECASE=1}
|
||||||
|
$0 ~ p {print; exit}
|
||||||
|
')"
|
||||||
|
[ -z "$url" ] && {
|
||||||
|
msg_error "asset not found for pattern: $pattern"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
filename="${url##*/}"
|
||||||
|
download_with_progress "$url" "$target/$app" || {
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
chmod +x "$target/$app"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
msg_error "Unknown mode: $mode"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "$version" >"$vfile"
|
||||||
|
ensure_usr_local_bin_persist
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
msg_ok "Deployed $app ($version) → $target"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# yq (mikefarah) – Alpine
|
||||||
|
# ------------------------------
|
||||||
|
setup_yq() {
|
||||||
|
# prefer apk, unless FORCE_GH=1
|
||||||
|
if [ "${FORCE_GH:-0}" != "1" ] && apk info -e yq >/dev/null 2>&1; then
|
||||||
|
msg_info "Updating yq via apk"
|
||||||
|
apk add --no-cache --upgrade yq >/dev/null 2>&1 || true
|
||||||
|
msg_ok "yq ready ($(yq --version 2>/dev/null))"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_tool curl || return 1
|
||||||
|
local arch bin url tmp
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch="amd64" ;;
|
||||||
|
aarch64) arch="arm64" ;;
|
||||||
|
*)
|
||||||
|
msg_error "Unsupported arch for yq: $(uname -m)"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}"
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
download_with_progress "$url" "$tmp" || return 1
|
||||||
|
install -m 0755 "$tmp" /usr/local/bin/yq
|
||||||
|
rm -f "$tmp"
|
||||||
|
msg_ok "Setup yq ($(yq --version 2>/dev/null))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Adminer – Alpine
|
||||||
|
# ------------------------------
|
||||||
|
setup_adminer() {
|
||||||
|
need_tool curl || return 1
|
||||||
|
msg_info "Setup Adminer (Alpine)"
|
||||||
|
mkdir -p /var/www/localhost/htdocs/adminer
|
||||||
|
curl -fsSL https://github.com/vrana/adminer/releases/latest/download/adminer.php \
|
||||||
|
-o /var/www/localhost/htdocs/adminer/index.php || {
|
||||||
|
msg_error "Adminer download failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
msg_ok "Adminer at /adminer (served by your webserver)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# uv – Alpine (musl tarball)
|
||||||
|
# optional: PYTHON_VERSION="3.12"
|
||||||
|
# ------------------------------
|
||||||
|
setup_uv() {
|
||||||
|
need_tool curl tar || return 1
|
||||||
|
local UV_BIN="/usr/local/bin/uv"
|
||||||
|
local arch tarball url tmpd ver installed
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch="x86_64-unknown-linux-musl" ;;
|
||||||
|
aarch64) arch="aarch64-unknown-linux-musl" ;;
|
||||||
|
*)
|
||||||
|
msg_error "Unsupported arch for uv: $(uname -m)"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest | jq -r '.tag_name' 2>/dev/null)"
|
||||||
|
ver="${ver#v}"
|
||||||
|
[ -z "$ver" ] && {
|
||||||
|
msg_error "uv: cannot determine latest version"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if has "$UV_BIN"; then
|
||||||
|
installed="$($UV_BIN -V 2>/dev/null | awk '{print $2}')"
|
||||||
|
[ "$installed" = "$ver" ] && {
|
||||||
|
msg_ok "uv $ver already installed"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
msg_info "Updating uv $installed → $ver"
|
||||||
|
else
|
||||||
|
msg_info "Setup uv $ver"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmpd="$(mktemp -d)" || return 1
|
||||||
|
tarball="uv-${arch}.tar.gz"
|
||||||
|
url="https://github.com/astral-sh/uv/releases/download/v${ver}/${tarball}"
|
||||||
|
|
||||||
|
download_with_progress "$url" "$tmpd/uv.tar.gz" || {
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
tar -xzf "$tmpd/uv.tar.gz" -C "$tmpd" || {
|
||||||
|
msg_error "uv: extract failed"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# tar contains ./uv
|
||||||
|
if [ -x "$tmpd/uv" ]; then
|
||||||
|
install -m 0755 "$tmpd/uv" "$UV_BIN"
|
||||||
|
else
|
||||||
|
# fallback: in subfolder
|
||||||
|
install -m 0755 "$tmpd"/*/uv "$UV_BIN" 2>/dev/null || {
|
||||||
|
msg_error "uv binary not found in tar"
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
rm -rf "$tmpd"
|
||||||
|
ensure_usr_local_bin_persist
|
||||||
|
msg_ok "Setup uv $ver"
|
||||||
|
|
||||||
|
if [ -n "${PYTHON_VERSION:-}" ]; then
|
||||||
|
local match
|
||||||
|
match="$(uv python list --only-downloads 2>/dev/null | awk -v maj="$PYTHON_VERSION" '
|
||||||
|
$0 ~ "^cpython-"maj"\\." { print $0 }' | awk -F- '{print $2}' | sort -V | tail -n1)"
|
||||||
|
[ -z "$match" ] && {
|
||||||
|
msg_error "No matching Python for $PYTHON_VERSION"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! uv python list | grep -q "cpython-${match}-linux"; then
|
||||||
|
msg_info "Installing Python $match via uv"
|
||||||
|
uv python install "$match" || {
|
||||||
|
msg_error "uv python install failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
msg_ok "Python $match installed (uv)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Java – Alpine (OpenJDK)
|
||||||
|
# JAVA_VERSION: 17|21 (Default 21)
|
||||||
|
# ------------------------------
|
||||||
|
setup_java() {
|
||||||
|
local JAVA_VERSION="${JAVA_VERSION:-21}" pkg
|
||||||
|
case "$JAVA_VERSION" in
|
||||||
|
17) pkg="openjdk17-jdk" ;;
|
||||||
|
21 | *) pkg="openjdk21-jdk" ;;
|
||||||
|
esac
|
||||||
|
msg_info "Setup Java (OpenJDK $JAVA_VERSION)"
|
||||||
|
apk add --no-cache "$pkg" >/dev/null 2>&1 || {
|
||||||
|
msg_error "apk add $pkg failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
# set JAVA_HOME
|
||||||
|
local prof="/etc/profile.d/20-java.sh"
|
||||||
|
if [ ! -f "$prof" ]; then
|
||||||
|
echo 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(command -v java))))' >"$prof"
|
||||||
|
echo 'case ":$PATH:" in *:$JAVA_HOME/bin:*) ;; *) export PATH="$JAVA_HOME/bin:$PATH";; esac' >>"$prof"
|
||||||
|
chmod +x "$prof"
|
||||||
|
fi
|
||||||
|
msg_ok "Java ready: $(java -version 2>&1 | head -n1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Go – Alpine (apk prefers, else tarball)
|
||||||
|
# ------------------------------
|
||||||
|
setup_go() {
|
||||||
|
if [ -z "${GO_VERSION:-}" ]; then
|
||||||
|
msg_info "Setup Go (apk)"
|
||||||
|
apk add --no-cache go >/dev/null 2>&1 || {
|
||||||
|
msg_error "apk add go failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
msg_ok "Go ready: $(go version 2>/dev/null)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_tool curl tar || return 1
|
||||||
|
local ARCH TARBALL URL TMP
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64) ARCH="arm64" ;;
|
||||||
|
*)
|
||||||
|
msg_error "Unsupported arch for Go: $(uname -m)"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz"
|
||||||
|
URL="https://go.dev/dl/${TARBALL}"
|
||||||
|
msg_info "Setup Go $GO_VERSION (tarball)"
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
download_with_progress "$URL" "$TMP" || return 1
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf "$TMP" || {
|
||||||
|
msg_error "extract go failed"
|
||||||
|
rm -f "$TMP"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
rm -f "$TMP"
|
||||||
|
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||||
|
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||||
|
ensure_usr_local_bin_persist
|
||||||
|
msg_ok "Go ready: $(go version 2>/dev/null)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Composer – Alpine
|
||||||
|
# uses php83-cli + openssl + phar
|
||||||
|
# ------------------------------
|
||||||
|
setup_composer() {
|
||||||
|
local COMPOSER_BIN="/usr/local/bin/composer"
|
||||||
|
if ! has php; then
|
||||||
|
# prefers php83
|
||||||
|
msg_info "Installing PHP CLI for Composer"
|
||||||
|
apk add --no-cache php83-cli php83-openssl php83-phar php83-iconv >/dev/null 2>&1 || {
|
||||||
|
# Fallback to generic php if 83 not available
|
||||||
|
apk add --no-cache php-cli php-openssl php-phar php-iconv >/dev/null 2>&1 || {
|
||||||
|
msg_error "Failed to install php-cli for composer"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg_ok "PHP CLI ready: $(php -v | head -n1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x "$COMPOSER_BIN" ]; then
|
||||||
|
msg_info "Updating Composer"
|
||||||
|
else
|
||||||
|
msg_info "Setup Composer"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_tool curl || return 1
|
||||||
|
curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php || {
|
||||||
|
msg_error "composer installer download failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer >/dev/null 2>&1 || {
|
||||||
|
msg_error "composer install failed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
rm -f /tmp/composer-setup.php
|
||||||
|
ensure_usr_local_bin_persist
|
||||||
|
msg_ok "Composer ready: $(composer --version 2>/dev/null)"
|
||||||
|
}
|
||||||
@@ -1,7 +1,154 @@
|
|||||||
# Copyright (c) 2021-2025 community-scripts ORG
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
# Author: michelroegl-brunner
|
# Author: michelroegl-brunner
|
||||||
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# API.FUNC - TELEMETRY & DIAGNOSTICS API
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# Provides functions for sending anonymous telemetry data to Community-Scripts
|
||||||
|
# API for analytics and diagnostics purposes.
|
||||||
|
#
|
||||||
|
# Features:
|
||||||
|
# - Container/VM creation statistics
|
||||||
|
# - Installation success/failure tracking
|
||||||
|
# - Error code mapping and reporting
|
||||||
|
# - Privacy-respecting anonymous telemetry
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source <(curl -fsSL .../api.func)
|
||||||
|
# post_to_api # Report container creation
|
||||||
|
# post_update_to_api # Report installation status
|
||||||
|
#
|
||||||
|
# Privacy:
|
||||||
|
# - Only anonymous statistics (no personal data)
|
||||||
|
# - User can opt-out via diagnostics settings
|
||||||
|
# - Random UUID for session tracking only
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 1: ERROR CODE DESCRIPTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# explain_exit_code()
|
||||||
|
#
|
||||||
|
# - Maps numeric exit codes to human-readable error descriptions
|
||||||
|
# - Supports:
|
||||||
|
# * Generic/Shell errors (1, 2, 126, 127, 128, 130, 137, 139, 143)
|
||||||
|
# * Package manager errors (APT, DPKG: 100, 101, 255)
|
||||||
|
# * Node.js/npm errors (243-249, 254)
|
||||||
|
# * Python/pip/uv errors (210-212)
|
||||||
|
# * PostgreSQL errors (231-234)
|
||||||
|
# * MySQL/MariaDB errors (241-244)
|
||||||
|
# * MongoDB errors (251-254)
|
||||||
|
# * Proxmox custom codes (200-231)
|
||||||
|
# - Returns description string for given exit code
|
||||||
|
# - Shared function with error_handler.func for consistency
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
explain_exit_code() {
|
||||||
|
local code="$1"
|
||||||
|
case "$code" in
|
||||||
|
# --- Generic / Shell ---
|
||||||
|
1) echo "General error / Operation not permitted" ;;
|
||||||
|
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
|
||||||
|
126) echo "Command invoked cannot execute (permission problem?)" ;;
|
||||||
|
127) echo "Command not found" ;;
|
||||||
|
128) echo "Invalid argument to exit" ;;
|
||||||
|
130) echo "Terminated by Ctrl+C (SIGINT)" ;;
|
||||||
|
137) echo "Killed (SIGKILL / Out of memory?)" ;;
|
||||||
|
139) echo "Segmentation fault (core dumped)" ;;
|
||||||
|
143) echo "Terminated (SIGTERM)" ;;
|
||||||
|
|
||||||
|
# --- Package manager / APT / DPKG ---
|
||||||
|
100) echo "APT: Package manager error (broken packages / dependency problems)" ;;
|
||||||
|
101) echo "APT: Configuration error (bad sources.list, malformed config)" ;;
|
||||||
|
255) echo "DPKG: Fatal internal error" ;;
|
||||||
|
|
||||||
|
# --- Node.js / npm / pnpm / yarn ---
|
||||||
|
243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;;
|
||||||
|
245) echo "Node.js: Invalid command-line option" ;;
|
||||||
|
246) echo "Node.js: Internal JavaScript Parse Error" ;;
|
||||||
|
247) echo "Node.js: Fatal internal error" ;;
|
||||||
|
248) echo "Node.js: Invalid C++ addon / N-API failure" ;;
|
||||||
|
249) echo "Node.js: Inspector error" ;;
|
||||||
|
254) echo "npm/pnpm/yarn: Unknown fatal error" ;;
|
||||||
|
|
||||||
|
# --- Python / pip / uv ---
|
||||||
|
210) echo "Python: Virtualenv / uv environment missing or broken" ;;
|
||||||
|
211) echo "Python: Dependency resolution failed" ;;
|
||||||
|
212) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;;
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
231) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;;
|
||||||
|
232) echo "PostgreSQL: Authentication failed (bad user/password)" ;;
|
||||||
|
233) echo "PostgreSQL: Database does not exist" ;;
|
||||||
|
234) echo "PostgreSQL: Fatal error in query / syntax" ;;
|
||||||
|
|
||||||
|
# --- MySQL / MariaDB ---
|
||||||
|
241) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;;
|
||||||
|
242) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;;
|
||||||
|
243) echo "MySQL/MariaDB: Database does not exist" ;;
|
||||||
|
244) echo "MySQL/MariaDB: Fatal error in query / syntax" ;;
|
||||||
|
|
||||||
|
# --- MongoDB ---
|
||||||
|
251) echo "MongoDB: Connection failed (server not running)" ;;
|
||||||
|
252) echo "MongoDB: Authentication failed (bad user/password)" ;;
|
||||||
|
253) echo "MongoDB: Database not found" ;;
|
||||||
|
254) echo "MongoDB: Fatal query error" ;;
|
||||||
|
|
||||||
|
# --- Proxmox Custom Codes ---
|
||||||
|
200) echo "Custom: Failed to create lock file" ;;
|
||||||
|
203) echo "Custom: Missing CTID variable" ;;
|
||||||
|
204) echo "Custom: Missing PCT_OSTYPE variable" ;;
|
||||||
|
205) echo "Custom: Invalid CTID (<100)" ;;
|
||||||
|
206) echo "Custom: CTID already in use (check 'pct list' and /etc/pve/lxc/)" ;;
|
||||||
|
207) echo "Custom: Password contains unescaped special characters (-, /, \\, *, etc.)" ;;
|
||||||
|
208) echo "Custom: Invalid configuration (DNS/MAC/Network format error)" ;;
|
||||||
|
209) echo "Custom: Container creation failed (check logs for pct create output)" ;;
|
||||||
|
210) echo "Custom: Cluster not quorate" ;;
|
||||||
|
211) echo "Custom: Timeout waiting for template lock (concurrent download in progress)" ;;
|
||||||
|
214) echo "Custom: Not enough storage space" ;;
|
||||||
|
215) echo "Custom: Container created but not listed (ghost state - check /etc/pve/lxc/)" ;;
|
||||||
|
216) echo "Custom: RootFS entry missing in config (incomplete creation)" ;;
|
||||||
|
217) echo "Custom: Storage does not support rootdir (check storage capabilities)" ;;
|
||||||
|
218) echo "Custom: Template file corrupted or incomplete download (size <1MB or invalid archive)" ;;
|
||||||
|
220) echo "Custom: Unable to resolve template path" ;;
|
||||||
|
221) echo "Custom: Template file exists but not readable (check file permissions)" ;;
|
||||||
|
222) echo "Custom: Template download failed after 3 attempts (network/storage issue)" ;;
|
||||||
|
223) echo "Custom: Template not available after download (storage sync issue)" ;;
|
||||||
|
225) echo "Custom: No template available for OS/Version (check 'pveam available')" ;;
|
||||||
|
231) echo "Custom: LXC stack upgrade/retry failed (outdated pve-container - check https://github.com/community-scripts/ProxmoxVE/discussions/8126)" ;;
|
||||||
|
|
||||||
|
# --- Default ---
|
||||||
|
*) echo "Unknown error" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 2: TELEMETRY FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# post_to_api()
|
||||||
|
#
|
||||||
|
# - Sends LXC container creation statistics to Community-Scripts API
|
||||||
|
# - Only executes if:
|
||||||
|
# * curl is available
|
||||||
|
# * DIAGNOSTICS=yes
|
||||||
|
# * RANDOM_UUID is set
|
||||||
|
# - Payload includes:
|
||||||
|
# * Container type, disk size, CPU cores, RAM
|
||||||
|
# * OS type and version
|
||||||
|
# * IPv6 disable status
|
||||||
|
# * Application name (NSAPP)
|
||||||
|
# * Installation method
|
||||||
|
# * PVE version
|
||||||
|
# * Status: "installing"
|
||||||
|
# * Random UUID for session tracking
|
||||||
|
# - Anonymous telemetry (no personal data)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
post_to_api() {
|
post_to_api() {
|
||||||
|
|
||||||
if ! command -v curl &>/dev/null; then
|
if ! command -v curl &>/dev/null; then
|
||||||
@@ -30,7 +177,6 @@ post_to_api() {
|
|||||||
"ram_size": $RAM_SIZE,
|
"ram_size": $RAM_SIZE,
|
||||||
"os_type": "$var_os",
|
"os_type": "$var_os",
|
||||||
"os_version": "$var_version",
|
"os_version": "$var_version",
|
||||||
"disableip6": "",
|
|
||||||
"nsapp": "$NSAPP",
|
"nsapp": "$NSAPP",
|
||||||
"method": "$METHOD(PVE-Local)",
|
"method": "$METHOD(PVE-Local)",
|
||||||
"pve_version": "$pve_version",
|
"pve_version": "$pve_version",
|
||||||
@@ -39,14 +185,26 @@ post_to_api() {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$JSON_PAYLOAD") || true
|
-d "$JSON_PAYLOAD") || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# post_to_api_vm()
|
||||||
|
#
|
||||||
|
# - Sends VM creation statistics to Community-Scripts API
|
||||||
|
# - Similar to post_to_api() but for virtual machines (not containers)
|
||||||
|
# - Reads DIAGNOSTICS from /usr/local/community-scripts/diagnostics file
|
||||||
|
# - Payload differences:
|
||||||
|
# * ct_type=2 (VM instead of LXC)
|
||||||
|
# * type="vm"
|
||||||
|
# * Disk size without 'G' suffix (parsed from DISK_SIZE variable)
|
||||||
|
# - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
post_to_api_vm() {
|
post_to_api_vm() {
|
||||||
|
|
||||||
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
|
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
|
||||||
@@ -81,7 +239,6 @@ post_to_api_vm() {
|
|||||||
"ram_size": $RAM_SIZE,
|
"ram_size": $RAM_SIZE,
|
||||||
"os_type": "$var_os",
|
"os_type": "$var_os",
|
||||||
"os_version": "$var_version",
|
"os_version": "$var_version",
|
||||||
"disableip6": "",
|
|
||||||
"nsapp": "$NSAPP",
|
"nsapp": "$NSAPP",
|
||||||
"method": "$METHOD(PVE-Local)",
|
"method": "$METHOD(PVE-Local)",
|
||||||
"pve_version": "$pve_version",
|
"pve_version": "$pve_version",
|
||||||
@@ -90,7 +247,6 @@ post_to_api_vm() {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -98,19 +254,54 @@ EOF
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
POST_UPDATE_DONE=false
|
# ------------------------------------------------------------------------------
|
||||||
|
# post_update_to_api()
|
||||||
|
#
|
||||||
|
# - Reports installation completion status to API
|
||||||
|
# - Prevents duplicate submissions via POST_UPDATE_DONE flag
|
||||||
|
# - Arguments:
|
||||||
|
# * $1: status ("success" or "failed")
|
||||||
|
# * $2: exit_code (default: 1 for failed, 0 for success)
|
||||||
|
# - Payload includes:
|
||||||
|
# * Final status (success/failed)
|
||||||
|
# * Error description via get_error_description()
|
||||||
|
# * Random UUID for session correlation
|
||||||
|
# - Only executes once per session
|
||||||
|
# - Silently returns if:
|
||||||
|
# * curl not available
|
||||||
|
# * Already reported (POST_UPDATE_DONE=true)
|
||||||
|
# * DIAGNOSTICS=no
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
post_update_to_api() {
|
post_update_to_api() {
|
||||||
|
|
||||||
if ! command -v curl &>/dev/null; then
|
if ! command -v curl &>/dev/null; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Initialize flag if not set (prevents 'unbound variable' error with set -u)
|
||||||
|
POST_UPDATE_DONE=${POST_UPDATE_DONE:-false}
|
||||||
|
|
||||||
if [ "$POST_UPDATE_DONE" = true ]; then
|
if [ "$POST_UPDATE_DONE" = true ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
exit_code=${2:-1}
|
||||||
local API_URL="http://api.community-scripts.org/upload/updatestatus"
|
local API_URL="http://api.community-scripts.org/upload/updatestatus"
|
||||||
local status="${1:-failed}"
|
local status="${1:-failed}"
|
||||||
local error="${2:-No error message}"
|
if [[ "$status" == "failed" ]]; then
|
||||||
|
local exit_code="${2:-1}"
|
||||||
|
elif [[ "$status" == "success" ]]; then
|
||||||
|
local exit_code="${2:-0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$exit_code" ]]; then
|
||||||
|
exit_code=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
error=$(explain_exit_code "$exit_code")
|
||||||
|
|
||||||
|
if [ -z "$error" ]; then
|
||||||
|
error="Unknown error"
|
||||||
|
fi
|
||||||
|
|
||||||
JSON_PAYLOAD=$(
|
JSON_PAYLOAD=$(
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -121,7 +312,6 @@ post_update_to_api() {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
505
scripts/core/cloud-init.func
Normal file
505
scripts/core/cloud-init.func
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
|
# Author: community-scripts ORG
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/branch/main/LICENSE
|
||||||
|
# Revision: 1
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# CLOUD-INIT.FUNC - VM CLOUD-INIT CONFIGURATION LIBRARY
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# Universal helper library for Cloud-Init configuration in Proxmox VMs.
|
||||||
|
# Provides functions for:
|
||||||
|
#
|
||||||
|
# - Native Proxmox Cloud-Init setup (user, password, network, SSH keys)
|
||||||
|
# - Interactive configuration dialogs (whiptail)
|
||||||
|
# - IP address retrieval via qemu-guest-agent
|
||||||
|
# - Cloud-Init status monitoring and waiting
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/cloud-init.func)
|
||||||
|
# setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes"
|
||||||
|
#
|
||||||
|
# Compatible with: Debian, Ubuntu, and all Cloud-Init enabled distributions
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 1: CONFIGURATION DEFAULTS
|
||||||
|
# ==============================================================================
|
||||||
|
# These can be overridden before sourcing this library
|
||||||
|
|
||||||
|
CLOUDINIT_DEFAULT_USER="${CLOUDINIT_DEFAULT_USER:-root}"
|
||||||
|
CLOUDINIT_DNS_SERVERS="${CLOUDINIT_DNS_SERVERS:-1.1.1.1 8.8.8.8}"
|
||||||
|
CLOUDINIT_SEARCH_DOMAIN="${CLOUDINIT_SEARCH_DOMAIN:-local}"
|
||||||
|
CLOUDINIT_SSH_KEYS="${CLOUDINIT_SSH_KEYS:-/root/.ssh/authorized_keys}"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 2: HELPER FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# _ci_msg - Internal message helper with fallback
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function _ci_msg_info() { msg_info "$1" 2>/dev/null || echo "[INFO] $1"; }
|
||||||
|
function _ci_msg_ok() { msg_ok "$1" 2>/dev/null || echo "[OK] $1"; }
|
||||||
|
function _ci_msg_warn() { msg_warn "$1" 2>/dev/null || echo "[WARN] $1"; }
|
||||||
|
function _ci_msg_error() { msg_error "$1" 2>/dev/null || echo "[ERROR] $1"; }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# validate_ip_cidr - Validate IP address in CIDR format
|
||||||
|
# Usage: validate_ip_cidr "192.168.1.100/24" && echo "Valid"
|
||||||
|
# Returns: 0 if valid, 1 if invalid
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function validate_ip_cidr() {
|
||||||
|
local ip_cidr="$1"
|
||||||
|
# Match: 0-255.0-255.0-255.0-255/0-32
|
||||||
|
if [[ "$ip_cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
|
||||||
|
# Validate each octet is 0-255
|
||||||
|
local ip="${ip_cidr%/*}"
|
||||||
|
IFS='.' read -ra octets <<<"$ip"
|
||||||
|
for octet in "${octets[@]}"; do
|
||||||
|
((octet > 255)) && return 1
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# validate_ip - Validate plain IP address (no CIDR)
|
||||||
|
# Usage: validate_ip "192.168.1.1" && echo "Valid"
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function validate_ip() {
|
||||||
|
local ip="$1"
|
||||||
|
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||||
|
IFS='.' read -ra octets <<<"$ip"
|
||||||
|
for octet in "${octets[@]}"; do
|
||||||
|
((octet > 255)) && return 1
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 3: MAIN CLOUD-INIT FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# setup_cloud_init - Configures Proxmox Native Cloud-Init
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Parameters:
|
||||||
|
# $1 - VMID (required)
|
||||||
|
# $2 - Storage name (required)
|
||||||
|
# $3 - Hostname (optional, default: vm-<vmid>)
|
||||||
|
# $4 - Enable Cloud-Init (yes/no, default: no)
|
||||||
|
# $5 - User (optional, default: root)
|
||||||
|
# $6 - Network mode (dhcp/static, default: dhcp)
|
||||||
|
# $7 - Static IP (optional, format: 192.168.1.100/24)
|
||||||
|
# $8 - Gateway (optional)
|
||||||
|
# $9 - Nameservers (optional, default: 1.1.1.1 8.8.8.8)
|
||||||
|
#
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
# Exports: CLOUDINIT_USER, CLOUDINIT_PASSWORD, CLOUDINIT_CRED_FILE
|
||||||
|
# ==============================================================================
|
||||||
|
function setup_cloud_init() {
|
||||||
|
local vmid="$1"
|
||||||
|
local storage="$2"
|
||||||
|
local hostname="${3:-vm-${vmid}}"
|
||||||
|
local enable="${4:-no}"
|
||||||
|
local ciuser="${5:-$CLOUDINIT_DEFAULT_USER}"
|
||||||
|
local network_mode="${6:-dhcp}"
|
||||||
|
local static_ip="${7:-}"
|
||||||
|
local gateway="${8:-}"
|
||||||
|
local nameservers="${9:-$CLOUDINIT_DNS_SERVERS}"
|
||||||
|
|
||||||
|
# Skip if not enabled
|
||||||
|
if [ "$enable" != "yes" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate static IP if provided
|
||||||
|
if [ "$network_mode" = "static" ]; then
|
||||||
|
if [ -n "$static_ip" ] && ! validate_ip_cidr "$static_ip"; then
|
||||||
|
_ci_msg_error "Invalid static IP format: $static_ip (expected: x.x.x.x/xx)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$gateway" ] && ! validate_ip "$gateway"; then
|
||||||
|
_ci_msg_error "Invalid gateway IP format: $gateway"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ci_msg_info "Configuring Cloud-Init"
|
||||||
|
|
||||||
|
# Create Cloud-Init drive (try ide2 first, then scsi1 as fallback)
|
||||||
|
if ! qm set "$vmid" --ide2 "${storage}:cloudinit" >/dev/null 2>&1; then
|
||||||
|
qm set "$vmid" --scsi1 "${storage}:cloudinit" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set user
|
||||||
|
qm set "$vmid" --ciuser "$ciuser" >/dev/null
|
||||||
|
|
||||||
|
# Generate and set secure random password
|
||||||
|
local cipassword=$(openssl rand -base64 16)
|
||||||
|
qm set "$vmid" --cipassword "$cipassword" >/dev/null
|
||||||
|
|
||||||
|
# Add SSH keys if available
|
||||||
|
if [ -f "$CLOUDINIT_SSH_KEYS" ]; then
|
||||||
|
qm set "$vmid" --sshkeys "$CLOUDINIT_SSH_KEYS" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure network
|
||||||
|
if [ "$network_mode" = "static" ] && [ -n "$static_ip" ] && [ -n "$gateway" ]; then
|
||||||
|
qm set "$vmid" --ipconfig0 "ip=${static_ip},gw=${gateway}" >/dev/null
|
||||||
|
else
|
||||||
|
qm set "$vmid" --ipconfig0 "ip=dhcp" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set DNS servers
|
||||||
|
qm set "$vmid" --nameserver "$nameservers" >/dev/null
|
||||||
|
|
||||||
|
# Set search domain
|
||||||
|
qm set "$vmid" --searchdomain "$CLOUDINIT_SEARCH_DOMAIN" >/dev/null
|
||||||
|
|
||||||
|
# Enable package upgrades on first boot (if supported by Proxmox version)
|
||||||
|
qm set "$vmid" --ciupgrade 1 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Save credentials to file (with restrictive permissions)
|
||||||
|
local cred_file="/tmp/${hostname}-${vmid}-cloud-init-credentials.txt"
|
||||||
|
umask 077
|
||||||
|
cat >"$cred_file" <<EOF
|
||||||
|
╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ⚠️ SECURITY WARNING: DELETE THIS FILE AFTER NOTING CREDENTIALS ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
Cloud-Init Credentials
|
||||||
|
────────────────────────────────────────
|
||||||
|
VM ID: ${vmid}
|
||||||
|
Hostname: ${hostname}
|
||||||
|
Created: $(date)
|
||||||
|
|
||||||
|
Username: ${ciuser}
|
||||||
|
Password: ${cipassword}
|
||||||
|
|
||||||
|
Network: ${network_mode}$([ "$network_mode" = "static" ] && echo " (IP: ${static_ip}, GW: ${gateway})" || echo " (DHCP)")
|
||||||
|
DNS: ${nameservers}
|
||||||
|
|
||||||
|
────────────────────────────────────────
|
||||||
|
SSH Access (if keys configured):
|
||||||
|
ssh ${ciuser}@<vm-ip>
|
||||||
|
|
||||||
|
Proxmox UI Configuration:
|
||||||
|
VM ${vmid} > Cloud-Init > Edit
|
||||||
|
- User, Password, SSH Keys
|
||||||
|
- Network (IP Config)
|
||||||
|
- DNS, Search Domain
|
||||||
|
|
||||||
|
────────────────────────────────────────
|
||||||
|
🗑️ To delete this file:
|
||||||
|
rm -f ${cred_file}
|
||||||
|
────────────────────────────────────────
|
||||||
|
EOF
|
||||||
|
chmod 600 "$cred_file"
|
||||||
|
|
||||||
|
_ci_msg_ok "Cloud-Init configured (User: ${ciuser})"
|
||||||
|
|
||||||
|
# Export for use in calling script (DO NOT display password here - will be shown in summary)
|
||||||
|
export CLOUDINIT_USER="$ciuser"
|
||||||
|
export CLOUDINIT_PASSWORD="$cipassword"
|
||||||
|
export CLOUDINIT_CRED_FILE="$cred_file"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 4: INTERACTIVE CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# configure_cloud_init_interactive - Whiptail dialog for Cloud-Init setup
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Prompts user for Cloud-Init configuration choices
|
||||||
|
# Returns configuration via exported variables:
|
||||||
|
# - CLOUDINIT_ENABLE (yes/no)
|
||||||
|
# - CLOUDINIT_USER
|
||||||
|
# - CLOUDINIT_NETWORK_MODE (dhcp/static)
|
||||||
|
# - CLOUDINIT_IP (if static)
|
||||||
|
# - CLOUDINIT_GW (if static)
|
||||||
|
# - CLOUDINIT_DNS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function configure_cloud_init_interactive() {
|
||||||
|
local default_user="${1:-root}"
|
||||||
|
|
||||||
|
# Check if whiptail is available
|
||||||
|
if ! command -v whiptail >/dev/null 2>&1; then
|
||||||
|
echo "Warning: whiptail not available, skipping interactive configuration"
|
||||||
|
export CLOUDINIT_ENABLE="no"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask if user wants to enable Cloud-Init
|
||||||
|
if ! (whiptail --backtitle "Proxmox VE Helper Scripts" --title "CLOUD-INIT" \
|
||||||
|
--yesno "Enable Cloud-Init for VM configuration?\n\nCloud-Init allows automatic configuration of:\n• User accounts and passwords\n• SSH keys\n• Network settings (DHCP/Static)\n• DNS configuration\n\nYou can also configure these settings later in Proxmox UI." 16 68); then
|
||||||
|
export CLOUDINIT_ENABLE="no"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
export CLOUDINIT_ENABLE="yes"
|
||||||
|
|
||||||
|
# Username
|
||||||
|
if CLOUDINIT_USER=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||||
|
"Cloud-Init Username" 8 58 "$default_user" --title "USERNAME" 3>&1 1>&2 2>&3); then
|
||||||
|
export CLOUDINIT_USER="${CLOUDINIT_USER:-$default_user}"
|
||||||
|
else
|
||||||
|
export CLOUDINIT_USER="$default_user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Network configuration
|
||||||
|
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "NETWORK MODE" \
|
||||||
|
--yesno "Use DHCP for network configuration?\n\nSelect 'No' for static IP configuration." 10 58); then
|
||||||
|
export CLOUDINIT_NETWORK_MODE="dhcp"
|
||||||
|
else
|
||||||
|
export CLOUDINIT_NETWORK_MODE="static"
|
||||||
|
|
||||||
|
# Static IP with validation
|
||||||
|
while true; do
|
||||||
|
if CLOUDINIT_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||||
|
"Static IP Address (CIDR format)\nExample: 192.168.1.100/24" 9 58 "" --title "IP ADDRESS" 3>&1 1>&2 2>&3); then
|
||||||
|
if validate_ip_cidr "$CLOUDINIT_IP"; then
|
||||||
|
export CLOUDINIT_IP
|
||||||
|
break
|
||||||
|
else
|
||||||
|
whiptail --backtitle "Proxmox VE Helper Scripts" --title "INVALID IP" \
|
||||||
|
--msgbox "Invalid IP format: $CLOUDINIT_IP\n\nPlease use CIDR format: x.x.x.x/xx\nExample: 192.168.1.100/24" 10 50
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
_ci_msg_warn "Static IP required, falling back to DHCP"
|
||||||
|
export CLOUDINIT_NETWORK_MODE="dhcp"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Gateway with validation
|
||||||
|
if [ "$CLOUDINIT_NETWORK_MODE" = "static" ]; then
|
||||||
|
while true; do
|
||||||
|
if CLOUDINIT_GW=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||||
|
"Gateway IP Address\nExample: 192.168.1.1" 8 58 "" --title "GATEWAY" 3>&1 1>&2 2>&3); then
|
||||||
|
if validate_ip "$CLOUDINIT_GW"; then
|
||||||
|
export CLOUDINIT_GW
|
||||||
|
break
|
||||||
|
else
|
||||||
|
whiptail --backtitle "Proxmox VE Helper Scripts" --title "INVALID GATEWAY" \
|
||||||
|
--msgbox "Invalid gateway format: $CLOUDINIT_GW\n\nPlease use format: x.x.x.x\nExample: 192.168.1.1" 10 50
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
_ci_msg_warn "Gateway required, falling back to DHCP"
|
||||||
|
export CLOUDINIT_NETWORK_MODE="dhcp"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DNS Servers
|
||||||
|
if CLOUDINIT_DNS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||||
|
"DNS Servers (space-separated)" 8 58 "1.1.1.1 8.8.8.8" --title "DNS SERVERS" 3>&1 1>&2 2>&3); then
|
||||||
|
export CLOUDINIT_DNS="${CLOUDINIT_DNS:-1.1.1.1 8.8.8.8}"
|
||||||
|
else
|
||||||
|
export CLOUDINIT_DNS="1.1.1.1 8.8.8.8"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 5: UTILITY FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# display_cloud_init_info - Show Cloud-Init summary after setup
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function display_cloud_init_info() {
|
||||||
|
local vmid="$1"
|
||||||
|
local hostname="${2:-}"
|
||||||
|
|
||||||
|
if [ -n "$CLOUDINIT_CRED_FILE" ] && [ -f "$CLOUDINIT_CRED_FILE" ]; then
|
||||||
|
if [ -n "${INFO:-}" ]; then
|
||||||
|
echo -e "\n${INFO}${BOLD:-}${GN:-} Cloud-Init Configuration:${CL:-}"
|
||||||
|
echo -e "${TAB:- }${DGN:-}User: ${BGN:-}${CLOUDINIT_USER:-root}${CL:-}"
|
||||||
|
echo -e "${TAB:- }${DGN:-}Password: ${BGN:-}${CLOUDINIT_PASSWORD}${CL:-}"
|
||||||
|
echo -e "${TAB:- }${DGN:-}Credentials: ${BL:-}${CLOUDINIT_CRED_FILE}${CL:-}"
|
||||||
|
echo -e "${TAB:- }${RD:-}⚠️ Delete credentials file after noting password!${CL:-}"
|
||||||
|
echo -e "${TAB:- }${YW:-}💡 Configure in Proxmox UI: VM ${vmid} > Cloud-Init${CL:-}"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Cloud-Init Configuration:"
|
||||||
|
echo " User: ${CLOUDINIT_USER:-root}"
|
||||||
|
echo " Password: ${CLOUDINIT_PASSWORD}"
|
||||||
|
echo " Credentials: ${CLOUDINIT_CRED_FILE}"
|
||||||
|
echo " ⚠️ Delete credentials file after noting password!"
|
||||||
|
echo " Configure in Proxmox UI: VM ${vmid} > Cloud-Init"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# cleanup_cloud_init_credentials - Remove credentials file
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Usage: cleanup_cloud_init_credentials
|
||||||
|
# Call this after user has noted/saved the credentials
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function cleanup_cloud_init_credentials() {
|
||||||
|
if [ -n "$CLOUDINIT_CRED_FILE" ] && [ -f "$CLOUDINIT_CRED_FILE" ]; then
|
||||||
|
rm -f "$CLOUDINIT_CRED_FILE"
|
||||||
|
_ci_msg_ok "Credentials file removed: $CLOUDINIT_CRED_FILE"
|
||||||
|
unset CLOUDINIT_CRED_FILE
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# has_cloud_init - Check if VM has Cloud-Init configured
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function has_cloud_init() {
|
||||||
|
local vmid="$1"
|
||||||
|
qm config "$vmid" 2>/dev/null | grep -qE "(ide2|scsi1):.*cloudinit"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# regenerate_cloud_init - Regenerate Cloud-Init configuration
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function regenerate_cloud_init() {
|
||||||
|
local vmid="$1"
|
||||||
|
|
||||||
|
if has_cloud_init "$vmid"; then
|
||||||
|
_ci_msg_info "Regenerating Cloud-Init configuration"
|
||||||
|
qm cloudinit update "$vmid" >/dev/null 2>&1 || true
|
||||||
|
_ci_msg_ok "Cloud-Init configuration regenerated"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
_ci_msg_warn "VM $vmid does not have Cloud-Init configured"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# get_vm_ip - Get VM IP address via qemu-guest-agent
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function get_vm_ip() {
|
||||||
|
local vmid="$1"
|
||||||
|
local timeout="${2:-30}"
|
||||||
|
|
||||||
|
local elapsed=0
|
||||||
|
while [ $elapsed -lt $timeout ]; do
|
||||||
|
local vm_ip=$(qm guest cmd "$vmid" network-get-interfaces 2>/dev/null |
|
||||||
|
jq -r '.[] | select(.name != "lo") | ."ip-addresses"[]? | select(."ip-address-type" == "ipv4") | ."ip-address"' 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$vm_ip" ]; then
|
||||||
|
echo "$vm_ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# wait_for_cloud_init - Wait for Cloud-Init to complete (requires SSH access)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
function wait_for_cloud_init() {
|
||||||
|
local vmid="$1"
|
||||||
|
local timeout="${2:-300}"
|
||||||
|
local vm_ip="${3:-}"
|
||||||
|
|
||||||
|
# Get IP if not provided
|
||||||
|
if [ -z "$vm_ip" ]; then
|
||||||
|
vm_ip=$(get_vm_ip "$vmid" 60)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$vm_ip" ]; then
|
||||||
|
_ci_msg_warn "Unable to determine VM IP address"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ci_msg_info "Waiting for Cloud-Init to complete on ${vm_ip}"
|
||||||
|
|
||||||
|
local elapsed=0
|
||||||
|
while [ $elapsed -lt $timeout ]; do
|
||||||
|
if timeout 10 ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
"${CLOUDINIT_USER:-root}@${vm_ip}" "cloud-init status --wait" 2>/dev/null; then
|
||||||
|
_ci_msg_ok "Cloud-Init completed successfully"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
elapsed=$((elapsed + 10))
|
||||||
|
done
|
||||||
|
|
||||||
|
_ci_msg_warn "Cloud-Init did not complete within ${timeout}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 6: EXPORTS
|
||||||
|
# ==============================================================================
|
||||||
|
# Export all functions for use in other scripts
|
||||||
|
|
||||||
|
export -f setup_cloud_init 2>/dev/null || true
|
||||||
|
export -f configure_cloud_init_interactive 2>/dev/null || true
|
||||||
|
export -f display_cloud_init_info 2>/dev/null || true
|
||||||
|
export -f cleanup_cloud_init_credentials 2>/dev/null || true
|
||||||
|
export -f has_cloud_init 2>/dev/null || true
|
||||||
|
export -f regenerate_cloud_init 2>/dev/null || true
|
||||||
|
export -f get_vm_ip 2>/dev/null || true
|
||||||
|
export -f wait_for_cloud_init 2>/dev/null || true
|
||||||
|
export -f validate_ip_cidr 2>/dev/null || true
|
||||||
|
export -f validate_ip 2>/dev/null || true
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 7: EXAMPLES & DOCUMENTATION
|
||||||
|
# ==============================================================================
|
||||||
|
: <<'EXAMPLES'
|
||||||
|
|
||||||
|
# Example 1: Simple DHCP setup (most common)
|
||||||
|
setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes"
|
||||||
|
|
||||||
|
# Example 2: Static IP setup
|
||||||
|
setup_cloud_init "$VMID" "$STORAGE" "myserver" "yes" "root" "static" "192.168.1.100/24" "192.168.1.1"
|
||||||
|
|
||||||
|
# Example 3: Interactive configuration in advanced_settings()
|
||||||
|
configure_cloud_init_interactive "admin"
|
||||||
|
if [ "$CLOUDINIT_ENABLE" = "yes" ]; then
|
||||||
|
setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes" "$CLOUDINIT_USER" \
|
||||||
|
"$CLOUDINIT_NETWORK_MODE" "$CLOUDINIT_IP" "$CLOUDINIT_GW" "$CLOUDINIT_DNS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Example 4: Display info after VM creation
|
||||||
|
display_cloud_init_info "$VMID" "$HN"
|
||||||
|
|
||||||
|
# Example 5: Check if VM has Cloud-Init
|
||||||
|
if has_cloud_init "$VMID"; then
|
||||||
|
echo "Cloud-Init is configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Example 6: Wait for Cloud-Init to complete after VM start
|
||||||
|
if [ "$START_VM" = "yes" ]; then
|
||||||
|
qm start "$VMID"
|
||||||
|
sleep 30
|
||||||
|
wait_for_cloud_init "$VMID" 300
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Example 7: Cleanup credentials file after user has noted password
|
||||||
|
display_cloud_init_info "$VMID" "$HN"
|
||||||
|
read -p "Have you saved the credentials? (y/N): " -r
|
||||||
|
[[ $REPLY =~ ^[Yy]$ ]] && cleanup_cloud_init_credentials
|
||||||
|
|
||||||
|
# Example 8: Validate IP before using
|
||||||
|
if validate_ip_cidr "192.168.1.100/24"; then
|
||||||
|
echo "Valid IP/CIDR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXAMPLES
|
||||||
@@ -1,699 +0,0 @@
|
|||||||
config_file() {
|
|
||||||
CONFIG_FILE="/opt/community-scripts/.settings"
|
|
||||||
|
|
||||||
if [[ -f "/opt/community-scripts/${NSAPP}.conf" ]]; then
|
|
||||||
CONFIG_FILE="/opt/community-scripts/${NSAPP}.conf"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if CONFIG_FILE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set absolute path to config file" 8 58 "$CONFIG_FILE" --title "CONFIG FILE" 3>&1 1>&2 2>&3); then
|
|
||||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
||||||
echo -e "${CROSS}${RD}Config file not found, exiting script!.${CL}"
|
|
||||||
exit
|
|
||||||
else
|
|
||||||
echo -e "${INFO}${BOLD}${DGN}Using config File: ${BGN}$CONFIG_FILE${CL}"
|
|
||||||
source "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [[ -n "${CT_ID-}" ]]; then
|
|
||||||
if [[ "$CT_ID" =~ ^([0-9]{3,4})-([0-9]{3,4})$ ]]; then
|
|
||||||
MIN_ID=${BASH_REMATCH[1]}
|
|
||||||
MAX_ID=${BASH_REMATCH[2]}
|
|
||||||
if ((MIN_ID >= MAX_ID)); then
|
|
||||||
msg_error "Invalid Container ID range. The first number must be smaller than the second number, was ${CT_ID}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
|
|
||||||
if [[ -n "$LIST_OF_IDS" ]]; then
|
|
||||||
for ((ID = MIN_ID; ID <= MAX_ID; ID++)); do
|
|
||||||
if ! grep -q "^$ID$" <<<"$LIST_OF_IDS"; then
|
|
||||||
CT_ID=$ID
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
|
||||||
|
|
||||||
elif [[ "$CT_ID" =~ ^[0-9]+$ ]]; then
|
|
||||||
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
|
|
||||||
if [[ -n "$LIST_OF_IDS" ]]; then
|
|
||||||
|
|
||||||
if ! grep -q "^$CT_ID$" <<<"$LIST_OF_IDS"; then
|
|
||||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
|
||||||
else
|
|
||||||
msg_error "Container ID $CT_ID already exists"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
msg_error "Invalid Container ID format. Needs to be 0000-9999 or 0-9999, was ${CT_ID}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
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
|
|
||||||
|
|
||||||
fi
|
|
||||||
if [[ -n "${CT_TYPE-}" ]]; then
|
|
||||||
if [[ "$CT_TYPE" -eq 0 ]]; then
|
|
||||||
CT_TYPE_DESC="Privileged"
|
|
||||||
elif [[ "$CT_TYPE" -eq 1 ]]; then
|
|
||||||
CT_TYPE_DESC="Unprivileged"
|
|
||||||
else
|
|
||||||
msg_error "Unknown setting for CT_TYPE, should be 1 or 0, was ${CT_TYPE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
|
|
||||||
else
|
|
||||||
if CT_TYPE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "CONTAINER TYPE" --radiolist "Choose Type" 10 58 2 \
|
|
||||||
"1" "Unprivileged" ON \
|
|
||||||
"0" "Privileged" OFF \
|
|
||||||
3>&1 1>&2 2>&3); then
|
|
||||||
if [ -n "$CT_TYPE" ]; then
|
|
||||||
CT_TYPE_DESC="Unprivileged"
|
|
||||||
if [ "$CT_TYPE" -eq 0 ]; then
|
|
||||||
CT_TYPE_DESC="Privileged"
|
|
||||||
fi
|
|
||||||
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${PW-}" ]]; then
|
|
||||||
if [[ "$PW" == "none" ]]; then
|
|
||||||
PW=""
|
|
||||||
else
|
|
||||||
if [[ "$PW" == *" "* ]]; then
|
|
||||||
msg_error "Password cannot be empty"
|
|
||||||
exit
|
|
||||||
elif [[ ${#PW} -lt 5 ]]; then
|
|
||||||
msg_error "Password must be at least 5 characters long"
|
|
||||||
exit
|
|
||||||
else
|
|
||||||
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
|
|
||||||
fi
|
|
||||||
PW="-password $PW"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
while true; do
|
|
||||||
if PW1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nSet Root Password (needed for root ssh access)" 9 58 --title "PASSWORD (leave blank for automatic login)" 3>&1 1>&2 2>&3); then
|
|
||||||
if [[ -n "$PW1" ]]; then
|
|
||||||
if [[ "$PW1" == *" "* ]]; then
|
|
||||||
whiptail --msgbox "Password cannot contain spaces. Please try again." 8 58
|
|
||||||
elif [ ${#PW1} -lt 5 ]; then
|
|
||||||
whiptail --msgbox "Password must be at least 5 characters long. Please try again." 8 58
|
|
||||||
else
|
|
||||||
if PW2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nVerify Root Password" 9 58 --title "PASSWORD VERIFICATION" 3>&1 1>&2 2>&3); then
|
|
||||||
if [[ "$PW1" == "$PW2" ]]; then
|
|
||||||
PW="-password $PW1"
|
|
||||||
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
whiptail --msgbox "Passwords do not match. Please try again." 8 58
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
PW1="Automatic Login"
|
|
||||||
PW=""
|
|
||||||
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}$PW1${CL}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${HN-}" ]]; then
|
|
||||||
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
|
|
||||||
else
|
|
||||||
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$CT_NAME" ]; then
|
|
||||||
HN="$NSAPP"
|
|
||||||
else
|
|
||||||
HN=$(echo "${CT_NAME,,}" | tr -d ' ')
|
|
||||||
fi
|
|
||||||
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${DISK_SIZE-}" ]]; then
|
|
||||||
if [[ "$DISK_SIZE" =~ ^-?[0-9]+$ ]]; then
|
|
||||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
|
|
||||||
else
|
|
||||||
msg_error "DISK_SIZE must be an integer, was ${DISK_SIZE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if DISK_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Disk Size in GB" 8 58 "$var_disk" --title "DISK SIZE" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$DISK_SIZE" ]; then
|
|
||||||
DISK_SIZE="$var_disk"
|
|
||||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
|
|
||||||
else
|
|
||||||
if ! [[ $DISK_SIZE =~ $INTEGER ]]; then
|
|
||||||
echo -e "{INFO}${HOLD}${RD} DISK SIZE MUST BE AN INTEGER NUMBER!${CL}"
|
|
||||||
advanced_settings
|
|
||||||
fi
|
|
||||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${CORE_COUNT-}" ]]; then
|
|
||||||
if [[ "$CORE_COUNT" =~ ^-?[0-9]+$ ]]; then
|
|
||||||
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}"
|
|
||||||
else
|
|
||||||
msg_error "CORE_COUNT must be an integer, was ${CORE_COUNT}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if CORE_COUNT=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate CPU Cores" 8 58 "$var_cpu" --title "CORE COUNT" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$CORE_COUNT" ]; then
|
|
||||||
CORE_COUNT="$var_cpu"
|
|
||||||
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
|
|
||||||
else
|
|
||||||
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${RAM_SIZE-}" ]]; then
|
|
||||||
if [[ "$RAM_SIZE" =~ ^-?[0-9]+$ ]]; then
|
|
||||||
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
|
|
||||||
else
|
|
||||||
msg_error "RAM_SIZE must be an integer, was ${RAM_SIZE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if RAM_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate RAM in MiB" 8 58 "$var_ram" --title "RAM" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$RAM_SIZE" ]; then
|
|
||||||
RAM_SIZE="$var_ram"
|
|
||||||
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
|
|
||||||
else
|
|
||||||
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
IFACE_FILEPATH_LIST="/etc/network/interfaces"$'\n'$(find "/etc/network/interfaces.d/" -type f)
|
|
||||||
BRIDGES=""
|
|
||||||
OLD_IFS=$IFS
|
|
||||||
IFS=$'\n'
|
|
||||||
|
|
||||||
for iface_filepath in ${IFACE_FILEPATH_LIST}; do
|
|
||||||
|
|
||||||
iface_indexes_tmpfile=$(mktemp -q -u '.iface-XXXX')
|
|
||||||
(grep -Pn '^\s*iface' "${iface_filepath}" | cut -d':' -f1 && wc -l "${iface_filepath}" | cut -d' ' -f1) | awk 'FNR==1 {line=$0; next} {print line":"$0-1; line=$0}' >"${iface_indexes_tmpfile}" || true
|
|
||||||
|
|
||||||
if [ -f "${iface_indexes_tmpfile}" ]; then
|
|
||||||
|
|
||||||
while read -r pair; do
|
|
||||||
start=$(echo "${pair}" | cut -d':' -f1)
|
|
||||||
end=$(echo "${pair}" | cut -d':' -f2)
|
|
||||||
if awk "NR >= ${start} && NR <= ${end}" "${iface_filepath}" | grep -qP '^\s*(bridge[-_](ports|stp|fd|vlan-aware|vids)|ovs_type\s+OVSBridge)\b'; then
|
|
||||||
iface_name=$(sed "${start}q;d" "${iface_filepath}" | awk '{print $2}')
|
|
||||||
BRIDGES="${iface_name}"$'\n'"${BRIDGES}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
done <"${iface_indexes_tmpfile}"
|
|
||||||
rm -f "${iface_indexes_tmpfile}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
done
|
|
||||||
IFS=$OLD_IFS
|
|
||||||
BRIDGES=$(echo "$BRIDGES" | grep -v '^\s*$' | sort | uniq)
|
|
||||||
|
|
||||||
if [[ -n "${BRG-}" ]]; then
|
|
||||||
if echo "$BRIDGES" | grep -q "${BRG}"; then
|
|
||||||
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
|
|
||||||
else
|
|
||||||
msg_error "Bridge '${BRG}' does not exist in /etc/network/interfaces or /etc/network/interfaces.d/sdn"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --menu "Select network bridge:" 15 40 6 $(echo "$BRIDGES" | awk '{print $0, "Bridge"}') 3>&1 1>&2 2>&3)
|
|
||||||
if [ -z "$BRG" ]; then
|
|
||||||
exit_script
|
|
||||||
else
|
|
||||||
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$'
|
|
||||||
local ip_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$'
|
|
||||||
|
|
||||||
if [[ -n ${NET-} ]]; then
|
|
||||||
if [ "$NET" == "dhcp" ]; then
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}DHCP${CL}"
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
|
|
||||||
GATE=""
|
|
||||||
elif [[ "$NET" =~ $ip_cidr_regex ]]; then
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
|
|
||||||
if [[ -n "$GATE" ]]; then
|
|
||||||
[[ "$GATE" =~ ",gw=" ]] && GATE="${GATE##,gw=}"
|
|
||||||
if [[ "$GATE" =~ $ip_regex ]]; then
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
|
|
||||||
GATE=",gw=$GATE"
|
|
||||||
else
|
|
||||||
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
|
||||||
while true; do
|
|
||||||
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
|
|
||||||
if [ -z "$GATE1" ]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
|
|
||||||
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
|
|
||||||
else
|
|
||||||
GATE=",gw=$GATE1"
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
elif [[ "$NET" == *-* ]]; then
|
|
||||||
IFS="-" read -r ip_start ip_end <<<"$NET"
|
|
||||||
|
|
||||||
if [[ ! "$ip_start" =~ $ip_cidr_regex ]] || [[ ! "$ip_end" =~ $ip_cidr_regex ]]; then
|
|
||||||
msg_error "Invalid IP range format, was $NET should be 0.0.0.0/0-0.0.0.0/0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ip1="${ip_start%%/*}"
|
|
||||||
ip2="${ip_end%%/*}"
|
|
||||||
cidr="${ip_start##*/}"
|
|
||||||
|
|
||||||
ip_to_int() {
|
|
||||||
local IFS=.
|
|
||||||
read -r i1 i2 i3 i4 <<<"$1"
|
|
||||||
echo $(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4))
|
|
||||||
}
|
|
||||||
|
|
||||||
int_to_ip() {
|
|
||||||
local ip=$1
|
|
||||||
echo "$(((ip >> 24) & 0xFF)).$(((ip >> 16) & 0xFF)).$(((ip >> 8) & 0xFF)).$((ip & 0xFF))"
|
|
||||||
}
|
|
||||||
|
|
||||||
start_int=$(ip_to_int "$ip1")
|
|
||||||
end_int=$(ip_to_int "$ip2")
|
|
||||||
|
|
||||||
for ((ip_int = start_int; ip_int <= end_int; ip_int++)); do
|
|
||||||
ip=$(int_to_ip $ip_int)
|
|
||||||
msg_info "Checking IP: $ip"
|
|
||||||
if ! ping -c 2 -W 1 "$ip" >/dev/null 2>&1; then
|
|
||||||
NET="$ip/$cidr"
|
|
||||||
msg_ok "Using free IP Address: ${BGN}$NET${CL}"
|
|
||||||
sleep 3
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "$NET" == *-* ]]; then
|
|
||||||
msg_error "No free IP found in range"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -n "$GATE" ]; then
|
|
||||||
if [[ "$GATE" =~ $ip_regex ]]; then
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
|
|
||||||
GATE=",gw=$GATE"
|
|
||||||
else
|
|
||||||
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
while true; do
|
|
||||||
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
|
|
||||||
if [ -z "$GATE1" ]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
|
|
||||||
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
|
|
||||||
else
|
|
||||||
GATE=",gw=$GATE1"
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
msg_error "Invalid IP Address format. Needs to be 0.0.0.0/0 or a range like 10.0.0.1/24-10.0.0.10/24, was ${NET}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
while true; do
|
|
||||||
NET=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Static IPv4 CIDR Address (/24)" 8 58 dhcp --title "IP ADDRESS" 3>&1 1>&2 2>&3)
|
|
||||||
exit_status=$?
|
|
||||||
if [ $exit_status -eq 0 ]; then
|
|
||||||
if [ "$NET" = "dhcp" ]; then
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
if [[ "$NET" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "$NET is an invalid IPv4 CIDR address. Please enter a valid IPv4 CIDR address or 'dhcp'" 8 58
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$NET" != "dhcp" ]; then
|
|
||||||
while true; do
|
|
||||||
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
|
|
||||||
if [ -z "$GATE1" ]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
|
|
||||||
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
|
||||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
|
|
||||||
else
|
|
||||||
GATE=",gw=$GATE1"
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
else
|
|
||||||
GATE=""
|
|
||||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$var_os" == "alpine" ]; then
|
|
||||||
APT_CACHER=""
|
|
||||||
APT_CACHER_IP=""
|
|
||||||
else
|
|
||||||
if [[ -n "${APT_CACHER_IP-}" ]]; then
|
|
||||||
if [[ ! $APT_CACHER_IP == "none" ]]; then
|
|
||||||
APT_CACHER="yes"
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}APT-CACHER IP Address: ${BGN}$APT_CACHER_IP${CL}"
|
|
||||||
else
|
|
||||||
APT_CACHER=""
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}No${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if APT_CACHER_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set APT-Cacher IP (leave blank for none)" 8 58 --title "APT-Cacher IP" 3>&1 1>&2 2>&3); then
|
|
||||||
APT_CACHER="${APT_CACHER_IP:+yes}"
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}${APT_CACHER_IP:-Default}${CL}"
|
|
||||||
if [[ -n $APT_CACHER_IP ]]; then
|
|
||||||
APT_CACHER_IP="none"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${MTU-}" ]]; then
|
|
||||||
if [[ "$MTU" =~ ^-?[0-9]+$ ]]; then
|
|
||||||
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU${CL}"
|
|
||||||
MTU=",mtu=$MTU"
|
|
||||||
else
|
|
||||||
msg_error "MTU must be an integer, was ${MTU}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if MTU1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Interface MTU Size (leave blank for default [The MTU of your selected vmbr, default is 1500])" 8 58 --title "MTU SIZE" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$MTU1" ]; then
|
|
||||||
MTU1="Default"
|
|
||||||
MTU=""
|
|
||||||
else
|
|
||||||
MTU=",mtu=$MTU1"
|
|
||||||
fi
|
|
||||||
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU1${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$IPV6_METHOD" == "static" ]]; then
|
|
||||||
if [[ -n "$IPV6STATIC" ]]; then
|
|
||||||
IP6=",ip6=${IPV6STATIC}"
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}${IPV6STATIC}${CL}"
|
|
||||||
else
|
|
||||||
msg_error "IPV6_METHOD is set to static but IPV6STATIC is empty"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
elif [[ "$IPV6_METHOD" == "auto" ]]; then
|
|
||||||
IP6=",ip6=auto"
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}auto${CL}"
|
|
||||||
else
|
|
||||||
IP6=""
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}none${CL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${SD-}" ]]; then
|
|
||||||
if [[ "$SD" == "none" ]]; then
|
|
||||||
SD=""
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}Host${CL}"
|
|
||||||
else
|
|
||||||
# Strip prefix if present for config file storage
|
|
||||||
local SD_VALUE="$SD"
|
|
||||||
[[ "$SD" =~ ^-searchdomain= ]] && SD_VALUE="${SD#-searchdomain=}"
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SD_VALUE${CL}"
|
|
||||||
SD="-searchdomain=$SD_VALUE"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if SD=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Search Domain (leave blank for HOST)" 8 58 --title "DNS Search Domain" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$SD" ]; then
|
|
||||||
SX=Host
|
|
||||||
SD=""
|
|
||||||
else
|
|
||||||
SX=$SD
|
|
||||||
SD="-searchdomain=$SD"
|
|
||||||
fi
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SX${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${NS-}" ]]; then
|
|
||||||
if [[ $NS == "none" ]]; then
|
|
||||||
NS=""
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}Host${CL}"
|
|
||||||
else
|
|
||||||
# Strip prefix if present for config file storage
|
|
||||||
local NS_VALUE="$NS"
|
|
||||||
[[ "$NS" =~ ^-nameserver= ]] && NS_VALUE="${NS#-nameserver=}"
|
|
||||||
if [[ "$NS_VALUE" =~ $ip_regex ]]; then
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NS_VALUE${CL}"
|
|
||||||
NS="-nameserver=$NS_VALUE"
|
|
||||||
else
|
|
||||||
msg_error "Invalid IP Address format for DNS Server. Needs to be 0.0.0.0, was ${NS_VALUE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if NX=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Server IP (leave blank for HOST)" 8 58 --title "DNS SERVER IP" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$NX" ]; then
|
|
||||||
NX=Host
|
|
||||||
NS=""
|
|
||||||
else
|
|
||||||
NS="-nameserver=$NX"
|
|
||||||
fi
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NX${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${MAC-}" ]]; then
|
|
||||||
if [[ "$MAC" == "none" ]]; then
|
|
||||||
MAC=""
|
|
||||||
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}Host${CL}"
|
|
||||||
else
|
|
||||||
# Strip prefix if present for config file storage
|
|
||||||
local MAC_VALUE="$MAC"
|
|
||||||
[[ "$MAC" =~ ^,hwaddr= ]] && MAC_VALUE="${MAC#,hwaddr=}"
|
|
||||||
if [[ "$MAC_VALUE" =~ ^([A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2}$ ]]; then
|
|
||||||
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC_VALUE${CL}"
|
|
||||||
MAC=",hwaddr=$MAC_VALUE"
|
|
||||||
else
|
|
||||||
msg_error "MAC Address must be in the format xx:xx:xx:xx:xx:xx, was ${MAC_VALUE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if MAC1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a MAC Address(leave blank for generated MAC)" 8 58 --title "MAC ADDRESS" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$MAC1" ]; then
|
|
||||||
MAC1="Default"
|
|
||||||
MAC=""
|
|
||||||
else
|
|
||||||
MAC=",hwaddr=$MAC1"
|
|
||||||
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC1${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${VLAN-}" ]]; then
|
|
||||||
if [[ "$VLAN" == "none" ]]; then
|
|
||||||
VLAN=""
|
|
||||||
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}Host${CL}"
|
|
||||||
else
|
|
||||||
# Strip prefix if present for config file storage
|
|
||||||
local VLAN_VALUE="$VLAN"
|
|
||||||
[[ "$VLAN" =~ ^,tag= ]] && VLAN_VALUE="${VLAN#,tag=}"
|
|
||||||
if [[ "$VLAN_VALUE" =~ ^-?[0-9]+$ ]]; then
|
|
||||||
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN_VALUE${CL}"
|
|
||||||
VLAN=",tag=$VLAN_VALUE"
|
|
||||||
else
|
|
||||||
msg_error "VLAN must be an integer, was ${VLAN_VALUE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if VLAN1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Vlan(leave blank for no VLAN)" 8 58 --title "VLAN" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -z "$VLAN1" ]; then
|
|
||||||
VLAN1="Default"
|
|
||||||
VLAN=""
|
|
||||||
else
|
|
||||||
VLAN=",tag=$VLAN1"
|
|
||||||
fi
|
|
||||||
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN1${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${TAGS-}" ]]; then
|
|
||||||
if [[ "$TAGS" == *"DEFAULT"* ]]; then
|
|
||||||
TAGS="${TAGS//DEFAULT/}"
|
|
||||||
TAGS="${TAGS//;/}"
|
|
||||||
TAGS="$TAGS;${var_tags:-}"
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
TAGS="community-scripts;"
|
|
||||||
if ADV_TAGS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Custom Tags?[If you remove all, there will be no tags!]" 8 58 "${TAGS}" --title "Advanced Tags" 3>&1 1>&2 2>&3); then
|
|
||||||
if [ -n "${ADV_TAGS}" ]; then
|
|
||||||
ADV_TAGS=$(echo "$ADV_TAGS" | tr -d '[:space:]')
|
|
||||||
TAGS="${ADV_TAGS}"
|
|
||||||
else
|
|
||||||
TAGS=";"
|
|
||||||
fi
|
|
||||||
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
|
|
||||||
else
|
|
||||||
exit_script
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${SSH-}" ]]; then
|
|
||||||
if [[ "$SSH" == "yes" ]]; then
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
|
||||||
if [[ ! -z "$SSH_AUTHORIZED_KEY" ]]; then
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}********************${CL}"
|
|
||||||
else
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}None${CL}"
|
|
||||||
fi
|
|
||||||
elif [[ "$SSH" == "no" ]]; then
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
|
||||||
else
|
|
||||||
msg_error "SSH needs to be 'yes' or 'no', was ${SSH}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
SSH_AUTHORIZED_KEY="$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "SSH Authorized key for root (leave empty for none)" 8 58 --title "SSH Key" 3>&1 1>&2 2>&3)"
|
|
||||||
if [[ -z "${SSH_AUTHORIZED_KEY}" ]]; then
|
|
||||||
SSH_AUTHORIZED_KEY=""
|
|
||||||
fi
|
|
||||||
if [[ "$PW" == -password* || -n "$SSH_AUTHORIZED_KEY" ]]; then
|
|
||||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH ACCESS" --yesno "Enable Root SSH Access?" 10 58); then
|
|
||||||
SSH="yes"
|
|
||||||
else
|
|
||||||
SSH="no"
|
|
||||||
fi
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
|
||||||
else
|
|
||||||
SSH="no"
|
|
||||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$ENABLE_FUSE" ]]; then
|
|
||||||
if [[ "$ENABLE_FUSE" == "yes" ]]; then
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}Yes${CL}"
|
|
||||||
elif [[ "$ENABLE_FUSE" == "no" ]]; then
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}No${CL}"
|
|
||||||
else
|
|
||||||
msg_error "Enable FUSE needs to be 'yes' or 'no', was ${ENABLE_FUSE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "FUSE" --yesno "Enable FUSE?" 10 58); then
|
|
||||||
ENABLE_FUSE="yes"
|
|
||||||
else
|
|
||||||
ENABLE_FUSE="no"
|
|
||||||
fi
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}$ENABLE_FUSE${CL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$ENABLE_TUN" ]]; then
|
|
||||||
if [[ "$ENABLE_TUN" == "yes" ]]; then
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}Yes${CL}"
|
|
||||||
elif [[ "$ENABLE_TUN" == "no" ]]; then
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}No${CL}"
|
|
||||||
else
|
|
||||||
msg_error "Enable TUN needs to be 'yes' or 'no', was ${ENABLE_TUN}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "TUN" --yesno "Enable TUN?" 10 58); then
|
|
||||||
ENABLE_TUN="yes"
|
|
||||||
else
|
|
||||||
ENABLE_TUN="no"
|
|
||||||
fi
|
|
||||||
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}$ENABLE_TUN${CL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${VERBOSE-}" ]]; then
|
|
||||||
if [[ "$VERBOSE" == "yes" ]]; then
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
|
|
||||||
elif [[ "$VERBOSE" == "no" ]]; then
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}No${CL}"
|
|
||||||
else
|
|
||||||
msg_error "Verbose Mode needs to be 'yes' or 'no', was ${VERBOSE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "VERBOSE MODE" --yesno "Enable Verbose Mode?" 10 58); then
|
|
||||||
VERBOSE="yes"
|
|
||||||
else
|
|
||||||
VERBOSE="no"
|
|
||||||
fi
|
|
||||||
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "ADVANCED SETTINGS WITH CONFIG FILE COMPLETE" --yesno "Ready to create ${APP} LXC?" 10 58); then
|
|
||||||
echo -e "${CREATING}${BOLD}${RD}Creating a ${APP} LXC using the above settings${CL}"
|
|
||||||
else
|
|
||||||
clear
|
|
||||||
header_info
|
|
||||||
echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}"
|
|
||||||
config_file
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
# Copyright (c) 2021-2025 community-scripts ORG
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ==============================================================================
|
||||||
# Loads core utility groups once (colors, formatting, icons, defaults).
|
# CORE FUNCTIONS - LXC CONTAINER UTILITIES
|
||||||
# ------------------------------------------------------------------------------
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# This file provides core utility functions for LXC container management
|
||||||
|
# including colors, formatting, validation checks, message output, and
|
||||||
|
# execution helpers used throughout the Community-Scripts ecosystem.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source <(curl -fsSL https://git.community-scripts.org/.../core.func)
|
||||||
|
# load_functions
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
[[ -n "${_CORE_FUNC_LOADED:-}" ]] && return
|
[[ -n "${_CORE_FUNC_LOADED:-}" ]] && return
|
||||||
_CORE_FUNC_LOADED=1
|
_CORE_FUNC_LOADED=1
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 1: INITIALIZATION & SETUP
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# load_functions()
|
||||||
|
#
|
||||||
|
# - Initializes all core utility groups (colors, formatting, icons, defaults)
|
||||||
|
# - Ensures functions are loaded only once via __FUNCTIONS_LOADED flag
|
||||||
|
# - Must be called at start of any script using these utilities
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
load_functions() {
|
load_functions() {
|
||||||
[[ -n "${__FUNCTIONS_LOADED:-}" ]] && return
|
[[ -n "${__FUNCTIONS_LOADED:-}" ]] && return
|
||||||
__FUNCTIONS_LOADED=1
|
__FUNCTIONS_LOADED=1
|
||||||
@@ -16,58 +38,14 @@ load_functions() {
|
|||||||
icons
|
icons
|
||||||
default_vars
|
default_vars
|
||||||
set_std_mode
|
set_std_mode
|
||||||
# add more
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Error & Signal Handling – robust, universal, subshell-safe
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
_tool_error_hint() {
|
|
||||||
local cmd="$1"
|
|
||||||
local code="$2"
|
|
||||||
case "$cmd" in
|
|
||||||
curl)
|
|
||||||
case "$code" in
|
|
||||||
6) echo "Curl: Could not resolve host (DNS problem)" ;;
|
|
||||||
7) echo "Curl: Failed to connect to host (connection refused)" ;;
|
|
||||||
22) echo "Curl: HTTP error (404/403 etc)" ;;
|
|
||||||
28) echo "Curl: Operation timeout" ;;
|
|
||||||
*) echo "Curl: Unknown error ($code)" ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
wget)
|
|
||||||
echo "Wget failed – URL unreachable or permission denied"
|
|
||||||
;;
|
|
||||||
systemctl)
|
|
||||||
echo "Systemd unit failure – check service name and permissions"
|
|
||||||
;;
|
|
||||||
jq)
|
|
||||||
echo "jq parse error – malformed JSON or missing key"
|
|
||||||
;;
|
|
||||||
mariadb | mysql)
|
|
||||||
echo "MySQL/MariaDB command failed – check credentials or DB"
|
|
||||||
;;
|
|
||||||
unzip)
|
|
||||||
echo "unzip failed – corrupt file or missing permission"
|
|
||||||
;;
|
|
||||||
tar)
|
|
||||||
echo "tar failed – invalid format or missing binary"
|
|
||||||
;;
|
|
||||||
node | npm | pnpm | yarn)
|
|
||||||
echo "Node tool failed – check version compatibility or package.json"
|
|
||||||
;;
|
|
||||||
*) echo "" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
catch_errors() {
|
|
||||||
set -Eeuo pipefail
|
|
||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Sets ANSI color codes used for styled terminal output.
|
# color()
|
||||||
|
#
|
||||||
|
# - Sets ANSI color codes for styled terminal output
|
||||||
|
# - Variables: YW (yellow), YWB (yellow bright), BL (blue), RD (red)
|
||||||
|
# GN (green), DGN (dark green), BGN (background green), CL (clear)
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
color() {
|
color() {
|
||||||
YW=$(echo "\033[33m")
|
YW=$(echo "\033[33m")
|
||||||
@@ -80,7 +58,14 @@ color() {
|
|||||||
CL=$(echo "\033[m")
|
CL=$(echo "\033[m")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Special for spinner and colorized output via printf
|
# ------------------------------------------------------------------------------
|
||||||
|
# color_spinner()
|
||||||
|
#
|
||||||
|
# - Sets ANSI color codes specifically for spinner animation
|
||||||
|
# - Variables: CS_YW (spinner yellow), CS_YWB (spinner yellow bright),
|
||||||
|
# CS_CL (spinner clear)
|
||||||
|
# - Used by spinner() function to avoid color conflicts
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
color_spinner() {
|
color_spinner() {
|
||||||
CS_YW=$'\033[33m'
|
CS_YW=$'\033[33m'
|
||||||
CS_YWB=$'\033[93m'
|
CS_YWB=$'\033[93m'
|
||||||
@@ -88,7 +73,12 @@ color_spinner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Defines formatting helpers like tab, bold, and line reset sequences.
|
# formatting()
|
||||||
|
#
|
||||||
|
# - Defines formatting helpers for terminal output
|
||||||
|
# - BFR: Backspace and clear line sequence
|
||||||
|
# - BOLD: Bold text escape code
|
||||||
|
# - TAB/TAB3: Indentation spacing
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
formatting() {
|
formatting() {
|
||||||
BFR="\\r\\033[K"
|
BFR="\\r\\033[K"
|
||||||
@@ -99,7 +89,11 @@ formatting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Sets symbolic icons used throughout user feedback and prompts.
|
# icons()
|
||||||
|
#
|
||||||
|
# - Sets symbolic emoji icons used throughout user feedback
|
||||||
|
# - Provides consistent visual indicators for success, error, info, etc.
|
||||||
|
# - Icons: CM (checkmark), CROSS (error), INFO (info), HOURGLASS (wait), etc.
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
icons() {
|
icons() {
|
||||||
CM="${TAB}✔️${TAB}"
|
CM="${TAB}✔️${TAB}"
|
||||||
@@ -129,22 +123,31 @@ icons() {
|
|||||||
CREATING="${TAB}🚀${TAB}${CL}"
|
CREATING="${TAB}🚀${TAB}${CL}"
|
||||||
ADVANCED="${TAB}🧩${TAB}${CL}"
|
ADVANCED="${TAB}🧩${TAB}${CL}"
|
||||||
FUSE="${TAB}🗂️${TAB}${CL}"
|
FUSE="${TAB}🗂️${TAB}${CL}"
|
||||||
|
GPU="${TAB}🎮${TAB}${CL}"
|
||||||
HOURGLASS="${TAB}⏳${TAB}"
|
HOURGLASS="${TAB}⏳${TAB}"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Sets default retry and wait variables used for system actions.
|
# default_vars()
|
||||||
|
#
|
||||||
|
# - Sets default retry and wait variables used for system actions
|
||||||
|
# - RETRY_NUM: Maximum number of retry attempts (default: 10)
|
||||||
|
# - RETRY_EVERY: Seconds to wait between retries (default: 3)
|
||||||
|
# - i: Counter variable initialized to RETRY_NUM
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
default_vars() {
|
default_vars() {
|
||||||
RETRY_NUM=10
|
RETRY_NUM=10
|
||||||
RETRY_EVERY=3
|
RETRY_EVERY=3
|
||||||
i=$RETRY_NUM
|
i=$RETRY_NUM
|
||||||
#[[ "${VAR_OS:-}" == "unknown" ]]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Sets default verbose mode for script and os execution.
|
# set_std_mode()
|
||||||
|
#
|
||||||
|
# - Sets default verbose mode for script and OS execution
|
||||||
|
# - If VERBOSE=yes: STD="" (show all output)
|
||||||
|
# - If VERBOSE=no: STD="silent" (suppress output via silent() wrapper)
|
||||||
|
# - If DEV_MODE_TRACE=true: Enables bash tracing (set -x)
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
set_std_mode() {
|
set_std_mode() {
|
||||||
if [ "${VERBOSE:-no}" = "yes" ]; then
|
if [ "${VERBOSE:-no}" = "yes" ]; then
|
||||||
@@ -152,138 +155,338 @@ set_std_mode() {
|
|||||||
else
|
else
|
||||||
STD="silent"
|
STD="silent"
|
||||||
fi
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
# Silent execution function
|
# Enable bash tracing if trace mode active
|
||||||
silent() {
|
if [[ "${DEV_MODE_TRACE:-false}" == "true" ]]; then
|
||||||
"$@" >/dev/null 2>&1
|
set -x
|
||||||
}
|
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
|
||||||
|
|
||||||
# Function to download & save header files
|
|
||||||
get_header() {
|
|
||||||
local app_name=$(echo "${APP,,}" | tr -d ' ')
|
|
||||||
local app_type=${APP_TYPE:-ct}
|
|
||||||
local header_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/${app_type}/headers/${app_name}"
|
|
||||||
local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}"
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$local_header_path")"
|
|
||||||
|
|
||||||
if [ ! -s "$local_header_path" ]; then
|
|
||||||
if ! curl -fsSL "$header_url" -o "$local_header_path"; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat "$local_header_path" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
header_info() {
|
|
||||||
local app_name=$(echo "${APP,,}" | tr -d ' ')
|
|
||||||
local header_content
|
|
||||||
|
|
||||||
header_content=$(get_header "$app_name") || header_content=""
|
|
||||||
|
|
||||||
clear
|
|
||||||
local term_width
|
|
||||||
term_width=$(tput cols 2>/dev/null || echo 120)
|
|
||||||
|
|
||||||
if [ -n "$header_content" ]; then
|
|
||||||
echo "$header_content"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_tput() {
|
# ------------------------------------------------------------------------------
|
||||||
if ! command -v tput >/dev/null 2>&1; then
|
# parse_dev_mode()
|
||||||
if grep -qi 'alpine' /etc/os-release; then
|
#
|
||||||
apk add --no-cache ncurses >/dev/null 2>&1
|
# - Parses comma-separated dev_mode variable (e.g., "motd,keep,trace")
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
# - Sets global flags for each mode:
|
||||||
apt-get update -qq >/dev/null
|
# * DEV_MODE_MOTD: Setup SSH/MOTD before installation
|
||||||
apt-get install -y -qq ncurses-bin >/dev/null 2>&1
|
# * DEV_MODE_KEEP: Never delete container on failure
|
||||||
fi
|
# * DEV_MODE_TRACE: Enable bash set -x tracing
|
||||||
fi
|
# * DEV_MODE_PAUSE: Pause after each msg_info step
|
||||||
}
|
# * DEV_MODE_BREAKPOINT: Open shell on error instead of cleanup
|
||||||
|
# * DEV_MODE_LOGS: Persist all logs to /var/log/community-scripts/
|
||||||
|
# * DEV_MODE_DRYRUN: Show commands without executing
|
||||||
|
# - Call this early in script execution
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
parse_dev_mode() {
|
||||||
|
local mode
|
||||||
|
# Initialize all flags to false
|
||||||
|
export DEV_MODE_MOTD=false
|
||||||
|
export DEV_MODE_KEEP=false
|
||||||
|
export DEV_MODE_TRACE=false
|
||||||
|
export DEV_MODE_PAUSE=false
|
||||||
|
export DEV_MODE_BREAKPOINT=false
|
||||||
|
export DEV_MODE_LOGS=false
|
||||||
|
export DEV_MODE_DRYRUN=false
|
||||||
|
|
||||||
is_alpine() {
|
# Parse comma-separated modes
|
||||||
local os_id="${var_os:-${PCT_OSTYPE:-}}"
|
if [[ -n "${dev_mode:-}" ]]; then
|
||||||
|
IFS=',' read -ra MODES <<<"$dev_mode"
|
||||||
if [[ -z "$os_id" && -f /etc/os-release ]]; then
|
for mode in "${MODES[@]}"; do
|
||||||
os_id="$(
|
mode="$(echo "$mode" | xargs)" # Trim whitespace
|
||||||
. /etc/os-release 2>/dev/null
|
case "$mode" in
|
||||||
echo "${ID:-}"
|
motd) export DEV_MODE_MOTD=true ;;
|
||||||
)"
|
keep) export DEV_MODE_KEEP=true ;;
|
||||||
fi
|
trace) export DEV_MODE_TRACE=true ;;
|
||||||
|
pause) export DEV_MODE_PAUSE=true ;;
|
||||||
[[ "$os_id" == "alpine" ]]
|
breakpoint) export DEV_MODE_BREAKPOINT=true ;;
|
||||||
}
|
logs) export DEV_MODE_LOGS=true ;;
|
||||||
|
dryrun) export DEV_MODE_DRYRUN=true ;;
|
||||||
is_verbose_mode() {
|
*)
|
||||||
local verbose="${VERBOSE:-${var_verbose:-no}}"
|
if declare -f msg_warn >/dev/null 2>&1; then
|
||||||
local tty_status
|
msg_warn "Unknown dev_mode: '$mode' (ignored)"
|
||||||
if [[ -t 2 ]]; then
|
|
||||||
tty_status="interactive"
|
|
||||||
else
|
else
|
||||||
tty_status="not-a-tty"
|
echo "[WARN] Unknown dev_mode: '$mode' (ignored)" >&2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Show active dev modes
|
||||||
|
local active_modes=()
|
||||||
|
[[ $DEV_MODE_MOTD == true ]] && active_modes+=("motd")
|
||||||
|
[[ $DEV_MODE_KEEP == true ]] && active_modes+=("keep")
|
||||||
|
[[ $DEV_MODE_TRACE == true ]] && active_modes+=("trace")
|
||||||
|
[[ $DEV_MODE_PAUSE == true ]] && active_modes+=("pause")
|
||||||
|
[[ $DEV_MODE_BREAKPOINT == true ]] && active_modes+=("breakpoint")
|
||||||
|
[[ $DEV_MODE_LOGS == true ]] && active_modes+=("logs")
|
||||||
|
[[ $DEV_MODE_DRYRUN == true ]] && active_modes+=("dryrun")
|
||||||
|
|
||||||
|
if [[ ${#active_modes[@]} -gt 0 ]]; then
|
||||||
|
if declare -f msg_custom >/dev/null 2>&1; then
|
||||||
|
msg_custom "🔧" "${YWB}" "Dev modes active: ${active_modes[*]}"
|
||||||
|
else
|
||||||
|
echo "[DEV] Active modes: ${active_modes[*]}" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 2: VALIDATION CHECKS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# shell_check()
|
||||||
|
#
|
||||||
|
# - Verifies that the script is running under Bash shell
|
||||||
|
# - Exits with error message if different shell is detected
|
||||||
|
# - Required because scripts use Bash-specific features
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
shell_check() {
|
||||||
|
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."
|
||||||
|
echo -e "\nExiting..."
|
||||||
|
sleep 2
|
||||||
|
exit
|
||||||
fi
|
fi
|
||||||
[[ "$verbose" != "no" || ! -t 2 ]]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Handles specific curl error codes and displays descriptive messages.
|
# root_check()
|
||||||
|
#
|
||||||
|
# - Verifies script is running with root privileges
|
||||||
|
# - Detects if executed via sudo (which can cause issues)
|
||||||
|
# - Exits with error if not running as root directly
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
__curl_err_handler() {
|
root_check() {
|
||||||
local exit_code="$1"
|
if [[ "$(id -u)" -ne 0 || $(ps -o comm= -p $PPID) == "sudo" ]]; then
|
||||||
local target="$2"
|
clear
|
||||||
local curl_msg="$3"
|
msg_error "Please run this script as root."
|
||||||
|
echo -e "\nExiting..."
|
||||||
|
sleep 2
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
case $exit_code in
|
# ------------------------------------------------------------------------------
|
||||||
1) msg_error "Unsupported protocol: $target" ;;
|
# pve_check()
|
||||||
2) msg_error "Curl init failed: $target" ;;
|
#
|
||||||
3) msg_error "Malformed URL: $target" ;;
|
# - Validates Proxmox VE version compatibility
|
||||||
5) msg_error "Proxy resolution failed: $target" ;;
|
# - Supported: PVE 8.0-8.9 and PVE 9.0-9.1
|
||||||
6) msg_error "Host resolution failed: $target" ;;
|
# - Exits with error message if unsupported version detected
|
||||||
7) msg_error "Connection failed: $target" ;;
|
# ------------------------------------------------------------------------------
|
||||||
9) msg_error "Access denied: $target" ;;
|
pve_check() {
|
||||||
18) msg_error "Partial file transfer: $target" ;;
|
local PVE_VER
|
||||||
22) msg_error "HTTP error (e.g. 400/404): $target" ;;
|
PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')"
|
||||||
23) msg_error "Write error on local system: $target" ;;
|
|
||||||
26) msg_error "Read error from local file: $target" ;;
|
|
||||||
28) msg_error "Timeout: $target" ;;
|
|
||||||
35) msg_error "SSL connect error: $target" ;;
|
|
||||||
47) msg_error "Too many redirects: $target" ;;
|
|
||||||
51) msg_error "SSL cert verify failed: $target" ;;
|
|
||||||
52) msg_error "Empty server response: $target" ;;
|
|
||||||
55) msg_error "Send error: $target" ;;
|
|
||||||
56) msg_error "Receive error: $target" ;;
|
|
||||||
60) msg_error "SSL CA not trusted: $target" ;;
|
|
||||||
67) msg_error "Login denied by server: $target" ;;
|
|
||||||
78) msg_error "Remote file not found (404): $target" ;;
|
|
||||||
*) msg_error "Curl failed with code $exit_code: $target" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[[ -n "$curl_msg" ]] && printf "%s\n" "$curl_msg" >&2
|
# Check for Proxmox VE 8.x: allow 8.0–8.9
|
||||||
|
if [[ "$PVE_VER" =~ ^8\.([0-9]+) ]]; then
|
||||||
|
local MINOR="${BASH_REMATCH[1]}"
|
||||||
|
if ((MINOR < 0 || MINOR > 9)); then
|
||||||
|
msg_error "This version of Proxmox VE is not supported."
|
||||||
|
msg_error "Supported: Proxmox VE version 8.0 – 8.9"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Proxmox VE 9.x: allow 9.0–9.1
|
||||||
|
if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then
|
||||||
|
local MINOR="${BASH_REMATCH[1]}"
|
||||||
|
if ((MINOR < 0 || MINOR > 1)); then
|
||||||
|
msg_error "This version of Proxmox VE is not yet supported."
|
||||||
|
msg_error "Supported: Proxmox VE version 9.0 – 9.1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All other unsupported versions
|
||||||
|
msg_error "This version of Proxmox VE is not supported."
|
||||||
|
msg_error "Supported versions: Proxmox VE 8.0 – 8.9 or 9.0 – 9.1"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fatal() {
|
# ------------------------------------------------------------------------------
|
||||||
msg_error "$1"
|
# arch_check()
|
||||||
kill -INT $$
|
#
|
||||||
|
# - Validates system architecture is amd64/x86_64
|
||||||
|
# - Exits with error message for unsupported architectures (e.g., ARM/PiMox)
|
||||||
|
# - Provides link to ARM64-compatible scripts
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
arch_check() {
|
||||||
|
if [ "$(dpkg --print-architecture)" != "amd64" ]; then
|
||||||
|
echo -e "\n ${INFO}${YWB}This script will not work with PiMox! \n"
|
||||||
|
echo -e "\n ${YWB}Visit https://github.com/asylumexp/Proxmox for ARM64 support. \n"
|
||||||
|
echo -e "Exiting..."
|
||||||
|
sleep 2
|
||||||
|
exit
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# ssh_check()
|
||||||
|
#
|
||||||
|
# - Detects if script is running over SSH connection
|
||||||
|
# - Warns user for external SSH connections (recommends Proxmox shell)
|
||||||
|
# - Skips warning for local/same-subnet connections
|
||||||
|
# - Does not abort execution, only warns
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
ssh_check() {
|
||||||
|
if [ -n "$SSH_CLIENT" ]; then
|
||||||
|
local client_ip=$(awk '{print $1}' <<<"$SSH_CLIENT")
|
||||||
|
local host_ip=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
# Check if connection is local (Proxmox WebUI or same machine)
|
||||||
|
# - localhost (127.0.0.1, ::1)
|
||||||
|
# - same IP as host
|
||||||
|
# - local network range (10.x, 172.16-31.x, 192.168.x)
|
||||||
|
if [[ "$client_ip" == "127.0.0.1" || "$client_ip" == "::1" || "$client_ip" == "$host_ip" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if client is in same local network (optional, safer approach)
|
||||||
|
local host_subnet=$(echo "$host_ip" | cut -d. -f1-3)
|
||||||
|
local client_subnet=$(echo "$client_ip" | cut -d. -f1-3)
|
||||||
|
if [[ "$host_subnet" == "$client_subnet" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only warn for truly external connections
|
||||||
|
msg_warn "Running via external SSH (client: $client_ip)."
|
||||||
|
msg_warn "For better stability, consider using the Proxmox Shell (Console) instead."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 3: EXECUTION HELPERS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# get_active_logfile()
|
||||||
|
#
|
||||||
|
# - Returns the appropriate log file based on execution context
|
||||||
|
# - BUILD_LOG: Host operations (container creation)
|
||||||
|
# - INSTALL_LOG: Container operations (application installation)
|
||||||
|
# - Fallback to BUILD_LOG if neither is set
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
get_active_logfile() {
|
||||||
|
if [[ -n "${INSTALL_LOG:-}" ]]; then
|
||||||
|
echo "$INSTALL_LOG"
|
||||||
|
elif [[ -n "${BUILD_LOG:-}" ]]; then
|
||||||
|
echo "$BUILD_LOG"
|
||||||
|
else
|
||||||
|
# Fallback for legacy scripts
|
||||||
|
echo "/tmp/build-$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy compatibility: SILENT_LOGFILE points to active log
|
||||||
|
SILENT_LOGFILE="$(get_active_logfile)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# silent()
|
||||||
|
#
|
||||||
|
# - Executes command with output redirected to active log file
|
||||||
|
# - On error: displays last 10 lines of log and exits with original exit code
|
||||||
|
# - Temporarily disables error trap to capture exit code correctly
|
||||||
|
# - Sources explain_exit_code() for detailed error messages
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
silent() {
|
||||||
|
local cmd="$*"
|
||||||
|
local caller_line="${BASH_LINENO[0]:-unknown}"
|
||||||
|
local logfile="$(get_active_logfile)"
|
||||||
|
|
||||||
|
# Dryrun mode: Show command without executing
|
||||||
|
if [[ "${DEV_MODE_DRYRUN:-false}" == "true" ]]; then
|
||||||
|
if declare -f msg_custom >/dev/null 2>&1; then
|
||||||
|
msg_custom "🔍" "${BL}" "[DRYRUN] $cmd"
|
||||||
|
else
|
||||||
|
echo "[DRYRUN] $cmd" >&2
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +Eeuo pipefail
|
||||||
|
trap - ERR
|
||||||
|
|
||||||
|
"$@" >>"$logfile" 2>&1
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
trap 'error_handler' ERR
|
||||||
|
|
||||||
|
if [[ $rc -ne 0 ]]; then
|
||||||
|
# Source explain_exit_code if needed
|
||||||
|
if ! declare -f explain_exit_code >/dev/null 2>&1; then
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local explanation
|
||||||
|
explanation="$(explain_exit_code "$rc")"
|
||||||
|
|
||||||
|
printf "\e[?25h"
|
||||||
|
msg_error "in line ${caller_line}: exit code ${rc} (${explanation})"
|
||||||
|
msg_custom "→" "${YWB}" "${cmd}"
|
||||||
|
|
||||||
|
if [[ -s "$logfile" ]]; then
|
||||||
|
local log_lines=$(wc -l <"$logfile")
|
||||||
|
echo "--- Last 10 lines of silent log ---"
|
||||||
|
tail -n 10 "$logfile"
|
||||||
|
echo "-----------------------------------"
|
||||||
|
|
||||||
|
# Show how to view full log if there are more lines
|
||||||
|
if [[ $log_lines -gt 10 ]]; then
|
||||||
|
msg_custom "📋" "${YW}" "View full log (${log_lines} lines): ${logfile}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# spinner()
|
||||||
|
#
|
||||||
|
# - Displays animated spinner with rotating characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
|
||||||
|
# - Shows SPINNER_MSG alongside animation
|
||||||
|
# - Runs in infinite loop until killed by stop_spinner()
|
||||||
|
# - Uses color_spinner() colors for output
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
spinner() {
|
spinner() {
|
||||||
local chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
|
local chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
|
||||||
|
local msg="${SPINNER_MSG:-Processing...}"
|
||||||
local i=0
|
local i=0
|
||||||
while true; do
|
while true; do
|
||||||
local index=$((i++ % ${#chars[@]}))
|
local index=$((i++ % ${#chars[@]}))
|
||||||
printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${SPINNER_MSG:-}${CS_CL}"
|
printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${msg}${CS_CL}"
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# clear_line()
|
||||||
|
#
|
||||||
|
# - Clears current terminal line using tput or ANSI escape codes
|
||||||
|
# - Moves cursor to beginning of line (carriage return)
|
||||||
|
# - Erases from cursor to end of line
|
||||||
|
# - Fallback to ANSI codes if tput not available
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
clear_line() {
|
clear_line() {
|
||||||
tput cr 2>/dev/null || echo -en "\r"
|
tput cr 2>/dev/null || echo -en "\r"
|
||||||
tput el 2>/dev/null || echo -en "\033[K"
|
tput el 2>/dev/null || echo -en "\033[K"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# stop_spinner()
|
||||||
|
#
|
||||||
|
# - Stops running spinner process by PID
|
||||||
|
# - Reads PID from SPINNER_PID variable or /tmp/.spinner.pid file
|
||||||
|
# - Attempts graceful kill, then forced kill if needed
|
||||||
|
# - Cleans up temp file and resets terminal state
|
||||||
|
# - Unsets SPINNER_PID and SPINNER_MSG variables
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
stop_spinner() {
|
stop_spinner() {
|
||||||
local pid="${SPINNER_PID:-}"
|
local pid="${SPINNER_PID:-}"
|
||||||
[[ -z "$pid" && -f /tmp/.spinner.pid ]] && pid=$(</tmp/.spinner.pid)
|
[[ -z "$pid" && -f /tmp/.spinner.pid ]] && pid=$(</tmp/.spinner.pid)
|
||||||
@@ -301,6 +504,19 @@ stop_spinner() {
|
|||||||
stty sane 2>/dev/null || true
|
stty sane 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 4: MESSAGE OUTPUT
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_info()
|
||||||
|
#
|
||||||
|
# - Displays informational message with spinner animation
|
||||||
|
# - Shows each unique message only once (tracked via MSG_INFO_SHOWN)
|
||||||
|
# - In verbose/Alpine mode: shows hourglass icon instead of spinner
|
||||||
|
# - Stops any existing spinner before starting new one
|
||||||
|
# - Backgrounds spinner process and stores PID for later cleanup
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
msg_info() {
|
msg_info() {
|
||||||
local msg="$1"
|
local msg="$1"
|
||||||
[[ -z "$msg" ]] && return
|
[[ -z "$msg" ]] && return
|
||||||
@@ -317,6 +533,12 @@ msg_info() {
|
|||||||
if is_verbose_mode || is_alpine; then
|
if is_verbose_mode || is_alpine; then
|
||||||
local HOURGLASS="${TAB}⏳${TAB}"
|
local HOURGLASS="${TAB}⏳${TAB}"
|
||||||
printf "\r\e[2K%s %b" "$HOURGLASS" "${YW}${msg}${CL}" >&2
|
printf "\r\e[2K%s %b" "$HOURGLASS" "${YW}${msg}${CL}" >&2
|
||||||
|
|
||||||
|
# Pause mode: Wait for Enter after each step
|
||||||
|
if [[ "${DEV_MODE_PAUSE:-false}" == "true" ]]; then
|
||||||
|
echo -en "\n${YWB}[PAUSE]${CL} Press Enter to continue..." >&2
|
||||||
|
read -r
|
||||||
|
fi
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -325,29 +547,68 @@ msg_info() {
|
|||||||
SPINNER_PID=$!
|
SPINNER_PID=$!
|
||||||
echo "$SPINNER_PID" >/tmp/.spinner.pid
|
echo "$SPINNER_PID" >/tmp/.spinner.pid
|
||||||
disown "$SPINNER_PID" 2>/dev/null || true
|
disown "$SPINNER_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Pause mode: Stop spinner and wait
|
||||||
|
if [[ "${DEV_MODE_PAUSE:-false}" == "true" ]]; then
|
||||||
|
stop_spinner
|
||||||
|
echo -en "\n${YWB}[PAUSE]${CL} Press Enter to continue..." >&2
|
||||||
|
read -r
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_ok()
|
||||||
|
#
|
||||||
|
# - Displays success message with checkmark icon
|
||||||
|
# - Stops spinner and clears line before output
|
||||||
|
# - Removes message from MSG_INFO_SHOWN to allow re-display
|
||||||
|
# - Uses green color for success indication
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
msg_ok() {
|
msg_ok() {
|
||||||
local msg="$1"
|
local msg="$1"
|
||||||
[[ -z "$msg" ]] && return
|
[[ -z "$msg" ]] && return
|
||||||
stop_spinner
|
stop_spinner
|
||||||
clear_line
|
clear_line
|
||||||
printf "%s %b\n" "$CM" "${GN}${msg}${CL}" >&2
|
echo -e "$CM ${GN}${msg}${CL}"
|
||||||
unset MSG_INFO_SHOWN["$msg"]
|
unset MSG_INFO_SHOWN["$msg"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_error()
|
||||||
|
#
|
||||||
|
# - Displays error message with cross/X icon
|
||||||
|
# - Stops spinner before output
|
||||||
|
# - Uses red color for error indication
|
||||||
|
# - Outputs to stderr
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
msg_error() {
|
msg_error() {
|
||||||
stop_spinner
|
stop_spinner
|
||||||
local msg="$1"
|
local msg="$1"
|
||||||
echo -e "${BFR:-} ${CROSS:-✖️} ${RD}${msg}${CL}"
|
echo -e "${BFR:-}${CROSS:-✖️} ${RD}${msg}${CL}" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_warn()
|
||||||
|
#
|
||||||
|
# - Displays warning message with info/lightbulb icon
|
||||||
|
# - Stops spinner before output
|
||||||
|
# - Uses bright yellow color for warning indication
|
||||||
|
# - Outputs to stderr
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
msg_warn() {
|
msg_warn() {
|
||||||
stop_spinner
|
stop_spinner
|
||||||
local msg="$1"
|
local msg="$1"
|
||||||
echo -e "${BFR:-} ${INFO:-ℹ️} ${YWB}${msg}${CL}"
|
echo -e "${BFR:-}${INFO:-ℹ️} ${YWB}${msg}${CL}" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_custom()
|
||||||
|
#
|
||||||
|
# - Displays custom message with user-defined symbol and color
|
||||||
|
# - Arguments: symbol, color code, message text
|
||||||
|
# - Stops spinner before output
|
||||||
|
# - Useful for specialized status messages
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
msg_custom() {
|
msg_custom() {
|
||||||
local symbol="${1:-"[*]"}"
|
local symbol="${1:-"[*]"}"
|
||||||
local color="${2:-"\e[36m"}"
|
local color="${2:-"\e[36m"}"
|
||||||
@@ -357,17 +618,181 @@ msg_custom() {
|
|||||||
echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}"
|
echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_container_safe() {
|
# ------------------------------------------------------------------------------
|
||||||
local ct="$1"
|
# msg_debug()
|
||||||
shift
|
#
|
||||||
local cmd="$*"
|
# - Displays debug message with timestamp when var_full_verbose=1
|
||||||
|
# - Automatically enables var_verbose if not already set
|
||||||
lxc-attach -n "$ct" -- bash -euo pipefail -c "
|
# - Shows date/time prefix for log correlation
|
||||||
trap 'echo Aborted in container; exit 130' SIGINT SIGTERM
|
# - Uses bright yellow color for debug output
|
||||||
$cmd
|
# ------------------------------------------------------------------------------
|
||||||
" || __handle_general_error "lxc-attach to CT $ct"
|
msg_debug() {
|
||||||
|
if [[ "${var_full_verbose:-0}" == "1" ]]; then
|
||||||
|
[[ "${var_verbose:-0}" != "1" ]] && var_verbose=1
|
||||||
|
echo -e "${YWB}[$(date '+%F %T')] [DEBUG]${CL} $*"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# msg_dev()
|
||||||
|
#
|
||||||
|
# - Display development mode messages with 🔧 icon
|
||||||
|
# - Only shown when dev_mode is active
|
||||||
|
# - Useful for debugging and development-specific output
|
||||||
|
# - Format: [DEV] message with distinct formatting
|
||||||
|
# - Usage: msg_dev "Container ready for debugging"
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
msg_dev() {
|
||||||
|
if [[ -n "${dev_mode:-}" ]]; then
|
||||||
|
echo -e "${SEARCH}${BOLD}${DGN}🔧 [DEV]${CL} $*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
#
|
||||||
|
# - Displays error message and immediately terminates script
|
||||||
|
# - Sends SIGINT to current process to trigger error handler
|
||||||
|
# - Use for unrecoverable errors that require immediate exit
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
fatal() {
|
||||||
|
msg_error "$1"
|
||||||
|
kill -INT $$
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 5: UTILITY FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# exit_script()
|
||||||
|
#
|
||||||
|
# - Called when user cancels an action
|
||||||
|
# - Clears screen and displays exit message
|
||||||
|
# - Exits with default exit code
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
exit_script() {
|
||||||
|
clear
|
||||||
|
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# get_header()
|
||||||
|
#
|
||||||
|
# - Downloads and caches application header ASCII art
|
||||||
|
# - Falls back to local cache if already downloaded
|
||||||
|
# - Determines app type (ct/vm) from APP_TYPE variable
|
||||||
|
# - Returns header content or empty string on failure
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
get_header() {
|
||||||
|
local app_name=$(echo "${APP,,}" | tr -d ' ')
|
||||||
|
local app_type=${APP_TYPE:-ct} # Default to 'ct' if not set
|
||||||
|
local header_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/${app_type}/headers/${app_name}"
|
||||||
|
local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$local_header_path")"
|
||||||
|
|
||||||
|
if [ ! -s "$local_header_path" ]; then
|
||||||
|
if ! curl -fsSL "$header_url" -o "$local_header_path"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat "$local_header_path" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# header_info()
|
||||||
|
#
|
||||||
|
# - Displays application header ASCII art at top of screen
|
||||||
|
# - Clears screen before displaying header
|
||||||
|
# - Detects terminal width for formatting
|
||||||
|
# - Returns silently if header not available
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
header_info() {
|
||||||
|
local app_name=$(echo "${APP,,}" | tr -d ' ')
|
||||||
|
local header_content
|
||||||
|
|
||||||
|
header_content=$(get_header "$app_name") || header_content=""
|
||||||
|
|
||||||
|
clear
|
||||||
|
local term_width
|
||||||
|
term_width=$(tput cols 2>/dev/null || echo 120)
|
||||||
|
|
||||||
|
if [ -n "$header_content" ]; then
|
||||||
|
echo "$header_content"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# ensure_tput()
|
||||||
|
#
|
||||||
|
# - Ensures tput command is available for terminal control
|
||||||
|
# - Installs ncurses-bin on Debian/Ubuntu or ncurses on Alpine
|
||||||
|
# - Required for clear_line() and terminal width detection
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
ensure_tput() {
|
||||||
|
if ! command -v tput >/dev/null 2>&1; then
|
||||||
|
if grep -qi 'alpine' /etc/os-release; then
|
||||||
|
apk add --no-cache ncurses >/dev/null 2>&1
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq >/dev/null
|
||||||
|
apt-get install -y -qq ncurses-bin >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# is_alpine()
|
||||||
|
#
|
||||||
|
# - Detects if running on Alpine Linux
|
||||||
|
# - Checks var_os, PCT_OSTYPE, or /etc/os-release
|
||||||
|
# - Returns 0 if Alpine, 1 otherwise
|
||||||
|
# - Used to adjust behavior for Alpine-specific commands
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
is_alpine() {
|
||||||
|
local os_id="${var_os:-${PCT_OSTYPE:-}}"
|
||||||
|
|
||||||
|
if [[ -z "$os_id" && -f /etc/os-release ]]; then
|
||||||
|
os_id="$(
|
||||||
|
. /etc/os-release 2>/dev/null
|
||||||
|
echo "${ID:-}"
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$os_id" == "alpine" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# is_verbose_mode()
|
||||||
|
#
|
||||||
|
# - Determines if script should run in verbose mode
|
||||||
|
# - Checks VERBOSE and var_verbose variables
|
||||||
|
# - Also returns true if not running in TTY (pipe/redirect scenario)
|
||||||
|
# - Used by msg_info() to decide between spinner and static output
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
is_verbose_mode() {
|
||||||
|
local verbose="${VERBOSE:-${var_verbose:-no}}"
|
||||||
|
local tty_status
|
||||||
|
if [[ -t 2 ]]; then
|
||||||
|
tty_status="interactive"
|
||||||
|
else
|
||||||
|
tty_status="not-a-tty"
|
||||||
|
fi
|
||||||
|
[[ "$verbose" != "no" || ! -t 2 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 6: CLEANUP & MAINTENANCE
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# cleanup_lxc()
|
||||||
|
#
|
||||||
|
# - Comprehensive cleanup of package managers, caches, and logs
|
||||||
|
# - Supports Alpine (apk), Debian/Ubuntu (apt), and language package managers
|
||||||
|
# - Cleans: Python (pip/uv), Node.js (npm/yarn/pnpm), Go, Rust, Ruby, PHP
|
||||||
|
# - Truncates log files and vacuums systemd journal
|
||||||
|
# - Run at end of container creation to minimize disk usage
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
cleanup_lxc() {
|
cleanup_lxc() {
|
||||||
msg_info "Cleaning up"
|
msg_info "Cleaning up"
|
||||||
|
|
||||||
@@ -384,20 +809,15 @@ cleanup_lxc() {
|
|||||||
find /tmp /var/tmp -type f -name 'tmp*' -delete 2>/dev/null || true
|
find /tmp /var/tmp -type f -name 'tmp*' -delete 2>/dev/null || true
|
||||||
find /tmp /var/tmp -type f -name 'tempfile*' -delete 2>/dev/null || true
|
find /tmp /var/tmp -type f -name 'tempfile*' -delete 2>/dev/null || true
|
||||||
|
|
||||||
# Truncate writable log files silently (permission errors ignored)
|
# Node.js npm - directly remove cache directory
|
||||||
if command -v truncate >/dev/null 2>&1; then
|
# npm cache clean/verify can fail with ENOTEMPTY errors, so we skip them
|
||||||
find /var/log -type f -writable -print0 2>/dev/null |
|
if command -v npm &>/dev/null; then
|
||||||
xargs -0 -n1 truncate -s 0 2>/dev/null || true
|
rm -rf /root/.npm/_cacache /root/.npm/_logs 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Python pip
|
|
||||||
if command -v pip &>/dev/null; then $STD pip cache purge || true; fi
|
|
||||||
# Node.js npm
|
|
||||||
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
|
|
||||||
# Node.js yarn
|
# Node.js yarn
|
||||||
if command -v yarn &>/dev/null; then $STD yarn cache clean || true; fi
|
if command -v yarn &>/dev/null; then yarn cache clean &>/dev/null || true; fi
|
||||||
# Node.js pnpm
|
# Node.js pnpm
|
||||||
if command -v pnpm &>/dev/null; then $STD pnpm store prune || true; fi
|
if command -v pnpm &>/dev/null; then pnpm store prune &>/dev/null || true; fi
|
||||||
# Go
|
# Go
|
||||||
if command -v go &>/dev/null; then $STD go clean -cache -modcache || true; fi
|
if command -v go &>/dev/null; then $STD go clean -cache -modcache || true; fi
|
||||||
# Rust cargo
|
# Rust cargo
|
||||||
@@ -405,14 +825,21 @@ cleanup_lxc() {
|
|||||||
# Ruby gem
|
# Ruby gem
|
||||||
if command -v gem &>/dev/null; then $STD gem cleanup || true; fi
|
if command -v gem &>/dev/null; then $STD gem cleanup || true; fi
|
||||||
# Composer (PHP)
|
# Composer (PHP)
|
||||||
if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
|
if command -v composer &>/dev/null; then COMPOSER_ALLOW_SUPERUSER=1 $STD composer clear-cache || true; fi
|
||||||
|
|
||||||
if command -v journalctl &>/dev/null; then
|
|
||||||
$STD journalctl --vacuum-time=10m || true
|
|
||||||
fi
|
|
||||||
msg_ok "Cleaned"
|
msg_ok "Cleaned"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# check_or_create_swap()
|
||||||
|
#
|
||||||
|
# - Checks if swap is active on system
|
||||||
|
# - Offers to create swap file if none exists
|
||||||
|
# - Prompts user for swap size in MB
|
||||||
|
# - Creates /swapfile with specified size
|
||||||
|
# - Activates swap immediately
|
||||||
|
# - Returns 0 if swap active or successfully created, 1 if declined/failed
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
check_or_create_swap() {
|
check_or_create_swap() {
|
||||||
msg_info "Checking for active swap"
|
msg_info "Checking for active swap"
|
||||||
|
|
||||||
@@ -451,7 +878,8 @@ check_or_create_swap() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
trap 'stop_spinner' EXIT INT TERM
|
# ==============================================================================
|
||||||
|
# SIGNAL TRAPS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
# Initialize functions when core.func is sourced
|
trap 'stop_spinner' EXIT INT TERM
|
||||||
load_functions
|
|
||||||
|
|||||||
@@ -1,380 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Copyright (c) 2021-2025 tteck
|
|
||||||
# Author: tteck (tteckster)
|
|
||||||
# Co-Author: MickLesk
|
|
||||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
|
||||||
|
|
||||||
# This sets verbose mode if the global variable is set to "yes"
|
|
||||||
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
|
|
||||||
|
|
||||||
source "$(dirname "$0")/core.func"
|
|
||||||
|
|
||||||
|
|
||||||
# This sets error handling options and defines the error_handler function to handle errors
|
|
||||||
set -Eeuo pipefail
|
|
||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
|
||||||
trap on_exit EXIT
|
|
||||||
trap on_interrupt INT
|
|
||||||
trap on_terminate TERM
|
|
||||||
|
|
||||||
function on_exit() {
|
|
||||||
local exit_code="$?"
|
|
||||||
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
|
|
||||||
exit "$exit_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
function error_handler() {
|
|
||||||
local exit_code="$?"
|
|
||||||
local line_number="$1"
|
|
||||||
local command="$2"
|
|
||||||
printf "\e[?25h"
|
|
||||||
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
|
|
||||||
exit "$exit_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
function on_interrupt() {
|
|
||||||
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
|
|
||||||
exit 130
|
|
||||||
}
|
|
||||||
|
|
||||||
function on_terminate() {
|
|
||||||
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
|
|
||||||
exit 143
|
|
||||||
}
|
|
||||||
|
|
||||||
function exit_script() {
|
|
||||||
clear
|
|
||||||
printf "\e[?25h"
|
|
||||||
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
|
|
||||||
kill 0
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function check_storage_support() {
|
|
||||||
local CONTENT="$1"
|
|
||||||
local -a VALID_STORAGES=()
|
|
||||||
while IFS= read -r line; do
|
|
||||||
local STORAGE_NAME
|
|
||||||
STORAGE_NAME=$(awk '{print $1}' <<<"$line")
|
|
||||||
[[ -z "$STORAGE_NAME" ]] && continue
|
|
||||||
VALID_STORAGES+=("$STORAGE_NAME")
|
|
||||||
done < <(pvesm status -content "$CONTENT" 2>/dev/null | awk 'NR>1')
|
|
||||||
|
|
||||||
[[ ${#VALID_STORAGES[@]} -gt 0 ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# This function selects a storage pool for a given content type (e.g., rootdir, vztmpl).
|
|
||||||
function select_storage() {
|
|
||||||
local CLASS=$1 CONTENT CONTENT_LABEL
|
|
||||||
|
|
||||||
case $CLASS in
|
|
||||||
container)
|
|
||||||
CONTENT='rootdir'
|
|
||||||
CONTENT_LABEL='Container'
|
|
||||||
;;
|
|
||||||
template)
|
|
||||||
CONTENT='vztmpl'
|
|
||||||
CONTENT_LABEL='Container template'
|
|
||||||
;;
|
|
||||||
iso)
|
|
||||||
CONTENT='iso'
|
|
||||||
CONTENT_LABEL='ISO image'
|
|
||||||
;;
|
|
||||||
images)
|
|
||||||
CONTENT='images'
|
|
||||||
CONTENT_LABEL='VM Disk image'
|
|
||||||
;;
|
|
||||||
backup)
|
|
||||||
CONTENT='backup'
|
|
||||||
CONTENT_LABEL='Backup'
|
|
||||||
;;
|
|
||||||
snippets)
|
|
||||||
CONTENT='snippets'
|
|
||||||
CONTENT_LABEL='Snippets'
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
msg_error "Invalid storage class '$CLASS'"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Check for preset STORAGE variable
|
|
||||||
if [ "$CONTENT" = "rootdir" ] && [ -n "${STORAGE:-}" ]; then
|
|
||||||
if pvesm status -content "$CONTENT" | awk 'NR>1 {print $1}' | grep -qx "$STORAGE"; then
|
|
||||||
STORAGE_RESULT="$STORAGE"
|
|
||||||
msg_info "Using preset storage: $STORAGE_RESULT for $CONTENT_LABEL"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
msg_error "Preset storage '$STORAGE' is not valid for content type '$CONTENT'."
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
local -A STORAGE_MAP
|
|
||||||
local -a MENU
|
|
||||||
local COL_WIDTH=0
|
|
||||||
|
|
||||||
while read -r TAG TYPE _ TOTAL USED FREE _; do
|
|
||||||
[[ -n "$TAG" && -n "$TYPE" ]] || continue
|
|
||||||
local STORAGE_NAME="$TAG"
|
|
||||||
local DISPLAY="${STORAGE_NAME} (${TYPE})"
|
|
||||||
local USED_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$USED")
|
|
||||||
local FREE_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$FREE")
|
|
||||||
local INFO="Free: ${FREE_FMT}B Used: ${USED_FMT}B"
|
|
||||||
STORAGE_MAP["$DISPLAY"]="$STORAGE_NAME"
|
|
||||||
MENU+=("$DISPLAY" "$INFO" "OFF")
|
|
||||||
((${#DISPLAY} > COL_WIDTH)) && COL_WIDTH=${#DISPLAY}
|
|
||||||
done < <(pvesm status -content "$CONTENT" | awk 'NR>1')
|
|
||||||
|
|
||||||
if [ ${#MENU[@]} -eq 0 ]; then
|
|
||||||
msg_error "No storage found for content type '$CONTENT'."
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $((${#MENU[@]} / 3)) -eq 1 ]; then
|
|
||||||
STORAGE_RESULT="${STORAGE_MAP[${MENU[0]}]}"
|
|
||||||
STORAGE_INFO="${MENU[1]}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local WIDTH=$((COL_WIDTH + 42))
|
|
||||||
while true; do
|
|
||||||
local DISPLAY_SELECTED
|
|
||||||
DISPLAY_SELECTED=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
|
||||||
--title "Storage Pools" \
|
|
||||||
--radiolist "Which storage pool for ${CONTENT_LABEL,,}?\n(Spacebar to select)" \
|
|
||||||
16 "$WIDTH" 6 "${MENU[@]}" 3>&1 1>&2 2>&3)
|
|
||||||
|
|
||||||
# Cancel or ESC
|
|
||||||
[[ $? -ne 0 ]] && exit_script
|
|
||||||
|
|
||||||
# Strip trailing whitespace or newline (important for storages like "storage (dir)")
|
|
||||||
DISPLAY_SELECTED=$(sed 's/[[:space:]]*$//' <<<"$DISPLAY_SELECTED")
|
|
||||||
|
|
||||||
if [[ -z "$DISPLAY_SELECTED" || -z "${STORAGE_MAP[$DISPLAY_SELECTED]+_}" ]]; then
|
|
||||||
whiptail --msgbox "No valid storage selected. Please try again." 8 58
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
STORAGE_RESULT="${STORAGE_MAP[$DISPLAY_SELECTED]}"
|
|
||||||
for ((i = 0; i < ${#MENU[@]}; i += 3)); do
|
|
||||||
if [[ "${MENU[$i]}" == "$DISPLAY_SELECTED" ]]; then
|
|
||||||
STORAGE_INFO="${MENU[$i + 1]}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test if required variables are set
|
|
||||||
[[ "${CTID:-}" ]] || {
|
|
||||||
msg_error "You need to set 'CTID' variable."
|
|
||||||
exit 203
|
|
||||||
}
|
|
||||||
[[ "${PCT_OSTYPE:-}" ]] || {
|
|
||||||
msg_error "You need to set 'PCT_OSTYPE' variable."
|
|
||||||
exit 204
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test if ID is valid
|
|
||||||
[ "$CTID" -ge "100" ] || {
|
|
||||||
msg_error "ID cannot be less than 100."
|
|
||||||
exit 205
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test if ID is in use
|
|
||||||
if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then
|
|
||||||
echo -e "ID '$CTID' is already in use."
|
|
||||||
unset CTID
|
|
||||||
msg_error "Cannot use ID that is already in use."
|
|
||||||
exit 206
|
|
||||||
fi
|
|
||||||
|
|
||||||
# This checks for the presence of valid Container Storage and Template Storage locations
|
|
||||||
msg_info "Validating storage"
|
|
||||||
if ! check_storage_support "rootdir"; then
|
|
||||||
msg_error "No valid storage found for 'rootdir' [Container]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! check_storage_support "vztmpl"; then
|
|
||||||
msg_error "No valid storage found for 'vztmpl' [Template]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
#msg_info "Checking template storage"
|
|
||||||
while true; do
|
|
||||||
if select_storage template; then
|
|
||||||
TEMPLATE_STORAGE="$STORAGE_RESULT"
|
|
||||||
TEMPLATE_STORAGE_INFO="$STORAGE_INFO"
|
|
||||||
msg_ok "Storage ${BL}$TEMPLATE_STORAGE${CL} ($TEMPLATE_STORAGE_INFO) [Template]"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if select_storage container; then
|
|
||||||
CONTAINER_STORAGE="$STORAGE_RESULT"
|
|
||||||
CONTAINER_STORAGE_INFO="$STORAGE_INFO"
|
|
||||||
msg_ok "Storage ${BL}$CONTAINER_STORAGE${CL} ($CONTAINER_STORAGE_INFO) [Container]"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Check free space on selected container storage
|
|
||||||
STORAGE_FREE=$(pvesm status | awk -v s="$CONTAINER_STORAGE" '$1 == s { print $6 }')
|
|
||||||
REQUIRED_KB=$((${PCT_DISK_SIZE:-8} * 1024 * 1024))
|
|
||||||
if [ "$STORAGE_FREE" -lt "$REQUIRED_KB" ]; then
|
|
||||||
msg_error "Not enough space on '$CONTAINER_STORAGE'. Needed: ${PCT_DISK_SIZE:-8}G."
|
|
||||||
exit 214
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Cluster Quorum if in Cluster
|
|
||||||
if [ -f /etc/pve/corosync.conf ]; then
|
|
||||||
msg_info "Checking cluster quorum"
|
|
||||||
if ! pvecm status | awk -F':' '/^Quorate/ { exit ($2 ~ /Yes/) ? 0 : 1 }'; then
|
|
||||||
|
|
||||||
msg_error "Cluster is not quorate. Start all nodes or configure quorum device (QDevice)."
|
|
||||||
exit 210
|
|
||||||
fi
|
|
||||||
msg_ok "Cluster is quorate"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update LXC template list
|
|
||||||
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION:-}"
|
|
||||||
case "$PCT_OSTYPE" in
|
|
||||||
debian | ubuntu)
|
|
||||||
TEMPLATE_PATTERN="-standard_"
|
|
||||||
;;
|
|
||||||
alpine | fedora | rocky | centos)
|
|
||||||
TEMPLATE_PATTERN="-default_"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
TEMPLATE_PATTERN=""
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 1. Check local templates first
|
|
||||||
msg_info "Searching for template '$TEMPLATE_SEARCH'"
|
|
||||||
mapfile -t TEMPLATES < <(
|
|
||||||
pveam list "$TEMPLATE_STORAGE" |
|
|
||||||
awk -v s="$TEMPLATE_SEARCH" -v p="$TEMPLATE_PATTERN" '$1 ~ s && $1 ~ p {print $1}' |
|
|
||||||
sed 's/.*\///' | sort -t - -k 2 -V
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ ${#TEMPLATES[@]} -gt 0 ]; then
|
|
||||||
TEMPLATE_SOURCE="local"
|
|
||||||
else
|
|
||||||
msg_info "No local template found, checking online repository"
|
|
||||||
pveam update >/dev/null 2>&1
|
|
||||||
mapfile -t TEMPLATES < <(
|
|
||||||
pveam update >/dev/null 2>&1 &&
|
|
||||||
pveam available -section system |
|
|
||||||
sed -n "s/.*\($TEMPLATE_SEARCH.*$TEMPLATE_PATTERN.*\)/\1/p" |
|
|
||||||
sort -t - -k 2 -V
|
|
||||||
)
|
|
||||||
TEMPLATE_SOURCE="online"
|
|
||||||
fi
|
|
||||||
|
|
||||||
TEMPLATE="${TEMPLATES[-1]}"
|
|
||||||
TEMPLATE_PATH="$(pvesm path $TEMPLATE_STORAGE:vztmpl/$TEMPLATE 2>/dev/null ||
|
|
||||||
echo "/var/lib/vz/template/cache/$TEMPLATE")"
|
|
||||||
msg_ok "Template ${BL}$TEMPLATE${CL} [$TEMPLATE_SOURCE]"
|
|
||||||
|
|
||||||
# 4. Validate template (exists & not corrupted)
|
|
||||||
TEMPLATE_VALID=1
|
|
||||||
|
|
||||||
if [ ! -s "$TEMPLATE_PATH" ]; then
|
|
||||||
TEMPLATE_VALID=0
|
|
||||||
elif ! tar --use-compress-program=zstdcat -tf "$TEMPLATE_PATH" >/dev/null 2>&1; then
|
|
||||||
TEMPLATE_VALID=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$TEMPLATE_VALID" -eq 0 ]; then
|
|
||||||
msg_warn "Template $TEMPLATE is missing or corrupted. Re-downloading."
|
|
||||||
[[ -f "$TEMPLATE_PATH" ]] && rm -f "$TEMPLATE_PATH"
|
|
||||||
for attempt in {1..3}; do
|
|
||||||
msg_info "Attempt $attempt: Downloading LXC template..."
|
|
||||||
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null 2>&1; then
|
|
||||||
msg_ok "Template download successful."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ $attempt -eq 3 ]; then
|
|
||||||
msg_error "Failed after 3 attempts. Please check network access or manually run:\n pveam download $TEMPLATE_STORAGE $TEMPLATE"
|
|
||||||
exit 208
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 5))
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
msg_info "Creating LXC Container"
|
|
||||||
# Check and fix subuid/subgid
|
|
||||||
grep -q "root:100000:65536" /etc/subuid || echo "root:100000:65536" >>/etc/subuid
|
|
||||||
grep -q "root:100000:65536" /etc/subgid || echo "root:100000:65536" >>/etc/subgid
|
|
||||||
|
|
||||||
# Combine all options
|
|
||||||
PCT_OPTIONS=(${PCT_OPTIONS[@]:-${DEFAULT_PCT_OPTIONS[@]}})
|
|
||||||
[[ " ${PCT_OPTIONS[@]} " =~ " -rootfs " ]] || PCT_OPTIONS+=(-rootfs "$CONTAINER_STORAGE:${PCT_DISK_SIZE:-8}")
|
|
||||||
|
|
||||||
# Secure creation of the LXC container with lock and template check
|
|
||||||
lockfile="/tmp/template.${TEMPLATE}.lock"
|
|
||||||
exec 9>"$lockfile" || {
|
|
||||||
msg_error "Failed to create lock file '$lockfile'."
|
|
||||||
exit 200
|
|
||||||
}
|
|
||||||
flock -w 60 9 || {
|
|
||||||
msg_error "Timeout while waiting for template lock"
|
|
||||||
exit 211
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${PCT_OPTIONS[@]}" &>/dev/null; then
|
|
||||||
msg_error "Container creation failed. Checking if template is corrupted or incomplete."
|
|
||||||
|
|
||||||
if [[ ! -s "$TEMPLATE_PATH" || "$(stat -c%s "$TEMPLATE_PATH")" -lt 1000000 ]]; then
|
|
||||||
msg_error "Template file too small or missing – re-downloading."
|
|
||||||
rm -f "$TEMPLATE_PATH"
|
|
||||||
elif ! zstdcat "$TEMPLATE_PATH" | tar -tf - &>/dev/null; then
|
|
||||||
msg_error "Template appears to be corrupted – re-downloading."
|
|
||||||
rm -f "$TEMPLATE_PATH"
|
|
||||||
else
|
|
||||||
msg_error "Template is valid, but container creation still failed."
|
|
||||||
exit 209
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Retry download
|
|
||||||
for attempt in {1..3}; do
|
|
||||||
msg_info "Attempt $attempt: Re-downloading template..."
|
|
||||||
if timeout 120 pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null; then
|
|
||||||
msg_ok "Template re-download successful."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -eq 3 ]; then
|
|
||||||
msg_error "Three failed attempts. Aborting."
|
|
||||||
exit 208
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 5))
|
|
||||||
done
|
|
||||||
|
|
||||||
sleep 1 # I/O-Sync-Delay
|
|
||||||
msg_ok "Re-downloaded LXC Template"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! pct list | awk '{print $1}' | grep -qx "$CTID"; then
|
|
||||||
msg_error "Container ID $CTID not listed in 'pct list' – unexpected failure."
|
|
||||||
exit 215
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! grep -q '^rootfs:' "/etc/pve/lxc/$CTID.conf"; then
|
|
||||||
msg_error "RootFS entry missing in container config – storage not correctly assigned."
|
|
||||||
exit 216
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -q '^hostname:' "/etc/pve/lxc/$CTID.conf"; then
|
|
||||||
CT_HOSTNAME=$(grep '^hostname:' "/etc/pve/lxc/$CTID.conf" | awk '{print $2}')
|
|
||||||
if [[ ! "$CT_HOSTNAME" =~ ^[a-z0-9-]+$ ]]; then
|
|
||||||
msg_warn "Hostname '$CT_HOSTNAME' contains invalid characters – may cause issues with networking or DNS."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
msg_ok "LXC Container ${BL}$CTID${CL} ${GN}was successfully created."
|
|
||||||
322
scripts/core/error-handler.func
Normal file
322
scripts/core/error-handler.func
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# ERROR HANDLER - ERROR & SIGNAL MANAGEMENT
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
|
# Author: MickLesk (CanbiZ)
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Provides comprehensive error handling and signal management for all scripts.
|
||||||
|
# Includes:
|
||||||
|
# - Exit code explanations (shell, package managers, databases, custom codes)
|
||||||
|
# - Error handler with detailed logging
|
||||||
|
# - Signal handlers (EXIT, INT, TERM)
|
||||||
|
# - Initialization function for trap setup
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source <(curl -fsSL .../error_handler.func)
|
||||||
|
# catch_errors
|
||||||
|
#
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 1: EXIT CODE EXPLANATIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# explain_exit_code()
|
||||||
|
#
|
||||||
|
# - Maps numeric exit codes to human-readable error descriptions
|
||||||
|
# - Supports:
|
||||||
|
# * Generic/Shell errors (1, 2, 126, 127, 128, 130, 137, 139, 143)
|
||||||
|
# * Package manager errors (APT, DPKG: 100, 101, 255)
|
||||||
|
# * Node.js/npm errors (243-249, 254)
|
||||||
|
# * Python/pip/uv errors (210-212)
|
||||||
|
# * PostgreSQL errors (231-234)
|
||||||
|
# * MySQL/MariaDB errors (241-244)
|
||||||
|
# * MongoDB errors (251-254)
|
||||||
|
# * Proxmox custom codes (200-231)
|
||||||
|
# - Returns description string for given exit code
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
explain_exit_code() {
|
||||||
|
local code="$1"
|
||||||
|
case "$code" in
|
||||||
|
# --- Generic / Shell ---
|
||||||
|
1) echo "General error / Operation not permitted" ;;
|
||||||
|
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
|
||||||
|
126) echo "Command invoked cannot execute (permission problem?)" ;;
|
||||||
|
127) echo "Command not found" ;;
|
||||||
|
128) echo "Invalid argument to exit" ;;
|
||||||
|
130) echo "Terminated by Ctrl+C (SIGINT)" ;;
|
||||||
|
137) echo "Killed (SIGKILL / Out of memory?)" ;;
|
||||||
|
139) echo "Segmentation fault (core dumped)" ;;
|
||||||
|
143) echo "Terminated (SIGTERM)" ;;
|
||||||
|
|
||||||
|
# --- Package manager / APT / DPKG ---
|
||||||
|
100) echo "APT: Package manager error (broken packages / dependency problems)" ;;
|
||||||
|
101) echo "APT: Configuration error (bad sources.list, malformed config)" ;;
|
||||||
|
255) echo "DPKG: Fatal internal error" ;;
|
||||||
|
|
||||||
|
# --- Node.js / npm / pnpm / yarn ---
|
||||||
|
243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;;
|
||||||
|
245) echo "Node.js: Invalid command-line option" ;;
|
||||||
|
246) echo "Node.js: Internal JavaScript Parse Error" ;;
|
||||||
|
247) echo "Node.js: Fatal internal error" ;;
|
||||||
|
248) echo "Node.js: Invalid C++ addon / N-API failure" ;;
|
||||||
|
249) echo "Node.js: Inspector error" ;;
|
||||||
|
254) echo "npm/pnpm/yarn: Unknown fatal error" ;;
|
||||||
|
|
||||||
|
# --- Python / pip / uv ---
|
||||||
|
210) echo "Python: Virtualenv / uv environment missing or broken" ;;
|
||||||
|
211) echo "Python: Dependency resolution failed" ;;
|
||||||
|
212) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;;
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
231) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;;
|
||||||
|
232) echo "PostgreSQL: Authentication failed (bad user/password)" ;;
|
||||||
|
233) echo "PostgreSQL: Database does not exist" ;;
|
||||||
|
234) echo "PostgreSQL: Fatal error in query / syntax" ;;
|
||||||
|
|
||||||
|
# --- MySQL / MariaDB ---
|
||||||
|
241) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;;
|
||||||
|
242) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;;
|
||||||
|
243) echo "MySQL/MariaDB: Database does not exist" ;;
|
||||||
|
244) echo "MySQL/MariaDB: Fatal error in query / syntax" ;;
|
||||||
|
|
||||||
|
# --- MongoDB ---
|
||||||
|
251) echo "MongoDB: Connection failed (server not running)" ;;
|
||||||
|
252) echo "MongoDB: Authentication failed (bad user/password)" ;;
|
||||||
|
253) echo "MongoDB: Database not found" ;;
|
||||||
|
254) echo "MongoDB: Fatal query error" ;;
|
||||||
|
|
||||||
|
# --- Proxmox Custom Codes ---
|
||||||
|
200) echo "Proxmox: Failed to create lock file" ;;
|
||||||
|
203) echo "Proxmox: Missing CTID variable" ;;
|
||||||
|
204) echo "Proxmox: Missing PCT_OSTYPE variable" ;;
|
||||||
|
205) echo "Proxmox: Invalid CTID (<100)" ;;
|
||||||
|
206) echo "Proxmox: CTID already in use" ;;
|
||||||
|
207) echo "Proxmox: Password contains unescaped special characters" ;;
|
||||||
|
208) echo "Proxmox: Invalid configuration (DNS/MAC/Network format)" ;;
|
||||||
|
209) echo "Proxmox: Container creation failed" ;;
|
||||||
|
210) echo "Proxmox: Cluster not quorate" ;;
|
||||||
|
211) echo "Proxmox: Timeout waiting for template lock" ;;
|
||||||
|
212) echo "Proxmox: Storage type 'iscsidirect' does not support containers (VMs only)" ;;
|
||||||
|
213) echo "Proxmox: Storage type does not support 'rootdir' content" ;;
|
||||||
|
214) echo "Proxmox: Not enough storage space" ;;
|
||||||
|
215) echo "Proxmox: Container created but not listed (ghost state)" ;;
|
||||||
|
216) echo "Proxmox: RootFS entry missing in config" ;;
|
||||||
|
217) echo "Proxmox: Storage not accessible" ;;
|
||||||
|
219) echo "Proxmox: CephFS does not support containers - use RBD" ;;
|
||||||
|
224) echo "Proxmox: PBS storage is for backups only" ;;
|
||||||
|
218) echo "Proxmox: Template file corrupted or incomplete" ;;
|
||||||
|
220) echo "Proxmox: Unable to resolve template path" ;;
|
||||||
|
221) echo "Proxmox: Template file not readable" ;;
|
||||||
|
222) echo "Proxmox: Template download failed" ;;
|
||||||
|
223) echo "Proxmox: Template not available after download" ;;
|
||||||
|
225) echo "Proxmox: No template available for OS/Version" ;;
|
||||||
|
231) echo "Proxmox: LXC stack upgrade failed" ;;
|
||||||
|
|
||||||
|
# --- Default ---
|
||||||
|
*) echo "Unknown error" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 2: ERROR HANDLERS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# error_handler()
|
||||||
|
#
|
||||||
|
# - Main error handler triggered by ERR trap
|
||||||
|
# - Arguments: exit_code, command, line_number
|
||||||
|
# - Behavior:
|
||||||
|
# * Returns silently if exit_code is 0 (success)
|
||||||
|
# * Sources explain_exit_code() for detailed error description
|
||||||
|
# * Displays error message with:
|
||||||
|
# - Line number where error occurred
|
||||||
|
# - Exit code with explanation
|
||||||
|
# - Command that failed
|
||||||
|
# * Shows last 20 lines of SILENT_LOGFILE if available
|
||||||
|
# * Copies log to container /root for later inspection
|
||||||
|
# * Exits with original exit code
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
error_handler() {
|
||||||
|
local exit_code=${1:-$?}
|
||||||
|
local command=${2:-${BASH_COMMAND:-unknown}}
|
||||||
|
local line_number=${BASH_LINENO[0]:-unknown}
|
||||||
|
|
||||||
|
command="${command//\$STD/}"
|
||||||
|
|
||||||
|
if [[ "$exit_code" -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local explanation
|
||||||
|
explanation="$(explain_exit_code "$exit_code")"
|
||||||
|
|
||||||
|
printf "\e[?25h"
|
||||||
|
|
||||||
|
# Use msg_error if available, fallback to echo
|
||||||
|
if declare -f msg_error >/dev/null 2>&1; then
|
||||||
|
msg_error "in line ${line_number}: exit code ${exit_code} (${explanation}): while executing command ${command}"
|
||||||
|
else
|
||||||
|
echo -e "\n${RD}[ERROR]${CL} in line ${RD}${line_number}${CL}: exit code ${RD}${exit_code}${CL} (${explanation}): while executing command ${YWB}${command}${CL}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${DEBUG_LOGFILE:-}" ]]; then
|
||||||
|
{
|
||||||
|
echo "------ ERROR ------"
|
||||||
|
echo "Timestamp : $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "Exit Code : $exit_code ($explanation)"
|
||||||
|
echo "Line : $line_number"
|
||||||
|
echo "Command : $command"
|
||||||
|
echo "-------------------"
|
||||||
|
} >>"$DEBUG_LOGFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get active log file (BUILD_LOG or INSTALL_LOG)
|
||||||
|
local active_log=""
|
||||||
|
if declare -f get_active_logfile >/dev/null 2>&1; then
|
||||||
|
active_log="$(get_active_logfile)"
|
||||||
|
elif [[ -n "${SILENT_LOGFILE:-}" ]]; then
|
||||||
|
active_log="$SILENT_LOGFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$active_log" && -s "$active_log" ]]; then
|
||||||
|
echo "--- Last 20 lines of silent log ---"
|
||||||
|
tail -n 20 "$active_log"
|
||||||
|
echo "-----------------------------------"
|
||||||
|
|
||||||
|
# Detect context: Container (INSTALL_LOG set + /root exists) vs Host (BUILD_LOG)
|
||||||
|
if [[ -n "${INSTALL_LOG:-}" && -d /root ]]; then
|
||||||
|
# CONTAINER CONTEXT: Copy log and create flag file for host
|
||||||
|
local container_log="/root/.install-${SESSION_ID:-error}.log"
|
||||||
|
cp "$active_log" "$container_log" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create error flag file with exit code for host detection
|
||||||
|
echo "$exit_code" >"/root/.install-${SESSION_ID:-error}.failed" 2>/dev/null || true
|
||||||
|
|
||||||
|
if declare -f msg_custom >/dev/null 2>&1; then
|
||||||
|
msg_custom "📋" "${YW}" "Log saved to: ${container_log}"
|
||||||
|
else
|
||||||
|
echo -e "${YW}Log saved to:${CL} ${BL}${container_log}${CL}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# HOST CONTEXT: Show local log path and offer container cleanup
|
||||||
|
if declare -f msg_custom >/dev/null 2>&1; then
|
||||||
|
msg_custom "📋" "${YW}" "Full log: ${active_log}"
|
||||||
|
else
|
||||||
|
echo -e "${YW}Full log:${CL} ${BL}${active_log}${CL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Offer to remove container if it exists (build errors after container creation)
|
||||||
|
if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null && pct status "$CTID" &>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo -en "${YW}Remove broken container ${CTID}? (Y/n) [auto-remove in 60s]: ${CL}"
|
||||||
|
|
||||||
|
if read -t 60 -r response; then
|
||||||
|
if [[ -z "$response" || "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "\n${YW}Removing container ${CTID}${CL}"
|
||||||
|
pct stop "$CTID" &>/dev/null || true
|
||||||
|
pct destroy "$CTID" &>/dev/null || true
|
||||||
|
echo -e "${GN}✔${CL} Container ${CTID} removed"
|
||||||
|
elif [[ "$response" =~ ^[Nn]$ ]]; then
|
||||||
|
echo -e "\n${YW}Container ${CTID} kept for debugging${CL}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Timeout - auto-remove
|
||||||
|
echo -e "\n${YW}No response - auto-removing container${CL}"
|
||||||
|
pct stop "$CTID" &>/dev/null || true
|
||||||
|
pct destroy "$CTID" &>/dev/null || true
|
||||||
|
echo -e "${GN}✔${CL} Container ${CTID} removed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 3: SIGNAL HANDLERS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# on_exit()
|
||||||
|
#
|
||||||
|
# - EXIT trap handler
|
||||||
|
# - Cleans up lock files if lockfile variable is set
|
||||||
|
# - Exits with captured exit code
|
||||||
|
# - Always runs on script termination (success or failure)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
on_exit() {
|
||||||
|
local exit_code=$?
|
||||||
|
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# on_interrupt()
|
||||||
|
#
|
||||||
|
# - SIGINT (Ctrl+C) trap handler
|
||||||
|
# - Displays "Interrupted by user" message
|
||||||
|
# - Exits with code 130 (128 + SIGINT=2)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
on_interrupt() {
|
||||||
|
if declare -f msg_error >/dev/null 2>&1; then
|
||||||
|
msg_error "Interrupted by user (SIGINT)"
|
||||||
|
else
|
||||||
|
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
|
||||||
|
fi
|
||||||
|
exit 130
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# on_terminate()
|
||||||
|
#
|
||||||
|
# - SIGTERM trap handler
|
||||||
|
# - Displays "Terminated by signal" message
|
||||||
|
# - Exits with code 143 (128 + SIGTERM=15)
|
||||||
|
# - Triggered by external process termination
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
on_terminate() {
|
||||||
|
if declare -f msg_error >/dev/null 2>&1; then
|
||||||
|
msg_error "Terminated by signal (SIGTERM)"
|
||||||
|
else
|
||||||
|
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
|
||||||
|
fi
|
||||||
|
exit 143
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 4: INITIALIZATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# catch_errors()
|
||||||
|
#
|
||||||
|
# - Initializes error handling and signal traps
|
||||||
|
# - Enables strict error handling:
|
||||||
|
# * set -Ee: Exit on error, inherit ERR trap in functions
|
||||||
|
# * set -o pipefail: Pipeline fails if any command fails
|
||||||
|
# * set -u: (optional) Exit on undefined variable (if STRICT_UNSET=1)
|
||||||
|
# - Sets up traps:
|
||||||
|
# * ERR → error_handler
|
||||||
|
# * EXIT → on_exit
|
||||||
|
# * INT → on_interrupt
|
||||||
|
# * TERM → on_terminate
|
||||||
|
# - Call this function early in every script
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
catch_errors() {
|
||||||
|
set -Ee -o pipefail
|
||||||
|
if [ "${STRICT_UNSET:-0}" = "1" ]; then
|
||||||
|
set -u
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap 'error_handler' ERR
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap on_interrupt INT
|
||||||
|
trap on_terminate TERM
|
||||||
|
}
|
||||||
@@ -1,48 +1,79 @@
|
|||||||
# Copyright (c) 2021-2025 michelroegl-brunner
|
# Copyright (c) 2021-2026 community-scripts ORG
|
||||||
# Author: michelroegl-brunner
|
# Author: tteck (tteckster)
|
||||||
# License: MIT
|
# Co-Author: MickLesk
|
||||||
# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# INSTALL.FUNC - CONTAINER INSTALLATION & SETUP
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# This file provides installation functions executed inside LXC containers
|
||||||
|
# after creation. Handles:
|
||||||
|
#
|
||||||
|
# - Network connectivity verification (IPv4/IPv6)
|
||||||
|
# - OS updates and package installation
|
||||||
|
# - DNS resolution checks
|
||||||
|
# - MOTD and SSH configuration
|
||||||
|
# - Container customization and auto-login
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# - Sourced by <app>-install.sh scripts
|
||||||
|
# - Executes via pct exec inside container
|
||||||
|
# - Requires internet connectivity
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 1: INITIALIZATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
|
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
|
||||||
apt-get update >/dev/null 2>&1
|
apt update >/dev/null 2>&1
|
||||||
apt-get install -y curl >/dev/null 2>&1
|
apt install -y curl >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
# core.func is included in FUNCTIONS_FILE_PATH
|
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
|
||||||
load_functions
|
load_functions
|
||||||
# This function enables IPv6 if it's not disabled and sets verbose mode
|
catch_errors
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# SECTION 2: NETWORK & CONNECTIVITY
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# verb_ip6()
|
||||||
|
#
|
||||||
|
# - Configures IPv6 based on DISABLEIPV6 variable
|
||||||
|
# - If DISABLEIPV6=yes: disables IPv6 via sysctl
|
||||||
|
# - Sets verbose mode via set_std_mode()
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
verb_ip6() {
|
verb_ip6() {
|
||||||
set_std_mode # Set STD mode based on VERBOSE
|
set_std_mode # Set STD mode based on VERBOSE
|
||||||
|
|
||||||
if [ "$DISABLEIPV6" == "yes" ]; then
|
if [ "${IPV6_METHOD:-}" = "disable" ]; then
|
||||||
echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
|
msg_info "Disabling IPv6 (this may affect some services)"
|
||||||
$STD sysctl -p
|
mkdir -p /etc/sysctl.d
|
||||||
|
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
|
||||||
|
# Disable IPv6 (set by community-scripts)
|
||||||
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.lo.disable_ipv6 = 1
|
||||||
|
EOF
|
||||||
|
$STD sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
|
||||||
|
msg_ok "Disabled IPv6"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function sets error handling options and defines the error_handler function to handle errors
|
# ------------------------------------------------------------------------------
|
||||||
catch_errors() {
|
# setting_up_container()
|
||||||
set -Eeuo pipefail
|
#
|
||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
# - Verifies network connectivity via hostname -I
|
||||||
}
|
# - Retries up to RETRY_NUM times with RETRY_EVERY seconds delay
|
||||||
|
# - Removes Python EXTERNALLY-MANAGED restrictions
|
||||||
# This function handles errors
|
# - Disables systemd-networkd-wait-online.service for faster boot
|
||||||
error_handler() {
|
# - Exits with error if network unavailable after retries
|
||||||
printf "\e[?25h"
|
# ------------------------------------------------------------------------------
|
||||||
local exit_code="$?"
|
|
||||||
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}"
|
|
||||||
echo -e "\n$error_message"
|
|
||||||
if [[ "$line_number" -eq 51 ]]; then
|
|
||||||
echo -e "The silent function has suppressed the error, run the script with verbose mode enabled, which will provide more detailed output.\n"
|
|
||||||
post_update_to_api "failed" "No error message, script ran in silent mode"
|
|
||||||
else
|
|
||||||
post_update_to_api "failed" "${command}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
|
|
||||||
setting_up_container() {
|
setting_up_container() {
|
||||||
msg_info "Setting up Container OS"
|
msg_info "Setting up Container OS"
|
||||||
for ((i = RETRY_NUM; i > 0; i--)); do
|
for ((i = RETRY_NUM; i > 0; i--)); do
|
||||||
@@ -64,8 +95,17 @@ setting_up_container() {
|
|||||||
msg_ok "Network Connected: ${BL}$(hostname -I)"
|
msg_ok "Network Connected: ${BL}$(hostname -I)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
|
# ------------------------------------------------------------------------------
|
||||||
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
|
# network_check()
|
||||||
|
#
|
||||||
|
# - Comprehensive network connectivity check for IPv4 and IPv6
|
||||||
|
# - Tests connectivity to multiple DNS servers:
|
||||||
|
# * IPv4: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9)
|
||||||
|
# * IPv6: 2606:4700:4700::1111, 2001:4860:4860::8888, 2620:fe::fe
|
||||||
|
# - Verifies DNS resolution for GitHub and Community-Scripts domains
|
||||||
|
# - Prompts user to continue if no internet detected
|
||||||
|
# - Uses fatal() on DNS resolution failure for critical hosts
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
network_check() {
|
network_check() {
|
||||||
set +e
|
set +e
|
||||||
trap - ERR
|
trap - ERR
|
||||||
@@ -125,7 +165,19 @@ network_check() {
|
|||||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function updates the Container OS by running apt-get update and upgrade
|
# ==============================================================================
|
||||||
|
# SECTION 3: OS UPDATE & PACKAGE MANAGEMENT
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# update_os()
|
||||||
|
#
|
||||||
|
# - Updates container OS via apt-get update and dist-upgrade
|
||||||
|
# - Configures APT cacher proxy if CACHER=yes (accelerates package downloads)
|
||||||
|
# - Removes Python EXTERNALLY-MANAGED restrictions for pip
|
||||||
|
# - Sources tools.func for additional setup functions after update
|
||||||
|
# - Uses $STD wrapper to suppress output unless VERBOSE=yes
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
update_os() {
|
update_os() {
|
||||||
msg_info "Updating Container OS"
|
msg_info "Updating Container OS"
|
||||||
if [[ "$CACHER" == "yes" ]]; then
|
if [[ "$CACHER" == "yes" ]]; then
|
||||||
@@ -145,29 +197,37 @@ EOF
|
|||||||
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
|
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
|
||||||
msg_ok "Updated Container OS"
|
msg_ok "Updated Container OS"
|
||||||
|
|
||||||
# tools.func is included in FUNCTIONS_FILE_PATH
|
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function modifies the message of the day (motd) and SSH settings
|
# ==============================================================================
|
||||||
|
# SECTION 4: MOTD & SSH CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# motd_ssh()
|
||||||
|
#
|
||||||
|
# - Configures Message of the Day (MOTD) with container information
|
||||||
|
# - Creates /etc/profile.d/00_lxc-details.sh with:
|
||||||
|
# * Application name
|
||||||
|
# * Warning banner (DEV repository)
|
||||||
|
# * OS name and version
|
||||||
|
# * Hostname and IP address
|
||||||
|
# * GitHub repository link
|
||||||
|
# - Disables executable flag on /etc/update-motd.d/* scripts
|
||||||
|
# - Enables root SSH access if SSH_ROOT=yes
|
||||||
|
# - Configures TERM environment variable for better terminal support
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
motd_ssh() {
|
motd_ssh() {
|
||||||
# Set terminal to 256-color mode
|
# Set terminal to 256-color mode
|
||||||
grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc
|
grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc
|
||||||
|
|
||||||
# Get OS information (Debian / Ubuntu)
|
|
||||||
if [ -f "/etc/os-release" ]; then
|
|
||||||
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
||||||
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
||||||
elif [ -f "/etc/debian_version" ]; then
|
|
||||||
OS_NAME="Debian"
|
|
||||||
OS_VERSION=$(cat /etc/debian_version)
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
|
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
|
||||||
echo "echo -e \"\"" >"$PROFILE_FILE"
|
echo "echo -e \"\"" >"$PROFILE_FILE"
|
||||||
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
|
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
|
||||||
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
|
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
|
||||||
echo "echo \"\"" >>"$PROFILE_FILE"
|
echo "echo \"\"" >>"$PROFILE_FILE"
|
||||||
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
|
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${CL}\"" >>"$PROFILE_FILE"
|
||||||
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
|
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
|
||||||
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
|
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
|
||||||
|
|
||||||
@@ -180,7 +240,19 @@ motd_ssh() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function customizes the container by modifying the getty service and enabling auto-login for the root user
|
# ==============================================================================
|
||||||
|
# SECTION 5: CONTAINER CUSTOMIZATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# customize()
|
||||||
|
#
|
||||||
|
# - Customizes container for passwordless root login if PASSWORD is empty
|
||||||
|
# - Configures getty for auto-login via /etc/systemd/system/container-getty@1.service.d/override.conf
|
||||||
|
# - Creates /usr/bin/update script for easy application updates
|
||||||
|
# - Injects SSH authorized keys if SSH_AUTHORIZED_KEY variable is set
|
||||||
|
# - Sets proper permissions on SSH directories and key files
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
customize() {
|
customize() {
|
||||||
if [[ "$PASSWORD" == "" ]]; then
|
if [[ "$PASSWORD" == "" ]]; then
|
||||||
msg_info "Customizing Container"
|
msg_info "Customizing Container"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
44
scripts/ct/debian.sh
Normal file
44
scripts/ct/debian.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
SCRIPT_DIR="$(dirname "$0")"
|
||||||
|
source "$SCRIPT_DIR/../core/build.func"
|
||||||
|
# Copyright (c) 2021-2025 tteck
|
||||||
|
# Author: tteck (tteckster)
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
# Source: https://www.debian.org/
|
||||||
|
|
||||||
|
APP="Debian"
|
||||||
|
var_tags="${var_tags:-os}"
|
||||||
|
var_cpu="${var_cpu:-1}"
|
||||||
|
var_ram="${var_ram:-512}"
|
||||||
|
var_disk="${var_disk:-2}"
|
||||||
|
var_os="${var_os:-debian}"
|
||||||
|
var_version="${var_version:-13}"
|
||||||
|
var_unprivileged="${var_unprivileged:-1}"
|
||||||
|
|
||||||
|
header_info "$APP"
|
||||||
|
variables
|
||||||
|
color
|
||||||
|
catch_errors
|
||||||
|
|
||||||
|
function update_script() {
|
||||||
|
header_info
|
||||||
|
check_container_storage
|
||||||
|
check_container_resources
|
||||||
|
if [[ ! -d /var ]]; then
|
||||||
|
msg_error "No ${APP} Installation Found!"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
msg_info "Updating $APP LXC"
|
||||||
|
$STD apt update
|
||||||
|
$STD apt -y upgrade
|
||||||
|
msg_ok "Updated $APP LXC"
|
||||||
|
msg_ok "Updated successfully!"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
start
|
||||||
|
build_container
|
||||||
|
description
|
||||||
|
|
||||||
|
msg_ok "Completed Successfully!\n"
|
||||||
|
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||||
18
scripts/install/debian-install.sh
Normal file
18
scripts/install/debian-install.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Copyright (c) 2021-2025 tteck
|
||||||
|
# Author: tteck (tteckster)
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
# Source: https://www.debian.org/
|
||||||
|
|
||||||
|
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||||
|
color
|
||||||
|
verb_ip6
|
||||||
|
catch_errors
|
||||||
|
setting_up_container
|
||||||
|
network_check
|
||||||
|
update_os
|
||||||
|
|
||||||
|
motd_ssh
|
||||||
|
customize
|
||||||
|
cleanup_lxc
|
||||||
570
server.js
570
server.js
@@ -8,9 +8,12 @@ import stripAnsi from 'strip-ansi';
|
|||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||||
import { getDatabase } from './src/server/database-prisma.js';
|
import { getDatabase } from './src/server/database-prisma.js';
|
||||||
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
|
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Dynamic import for auto sync init to avoid tsx caching issues
|
||||||
|
/** @type {any} */
|
||||||
|
let autoSyncModule = null;
|
||||||
|
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
// Fallback minimal global error handlers for Node runtime (avoid TS import)
|
// Fallback minimal global error handlers for Node runtime (avoid TS import)
|
||||||
@@ -71,7 +74,15 @@ const handle = app.getRequestHandler();
|
|||||||
* @property {ServerInfo} [server]
|
* @property {ServerInfo} [server]
|
||||||
* @property {boolean} [isUpdate]
|
* @property {boolean} [isUpdate]
|
||||||
* @property {boolean} [isShell]
|
* @property {boolean} [isShell]
|
||||||
|
* @property {boolean} [isBackup]
|
||||||
|
* @property {boolean} [isClone]
|
||||||
* @property {string} [containerId]
|
* @property {string} [containerId]
|
||||||
|
* @property {string} [storage]
|
||||||
|
* @property {string} [backupStorage]
|
||||||
|
* @property {number} [cloneCount]
|
||||||
|
* @property {string[]} [hostnames]
|
||||||
|
* @property {'lxc'|'vm'} [containerType]
|
||||||
|
* @property {Record<string, string|number|boolean>} [envVars]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class ScriptExecutionHandler {
|
class ScriptExecutionHandler {
|
||||||
@@ -79,15 +90,28 @@ class ScriptExecutionHandler {
|
|||||||
* @param {import('http').Server} server
|
* @param {import('http').Server} server
|
||||||
*/
|
*/
|
||||||
constructor(server) {
|
constructor(server) {
|
||||||
|
// Create WebSocketServer without attaching to server
|
||||||
|
// We'll handle upgrades manually to avoid interfering with Next.js HMR
|
||||||
this.wss = new WebSocketServer({
|
this.wss = new WebSocketServer({
|
||||||
server,
|
noServer: true
|
||||||
path: '/ws/script-execution'
|
|
||||||
});
|
});
|
||||||
this.activeExecutions = new Map();
|
this.activeExecutions = new Map();
|
||||||
this.db = getDatabase();
|
this.db = getDatabase();
|
||||||
this.setupWebSocket();
|
this.setupWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebSocket upgrade for our endpoint
|
||||||
|
* @param {import('http').IncomingMessage} request
|
||||||
|
* @param {import('stream').Duplex} socket
|
||||||
|
* @param {Buffer} head
|
||||||
|
*/
|
||||||
|
handleUpgrade(request, socket, head) {
|
||||||
|
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
this.wss.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Container ID from terminal output
|
* Parse Container ID from terminal output
|
||||||
* @param {string} output - Terminal output to parse
|
* @param {string} output - Terminal output to parse
|
||||||
@@ -276,19 +300,21 @@ class ScriptExecutionHandler {
|
|||||||
* @param {WebSocketMessage} message
|
* @param {WebSocketMessage} message
|
||||||
*/
|
*/
|
||||||
async handleMessage(ws, message) {
|
async handleMessage(ws, message) {
|
||||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
|
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType, envVars } = message;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (scriptPath && executionId) {
|
if (scriptPath && executionId) {
|
||||||
if (isBackup && containerId && storage) {
|
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
|
||||||
|
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames);
|
||||||
|
} else if (isBackup && containerId && storage) {
|
||||||
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
||||||
} else if (isUpdate && containerId) {
|
} else if (isUpdate && containerId) {
|
||||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
||||||
} else if (isShell && containerId) {
|
} else if (isShell && containerId) {
|
||||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||||
} else {
|
} else {
|
||||||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
|
await this.startScriptExecution(ws, scriptPath, executionId, mode, server, envVars);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
@@ -326,8 +352,9 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {string} mode
|
* @param {string} mode
|
||||||
* @param {ServerInfo|null} server
|
* @param {ServerInfo|null} server
|
||||||
|
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||||||
*/
|
*/
|
||||||
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) {
|
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null, envVars = {}) {
|
||||||
/** @type {number|null} */
|
/** @type {number|null} */
|
||||||
let installationId = null;
|
let installationId = null;
|
||||||
|
|
||||||
@@ -356,7 +383,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Handle SSH execution
|
// Handle SSH execution
|
||||||
if (mode === 'ssh' && server) {
|
if (mode === 'ssh' && server) {
|
||||||
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId);
|
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId, envVars);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,19 +409,32 @@ class ScriptExecutionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format environment variables for local execution
|
||||||
|
// Convert envVars object to environment variables
|
||||||
|
const envWithVars = {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color', // Enable proper terminal support
|
||||||
|
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
|
||||||
|
COLUMNS: '80', // Set terminal width
|
||||||
|
LINES: '24' // Set terminal height
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add envVars to environment
|
||||||
|
if (envVars && typeof envVars === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(envVars)) {
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const envRecord = envWithVars;
|
||||||
|
envRecord[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start script execution with pty for proper TTY support
|
// Start script execution with pty for proper TTY support
|
||||||
const childProcess = ptySpawn('bash', [resolvedPath], {
|
const childProcess = ptySpawn('bash', [resolvedPath], {
|
||||||
cwd: scriptsDir,
|
cwd: scriptsDir,
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 24,
|
rows: 24,
|
||||||
env: {
|
env: envWithVars
|
||||||
...process.env,
|
|
||||||
TERM: 'xterm-256color', // Enable proper terminal support
|
|
||||||
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
|
|
||||||
COLUMNS: '80', // Set terminal width
|
|
||||||
LINES: '24' // Set terminal height
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// pty handles encoding automatically
|
// pty handles encoding automatically
|
||||||
@@ -497,8 +537,9 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {ServerInfo} server
|
* @param {ServerInfo} server
|
||||||
* @param {number|null} installationId
|
* @param {number|null} installationId
|
||||||
|
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||||||
*/
|
*/
|
||||||
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) {
|
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null, envVars = {}) {
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
|
|
||||||
// Send start message
|
// Send start message
|
||||||
@@ -587,7 +628,8 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
this.activeExecutions.delete(executionId);
|
this.activeExecutions.delete(executionId);
|
||||||
}
|
},
|
||||||
|
envVars
|
||||||
));
|
));
|
||||||
|
|
||||||
// Store the execution with installation ID
|
// Store the execution with installation ID
|
||||||
@@ -707,7 +749,7 @@ class ScriptExecutionHandler {
|
|||||||
* @param {ServerInfo} server
|
* @param {ServerInfo} server
|
||||||
* @param {Function} [onComplete] - Optional callback when backup completes
|
* @param {Function} [onComplete] - Optional callback when backup completes
|
||||||
*/
|
*/
|
||||||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
|
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -813,16 +855,432 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSH clone execution
|
||||||
|
* Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc.
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {string} storage
|
||||||
|
* @param {ServerInfo} server
|
||||||
|
* @param {'lxc'|'vm'} containerType
|
||||||
|
* @param {number} cloneCount
|
||||||
|
* @param {string[]} hostnames
|
||||||
|
*/
|
||||||
|
async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) {
|
||||||
|
const sshService = getSSHExecutionService();
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'start',
|
||||||
|
data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Stop source container/VM
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`;
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
stopCommand,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
// Continue even if stop fails (might already be stopped)
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Step 2: Clone for each clone count (get next ID sequentially before each clone)
|
||||||
|
const clonedIds = [];
|
||||||
|
for (let i = 0; i < cloneCount; i++) {
|
||||||
|
const cloneNumber = i + 1;
|
||||||
|
const hostname = hostnames[i];
|
||||||
|
|
||||||
|
// Get next ID for this clone
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextId = '';
|
||||||
|
try {
|
||||||
|
let output = '';
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
'pvesh get /cluster/nextid',
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
output += data;
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
reject(new Error(`Failed to get next ID: ${error}`));
|
||||||
|
},
|
||||||
|
/** @param {number} exitCode */
|
||||||
|
(exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
nextId = output.trim();
|
||||||
|
if (!nextId || !/^\d+$/.test(nextId)) {
|
||||||
|
throw new Error('Invalid next ID received');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
clonedIds.push(nextId);
|
||||||
|
|
||||||
|
// Clone the container/VM
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneCommand = containerType === 'lxc'
|
||||||
|
? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}`
|
||||||
|
: `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`;
|
||||||
|
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
cloneCommand,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Start source container/VM
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`;
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
startSourceCommand,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Step 4: Start target containers/VMs
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < cloneCount; i++) {
|
||||||
|
const cloneNumber = i + 1;
|
||||||
|
const nextId = clonedIds[i];
|
||||||
|
|
||||||
|
const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`;
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
startTargetCommand,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Add to database
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < cloneCount; i++) {
|
||||||
|
const nextId = clonedIds[i];
|
||||||
|
const hostname = hostnames[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read config file to get hostname/name
|
||||||
|
const configPath = containerType === 'lxc'
|
||||||
|
? `/etc/pve/lxc/${nextId}.conf`
|
||||||
|
: `/etc/pve/qemu-server/${nextId}.conf`;
|
||||||
|
|
||||||
|
let configContent = '';
|
||||||
|
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
|
||||||
|
sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
`cat "${configPath}" 2>/dev/null || echo ""`,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
configContent += data;
|
||||||
|
},
|
||||||
|
() => resolve(),
|
||||||
|
() => resolve()
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Parse config for hostname/name
|
||||||
|
let finalHostname = hostname;
|
||||||
|
if (configContent.trim()) {
|
||||||
|
const lines = configContent.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (containerType === 'lxc' && trimmed.startsWith('hostname:')) {
|
||||||
|
finalHostname = trimmed.substring(9).trim();
|
||||||
|
break;
|
||||||
|
} else if (containerType === 'vm' && trimmed.startsWith('name:')) {
|
||||||
|
finalHostname = trimmed.substring(5).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalHostname) {
|
||||||
|
finalHostname = `${containerType}-${nextId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create installed script record
|
||||||
|
const script = await this.db.createInstalledScript({
|
||||||
|
script_name: finalHostname,
|
||||||
|
script_path: `cloned/${finalHostname}`,
|
||||||
|
container_id: nextId,
|
||||||
|
server_id: server.id,
|
||||||
|
execution_mode: 'ssh',
|
||||||
|
status: 'success',
|
||||||
|
output_log: `Cloned ${containerType.toUpperCase()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// For LXC, store config in database
|
||||||
|
if (containerType === 'lxc' && configContent.trim()) {
|
||||||
|
// Simple config parser
|
||||||
|
/** @type {any} */
|
||||||
|
const configData = {};
|
||||||
|
const lines = configContent.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
|
const [key, ...valueParts] = trimmed.split(':');
|
||||||
|
const value = valueParts.join(':').trim();
|
||||||
|
|
||||||
|
if (key === 'hostname') configData.hostname = value;
|
||||||
|
else if (key === 'arch') configData.arch = value;
|
||||||
|
else if (key === 'cores') configData.cores = parseInt(value) || null;
|
||||||
|
else if (key === 'memory') configData.memory = parseInt(value) || null;
|
||||||
|
else if (key === 'swap') configData.swap = parseInt(value) || null;
|
||||||
|
else if (key === 'onboot') configData.onboot = parseInt(value) || null;
|
||||||
|
else if (key === 'ostype') configData.ostype = value;
|
||||||
|
else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null;
|
||||||
|
else if (key === 'tags') configData.tags = value;
|
||||||
|
else if (key === 'rootfs') {
|
||||||
|
const match = value.match(/^([^:]+):([^,]+)/);
|
||||||
|
if (match) {
|
||||||
|
configData.rootfs_storage = match[1];
|
||||||
|
const sizeMatch = value.match(/size=([^,]+)/);
|
||||||
|
if (sizeMatch) {
|
||||||
|
configData.rootfs_size = sizeMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.createLXCConfig(script.id, configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start update execution (pct enter + update command)
|
* Start update execution (pct enter + update command)
|
||||||
* @param {ExtendedWebSocket} ws
|
* @param {ExtendedWebSocket} ws
|
||||||
* @param {string} containerId
|
* @param {string} containerId
|
||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {string} mode
|
* @param {string} mode
|
||||||
* @param {ServerInfo|null} server
|
* @param {ServerInfo|undefined} server
|
||||||
* @param {string} [backupStorage] - Optional storage to backup to before update
|
* @param {string} [backupStorage] - Optional storage to backup to before update
|
||||||
*/
|
*/
|
||||||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
|
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
|
||||||
try {
|
try {
|
||||||
// If backup storage is provided, run backup first
|
// If backup storage is provided, run backup first
|
||||||
if (backupStorage && mode === 'ssh' && server) {
|
if (backupStorage && mode === 'ssh' && server) {
|
||||||
@@ -1159,12 +1617,22 @@ app.prepare().then(() => {
|
|||||||
const parsedUrl = parse(req.url || '', true);
|
const parsedUrl = parse(req.url || '', true);
|
||||||
const { pathname, query } = parsedUrl;
|
const { pathname, query } = parsedUrl;
|
||||||
|
|
||||||
if (pathname === '/ws/script-execution') {
|
// Check if this is a WebSocket upgrade request
|
||||||
|
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
|
||||||
|
|
||||||
|
// Only intercept WebSocket upgrades for /ws/script-execution
|
||||||
|
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
|
||||||
|
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
|
||||||
// WebSocket upgrade will be handled by the WebSocket server
|
// WebSocket upgrade will be handled by the WebSocket server
|
||||||
|
// Don't call handle() for this path - let WebSocketServer handle it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let Next.js handle all other requests including HMR
|
// Let Next.js handle all other requests including:
|
||||||
|
// - HTTP requests to /ws/script-execution (non-WebSocket)
|
||||||
|
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
|
||||||
|
// - All static assets (_next routes)
|
||||||
|
// - All other routes
|
||||||
await handle(req, res, parsedUrl);
|
await handle(req, res, parsedUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error occurred handling', req.url, err);
|
console.error('Error occurred handling', req.url, err);
|
||||||
@@ -1175,6 +1643,33 @@ app.prepare().then(() => {
|
|||||||
|
|
||||||
// Create WebSocket handlers
|
// Create WebSocket handlers
|
||||||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||||||
|
|
||||||
|
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||||||
|
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||||||
|
// Save any existing upgrade listeners (Next.js might have set them up)
|
||||||
|
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||||||
|
httpServer.removeAllListeners('upgrade');
|
||||||
|
|
||||||
|
// Add our upgrade handler that routes based on path
|
||||||
|
httpServer.on('upgrade', (request, socket, head) => {
|
||||||
|
const parsedUrl = parse(request.url || '', true);
|
||||||
|
const { pathname } = parsedUrl;
|
||||||
|
|
||||||
|
if (pathname === '/ws/script-execution') {
|
||||||
|
// Handle our custom WebSocket endpoint
|
||||||
|
scriptHandler.handleUpgrade(request, socket, head);
|
||||||
|
} else {
|
||||||
|
// For all other paths (including Next.js HMR), call existing listeners
|
||||||
|
// This allows Next.js to handle its own WebSocket upgrades
|
||||||
|
for (const listener of existingUpgradeListeners) {
|
||||||
|
try {
|
||||||
|
listener.call(httpServer, request, socket, head);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in upgrade listener:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
// Note: TerminalHandler removed as it's not being used by the current application
|
// Note: TerminalHandler removed as it's not being used by the current application
|
||||||
|
|
||||||
httpServer
|
httpServer
|
||||||
@@ -1186,13 +1681,38 @@ app.prepare().then(() => {
|
|||||||
console.log(`> Ready on http://${hostname}:${port}`);
|
console.log(`> Ready on http://${hostname}:${port}`);
|
||||||
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
||||||
|
|
||||||
|
// Initialize auto sync module and run initialization
|
||||||
|
if (!autoSyncModule) {
|
||||||
|
try {
|
||||||
|
console.log('Dynamically importing autoSyncInit...');
|
||||||
|
autoSyncModule = await import('./src/server/lib/autoSyncInit.js');
|
||||||
|
console.log('autoSyncModule loaded, exports:', Object.keys(autoSyncModule));
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error('Failed to import autoSyncInit:', err.message);
|
||||||
|
console.error('Stack:', err.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize default repositories
|
// Initialize default repositories
|
||||||
await initializeRepositories();
|
if (typeof autoSyncModule.initializeRepositories === 'function') {
|
||||||
|
console.log('Calling initializeRepositories...');
|
||||||
|
await autoSyncModule.initializeRepositories();
|
||||||
|
} else {
|
||||||
|
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize auto-sync service
|
// Initialize auto-sync service
|
||||||
initializeAutoSync();
|
if (typeof autoSyncModule.initializeAutoSync === 'function') {
|
||||||
|
console.log('Calling initializeAutoSync...');
|
||||||
|
autoSyncModule.initializeAutoSync();
|
||||||
|
}
|
||||||
|
|
||||||
// Setup graceful shutdown handlers
|
// Setup graceful shutdown handlers
|
||||||
setupGracefulShutdown();
|
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
|
||||||
|
console.log('Setting up graceful shutdown...');
|
||||||
|
autoSyncModule.setupGracefulShutdown();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -27,9 +34,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
const checkAuthInternal = async (retryCount = 0) => {
|
const checkAuthInternal = async (retryCount = 0) => {
|
||||||
try {
|
try {
|
||||||
// First check if setup is completed
|
// First check if setup is completed
|
||||||
const setupResponse = await fetch('/api/settings/auth-credentials');
|
const setupResponse = await fetch("/api/settings/auth-credentials");
|
||||||
if (setupResponse.ok) {
|
if (setupResponse.ok) {
|
||||||
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
const setupData = (await setupResponse.json()) as {
|
||||||
|
setupCompleted: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// If setup is not completed or auth is disabled, don't verify
|
// If setup is not completed or auth is disabled, don't verify
|
||||||
if (!setupData.setupCompleted || !setupData.enabled) {
|
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||||
@@ -42,11 +52,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only verify authentication if setup is completed and auth is enabled
|
// 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
|
credentials: "include", // Ensure cookies are sent
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as {
|
const data = (await response.json()) as {
|
||||||
username: string;
|
username: string;
|
||||||
expirationTime?: number | null;
|
expirationTime?: number | null;
|
||||||
timeUntilExpiration?: number | null;
|
timeUntilExpiration?: number | null;
|
||||||
@@ -68,7 +78,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking auth:', error);
|
console.error("Error checking auth:", error);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setExpirationTime(null);
|
setExpirationTime(null);
|
||||||
@@ -89,44 +99,49 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
return checkAuthInternal(0);
|
return checkAuthInternal(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (username: string, password: string): Promise<boolean> => {
|
const login = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch("/api/auth/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
credentials: 'include', // Ensure cookies are received
|
credentials: "include", // Ensure cookies are received
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as { username: string };
|
const data = (await response.json()) as {
|
||||||
|
username: string;
|
||||||
|
expirationTime?: number;
|
||||||
|
};
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setUsername(data.username);
|
setUsername(data.username);
|
||||||
|
// Set expiration time from login response if available
|
||||||
// Check auth again to get expiration time
|
if (data.expirationTime) {
|
||||||
// Add a small delay to ensure the httpOnly cookie is available
|
setExpirationTime(data.expirationTime);
|
||||||
await new Promise<void>((resolve) => {
|
}
|
||||||
setTimeout(() => {
|
// Don't call checkAuth after login - we already know we're authenticated
|
||||||
void checkAuth().then(() => resolve());
|
// The cookie is set by the server response
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
console.error('Login failed:', errorData.error);
|
console.error("Login failed:", errorData.error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error("Login error:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
// Clear the auth cookie by setting it to expire
|
// Clear the auth cookie by setting it to expire
|
||||||
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie =
|
||||||
|
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setExpirationTime(null);
|
setExpirationTime(null);
|
||||||
@@ -156,7 +171,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
|
|
||||||
interface BackupWarningModalProps {
|
interface BackupWarningModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
|
|||||||
export function BackupWarningModal({
|
export function BackupWarningModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onProceed
|
onProceed,
|
||||||
}: BackupWarningModalProps) {
|
}: BackupWarningModalProps) {
|
||||||
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, {
|
||||||
|
id: "backup-warning-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
<div className="border-border flex items-center justify-center border-b p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="h-8 w-8 text-warning" />
|
<AlertTriangle className="text-warning h-8 w-8" />
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
|
<h2 className="text-card-foreground text-2xl font-bold">
|
||||||
|
Backup Failed
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
The backup failed, but you can still proceed with the update if you wish.
|
The backup failed, but you can still proceed with the update if you
|
||||||
<br /><br />
|
wish.
|
||||||
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
|
<br />
|
||||||
|
<br />
|
||||||
|
<strong className="text-foreground">Warning:</strong> Proceeding
|
||||||
|
without a backup means you won't be able to restore the
|
||||||
|
container if something goes wrong during the update.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
<div className="flex flex-col justify-end gap-3 sm:flex-row">
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -52,7 +62,7 @@ export function BackupWarningModal({
|
|||||||
onClick={onProceed}
|
onClick={onProceed}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
|
className="bg-warning hover:bg-warning/90 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Proceed Anyway
|
Proceed Anyway
|
||||||
</Button>
|
</Button>
|
||||||
@@ -62,6 +72,3 @@ export function BackupWarningModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { api } from '~/trpc/react';
|
import { api } from "~/trpc/react";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from "./ui/badge";
|
||||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
HardDrive,
|
||||||
|
Database,
|
||||||
|
Server,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from './ui/dropdown-menu';
|
} from "./ui/dropdown-menu";
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from "./ConfirmationModal";
|
||||||
import { LoadingModal } from './LoadingModal';
|
import { LoadingModal } from "./LoadingModal";
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,7 +32,7 @@ interface Backup {
|
|||||||
storage_name: string;
|
storage_name: string;
|
||||||
storage_type: string;
|
storage_type: string;
|
||||||
discovered_at: Date;
|
discovered_at: Date;
|
||||||
server_id: number;
|
server_id?: number;
|
||||||
server_name: string | null;
|
server_name: string | null;
|
||||||
server_color: string | null;
|
server_color: string | null;
|
||||||
}
|
}
|
||||||
@@ -35,16 +44,25 @@ interface ContainerBackups {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BackupsTab() {
|
export function BackupsTab() {
|
||||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
|
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
||||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
||||||
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
|
const [selectedBackup, setSelectedBackup] = useState<{
|
||||||
|
backup: Backup;
|
||||||
|
containerId: string;
|
||||||
|
} | null>(null);
|
||||||
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
||||||
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
const [shouldPollRestore, setShouldPollRestore] = useState(false);
|
const [shouldPollRestore, setShouldPollRestore] = useState(false);
|
||||||
|
|
||||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
const {
|
||||||
|
data: backupsData,
|
||||||
|
refetch: refetchBackups,
|
||||||
|
isLoading,
|
||||||
|
} = api.backups.getAllBackupsGrouped.useQuery();
|
||||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void refetchBackups();
|
void refetchBackups();
|
||||||
@@ -52,11 +70,14 @@ export function BackupsTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Poll for restore progress
|
// Poll for restore progress
|
||||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
|
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
enabled: shouldPollRestore,
|
enabled: shouldPollRestore,
|
||||||
refetchInterval: 1000, // Poll every second
|
refetchInterval: 1000, // Poll every second
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update restore progress when log data changes
|
// Update restore progress when log data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,11 +88,12 @@ export function BackupsTab() {
|
|||||||
if (restoreLogsData.isComplete) {
|
if (restoreLogsData.isComplete) {
|
||||||
setShouldPollRestore(false);
|
setShouldPollRestore(false);
|
||||||
// Check if restore was successful or failed
|
// Check if restore was successful or failed
|
||||||
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
|
const lastLog =
|
||||||
if (lastLog.includes('Restore completed successfully')) {
|
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? "";
|
||||||
|
if (lastLog.includes("Restore completed successfully")) {
|
||||||
setRestoreSuccess(true);
|
setRestoreSuccess(true);
|
||||||
setRestoreError(null);
|
setRestoreError(null);
|
||||||
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
|
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
|
||||||
setRestoreError(lastLog);
|
setRestoreError(lastLog);
|
||||||
setRestoreSuccess(false);
|
setRestoreSuccess(false);
|
||||||
}
|
}
|
||||||
@@ -83,7 +105,7 @@ export function BackupsTab() {
|
|||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
// Start polling for progress
|
// Start polling for progress
|
||||||
setShouldPollRestore(true);
|
setShouldPollRestore(true);
|
||||||
setRestoreProgress(['Starting restore...']);
|
setRestoreProgress(["Starting restore..."]);
|
||||||
setRestoreError(null);
|
setRestoreError(null);
|
||||||
setRestoreSuccess(false);
|
setRestoreSuccess(false);
|
||||||
},
|
},
|
||||||
@@ -93,7 +115,12 @@ export function BackupsTab() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update progress with all messages from backend (fallback if polling didn't work)
|
// Update progress with all messages from backend (fallback if polling didn't work)
|
||||||
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
|
const progressMessages =
|
||||||
|
restoreProgress.length > 0
|
||||||
|
? restoreProgress
|
||||||
|
: (result.progress?.map((p) => p.message) ?? [
|
||||||
|
"Restore completed successfully",
|
||||||
|
]);
|
||||||
setRestoreProgress(progressMessages);
|
setRestoreProgress(progressMessages);
|
||||||
setRestoreSuccess(true);
|
setRestoreSuccess(true);
|
||||||
setRestoreError(null);
|
setRestoreError(null);
|
||||||
@@ -101,8 +128,10 @@ export function BackupsTab() {
|
|||||||
setSelectedBackup(null);
|
setSelectedBackup(null);
|
||||||
// Keep success message visible - user can dismiss manually
|
// Keep success message visible - user can dismiss manually
|
||||||
} else {
|
} else {
|
||||||
setRestoreError(result.error || 'Restore failed');
|
setRestoreError(result.error ?? "Restore failed");
|
||||||
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
|
setRestoreProgress(
|
||||||
|
result.progress?.map((p) => p.message) ?? restoreProgress,
|
||||||
|
);
|
||||||
setRestoreSuccess(false);
|
setRestoreSuccess(false);
|
||||||
setRestoreConfirmOpen(false);
|
setRestoreConfirmOpen(false);
|
||||||
setSelectedBackup(null);
|
setSelectedBackup(null);
|
||||||
@@ -112,7 +141,7 @@ export function BackupsTab() {
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Stop polling on error
|
// Stop polling on error
|
||||||
setShouldPollRestore(false);
|
setShouldPollRestore(false);
|
||||||
setRestoreError(error.message || 'Restore failed');
|
setRestoreError(error.message ?? "Restore failed");
|
||||||
setRestoreConfirmOpen(false);
|
setRestoreConfirmOpen(false);
|
||||||
setSelectedBackup(null);
|
setSelectedBackup(null);
|
||||||
setRestoreProgress([]);
|
setRestoreProgress([]);
|
||||||
@@ -120,16 +149,17 @@ export function BackupsTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update progress text in modal based on current progress
|
// Update progress text in modal based on current progress
|
||||||
const currentProgressText = restoreProgress.length > 0
|
const currentProgressText =
|
||||||
|
restoreProgress.length > 0
|
||||||
? restoreProgress[restoreProgress.length - 1]
|
? restoreProgress[restoreProgress.length - 1]
|
||||||
: 'Restoring backup...';
|
: "Restoring backup...";
|
||||||
|
|
||||||
// Auto-discover backups when tab is first opened
|
// Auto-discover backups when tab is first opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
||||||
// Only auto-discover if there are no backups yet
|
// Only auto-discover if there are no backups yet
|
||||||
if (!backupsData.backups || backupsData.backups.length === 0) {
|
if (!backupsData.backups?.length) {
|
||||||
handleDiscoverBackups();
|
void handleDiscoverBackups();
|
||||||
}
|
}
|
||||||
setHasAutoDiscovered(true);
|
setHasAutoDiscovered(true);
|
||||||
}
|
}
|
||||||
@@ -157,7 +187,7 @@ export function BackupsTab() {
|
|||||||
restoreMutation.mutate({
|
restoreMutation.mutate({
|
||||||
backupId: selectedBackup.backup.id,
|
backupId: selectedBackup.backup.id,
|
||||||
containerId: selectedBackup.containerId,
|
containerId: selectedBackup.containerId,
|
||||||
serverId: selectedBackup.backup.server_id,
|
serverId: selectedBackup.backup.server_id ?? 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,39 +202,41 @@ export function BackupsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: bigint | null): string => {
|
const formatFileSize = (bytes: bigint | null): string => {
|
||||||
if (!bytes) return 'Unknown size';
|
if (!bytes) return "Unknown size";
|
||||||
const b = Number(bytes);
|
const b = Number(bytes);
|
||||||
if (b === 0) return '0 B';
|
if (b === 0) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||||
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date | null): string => {
|
const formatDate = (date: Date | null): string => {
|
||||||
if (!date) return 'Unknown date';
|
if (!date) return "Unknown date";
|
||||||
return new Date(date).toLocaleString();
|
return new Date(date).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStorageTypeIcon = (type: string) => {
|
const getStorageTypeIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'pbs':
|
case "pbs":
|
||||||
return <Database className="h-4 w-4" />;
|
return <Database className="h-4 w-4" />;
|
||||||
case 'local':
|
case "local":
|
||||||
return <HardDrive className="h-4 w-4" />;
|
return <HardDrive className="h-4 w-4" />;
|
||||||
default:
|
default:
|
||||||
return <Server className="h-4 w-4" />;
|
return <Server className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
|
const getStorageTypeBadgeVariant = (
|
||||||
|
type: string,
|
||||||
|
): "default" | "secondary" | "outline" => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'pbs':
|
case "pbs":
|
||||||
return 'default';
|
return "default";
|
||||||
case 'local':
|
case "local":
|
||||||
return 'secondary';
|
return "secondary";
|
||||||
default:
|
default:
|
||||||
return 'outline';
|
return "outline";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,8 +248,8 @@ export function BackupsTab() {
|
|||||||
{/* Header with refresh button */}
|
{/* Header with refresh button */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
|
<h2 className="text-foreground text-2xl font-bold">Backups</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
Discovered backups grouped by container ID
|
Discovered backups grouped by container ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,31 +258,38 @@ export function BackupsTab() {
|
|||||||
disabled={isDiscovering}
|
disabled={isDiscovering}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
|
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{isDiscovering ? "Discovering..." : "Discover Backups"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{(isLoading || isDiscovering) && backups.length === 0 && (
|
{(isLoading || isDiscovering) && backups.length === 0 && (
|
||||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
|
{isDiscovering ? "Discovering backups..." : "Loading backups..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!isLoading && !isDiscovering && backups.length === 0 && (
|
{!isLoading && !isDiscovering && backups.length === 0 && (
|
||||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||||
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
|
<h3 className="text-foreground mb-2 text-lg font-semibold">
|
||||||
|
No backups found
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Click "Discover Backups" to scan for backups on your servers.
|
Click "Discover Backups" to scan for backups on your
|
||||||
|
servers.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
|
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Discover Backups
|
Discover Backups
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,33 +305,35 @@ export function BackupsTab() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={container.container_id}
|
key={container.container_id}
|
||||||
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
|
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm"
|
||||||
>
|
>
|
||||||
{/* Container header - collapsible */}
|
{/* Container header - collapsible */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleContainer(container.container_id)}
|
onClick={() => toggleContainer(container.container_id)}
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
|
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-foreground">
|
<span className="text-foreground font-semibold">
|
||||||
CT {container.container_id}
|
CT {container.container_id}
|
||||||
</span>
|
</span>
|
||||||
{container.hostname && (
|
{container.hostname && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">•</span>
|
<span className="text-muted-foreground">•</span>
|
||||||
<span className="text-muted-foreground">{container.hostname}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{container.hostname}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
{backupCount} {backupCount === 1 ? "backup" : "backups"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,28 +341,30 @@ export function BackupsTab() {
|
|||||||
|
|
||||||
{/* Container content - backups list */}
|
{/* Container content - backups list */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border">
|
<div className="border-border border-t">
|
||||||
<div className="p-4 space-y-3">
|
<div className="space-y-3 p-4">
|
||||||
{container.backups.map((backup) => (
|
{container.backups.map((backup) => (
|
||||||
<div
|
<div
|
||||||
key={backup.id}
|
key={backup.id}
|
||||||
className="bg-muted/50 rounded-lg p-4 border border-border/50"
|
className="bg-muted/50 border-border/50 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
<span className="font-medium text-foreground break-all">
|
<span className="text-foreground font-medium break-all">
|
||||||
{backup.backup_name}
|
{backup.backup_name}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={getStorageTypeBadgeVariant(backup.storage_type)}
|
variant={getStorageTypeBadgeVariant(
|
||||||
|
backup.storage_type,
|
||||||
|
)}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{getStorageTypeIcon(backup.storage_type)}
|
{getStorageTypeIcon(backup.storage_type)}
|
||||||
{backup.storage_name}
|
{backup.storage_name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm">
|
||||||
{backup.size && (
|
{backup.size && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3" />
|
<HardDrive className="h-3 w-3" />
|
||||||
@@ -339,7 +382,7 @@ export function BackupsTab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<code className="text-xs text-muted-foreground break-all">
|
<code className="text-muted-foreground text-xs break-all">
|
||||||
{backup.backup_path}
|
{backup.backup_path}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,14 +393,19 @@ export function BackupsTab() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
|
className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||||
>
|
>
|
||||||
Actions
|
Actions
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
<DropdownMenuContent className="bg-card border-border w-48">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleRestoreClick(backup, container.container_id)}
|
onClick={() =>
|
||||||
|
handleRestoreClick(
|
||||||
|
backup,
|
||||||
|
container.container_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={restoreMutation.isPending}
|
disabled={restoreMutation.isPending}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||||
>
|
>
|
||||||
@@ -386,9 +434,9 @@ export function BackupsTab() {
|
|||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
{backupsData && !backupsData.success && (
|
{backupsData && !backupsData.success && (
|
||||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
<div className="bg-destructive/10 border-destructive rounded-lg border p-4">
|
||||||
<p className="text-destructive">
|
<p className="text-destructive">
|
||||||
Error loading backups: {backupsData.error || 'Unknown error'}
|
Error loading backups: {backupsData.error ?? "Unknown error"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -412,7 +460,8 @@ export function BackupsTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Restore Progress Modal */}
|
{/* Restore Progress Modal */}
|
||||||
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
|
{(restoreMutation.isPending ||
|
||||||
|
(restoreSuccess && restoreProgress.length > 0)) && (
|
||||||
<LoadingModal
|
<LoadingModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
action={currentProgressText}
|
action={currentProgressText}
|
||||||
@@ -428,11 +477,13 @@ export function BackupsTab() {
|
|||||||
|
|
||||||
{/* Restore Success */}
|
{/* Restore Success */}
|
||||||
{restoreSuccess && (
|
{restoreSuccess && (
|
||||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
<div className="bg-success/10 border-success/20 rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-success" />
|
<CheckCircle className="text-success h-5 w-5" />
|
||||||
<span className="font-medium text-success">Restore Completed Successfully</span>
|
<span className="text-success font-medium">
|
||||||
|
Restore Completed Successfully
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -446,7 +497,7 @@ export function BackupsTab() {
|
|||||||
×
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
The container has been restored from backup.
|
The container has been restored from backup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,11 +505,11 @@ export function BackupsTab() {
|
|||||||
|
|
||||||
{/* Restore Error */}
|
{/* Restore Error */}
|
||||||
{restoreError && (
|
{restoreError && (
|
||||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
<div className="bg-error/10 border-error/20 rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-error" />
|
<AlertCircle className="text-error h-5 w-5" />
|
||||||
<span className="font-medium text-error">Restore Failed</span>
|
<span className="text-error font-medium">Restore Failed</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -472,13 +523,11 @@ export function BackupsTab() {
|
|||||||
×
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">{restoreError}</p>
|
||||||
{restoreError}
|
|
||||||
</p>
|
|
||||||
{restoreProgress.length > 0 && (
|
{restoreProgress.length > 0 && (
|
||||||
<div className="space-y-1 mt-2">
|
<div className="mt-2 space-y-1">
|
||||||
{restoreProgress.map((message, index) => (
|
{restoreProgress.map((message, index) => (
|
||||||
<p key={index} className="text-sm text-muted-foreground">
|
<p key={index} className="text-muted-foreground text-sm">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
@@ -500,4 +549,3 @@ export function BackupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,10 @@ export function CategorySidebar({
|
|||||||
'Miscellaneous': 'box'
|
'Miscellaneous': 'box'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort categories by count (descending) and then alphabetically
|
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically
|
||||||
const sortedCategories = categories
|
const sortedCategories = categories
|
||||||
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
||||||
|
.filter(([, count]) => count > 0) // Only show categories with at least one script
|
||||||
.sort(([a, countA], [b, countB]) => {
|
.sort(([a, countA], [b, countB]) => {
|
||||||
if (countB !== countA) return countB - countA;
|
if (countB !== countA) return countB - countA;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
|
|||||||
129
src/app/_components/CloneCountInputModal.tsx
Normal file
129
src/app/_components/CloneCountInputModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Copy, X } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
|
interface CloneCountInputModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (count: number) => void;
|
||||||
|
storageName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloneCountInputModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
storageName
|
||||||
|
}: CloneCountInputModalProps) {
|
||||||
|
const [cloneCount, setCloneCount] = useState<number>(1);
|
||||||
|
|
||||||
|
useRegisterModal(isOpen, { id: 'clone-count-input-modal', allowEscape: true, onClose });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setCloneCount(1); // Reset to default when modal opens
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (cloneCount >= 1) {
|
||||||
|
onSubmit(cloneCount);
|
||||||
|
setCloneCount(1); // Reset after submit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCloneCount(1); // Reset on close
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Copy className="h-6 w-6 text-primary" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Clone Count</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
How many clones would you like to create?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{storageName && (
|
||||||
|
<div className="mb-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Storage:</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">{storageName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
<label htmlFor="cloneCount" className="block text-sm font-medium text-foreground">
|
||||||
|
Number of Clones
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="cloneCount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={cloneCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 100) {
|
||||||
|
setCloneCount(value);
|
||||||
|
} else if (e.target.value === '') {
|
||||||
|
setCloneCount(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter a number between 1 and 100
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={cloneCount < 1 || cloneCount > 100}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
899
src/app/_components/ConfigurationModal.tsx
Normal file
899
src/app/_components/ConfigurationModal.tsx
Normal file
@@ -0,0 +1,899 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import type { Script } from '~/types/script';
|
||||||
|
import type { Server } from '~/types/server';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
|
export type EnvVars = Record<string, string | number | boolean>;
|
||||||
|
|
||||||
|
interface ConfigurationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (envVars: EnvVars) => void;
|
||||||
|
script: Script | null;
|
||||||
|
server: Server | null;
|
||||||
|
mode: 'default' | 'advanced';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigurationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
script,
|
||||||
|
server,
|
||||||
|
mode,
|
||||||
|
}: ConfigurationModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'configuration-modal', allowEscape: true, onClose });
|
||||||
|
|
||||||
|
// Fetch script data if we only have slug
|
||||||
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
|
{ slug: script?.slug ?? '' },
|
||||||
|
{ enabled: !!script?.slug && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualScript = script ?? (scriptData?.script ?? null);
|
||||||
|
|
||||||
|
// Fetch storages
|
||||||
|
const { data: rootfsStoragesData } = api.scripts.getRootfsStorages.useQuery(
|
||||||
|
{ serverId: server?.id ?? 0, forceRefresh: false },
|
||||||
|
{ enabled: !!server?.id && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: templateStoragesData } = api.scripts.getTemplateStorages.useQuery(
|
||||||
|
{ serverId: server?.id ?? 0, forceRefresh: false },
|
||||||
|
{ enabled: !!server?.id && isOpen && mode === 'advanced' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get resources from JSON
|
||||||
|
const resources = actualScript?.install_methods?.[0]?.resources;
|
||||||
|
const slug = actualScript?.slug ?? '';
|
||||||
|
|
||||||
|
// Default mode state
|
||||||
|
const [containerStorage, setContainerStorage] = useState<string>('');
|
||||||
|
|
||||||
|
// Advanced mode state
|
||||||
|
const [advancedVars, setAdvancedVars] = useState<EnvVars>({});
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Initialize defaults when script/server data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actualScript || !server) return;
|
||||||
|
|
||||||
|
if (mode === 'default') {
|
||||||
|
// Default mode: minimal vars
|
||||||
|
setContainerStorage('');
|
||||||
|
} else {
|
||||||
|
// Advanced mode: all vars with defaults
|
||||||
|
const defaults: EnvVars = {
|
||||||
|
// Resources from JSON
|
||||||
|
var_cpu: resources?.cpu ?? 1,
|
||||||
|
var_ram: resources?.ram ?? 1024,
|
||||||
|
var_disk: resources?.hdd ?? 4,
|
||||||
|
var_unprivileged: script?.privileged === false ? 1 : (script?.privileged === true ? 0 : 1),
|
||||||
|
|
||||||
|
// Network defaults
|
||||||
|
var_net: 'dhcp',
|
||||||
|
var_brg: 'vmbr0',
|
||||||
|
var_gateway: '',
|
||||||
|
var_ipv6_method: 'none',
|
||||||
|
var_ipv6_static: '',
|
||||||
|
var_vlan: '',
|
||||||
|
var_mtu: 1500,
|
||||||
|
var_mac: '',
|
||||||
|
var_ns: '',
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
var_hostname: slug,
|
||||||
|
var_pw: '',
|
||||||
|
var_tags: 'community-script',
|
||||||
|
|
||||||
|
// SSH
|
||||||
|
var_ssh: 'no',
|
||||||
|
var_ssh_authorized_key: '',
|
||||||
|
|
||||||
|
// Features
|
||||||
|
var_nesting: 1,
|
||||||
|
var_fuse: 0,
|
||||||
|
var_keyctl: 0,
|
||||||
|
var_mknod: 0,
|
||||||
|
var_mount_fs: '',
|
||||||
|
var_protection: 'no',
|
||||||
|
|
||||||
|
// System
|
||||||
|
var_timezone: '',
|
||||||
|
var_verbose: 'no',
|
||||||
|
var_apt_cacher: 'no',
|
||||||
|
var_apt_cacher_ip: '',
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
var_container_storage: '',
|
||||||
|
var_template_storage: '',
|
||||||
|
};
|
||||||
|
setAdvancedVars(defaults);
|
||||||
|
}
|
||||||
|
}, [actualScript, server, mode, resources, slug]);
|
||||||
|
|
||||||
|
// Validation functions
|
||||||
|
const validateIPv4 = (ip: string): boolean => {
|
||||||
|
if (!ip) return true; // Empty is allowed (auto)
|
||||||
|
const pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
if (!pattern.test(ip)) return false;
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
return parts.every(p => p >= 0 && p <= 255);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCIDR = (cidr: string): boolean => {
|
||||||
|
if (!cidr) return true; // Empty is allowed
|
||||||
|
const pattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
||||||
|
if (!pattern.test(cidr)) return false;
|
||||||
|
const parts = cidr.split('/');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [ip, prefix] = parts;
|
||||||
|
if (!ip || !prefix) return false;
|
||||||
|
const ipParts = ip.split('.').map(Number);
|
||||||
|
if (!ipParts.every(p => p >= 0 && p <= 255)) return false;
|
||||||
|
const prefixNum = parseInt(prefix, 10);
|
||||||
|
return prefixNum >= 0 && prefixNum <= 32;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateIPv6 = (ipv6: string): boolean => {
|
||||||
|
if (!ipv6) return true; // Empty is allowed
|
||||||
|
// Basic IPv6 validation (simplified - allows compressed format)
|
||||||
|
const pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
|
||||||
|
return pattern.test(ipv6);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateMAC = (mac: string): boolean => {
|
||||||
|
if (!mac) return true; // Empty is allowed (auto)
|
||||||
|
const pattern = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
|
||||||
|
return pattern.test(mac);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePositiveInt = (value: string | number | undefined): boolean => {
|
||||||
|
if (value === '' || value === undefined) return true;
|
||||||
|
const num = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||||
|
return !isNaN(num) && num > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (mode === 'default') {
|
||||||
|
// Default mode: only storage is optional
|
||||||
|
// No validation needed
|
||||||
|
} else {
|
||||||
|
// Advanced mode: validate all fields
|
||||||
|
if (advancedVars.var_gateway && !validateIPv4(advancedVars.var_gateway as string)) {
|
||||||
|
newErrors.var_gateway = 'Invalid IPv4 address';
|
||||||
|
}
|
||||||
|
if (advancedVars.var_mac && !validateMAC(advancedVars.var_mac as string)) {
|
||||||
|
newErrors.var_mac = 'Invalid MAC address format (XX:XX:XX:XX:XX:XX)';
|
||||||
|
}
|
||||||
|
if (advancedVars.var_ns && !validateIPv4(advancedVars.var_ns as string)) {
|
||||||
|
newErrors.var_ns = 'Invalid IPv4 address';
|
||||||
|
}
|
||||||
|
if (advancedVars.var_apt_cacher_ip && !validateIPv4(advancedVars.var_apt_cacher_ip as string)) {
|
||||||
|
newErrors.var_apt_cacher_ip = 'Invalid IPv4 address';
|
||||||
|
}
|
||||||
|
// Validate IPv4 CIDR if network mode is static
|
||||||
|
const netValue = advancedVars.var_net;
|
||||||
|
const isStaticMode = netValue === 'static' || (typeof netValue === 'string' && netValue.includes('/'));
|
||||||
|
if (isStaticMode) {
|
||||||
|
const cidrValue = (typeof netValue === 'string' && netValue.includes('/')) ? netValue : (advancedVars.var_ip as string ?? '');
|
||||||
|
if (cidrValue && !validateCIDR(cidrValue)) {
|
||||||
|
newErrors.var_ip = 'Invalid CIDR format (e.g., 10.10.10.1/24)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate IPv6 static if IPv6 method is static
|
||||||
|
if (advancedVars.var_ipv6_method === 'static' && advancedVars.var_ipv6_static) {
|
||||||
|
if (!validateIPv6(advancedVars.var_ipv6_static as string)) {
|
||||||
|
newErrors.var_ipv6_static = 'Invalid IPv6 address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!validatePositiveInt(advancedVars.var_cpu as string | number | undefined)) {
|
||||||
|
newErrors.var_cpu = 'Must be a positive integer';
|
||||||
|
}
|
||||||
|
if (!validatePositiveInt(advancedVars.var_ram as string | number | undefined)) {
|
||||||
|
newErrors.var_ram = 'Must be a positive integer';
|
||||||
|
}
|
||||||
|
if (!validatePositiveInt(advancedVars.var_disk as string | number | undefined)) {
|
||||||
|
newErrors.var_disk = 'Must be a positive integer';
|
||||||
|
}
|
||||||
|
if (advancedVars.var_mtu && !validatePositiveInt(advancedVars.var_mtu as string | number | undefined)) {
|
||||||
|
newErrors.var_mtu = 'Must be a positive integer';
|
||||||
|
}
|
||||||
|
if (advancedVars.var_vlan && !validatePositiveInt(advancedVars.var_vlan as string | number | undefined)) {
|
||||||
|
newErrors.var_vlan = 'Must be a positive integer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let envVars: EnvVars = {};
|
||||||
|
|
||||||
|
if (mode === 'default') {
|
||||||
|
// Default mode: minimal vars
|
||||||
|
envVars = {
|
||||||
|
var_hostname: slug,
|
||||||
|
var_brg: 'vmbr0',
|
||||||
|
var_net: 'dhcp',
|
||||||
|
var_ipv6_method: 'auto',
|
||||||
|
var_ssh: 'no',
|
||||||
|
var_nesting: 1,
|
||||||
|
var_verbose: 'no',
|
||||||
|
var_cpu: resources?.cpu ?? 1,
|
||||||
|
var_ram: resources?.ram ?? 1024,
|
||||||
|
var_disk: resources?.hdd ?? 4,
|
||||||
|
var_unprivileged: script?.privileged === false ? 1 : (script?.privileged === true ? 0 : 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (containerStorage) {
|
||||||
|
envVars.var_container_storage = containerStorage;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Advanced mode: all vars
|
||||||
|
envVars = { ...advancedVars };
|
||||||
|
|
||||||
|
// If network mode is static and var_ip is set, replace var_net with the CIDR
|
||||||
|
if (envVars.var_net === 'static' && envVars.var_ip) {
|
||||||
|
envVars.var_net = envVars.var_ip as string;
|
||||||
|
delete envVars.var_ip; // Remove the temporary var_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format password correctly: if var_pw is set, format it as "-password <password>"
|
||||||
|
// build.func expects PW to be in "-password <password>" format when added to PCT_OPTIONS
|
||||||
|
const rawPassword = envVars.var_pw;
|
||||||
|
const hasPassword = rawPassword && typeof rawPassword === 'string' && rawPassword.trim() !== '';
|
||||||
|
const hasSSHKey = envVars.var_ssh_authorized_key && typeof envVars.var_ssh_authorized_key === 'string' && envVars.var_ssh_authorized_key.trim() !== '';
|
||||||
|
|
||||||
|
if (hasPassword) {
|
||||||
|
// Remove any existing "-password" prefix to avoid double-formatting
|
||||||
|
const cleanPassword = rawPassword.startsWith('-password ')
|
||||||
|
? rawPassword.substring(11)
|
||||||
|
: rawPassword;
|
||||||
|
// Format as "-password <password>" for build.func
|
||||||
|
envVars.var_pw = `-password ${cleanPassword}`;
|
||||||
|
} else {
|
||||||
|
// Empty password means auto-login, clear var_pw
|
||||||
|
envVars.var_pw = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if ((hasPassword || hasSSHKey) && envVars.var_ssh !== 'no') {
|
||||||
|
envVars.var_ssh = 'yes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty string values (but keep 0, false, etc.)
|
||||||
|
const cleaned: EnvVars = {};
|
||||||
|
for (const [key, value] of Object.entries(envVars)) {
|
||||||
|
if (value !== '' && value !== undefined) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always set mode to "default" (build.func line 1783 expects this)
|
||||||
|
cleaned.mode = 'default';
|
||||||
|
|
||||||
|
onConfirm(cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAdvancedVar = (key: string, value: string | number | boolean) => {
|
||||||
|
setAdvancedVars(prev => ({ ...prev, [key]: value }));
|
||||||
|
// Clear error for this field
|
||||||
|
if (errors[key]) {
|
||||||
|
setErrors(prev => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[key];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const rootfsStorages = rootfsStoragesData?.storages ?? [];
|
||||||
|
const templateStorages = templateStoragesData?.storages ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full border border-border max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">
|
||||||
|
{mode === 'default' ? 'Default Configuration' : 'Advanced Configuration'}
|
||||||
|
</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">
|
||||||
|
{mode === 'default' ? (
|
||||||
|
/* Default Mode */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Container Storage
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={containerStorage}
|
||||||
|
onChange={(e) => setContainerStorage(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Auto (let script choose)</option>
|
||||||
|
{rootfsStorages.map((storage) => (
|
||||||
|
<option key={storage.name} value={storage.name}>
|
||||||
|
{storage.name} ({storage.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{rootfsStorages.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Could not fetch storages. Script will use default selection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<h3 className="text-sm font-medium text-foreground mb-2">Default Values</h3>
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p>Hostname: {slug}</p>
|
||||||
|
<p>Bridge: vmbr0</p>
|
||||||
|
<p>Network: DHCP</p>
|
||||||
|
<p>IPv6: Auto</p>
|
||||||
|
<p>SSH: Disabled</p>
|
||||||
|
<p>Nesting: Enabled</p>
|
||||||
|
<p>CPU: {resources?.cpu ?? 1}</p>
|
||||||
|
<p>RAM: {resources?.ram ?? 1024} MB</p>
|
||||||
|
<p>Disk: {resources?.hdd ?? 4} GB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Advanced Mode */
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Resources */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Resources</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
CPU Cores *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={typeof advancedVars.var_cpu === 'boolean' ? '' : (advancedVars.var_cpu ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_cpu', parseInt(e.target.value) || 1)}
|
||||||
|
className={errors.var_cpu ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_cpu && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_cpu}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
RAM (MB) *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={typeof advancedVars.var_ram === 'boolean' ? '' : (advancedVars.var_ram ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_ram', parseInt(e.target.value) || 1024)}
|
||||||
|
className={errors.var_ram ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_ram && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_ram}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Disk Size (GB) *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={typeof advancedVars.var_disk === 'boolean' ? '' : (advancedVars.var_disk ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_disk', parseInt(e.target.value) || 4)}
|
||||||
|
className={errors.var_disk ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_disk && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_disk}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Unprivileged
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_unprivileged === 'boolean' ? (advancedVars.var_unprivileged ? 0 : 1) : (advancedVars.var_unprivileged ?? 1)}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_unprivileged', parseInt(e.target.value))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={1}>Yes (Unprivileged)</option>
|
||||||
|
<option value={0}>No (Privileged)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Network</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Network Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={(typeof advancedVars.var_net === 'string' && advancedVars.var_net.includes('/')) ? 'static' : (typeof advancedVars.var_net === 'boolean' ? 'dhcp' : (advancedVars.var_net ?? 'dhcp'))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === 'static') {
|
||||||
|
updateAdvancedVar('var_net', 'static');
|
||||||
|
} else {
|
||||||
|
updateAdvancedVar('var_net', e.target.value);
|
||||||
|
// Clear IPv4 IP when switching away from static
|
||||||
|
if (advancedVars.var_ip) {
|
||||||
|
updateAdvancedVar('var_ip', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="dhcp">DHCP</option>
|
||||||
|
<option value="static">Static</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(advancedVars.var_net === 'static' || (typeof advancedVars.var_net === 'string' && advancedVars.var_net.includes('/'))) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
IPv4 Address (CIDR) *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={(typeof advancedVars.var_net === 'string' && advancedVars.var_net.includes('/')) ? advancedVars.var_net : (advancedVars.var_ip as string | undefined ?? '')}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Store in var_ip temporarily, will be moved to var_net on confirm
|
||||||
|
updateAdvancedVar('var_ip', e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="10.10.10.1/24"
|
||||||
|
className={errors.var_ip ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_ip && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_ip}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Bridge
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_brg === 'boolean' ? '' : String(advancedVars.var_brg ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_brg', e.target.value)}
|
||||||
|
placeholder="vmbr0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Gateway (IP)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_gateway === 'boolean' ? '' : String(advancedVars.var_gateway ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_gateway', e.target.value)}
|
||||||
|
placeholder="Auto"
|
||||||
|
className={errors.var_gateway ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_gateway && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_gateway}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
IPv6 Method
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_ipv6_method === 'boolean' ? 'none' : String(advancedVars.var_ipv6_method ?? 'none')}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateAdvancedVar('var_ipv6_method', e.target.value);
|
||||||
|
// Clear IPv6 static when switching away from static
|
||||||
|
if (e.target.value !== 'static' && advancedVars.var_ipv6_static) {
|
||||||
|
updateAdvancedVar('var_ipv6_static', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="dhcp">DHCP</option>
|
||||||
|
<option value="static">Static</option>
|
||||||
|
<option value="disable">Disable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{advancedVars.var_ipv6_method === 'static' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
IPv6 Static Address *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_ipv6_static === 'boolean' ? '' : String(advancedVars.var_ipv6_static ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_ipv6_static', e.target.value)}
|
||||||
|
placeholder="2001:db8::1/64"
|
||||||
|
className={errors.var_ipv6_static ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_ipv6_static && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_ipv6_static}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
VLAN Tag
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={typeof advancedVars.var_vlan === 'boolean' ? '' : String(advancedVars.var_vlan ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_vlan', e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
placeholder="None"
|
||||||
|
className={errors.var_vlan ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_vlan && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_vlan}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
MTU
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={typeof advancedVars.var_mtu === 'boolean' ? '' : String(advancedVars.var_mtu ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_mtu', e.target.value ? parseInt(e.target.value) : 1500)}
|
||||||
|
placeholder="1500"
|
||||||
|
className={errors.var_mtu ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_mtu && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_mtu}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
MAC Address
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_mac === 'boolean' ? '' : String(advancedVars.var_mac ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_mac', e.target.value)}
|
||||||
|
placeholder="Auto"
|
||||||
|
className={errors.var_mac ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_mac && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_mac}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
DNS Nameserver (IP)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_ns === 'boolean' ? '' : String(advancedVars.var_ns ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_ns', e.target.value)}
|
||||||
|
placeholder="Auto"
|
||||||
|
className={errors.var_ns ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_ns && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_ns}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity & Metadata */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Identity & Metadata</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Hostname *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_hostname === 'boolean' ? '' : String(advancedVars.var_hostname ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_hostname', e.target.value)}
|
||||||
|
placeholder={slug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Root Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={typeof advancedVars.var_pw === 'boolean' ? '' : String(advancedVars.var_pw ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_pw', e.target.value)}
|
||||||
|
placeholder="Random (empty = auto-login)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Tags (comma-separated)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_tags === 'boolean' ? '' : String(advancedVars.var_tags ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_tags', e.target.value)}
|
||||||
|
placeholder="community-script"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Access */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">SSH Access</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Enable SSH
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_ssh === 'boolean' ? (advancedVars.var_ssh ? 'yes' : 'no') : String(advancedVars.var_ssh ?? 'no')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_ssh', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
SSH Authorized Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_ssh_authorized_key === 'boolean' ? '' : String(advancedVars.var_ssh_authorized_key ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_ssh_authorized_key', e.target.value)}
|
||||||
|
placeholder="ssh-rsa AAAA..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container Features */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Container Features</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Nesting (Docker)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_nesting === 'boolean' ? 1 : (advancedVars.var_nesting ?? 1)}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_nesting', parseInt(e.target.value))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={1}>Enabled</option>
|
||||||
|
<option value={0}>Disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
FUSE
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_fuse === 'boolean' ? 0 : (advancedVars.var_fuse ?? 0)}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_fuse', parseInt(e.target.value))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={0}>Disabled</option>
|
||||||
|
<option value={1}>Enabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Keyctl
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_keyctl === 'boolean' ? 0 : (advancedVars.var_keyctl ?? 0)}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_keyctl', parseInt(e.target.value))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={0}>Disabled</option>
|
||||||
|
<option value={1}>Enabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Mknod
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_mknod === 'boolean' ? 0 : (advancedVars.var_mknod ?? 0)}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_mknod', parseInt(e.target.value))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={0}>Disabled</option>
|
||||||
|
<option value={1}>Enabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Mount Filesystems
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_mount_fs === 'boolean' ? '' : String(advancedVars.var_mount_fs ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_mount_fs', e.target.value)}
|
||||||
|
placeholder="nfs,cifs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Protection
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_protection === 'boolean' ? (advancedVars.var_protection ? 'yes' : 'no') : String(advancedVars.var_protection ?? 'no')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_protection', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Configuration */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">System Configuration</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Timezone
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_timezone === 'boolean' ? '' : String(advancedVars.var_timezone ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_timezone', e.target.value)}
|
||||||
|
placeholder="System"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Verbose
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_verbose === 'boolean' ? (advancedVars.var_verbose ? 'yes' : 'no') : String(advancedVars.var_verbose ?? 'no')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_verbose', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
APT Cacher
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_apt_cacher === 'boolean' ? (advancedVars.var_apt_cacher ? 'yes' : 'no') : String(advancedVars.var_apt_cacher ?? 'no')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_apt_cacher', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
APT Cacher IP
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof advancedVars.var_apt_cacher_ip === 'boolean' ? '' : String(advancedVars.var_apt_cacher_ip ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_apt_cacher_ip', e.target.value)}
|
||||||
|
placeholder="192.168.1.10"
|
||||||
|
className={errors.var_apt_cacher_ip ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.var_apt_cacher_ip && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{errors.var_apt_cacher_ip}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage Selection */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Storage Selection</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Container Storage
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_container_storage === 'boolean' ? '' : String(advancedVars.var_container_storage ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_container_storage', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Auto</option>
|
||||||
|
{rootfsStorages.map((storage) => (
|
||||||
|
<option key={storage.name} value={storage.name}>
|
||||||
|
{storage.name} ({storage.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{rootfsStorages.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Could not fetch storages. Leave empty for auto selection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Template Storage
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeof advancedVars.var_template_storage === 'boolean' ? '' : String(advancedVars.var_template_storage ?? '')}
|
||||||
|
onChange={(e) => updateAdvancedVar('var_template_storage', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Auto</option>
|
||||||
|
{templateStorages.map((storage) => (
|
||||||
|
<option key={storage.name} value={storage.name}>
|
||||||
|
{storage.name} ({storage.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{templateStorages.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Could not fetch storages. Leave empty for auto selection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-3 mt-6 pt-6 border-t border-border">
|
||||||
|
<Button onClick={onClose} variant="outline" size="default">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} variant="default" size="default">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,41 +1,53 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { api } from '~/trpc/react';
|
import { api } from "~/trpc/react";
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from "./ScriptCard";
|
||||||
import { ScriptCardList } from './ScriptCardList';
|
import { ScriptCardList } from "./ScriptCardList";
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from "./ScriptDetailModal";
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from "./CategorySidebar";
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from "./FilterBar";
|
||||||
import { ViewToggle } from './ViewToggle';
|
import { ViewToggle } from "./ViewToggle";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from "~/types/script";
|
||||||
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
|
import type { Server } from "~/types/server";
|
||||||
|
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
|
||||||
|
|
||||||
interface DownloadedScriptsTabProps {
|
interface DownloadedScriptsTabProps {
|
||||||
onInstallScript?: (
|
onInstallScript?: (
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
mode?: "local" | "ssh",
|
mode?: "local" | "ssh",
|
||||||
server?: any,
|
server?: Server,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
export function DownloadedScriptsTab({
|
||||||
|
onInstallScript,
|
||||||
|
}: DownloadedScriptsTabProps) {
|
||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||||
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
||||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const {
|
||||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
data: scriptCardsData,
|
||||||
|
isLoading: githubLoading,
|
||||||
|
error: githubError,
|
||||||
|
refetch,
|
||||||
|
} = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
|
const {
|
||||||
|
data: localScriptsData,
|
||||||
|
isLoading: localLoading,
|
||||||
|
error: localError,
|
||||||
|
} = api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
{ slug: selectedSlug ?? '' },
|
{ slug: selectedSlug ?? "" },
|
||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
@@ -43,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
// Load SAVE_FILTER setting
|
// Load SAVE_FILTER setting
|
||||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
const saveFilterResponse = await fetch("/api/settings/save-filter");
|
||||||
let saveFilterEnabled = false;
|
let saveFilterEnabled = false;
|
||||||
if (saveFilterResponse.ok) {
|
if (saveFilterResponse.ok) {
|
||||||
const saveFilterData = await saveFilterResponse.json();
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
@@ -53,9 +65,11 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Load saved filters if SAVE_FILTER is enabled
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
if (saveFilterEnabled) {
|
if (saveFilterEnabled) {
|
||||||
const filtersResponse = await fetch('/api/settings/filters');
|
const filtersResponse = await fetch("/api/settings/filters");
|
||||||
if (filtersResponse.ok) {
|
if (filtersResponse.ok) {
|
||||||
const filtersData = await filtersResponse.json();
|
const filtersData = (await filtersResponse.json()) as {
|
||||||
|
filters?: Partial<FilterState>;
|
||||||
|
};
|
||||||
if (filtersData.filters) {
|
if (filtersData.filters) {
|
||||||
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
||||||
}
|
}
|
||||||
@@ -63,16 +77,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load view mode
|
// Load view mode
|
||||||
const viewModeResponse = await fetch('/api/settings/view-mode');
|
const viewModeResponse = await fetch("/api/settings/view-mode");
|
||||||
if (viewModeResponse.ok) {
|
if (viewModeResponse.ok) {
|
||||||
const viewModeData = await viewModeResponse.json();
|
const viewModeData = await viewModeResponse.json();
|
||||||
const viewMode = viewModeData.viewMode;
|
const viewMode = viewModeData.viewMode;
|
||||||
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
if (
|
||||||
|
viewMode &&
|
||||||
|
typeof viewMode === "string" &&
|
||||||
|
(viewMode === "card" || viewMode === "list")
|
||||||
|
) {
|
||||||
setViewMode(viewMode);
|
setViewMode(viewMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error("Error loading settings:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFilters(false);
|
setIsLoadingFilters(false);
|
||||||
}
|
}
|
||||||
@@ -87,15 +105,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
const saveFilters = async () => {
|
const saveFilters = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/settings/filters', {
|
await fetch("/api/settings/filters", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ filters }),
|
body: JSON.stringify({ filters }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving filters:', error);
|
console.error("Error saving filters:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,15 +128,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
const saveViewMode = async () => {
|
const saveViewMode = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/settings/view-mode', {
|
await fetch("/api/settings/view-mode", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ viewMode }),
|
body: JSON.stringify({ viewMode }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving view mode:', error);
|
console.error("Error saving view mode:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,13 +147,14 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories)
|
||||||
|
return [];
|
||||||
|
|
||||||
return (scriptCardsData.metadata.categories as any[])
|
return (scriptCardsData.metadata.categories as any[])
|
||||||
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
.map((cat) => cat.name as string)
|
.map((cat) => cat.name as string)
|
||||||
.filter((name): name is string => typeof name === 'string');
|
.filter((name): name is string => typeof name === "string");
|
||||||
}, [scriptCardsData]);
|
}, [scriptCardsData]);
|
||||||
|
|
||||||
// Get GitHub scripts with download status (deduplicated)
|
// Get GitHub scripts with download status (deduplicated)
|
||||||
@@ -145,13 +164,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
// Use Map to deduplicate by slug/name
|
// Use Map to deduplicate by slug/name
|
||||||
const scriptMap = new Map<string, ScriptCardType>();
|
const scriptMap = new Map<string, ScriptCardType>();
|
||||||
|
|
||||||
scriptCardsData.cards?.forEach(script => {
|
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
|
||||||
if (script?.name && script?.slug) {
|
if (script?.name && script?.slug) {
|
||||||
// Use slug as unique identifier, only keep first occurrence
|
// Use slug as unique identifier, only keep first occurrence
|
||||||
if (!scriptMap.has(script.slug)) {
|
if (!scriptMap.has(script.slug)) {
|
||||||
scriptMap.set(script.slug, {
|
scriptMap.set(script.slug, {
|
||||||
...script,
|
...script,
|
||||||
source: 'github' as const,
|
source: "github" as const,
|
||||||
isDownloaded: false, // Will be updated by status check
|
isDownloaded: false, // Will be updated by status check
|
||||||
isUpToDate: false, // Will be updated by status check
|
isUpToDate: false, // Will be updated by status check
|
||||||
});
|
});
|
||||||
@@ -165,20 +184,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
// Update scripts with download status and filter to only downloaded scripts
|
// Update scripts with download status and filter to only downloaded scripts
|
||||||
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
||||||
// Helper to normalize identifiers so underscores vs hyphens don't break matches
|
// Helper to normalize identifiers so underscores vs hyphens don't break matches
|
||||||
const normalizeId = (s?: string): string => (s ?? '')
|
const normalizeId = (s?: string): string =>
|
||||||
|
(s ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
return combinedScripts
|
return combinedScripts
|
||||||
.map(script => {
|
.map((script) => {
|
||||||
if (!script?.name) {
|
if (!script?.name) {
|
||||||
return script; // Return as-is if invalid
|
return script; // Return as-is if invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's a corresponding local script
|
// Check if there's a corresponding local script
|
||||||
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
const hasLocalVersion =
|
||||||
|
localScriptsData?.scripts?.some((local) => {
|
||||||
if (!local?.name) return false;
|
if (!local?.name) return false;
|
||||||
|
|
||||||
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
||||||
@@ -191,7 +212,10 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||||
// Only use normalized matching for install basenames, not for slug/name matching
|
// Only use normalized matching for install basenames, not for slug/name matching
|
||||||
const normalizedLocal = normalizeId(local.name);
|
const normalizedLocal = normalizeId(local.name);
|
||||||
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
|
const matchesInstallBasename =
|
||||||
|
(script as any)?.install_basenames?.some(
|
||||||
|
(base: string) => normalizeId(base) === normalizedLocal,
|
||||||
|
) ?? false;
|
||||||
return matchesInstallBasename;
|
return matchesInstallBasename;
|
||||||
}) ?? false;
|
}) ?? false;
|
||||||
|
|
||||||
@@ -200,7 +224,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
isDownloaded: hasLocalVersion,
|
isDownloaded: hasLocalVersion,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(script => script.isDownloaded); // Only show downloaded scripts
|
.filter((script) => script.isDownloaded); // Only show downloaded scripts
|
||||||
}, [combinedScripts, localScriptsData]);
|
}, [combinedScripts, localScriptsData]);
|
||||||
|
|
||||||
// Count scripts per category (using downloaded scripts only)
|
// Count scripts per category (using downloaded scripts only)
|
||||||
@@ -215,11 +239,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Count each unique downloaded script only once per category
|
// Count each unique downloaded script only once per category
|
||||||
downloadedScripts.forEach(script => {
|
downloadedScripts.forEach((script) => {
|
||||||
if (script.categoryNames && script.slug) {
|
if (script.categoryNames && script.slug) {
|
||||||
const countedCategories = new Set<string>();
|
const countedCategories = new Set<string>();
|
||||||
script.categoryNames.forEach((categoryName: unknown) => {
|
script.categoryNames.forEach((categoryName: unknown) => {
|
||||||
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
|
if (
|
||||||
|
typeof categoryName === "string" &&
|
||||||
|
counts[categoryName] !== undefined &&
|
||||||
|
!countedCategories.has(categoryName)
|
||||||
|
) {
|
||||||
countedCategories.add(categoryName);
|
countedCategories.add(categoryName);
|
||||||
counts[categoryName]++;
|
counts[categoryName]++;
|
||||||
}
|
}
|
||||||
@@ -239,13 +267,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
const query = filters.searchQuery.toLowerCase().trim();
|
const query = filters.searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
if (query.length >= 1) {
|
if (query.length >= 1) {
|
||||||
scripts = scripts.filter(script => {
|
scripts = scripts.filter((script) => {
|
||||||
if (!script || typeof script !== 'object') {
|
if (!script || typeof script !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = (script.name ?? '').toLowerCase();
|
const name = (script.name ?? "").toLowerCase();
|
||||||
const slug = (script.slug ?? '').toLowerCase();
|
const slug = (script.slug ?? "").toLowerCase();
|
||||||
|
|
||||||
return name.includes(query) ?? slug.includes(query);
|
return name.includes(query) ?? slug.includes(query);
|
||||||
});
|
});
|
||||||
@@ -254,7 +282,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Filter by category using real category data from downloaded scripts
|
// Filter by category using real category data from downloaded scripts
|
||||||
if (selectedCategory) {
|
if (selectedCategory) {
|
||||||
scripts = scripts.filter(script => {
|
scripts = scripts.filter((script) => {
|
||||||
if (!script) return false;
|
if (!script) return false;
|
||||||
|
|
||||||
// Check if the downloaded script has categoryNames that include the selected category
|
// Check if the downloaded script has categoryNames that include the selected category
|
||||||
@@ -264,7 +292,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Filter by updateable status
|
// Filter by updateable status
|
||||||
if (filters.showUpdatable !== null) {
|
if (filters.showUpdatable !== null) {
|
||||||
scripts = scripts.filter(script => {
|
scripts = scripts.filter((script) => {
|
||||||
if (!script) return false;
|
if (!script) return false;
|
||||||
const isUpdatable = script.updateable ?? false;
|
const isUpdatable = script.updateable ?? false;
|
||||||
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
||||||
@@ -273,20 +301,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Filter by script types
|
// Filter by script types
|
||||||
if (filters.selectedTypes.length > 0) {
|
if (filters.selectedTypes.length > 0) {
|
||||||
scripts = scripts.filter(script => {
|
scripts = scripts.filter((script) => {
|
||||||
if (!script) return false;
|
if (!script) return false;
|
||||||
const scriptType = (script.type ?? '').toLowerCase();
|
const scriptType = (script.type ?? "").toLowerCase();
|
||||||
|
|
||||||
// Map non-standard types to standard categories
|
// Map non-standard types to standard categories
|
||||||
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
|
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
|
||||||
|
|
||||||
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
|
return filters.selectedTypes.some(
|
||||||
|
(type) => type.toLowerCase() === mappedType,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by repositories
|
// Filter by repositories
|
||||||
if (filters.selectedRepositories.length > 0) {
|
if (filters.selectedRepositories.length > 0) {
|
||||||
scripts = scripts.filter(script => {
|
scripts = scripts.filter((script) => {
|
||||||
if (!script) return false;
|
if (!script) return false;
|
||||||
const repoUrl = script.repository_url;
|
const repoUrl = script.repository_url;
|
||||||
|
|
||||||
@@ -307,13 +337,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
let compareValue = 0;
|
let compareValue = 0;
|
||||||
|
|
||||||
switch (filters.sortBy) {
|
switch (filters.sortBy) {
|
||||||
case 'name':
|
case "name":
|
||||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||||
break;
|
break;
|
||||||
case 'created':
|
case "created":
|
||||||
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
||||||
const aCreated = a?.date_created ?? '';
|
const aCreated = a?.date_created ?? "";
|
||||||
const bCreated = b?.date_created ?? '';
|
const bCreated = b?.date_created ?? "";
|
||||||
|
|
||||||
// If both have dates, compare them directly
|
// If both have dates, compare them directly
|
||||||
if (aCreated && bCreated) {
|
if (aCreated && bCreated) {
|
||||||
@@ -327,15 +357,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
compareValue = 1;
|
compareValue = 1;
|
||||||
} else {
|
} else {
|
||||||
// Both have no dates, fallback to name comparison
|
// Both have no dates, fallback to name comparison
|
||||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sort order
|
// Apply sort order
|
||||||
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
|
return filters.sortOrder === "asc" ? compareValue : -compareValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
return scripts;
|
return scripts;
|
||||||
@@ -343,7 +373,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
// Calculate filter counts for FilterBar
|
// Calculate filter counts for FilterBar
|
||||||
const filterCounts = React.useMemo(() => {
|
const filterCounts = React.useMemo(() => {
|
||||||
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
|
const updatableCount = downloadedScripts.filter(
|
||||||
|
(script) => script?.updateable,
|
||||||
|
).length;
|
||||||
|
|
||||||
return { installedCount: downloadedScripts.length, updatableCount };
|
return { installedCount: downloadedScripts.length, updatableCount };
|
||||||
}, [downloadedScripts]);
|
}, [downloadedScripts]);
|
||||||
@@ -363,9 +395,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
if (selectedCategory && gridRef.current) {
|
if (selectedCategory && gridRef.current) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
gridRef.current?.scrollIntoView({
|
gridRef.current?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: "smooth",
|
||||||
block: 'start',
|
block: "start",
|
||||||
inline: 'nearest'
|
inline: "nearest",
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -387,22 +419,38 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
if (githubLoading || localLoading) {
|
if (githubLoading || localLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||||
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
|
<span className="text-muted-foreground ml-2">
|
||||||
|
Loading downloaded scripts...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (githubError || localError) {
|
if (githubError || localError) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<div className="text-error mb-4">
|
<div className="text-error mb-4">
|
||||||
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
className="mx-auto mb-2 h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
|
<p className="text-lg font-medium">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
Failed to load downloaded scripts
|
||||||
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{githubError?.message ??
|
||||||
|
localError?.message ??
|
||||||
|
"Unknown error occurred"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -419,14 +467,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
if (!downloadedScripts?.length) {
|
if (!downloadedScripts?.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
className="mx-auto mb-4 h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">No downloaded scripts found</p>
|
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
|
You haven't downloaded any scripts yet. Visit the Available
|
||||||
|
Scripts tab to download some scripts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,12 +494,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
|
||||||
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
<div className="order-2 flex-shrink-0 lg:order-1">
|
||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
@@ -451,7 +507,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
|
||||||
{/* Enhanced Filter Bar */}
|
{/* Enhanced Filter Bar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -464,26 +520,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View Toggle */}
|
{/* View Toggle */}
|
||||||
<ViewToggle
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
{filteredScripts.length === 0 &&
|
||||||
<div className="text-center py-12">
|
(filters.searchQuery ||
|
||||||
|
selectedCategory ||
|
||||||
|
filters.showUpdatable !== null ||
|
||||||
|
filters.selectedTypes.length > 0) ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
className="mx-auto mb-4 h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">No matching downloaded scripts found</p>
|
<p className="text-lg font-medium">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
No matching downloaded scripts found
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
Try different filter settings or clear all filters.
|
Try different filter settings or clear all filters.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
{filters.searchQuery && (
|
{filters.searchQuery && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
onClick={() =>
|
||||||
|
handleFiltersChange({ ...filters, searchQuery: "" })
|
||||||
|
}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
>
|
>
|
||||||
@@ -502,17 +573,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === "card" ? (
|
||||||
viewMode === 'card' ? (
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{filteredScripts.map((script, index) => {
|
{filteredScripts.map((script, index) => {
|
||||||
// Add validation to ensure script has required properties
|
// Add validation to ensure script has required properties
|
||||||
if (!script || typeof script !== 'object') {
|
if (!script || typeof script !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScriptCard
|
<ScriptCard
|
||||||
@@ -527,12 +597,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredScripts.map((script, index) => {
|
{filteredScripts.map((script, index) => {
|
||||||
// Add validation to ensure script has required properties
|
// Add validation to ensure script has required properties
|
||||||
if (!script || typeof script !== 'object') {
|
if (!script || typeof script !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScriptCardList
|
<ScriptCardList
|
||||||
@@ -543,7 +613,6 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
@@ -2,26 +2,31 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Server } from '../../types/server';
|
import type { Server } from '../../types/server';
|
||||||
|
import type { Script } from '../../types/script';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
import { ConfigurationModal, type EnvVars } from './ConfigurationModal';
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
|
|
||||||
interface ExecutionModeModalProps {
|
interface ExecutionModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
|
onExecute: (mode: 'local' | 'ssh', server?: Server, envVars?: EnvVars) => void;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
|
script?: Script | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
|
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) {
|
||||||
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
|
const [configMode, setConfigMode] = useState<'default' | 'advanced'>('default');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -64,19 +69,25 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExecute = () => {
|
const handleConfigModeSelect = (mode: 'default' | 'advanced') => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Please select a server for SSH execution');
|
setError('Please select a server first');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setConfigMode(mode);
|
||||||
|
setConfigModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
onExecute('ssh', selectedServer);
|
const handleConfigConfirm = (envVars: EnvVars) => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
setConfigModalOpen(false);
|
||||||
|
onExecute('ssh', selectedServer, envVars);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleServerSelect = (server: Server | null) => {
|
const handleServerSelect = (server: Server | null) => {
|
||||||
setSelectedServer(server);
|
setSelectedServer(server);
|
||||||
|
setError(null); // Clear error when server is selected
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +175,31 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Mode Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Choose configuration mode:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleConfigModeSelect('default')}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleConfigModeSelect('advanced')}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Advanced (Beta)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -173,13 +209,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
onClick={handleExecute}
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -204,6 +233,33 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Mode Selection - only show when server is selected */}
|
||||||
|
{selectedServer && (
|
||||||
|
<div className="space-y-3 pt-4 border-t border-border">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Choose configuration mode:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleConfigModeSelect('default')}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleConfigModeSelect('advanced')}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -213,15 +269,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
onClick={handleExecute}
|
|
||||||
disabled={!selectedServer}
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
|
||||||
>
|
|
||||||
Run on Server
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -234,6 +281,16 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
isOpen={settingsModalOpen}
|
isOpen={settingsModalOpen}
|
||||||
onClose={handleSettingsModalClose}
|
onClose={handleSettingsModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Configuration Modal */}
|
||||||
|
<ConfigurationModal
|
||||||
|
isOpen={configModalOpen}
|
||||||
|
onClose={() => setConfigModalOpen(false)}
|
||||||
|
onConfirm={handleConfigConfirm}
|
||||||
|
script={script ?? null}
|
||||||
|
server={selectedServer}
|
||||||
|
mode={configMode}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,17 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
|
import {
|
||||||
|
Package,
|
||||||
|
Monitor,
|
||||||
|
Wrench,
|
||||||
|
Server,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
Filter,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { getDefaultFilters } from "./filterUtils";
|
import { getDefaultFilters } from "./filterUtils";
|
||||||
|
|
||||||
@@ -53,7 +63,7 @@ export function FilterBar({
|
|||||||
// Helper function to extract repository name from URL
|
// Helper function to extract repository name from URL
|
||||||
const getRepoName = (url: string): string => {
|
const getRepoName = (url: string): string => {
|
||||||
try {
|
try {
|
||||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||||
if (match) {
|
if (match) {
|
||||||
return `${match[1]}/${match[2]}`;
|
return `${match[1]}/${match[2]}`;
|
||||||
}
|
}
|
||||||
@@ -98,29 +108,33 @@ export function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{isLoadingFilters && (
|
{isLoadingFilters && (
|
||||||
<div className="mb-4 flex items-center justify-center py-2">
|
<div className="mb-4 flex items-center justify-center py-2">
|
||||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||||
<span>Loading saved filters...</span>
|
<span>Loading saved filters...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Filter Header */}
|
{/* Filter Header */}
|
||||||
{!isLoadingFilters && (
|
{!isLoadingFilters && (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
<h3 className="text-foreground text-lg font-medium">
|
||||||
|
Filter Scripts
|
||||||
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
<ContextualHelpIcon
|
||||||
|
section="available-scripts"
|
||||||
|
tooltip="Help with filtering and searching"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground h-8 w-8"
|
||||||
title={isMinimized ? "Expand filters" : "Minimize filters"}
|
title={isMinimized ? "Expand filters" : "Minimize filters"}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -146,10 +160,10 @@ export function FilterBar({
|
|||||||
<>
|
<>
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative w-full max-w-md">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-muted-foreground"
|
className="text-muted-foreground h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -167,13 +181,13 @@ export function FilterBar({
|
|||||||
placeholder="Search scripts..."
|
placeholder="Search scripts..."
|
||||||
value={filters.searchQuery}
|
value={filters.searchQuery}
|
||||||
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
||||||
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
|
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{filters.searchQuery && (
|
{filters.searchQuery && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateFilters({ searchQuery: "" })}
|
onClick={() => updateFilters({ searchQuery: "" })}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@@ -194,7 +208,7 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Buttons */}
|
{/* Filter Buttons */}
|
||||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
|
||||||
{/* Updateable Filter */}
|
{/* Updateable Filter */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -208,12 +222,12 @@ export function FilterBar({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||||
filters.showUpdatable === null
|
filters.showUpdatable === null
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: filters.showUpdatable === true
|
: filters.showUpdatable === true
|
||||||
? "border border-success/20 bg-success/10 text-success"
|
? "border-success/20 bg-success/10 text-success border"
|
||||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
: "border-destructive/20 bg-destructive/10 text-destructive border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
@@ -226,10 +240,10 @@ export function FilterBar({
|
|||||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`w-full flex items-center justify-center space-x-2 ${
|
className={`flex w-full items-center justify-center space-x-2 ${
|
||||||
filters.selectedTypes.length === 0
|
filters.selectedTypes.length === 0
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: "border border-primary/20 bg-primary/10 text-primary"
|
: "border-primary/20 bg-primary/10 text-primary border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
@@ -250,14 +264,14 @@ export function FilterBar({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isTypeDropdownOpen && (
|
{isTypeDropdownOpen && (
|
||||||
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
|
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{SCRIPT_TYPES.map((type) => {
|
{SCRIPT_TYPES.map((type) => {
|
||||||
const IconComponent = type.Icon;
|
const IconComponent = type.Icon;
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={type.value}
|
key={type.value}
|
||||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -278,17 +292,17 @@ export function FilterBar({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="rounded border-input text-primary focus:ring-primary"
|
className="border-input text-primary focus:ring-primary rounded"
|
||||||
/>
|
/>
|
||||||
<IconComponent className="h-4 w-4" />
|
<IconComponent className="h-4 w-4" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{type.label}
|
{type.label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border p-2">
|
<div className="border-border border-t p-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateFilters({ selectedTypes: [] });
|
updateFilters({ selectedTypes: [] });
|
||||||
@@ -296,7 +310,7 @@ export function FilterBar({
|
|||||||
}}
|
}}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
@@ -306,8 +320,11 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
||||||
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
|
{enabledRepos.length > 1 &&
|
||||||
const isSelected = filters.selectedRepositories.includes(repo.url);
|
enabledRepos.map((repo: { id: number; url: string }) => {
|
||||||
|
const repoUrl = String(repo.url);
|
||||||
|
const isSelected =
|
||||||
|
filters.selectedRepositories.includes(repoUrl);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={repo.id}
|
key={repo.id}
|
||||||
@@ -316,25 +333,27 @@ export function FilterBar({
|
|||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// Remove repository from selection
|
// Remove repository from selection
|
||||||
updateFilters({
|
updateFilters({
|
||||||
selectedRepositories: currentSelected.filter(url => url !== repo.url)
|
selectedRepositories: currentSelected.filter(
|
||||||
|
(url) => url !== repoUrl,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add repository to selection
|
// Add repository to selection
|
||||||
updateFilters({
|
updateFilters({
|
||||||
selectedRepositories: [...currentSelected, repo.url]
|
selectedRepositories: [...currentSelected, repoUrl],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border border-primary/20 bg-primary/10 text-primary"
|
? "border-primary/20 bg-primary/10 text-primary border"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GitBranch className="h-4 w-4" />
|
<GitBranch className="h-4 w-4" />
|
||||||
<span>{getRepoName(repo.url)}</span>
|
<span>{getRepoName(repoUrl)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -345,14 +364,16 @@ export function FilterBar({
|
|||||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||||
>
|
>
|
||||||
{filters.sortBy === "name" ? (
|
{filters.sortBy === "name" ? (
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
<span>
|
||||||
|
{filters.sortBy === "name" ? "By Name" : "By Created Date"}
|
||||||
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -369,15 +390,17 @@ export function FilterBar({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isSortDropdownOpen && (
|
{isSortDropdownOpen && (
|
||||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateFilters({ sortBy: "name" });
|
updateFilters({ sortBy: "name" });
|
||||||
setIsSortDropdownOpen(false);
|
setIsSortDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
filters.sortBy === "name"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
@@ -388,8 +411,10 @@ export function FilterBar({
|
|||||||
updateFilters({ sortBy: "created" });
|
updateFilters({ sortBy: "created" });
|
||||||
setIsSortDropdownOpen(false);
|
setIsSortDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
filters.sortBy === "created"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
@@ -409,7 +434,7 @@ export function FilterBar({
|
|||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
|
||||||
>
|
>
|
||||||
{filters.sortOrder === "asc" ? (
|
{filters.sortOrder === "asc" ? (
|
||||||
<>
|
<>
|
||||||
@@ -454,18 +479,16 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Summary and Clear All */}
|
{/* Filter Summary and Clear All */}
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
{filteredCount === totalScripts ? (
|
{filteredCount === totalScripts ? (
|
||||||
<span>Showing all {totalScripts} scripts</span>
|
<span>Showing all {totalScripts} scripts</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{filteredCount} of {totalScripts} scripts{" "}
|
{filteredCount} of {totalScripts} scripts{" "}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="font-medium text-info">
|
<span className="text-info font-medium">(filtered)</span>
|
||||||
(filtered)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -473,9 +496,17 @@ export function FilterBar({
|
|||||||
|
|
||||||
{/* Filter Persistence Status */}
|
{/* Filter Persistence Status */}
|
||||||
{!isLoadingFilters && saveFiltersEnabled && (
|
{!isLoadingFilters && saveFiltersEnabled && (
|
||||||
<div className="flex items-center space-x-1 text-xs text-success">
|
<div className="text-success flex items-center space-x-1 text-xs">
|
||||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<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" />
|
className="h-3 w-3"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Filters are being saved automatically</span>
|
<span>Filters are being saved automatically</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,7 +518,7 @@ export function FilterBar({
|
|||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
|
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
|
|||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>© 2024 PVE Scripts Local</span>
|
<span>© 2026 PVE Scripts Local</span>
|
||||||
{versionData?.success && versionData.version && (
|
{versionData?.success && versionData.version && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, startTransition } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
@@ -159,9 +159,13 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configData?.success) {
|
if (configData?.success) {
|
||||||
populateFormData(configData);
|
populateFormData(configData);
|
||||||
|
startTransition(() => {
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
});
|
||||||
} else if (configData && !configData.success) {
|
} else if (configData && !configData.success) {
|
||||||
|
startTransition(() => {
|
||||||
setError(String(configData.error ?? 'Failed to load configuration'));
|
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [configData]);
|
}, [configData]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,45 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Loader2, CheckCircle, X } from 'lucide-react';
|
import { Loader2, CheckCircle, X } from "lucide-react";
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface LoadingModalProps {
|
interface LoadingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
action: string;
|
action?: string;
|
||||||
logs?: string[];
|
logs?: string[];
|
||||||
isComplete?: boolean;
|
isComplete?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
|
export function LoadingModal({
|
||||||
|
isOpen,
|
||||||
|
action,
|
||||||
|
logs = [],
|
||||||
|
isComplete = false,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: LoadingModalProps) {
|
||||||
// Allow dismissing with ESC only when complete, prevent during running
|
// Allow dismissing with ESC only when complete, prevent during running
|
||||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
|
useRegisterModal(isOpen, {
|
||||||
|
id: "loading-modal",
|
||||||
|
allowEscape: isComplete,
|
||||||
|
onClose: onClose ?? (() => null),
|
||||||
|
});
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs arrive
|
// Auto-scroll to bottom when new logs arrive
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
|
<div className="bg-card border-border relative flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-xl">
|
||||||
{/* Close button - only show when complete */}
|
{/* Close button - only show when complete */}
|
||||||
{isComplete && onClose && (
|
{isComplete && onClose && (
|
||||||
<Button
|
<Button
|
||||||
@@ -44,27 +55,31 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
|||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<CheckCircle className="h-12 w-12 text-success" />
|
<CheckCircle className="text-success h-12 w-12" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Static title text */}
|
{/* Action text - displayed prominently */}
|
||||||
{title && (
|
{action && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-foreground text-base font-medium">{action}</p>
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Static title text */}
|
||||||
|
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||||
|
|
||||||
{/* Log output */}
|
{/* Log output */}
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
|
<div className="bg-card border-border text-chart-2 terminal-output max-h-[60vh] w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
|
||||||
{logs.map((log, index) => (
|
{logs.map((log, index) => (
|
||||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="mb-1 break-words whitespace-pre-wrap"
|
||||||
|
>
|
||||||
{log}
|
{log}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -74,9 +89,15 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
|||||||
|
|
||||||
{!isComplete && (
|
{!isComplete && (
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
<div
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||||
|
style={{ animationDelay: "0.1s" }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||||
|
style={{ animationDelay: "0.2s" }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,4 +105,3 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Lock, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
import { api } from '~/trpc/react';
|
import { api } from "~/trpc/react";
|
||||||
import type { Storage } from '~/server/services/storageService';
|
import type { Storage } from "~/server/services/storageService";
|
||||||
|
|
||||||
interface PBSCredentialsModalProps {
|
interface PBSCredentialsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -19,23 +19,25 @@ export function PBSCredentialsModal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
serverId,
|
serverId,
|
||||||
serverName,
|
serverName: _serverName,
|
||||||
storage
|
storage,
|
||||||
}: PBSCredentialsModalProps) {
|
}: PBSCredentialsModalProps) {
|
||||||
const [pbsIp, setPbsIp] = useState('');
|
const [pbsIp, setPbsIp] = useState("");
|
||||||
const [pbsDatastore, setPbsDatastore] = useState('');
|
const [pbsDatastore, setPbsDatastore] = useState("");
|
||||||
const [pbsPassword, setPbsPassword] = useState('');
|
const [pbsPassword, setPbsPassword] = useState("");
|
||||||
const [pbsFingerprint, setPbsFingerprint] = useState('');
|
const [pbsFingerprint, setPbsFingerprint] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Extract PBS info from storage object
|
// Extract PBS info from storage object
|
||||||
const pbsIpFromStorage = (storage as any).server || null;
|
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
|
||||||
const pbsDatastoreFromStorage = (storage as any).datastore || null;
|
const pbsDatastoreFromStorage =
|
||||||
|
(storage as { datastore?: string }).datastore ?? null;
|
||||||
|
|
||||||
// Fetch existing credentials
|
// Fetch existing credentials
|
||||||
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
|
const { data: credentialData, refetch } =
|
||||||
|
api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||||
{ serverId, storageName: storage.name },
|
{ serverId, storageName: storage.name },
|
||||||
{ enabled: isOpen }
|
{ enabled: isOpen },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize form with storage config values or existing credentials
|
// Initialize form with storage config values or existing credentials
|
||||||
@@ -43,16 +45,18 @@ export function PBSCredentialsModal({
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (credentialData?.success && credentialData.credential) {
|
if (credentialData?.success && credentialData.credential) {
|
||||||
// Load existing credentials
|
// Load existing credentials
|
||||||
setPbsIp(credentialData.credential.pbs_ip);
|
setPbsIp(String(credentialData.credential.pbs_ip));
|
||||||
setPbsDatastore(credentialData.credential.pbs_datastore);
|
setPbsDatastore(String(credentialData.credential.pbs_datastore));
|
||||||
setPbsPassword(''); // Don't show password
|
setPbsPassword(""); // Don't show password
|
||||||
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
|
setPbsFingerprint(
|
||||||
|
String(credentialData.credential.pbs_fingerprint ?? ""),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Initialize with storage config values
|
// Initialize with storage config values
|
||||||
setPbsIp(pbsIpFromStorage || '');
|
setPbsIp(pbsIpFromStorage ?? "");
|
||||||
setPbsDatastore(pbsDatastoreFromStorage || '');
|
setPbsDatastore(pbsDatastoreFromStorage ?? "");
|
||||||
setPbsPassword('');
|
setPbsPassword("");
|
||||||
setPbsFingerprint('');
|
setPbsFingerprint("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
|
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
|
||||||
@@ -63,7 +67,7 @@ export function PBSCredentialsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Failed to save PBS credentials:', error);
|
console.error("Failed to save PBS credentials:", error);
|
||||||
alert(`Failed to save credentials: ${error.message}`);
|
alert(`Failed to save credentials: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -74,18 +78,22 @@ export function PBSCredentialsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Failed to delete PBS credentials:', error);
|
console.error("Failed to delete PBS credentials:", error);
|
||||||
alert(`Failed to delete credentials: ${error.message}`);
|
alert(`Failed to delete credentials: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, {
|
||||||
|
id: "pbs-credentials-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
|
if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
|
||||||
alert('Please fill in all required fields (IP, Datastore, Fingerprint)');
|
alert("Please fill in all required fields (IP, Datastore, Fingerprint)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +114,11 @@ export function PBSCredentialsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to delete the PBS credentials for this storage?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +138,13 @@ export function PBSCredentialsModal({
|
|||||||
const hasCredentials = credentialData?.success && credentialData.credential;
|
const hasCredentials = credentialData?.success && credentialData.credential;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
|
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="border-border flex items-center justify-between border-b p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Lock className="h-6 w-6 text-primary" />
|
<Lock className="text-primary h-6 w-6" />
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">
|
<h2 className="text-card-foreground text-2xl font-bold">
|
||||||
PBS Credentials - {storage.name}
|
PBS Credentials - {storage.name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,8 +154,18 @@ export function PBSCredentialsModal({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +175,10 @@ export function PBSCredentialsModal({
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Storage Name (read-only) */}
|
{/* Storage Name (read-only) */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
|
<label
|
||||||
|
htmlFor="storage-name"
|
||||||
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Storage Name
|
Storage Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -161,13 +186,16 @@ export function PBSCredentialsModal({
|
|||||||
id="storage-name"
|
id="storage-name"
|
||||||
value={storage.name}
|
value={storage.name}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
|
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBS IP */}
|
{/* PBS IP */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
|
<label
|
||||||
|
htmlFor="pbs-ip"
|
||||||
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
PBS Server IP <span className="text-error">*</span>
|
PBS Server IP <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -177,17 +205,20 @@ export function PBSCredentialsModal({
|
|||||||
onChange={(e) => setPbsIp(e.target.value)}
|
onChange={(e) => setPbsIp(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
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 border-border"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder="e.g., 10.10.10.226"
|
placeholder="e.g., 10.10.10.226"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
IP address of the Proxmox Backup Server
|
IP address of the Proxmox Backup Server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBS Datastore */}
|
{/* PBS Datastore */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
|
<label
|
||||||
|
htmlFor="pbs-datastore"
|
||||||
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
PBS Datastore <span className="text-error">*</span>
|
PBS Datastore <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -197,18 +228,22 @@ export function PBSCredentialsModal({
|
|||||||
onChange={(e) => setPbsDatastore(e.target.value)}
|
onChange={(e) => setPbsDatastore(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
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 border-border"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder="e.g., NAS03-ISCSI-BACKUP"
|
placeholder="e.g., NAS03-ISCSI-BACKUP"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Name of the datastore on the PBS server
|
Name of the datastore on the PBS server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBS Password */}
|
{/* PBS Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
|
<label
|
||||||
Password {!hasCredentials && <span className="text-error">*</span>}
|
htmlFor="pbs-password"
|
||||||
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Password{" "}
|
||||||
|
{!hasCredentials && <span className="text-error">*</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -217,17 +252,24 @@ export function PBSCredentialsModal({
|
|||||||
onChange={(e) => setPbsPassword(e.target.value)}
|
onChange={(e) => setPbsPassword(e.target.value)}
|
||||||
required={!hasCredentials}
|
required={!hasCredentials}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
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 border-border"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
|
placeholder={
|
||||||
|
hasCredentials
|
||||||
|
? "Enter new password (leave empty to keep existing)"
|
||||||
|
: "Enter PBS password"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Password for root@pam user on PBS server
|
Password for root@pam user on PBS server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBS Fingerprint */}
|
{/* PBS Fingerprint */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
|
<label
|
||||||
|
htmlFor="pbs-fingerprint"
|
||||||
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Fingerprint <span className="text-error">*</span>
|
Fingerprint <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -237,35 +279,37 @@ export function PBSCredentialsModal({
|
|||||||
onChange={(e) => setPbsFingerprint(e.target.value)}
|
onChange={(e) => setPbsFingerprint(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
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 border-border"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
|
Server fingerprint for auto-acceptance. You can find this on
|
||||||
|
your PBS dashboard by clicking the "Show Fingerprint"
|
||||||
|
button.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
{hasCredentials && (
|
{hasCredentials && (
|
||||||
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
|
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
|
||||||
<CheckCircle className="h-4 w-4 text-success" />
|
<CheckCircle className="text-success h-4 w-4" />
|
||||||
<span className="text-sm text-success font-medium">
|
<span className="text-success text-sm font-medium">
|
||||||
Credentials are configured for this storage
|
Credentials are configured for this storage
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
|
<div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row">
|
||||||
{hasCredentials && (
|
{hasCredentials && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-auto order-3"
|
className="order-3 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4 mr-2" />
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||||||
Delete Credentials
|
Delete Credentials
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -274,7 +318,7 @@ export function PBSCredentialsModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-auto order-2"
|
className="order-2 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -282,9 +326,13 @@ export function PBSCredentialsModal({
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-auto order-1"
|
className="order-1 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
|
{isLoading
|
||||||
|
? "Saving..."
|
||||||
|
: hasCredentials
|
||||||
|
? "Update Credentials"
|
||||||
|
: "Save Credentials"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -293,4 +341,3 @@ export function PBSCredentialsModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, startTransition } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -47,7 +47,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
|
|||||||
// Get current version when modal opens
|
// Get current version when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && versionData?.success && versionData.version) {
|
if (isOpen && versionData?.success && versionData.version) {
|
||||||
|
startTransition(() => {
|
||||||
setCurrentVersion(versionData.version);
|
setCurrentVersion(versionData.version);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen, versionData]);
|
}, [isOpen, versionData]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
@@ -9,6 +9,10 @@ export function ResyncButton() {
|
|||||||
const [isResyncing, setIsResyncing] = useState(false);
|
const [isResyncing, setIsResyncing] = useState(false);
|
||||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||||
|
const hasReloadedRef = useRef<boolean>(false);
|
||||||
|
const isUserInitiatedRef = useRef<boolean>(false);
|
||||||
|
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -16,29 +20,87 @@ export function ResyncButton() {
|
|||||||
setLastSync(new Date());
|
setLastSync(new Date());
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setSyncMessage(data.message ?? 'Scripts synced successfully');
|
setSyncMessage(data.message ?? 'Scripts synced successfully');
|
||||||
// Reload the page after successful sync
|
// Only reload if this was triggered by user action
|
||||||
setTimeout(() => {
|
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||||
|
hasReloadedRef.current = true;
|
||||||
|
|
||||||
|
// Clear any existing reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new reload timeout
|
||||||
|
reloadTimeoutRef.current = setTimeout(() => {
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 2000); // Wait 2 seconds to show the success message
|
}, 2000); // Wait 2 seconds to show the success message
|
||||||
|
} else {
|
||||||
|
// Reset flag if reload didn't happen
|
||||||
|
isUserInitiatedRef.current = false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||||
// Clear message after 3 seconds for errors
|
// Clear message after 3 seconds for errors
|
||||||
setTimeout(() => setSyncMessage(null), 3000);
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
messageTimeoutRef.current = setTimeout(() => {
|
||||||
|
setSyncMessage(null);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}, 3000);
|
||||||
|
isUserInitiatedRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setIsResyncing(false);
|
setIsResyncing(false);
|
||||||
setSyncMessage(`Error: ${error.message}`);
|
setSyncMessage(`Error: ${error.message}`);
|
||||||
setTimeout(() => setSyncMessage(null), 3000);
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
messageTimeoutRef.current = setTimeout(() => {
|
||||||
|
setSyncMessage(null);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}, 3000);
|
||||||
|
isUserInitiatedRef.current = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResync = async () => {
|
const handleResync = async () => {
|
||||||
|
// Prevent multiple simultaneous sync operations
|
||||||
|
if (isResyncing) return;
|
||||||
|
|
||||||
|
// Clear any pending reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as user-initiated before starting
|
||||||
|
isUserInitiatedRef.current = true;
|
||||||
|
hasReloadedRef.current = false;
|
||||||
setIsResyncing(true);
|
setIsResyncing(true);
|
||||||
setSyncMessage(null);
|
setSyncMessage(null);
|
||||||
resyncMutation.mutate();
|
resyncMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount - clear any pending timeouts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset refs on unmount
|
||||||
|
hasReloadedRef.current = false;
|
||||||
|
isUserInitiatedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import type { ScriptCard } from '~/types/script';
|
import type { ScriptCard } from "~/types/script";
|
||||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||||
|
|
||||||
interface ScriptCardProps {
|
interface ScriptCardProps {
|
||||||
script: ScriptCard;
|
script: ScriptCard;
|
||||||
@@ -12,7 +12,12 @@ interface ScriptCardProps {
|
|||||||
onToggleSelect?: (slug: string) => void;
|
onToggleSelect?: (slug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
|
export function ScriptCard({
|
||||||
|
script,
|
||||||
|
onClick,
|
||||||
|
isSelected = false,
|
||||||
|
onToggleSelect,
|
||||||
|
}: ScriptCardProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
@@ -27,8 +32,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRepoName = (url?: string): string => {
|
const getRepoName = (url?: string): string => {
|
||||||
if (!url) return '';
|
if (!url) return "";
|
||||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||||
if (match) {
|
if (match) {
|
||||||
return `${match[1]}/${match[2]}`;
|
return `${match[1]}/${match[2]}`;
|
||||||
}
|
}
|
||||||
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
|
||||||
onClick={() => onClick(script)}
|
onClick={() => onClick(script)}
|
||||||
>
|
>
|
||||||
{/* Checkbox in top-left corner */}
|
{/* Checkbox in top-left corner */}
|
||||||
{onToggleSelect && (
|
{onToggleSelect && (
|
||||||
<div className="absolute top-2 left-2 z-10">
|
<div className="absolute top-2 left-2 z-10">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6 flex-1 flex flex-col">
|
<div className="flex flex-1 flex-col p-6">
|
||||||
{/* Header with logo and name */}
|
{/* Header with logo and name */}
|
||||||
<div className="flex items-start space-x-4 mb-4">
|
<div className="mb-4 flex items-start space-x-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{script.logo && !imageError ? (
|
{script.logo && !imageError ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -70,28 +79,31 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
className="w-12 h-12 rounded-lg object-contain"
|
className="h-12 w-12 rounded-lg object-contain"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
|
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
<span className="text-muted-foreground text-lg font-semibold">
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-lg font-semibold text-foreground truncate">
|
<h3 className="text-foreground truncate text-lg font-semibold">
|
||||||
{script.name || 'Unnamed Script'}
|
{script.name || "Unnamed Script"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{/* Type and Updateable status on first row */}
|
{/* Type and Updateable status on first row */}
|
||||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
<div className="flex flex-wrap items-center gap-1 space-x-2">
|
||||||
<TypeBadge type={script.type ?? 'unknown'} />
|
<TypeBadge type={script.type ?? "unknown"} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
{script.repository_url && (
|
{script.repository_url && (
|
||||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
<span
|
||||||
|
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
|
||||||
|
title={script.repository_url}
|
||||||
|
>
|
||||||
{getRepoName(script.repository_url)}
|
{getRepoName(script.repository_url)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -99,13 +111,17 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
|
|
||||||
{/* Download Status */}
|
{/* Download Status */}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
<div
|
||||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
className={`h-2 w-2 rounded-full ${
|
||||||
}`}></div>
|
script.isDownloaded ? "bg-success" : "bg-error"
|
||||||
<span className={`text-xs font-medium ${
|
}`}
|
||||||
script.isDownloaded ? 'text-success' : 'text-error'
|
></div>
|
||||||
}`}>
|
<span
|
||||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
className={`text-xs font-medium ${
|
||||||
|
script.isDownloaded ? "text-success" : "text-error"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
|
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
|
||||||
{script.description || 'No description available'}
|
{script.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer with website link */}
|
{/* Footer with website link */}
|
||||||
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
|||||||
href={script.website}
|
href={script.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
|
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import type { ScriptCard } from '~/types/script';
|
import type { ScriptCard } from "~/types/script";
|
||||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||||
|
|
||||||
interface ScriptCardListProps {
|
interface ScriptCardListProps {
|
||||||
script: ScriptCard;
|
script: ScriptCard;
|
||||||
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
|
|||||||
onToggleSelect?: (slug: string) => void;
|
onToggleSelect?: (slug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
|
export function ScriptCardList({
|
||||||
|
script,
|
||||||
|
onClick,
|
||||||
|
isSelected = false,
|
||||||
|
onToggleSelect,
|
||||||
|
}: ScriptCardListProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
@@ -27,26 +32,27 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
const formatDate = (dateString?: string) => {
|
||||||
if (!dateString) return 'Unknown';
|
if (!dateString) return "Unknown";
|
||||||
try {
|
try {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return 'Unknown';
|
return "Unknown";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryNames = () => {
|
const getCategoryNames = () => {
|
||||||
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
if (!script.categoryNames || script.categoryNames.length === 0)
|
||||||
return script.categoryNames.join(', ');
|
return "Uncategorized";
|
||||||
|
return script.categoryNames.join(", ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRepoName = (url?: string): string => {
|
const getRepoName = (url?: string): string => {
|
||||||
if (!url) return '';
|
if (!url) return "";
|
||||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||||
if (match) {
|
if (match) {
|
||||||
return `${match[1]}/${match[2]}`;
|
return `${match[1]}/${match[2]}`;
|
||||||
}
|
}
|
||||||
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md"
|
||||||
onClick={() => onClick(script)}
|
onClick={() => onClick(script)}
|
||||||
>
|
>
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
{onToggleSelect && (
|
{onToggleSelect && (
|
||||||
<div className="absolute top-4 left-4 z-10">
|
<div className="absolute top-4 left-4 z-10">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
|||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
className="w-14 h-14 rounded-lg object-contain"
|
className="h-14 w-14 rounded-lg object-contain"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg">
|
||||||
<span className="text-muted-foreground text-lg font-semibold">
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
<h3 className="text-foreground mb-2 truncate text-xl font-semibold">
|
||||||
{script.name || 'Unnamed Script'}
|
{script.name || "Unnamed Script"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2 space-x-3">
|
||||||
<TypeBadge type={script.type ?? 'unknown'} />
|
<TypeBadge type={script.type ?? "unknown"} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
{script.repository_url && (
|
{script.repository_url && (
|
||||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
<span
|
||||||
|
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
|
||||||
|
title={script.repository_url}
|
||||||
|
>
|
||||||
{getRepoName(script.repository_url)}
|
{getRepoName(script.repository_url)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
<div
|
||||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
className={`h-2 w-2 rounded-full ${
|
||||||
}`}></div>
|
script.isDownloaded ? "bg-success" : "bg-error"
|
||||||
<span className={`text-sm font-medium ${
|
}`}
|
||||||
script.isDownloaded ? 'text-success' : 'text-error'
|
></div>
|
||||||
}`}>
|
<span
|
||||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
className={`text-sm font-medium ${
|
||||||
|
script.isDownloaded ? "text-success" : "text-error"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
|||||||
href={script.website}
|
href={script.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
|
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">
|
||||||
{script.description || 'No description available'}
|
{script.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Metadata Row */}
|
{/* Metadata Row */}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Categories: {getCategoryNames()}</span>
|
<span>Categories: {getCategoryNames()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Created: {formatDate(script.date_created)}</span>
|
<span>Created: {formatDate(script.date_created)}</span>
|
||||||
</div>
|
</div>
|
||||||
{(script.os ?? script.version) && (
|
{(script.os ?? script.version) && (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
{script.os && script.version
|
{script.os && script.version
|
||||||
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||||
: script.os
|
: script.os
|
||||||
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
? script.os.charAt(0).toUpperCase() +
|
||||||
|
script.os.slice(1)
|
||||||
: script.version
|
: script.version
|
||||||
? `Version ${script.version}`
|
? `Version ${script.version}`
|
||||||
: ''
|
: ""}
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script.interface_port && (
|
{script.interface_port && (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Port: {script.interface_port}</span>
|
<span>Port: {script.interface_port}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>ID: {script.slug || 'unknown'}</span>
|
<span>ID: {script.slug || "unknown"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import type { Script } from "~/types/script";
|
import type { Script } from "~/types/script";
|
||||||
|
import type { Server } from "~/types/server";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { TextViewer } from "./TextViewer";
|
import { TextViewer } from "./TextViewer";
|
||||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||||
import { ConfirmationModal } from "./ConfirmationModal";
|
import { ConfirmationModal } from "./ConfirmationModal";
|
||||||
import { ScriptVersionModal } from "./ScriptVersionModal";
|
import { ScriptVersionModal } from "./ScriptVersionModal";
|
||||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
import {
|
||||||
|
TypeBadge,
|
||||||
|
UpdateableBadge,
|
||||||
|
PrivilegedBadge,
|
||||||
|
NoteBadge,
|
||||||
|
} from "./Badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
|
|
||||||
interface ScriptDetailModalProps {
|
interface ScriptDetailModalProps {
|
||||||
script: Script | null;
|
script: Script | null;
|
||||||
@@ -21,7 +27,8 @@ interface ScriptDetailModalProps {
|
|||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
mode?: "local" | "ssh",
|
mode?: "local" | "ssh",
|
||||||
server?: any,
|
server?: Server,
|
||||||
|
envVars?: Record<string, string | number | boolean>,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +38,11 @@ export function ScriptDetailModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onInstallScript,
|
onInstallScript,
|
||||||
}: ScriptDetailModalProps) {
|
}: ScriptDetailModalProps) {
|
||||||
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, {
|
||||||
|
id: "script-detail-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
||||||
@@ -40,7 +51,9 @@ export function ScriptDetailModal({
|
|||||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||||
const [executionModeOpen, setExecutionModeOpen] = useState(false);
|
const [executionModeOpen, setExecutionModeOpen] = useState(false);
|
||||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||||
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
|
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
|
||||||
@@ -61,7 +74,11 @@ export function ScriptDetailModal({
|
|||||||
isLoading: comparisonLoading,
|
isLoading: comparisonLoading,
|
||||||
} = api.scripts.compareScriptContent.useQuery(
|
} = api.scripts.compareScriptContent.useQuery(
|
||||||
{ slug: script?.slug ?? "" },
|
{ slug: script?.slug ?? "" },
|
||||||
{ enabled: !!script && isOpen },
|
{
|
||||||
|
enabled: !!script && isOpen,
|
||||||
|
refetchOnMount: true,
|
||||||
|
staleTime: 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load script mutation
|
// Load script mutation
|
||||||
@@ -139,8 +156,9 @@ export function ScriptDetailModal({
|
|||||||
|
|
||||||
// Check if script has multiple variants (default and alpine)
|
// Check if script has multiple variants (default and alpine)
|
||||||
const installMethods = script.install_methods || [];
|
const installMethods = script.install_methods || [];
|
||||||
const hasMultipleVariants = installMethods.filter(method =>
|
const hasMultipleVariants =
|
||||||
method.type === 'default' || method.type === 'alpine'
|
installMethods.filter(
|
||||||
|
(method) => method.type === "default" || method.type === "alpine",
|
||||||
).length > 1;
|
).length > 1;
|
||||||
|
|
||||||
if (hasMultipleVariants) {
|
if (hasMultipleVariants) {
|
||||||
@@ -149,9 +167,13 @@ export function ScriptDetailModal({
|
|||||||
} else {
|
} else {
|
||||||
// Only one variant, proceed directly to execution mode
|
// Only one variant, proceed directly to execution mode
|
||||||
// Use the first available method or default to 'default' type
|
// Use the first available method or default to 'default' type
|
||||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
const defaultMethod = installMethods.find(
|
||||||
|
(method) => method.type === "default",
|
||||||
|
);
|
||||||
const firstMethod = installMethods[0];
|
const firstMethod = installMethods[0];
|
||||||
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
|
setSelectedVersionType(
|
||||||
|
defaultMethod?.type ?? firstMethod?.type ?? "default",
|
||||||
|
);
|
||||||
setExecutionModeOpen(true);
|
setExecutionModeOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -162,23 +184,22 @@ export function ScriptDetailModal({
|
|||||||
setExecutionModeOpen(true);
|
setExecutionModeOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
|
const handleExecuteScript = (mode: "local" | "ssh", server?: Server, envVars?: Record<string, string | number | boolean>) => {
|
||||||
if (!script || !onInstallScript) return;
|
if (!script || !onInstallScript) return;
|
||||||
|
|
||||||
// Find the script path based on selected version type
|
// Find the script path based on selected version type
|
||||||
const versionType = selectedVersionType || 'default';
|
const versionType = selectedVersionType ?? "default";
|
||||||
const scriptMethod = script.install_methods?.find(
|
const scriptMethod =
|
||||||
|
script.install_methods?.find(
|
||||||
(method) => method.type === versionType && method.script,
|
(method) => method.type === versionType && method.script,
|
||||||
) || script.install_methods?.find(
|
) ?? script.install_methods?.find((method) => method.script);
|
||||||
(method) => method.script,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scriptMethod?.script) {
|
if (scriptMethod?.script) {
|
||||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||||
const scriptName = script.name;
|
const scriptName = script.name;
|
||||||
|
|
||||||
// Pass execution mode and server info to the parent
|
// Pass execution mode, server info, and envVars to the parent
|
||||||
onInstallScript(scriptPath, scriptName, mode, server);
|
onInstallScript(scriptPath, scriptName, mode, server, envVars);
|
||||||
|
|
||||||
onClose(); // Close the modal when starting installation
|
onClose(); // Close the modal when starting installation
|
||||||
}
|
}
|
||||||
@@ -203,31 +224,31 @@ export function ScriptDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
|
<div className="bg-card border-border mx-2 max-h-[95vh] min-h-[80vh] w-full max-w-6xl overflow-y-auto rounded-lg border shadow-xl sm:mx-4 lg:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
|
||||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
|
||||||
{script.logo && !imageError ? (
|
{script.logo && !imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={script.logo}
|
src={script.logo}
|
||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16">
|
||||||
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
<span className="text-muted-foreground text-lg font-semibold sm:text-2xl">
|
||||||
{script.name.charAt(0).toUpperCase()}
|
{script.name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl">
|
||||||
{script.name}
|
{script.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
@@ -239,11 +260,13 @@ export function ScriptDetailModal({
|
|||||||
href={script.repository_url}
|
href={script.repository_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors"
|
className="bg-muted text-muted-foreground border-border hover:bg-accent hover:text-foreground rounded border px-2 py-0.5 text-xs transition-colors"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
title={`Source: ${script.repository_url}`}
|
title={`Source: ${script.repository_url}`}
|
||||||
>
|
>
|
||||||
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
|
{/github\.com\/([^\/]+)\/([^\/]+)/
|
||||||
|
.exec(script.repository_url)?.[0]
|
||||||
|
?.replace("https://", "") ?? script.repository_url}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,12 +274,12 @@ export function ScriptDetailModal({
|
|||||||
|
|
||||||
{/* Interface Port*/}
|
{/* Interface Port*/}
|
||||||
{script.interface_port && (
|
{script.interface_port && (
|
||||||
<div className="ml-3 sm:ml-4 flex-shrink-0">
|
<div className="ml-3 flex-shrink-0 sm:ml-4">
|
||||||
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
|
<div className="bg-primary/10 border-primary/30 rounded-lg border 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">
|
<span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm">
|
||||||
Port:
|
Port:
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
|
<span className="text-foreground font-mono text-sm font-semibold sm:text-base">
|
||||||
{script.interface_port}
|
{script.interface_port}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +292,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||||
@@ -288,7 +311,7 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
<div className="border-border flex flex-col items-stretch space-y-2 border-b p-4 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-2 sm:p-6">
|
||||||
{/* Install Button - only show if script files exist */}
|
{/* Install Button - only show if script files exist */}
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
scriptFilesData.ctExists &&
|
scriptFilesData.ctExists &&
|
||||||
@@ -297,7 +320,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleInstallScript}
|
onClick={handleInstallScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -323,7 +346,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleViewScript}
|
onClick={handleViewScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -365,7 +388,7 @@ export function ScriptDetailModal({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
isLoading
|
isLoading
|
||||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||||
: "bg-success text-success-foreground hover:bg-success/90"
|
: "bg-success text-success-foreground hover:bg-success/90"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -399,7 +422,7 @@ export function ScriptDetailModal({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
|
className="bg-muted text-muted-foreground flex cursor-not-allowed items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -425,7 +448,7 @@ export function ScriptDetailModal({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
isLoading
|
isLoading
|
||||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||||
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -465,7 +488,7 @@ export function ScriptDetailModal({
|
|||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{isDeleting ? (
|
||||||
<>
|
<>
|
||||||
@@ -495,12 +518,12 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6">
|
||||||
{/* Script Files Status */}
|
{/* Script Files Status */}
|
||||||
{(scriptFilesLoading || comparisonLoading) && (
|
{(scriptFilesLoading || comparisonLoading) && (
|
||||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
|
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div>
|
||||||
<span>Loading script status...</span>
|
<span>Loading script status...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,8 +546,8 @@ export function ScriptDetailModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
<div className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
|
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
|
||||||
@@ -547,10 +570,10 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
(scriptFilesData.ctExists ||
|
(scriptFilesData.ctExists ||
|
||||||
scriptFilesData.installExists) &&
|
scriptFilesData.installExists) && (
|
||||||
comparisonData?.success &&
|
|
||||||
!comparisonLoading && (
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{comparisonData?.success ? (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||||
></div>
|
></div>
|
||||||
@@ -560,11 +583,54 @@ export function ScriptDetailModal({
|
|||||||
? "Update available"
|
? "Update available"
|
||||||
: "Up to date"}
|
: "Up to date"}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
) : comparisonLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div>
|
||||||
|
<span>Checking for updates...</span>
|
||||||
|
</>
|
||||||
|
) : comparisonData?.error ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-destructive h-2 w-2 rounded-full"></div>
|
||||||
|
<span className="text-destructive">
|
||||||
|
Error: {comparisonData.error}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-muted h-2 w-2 rounded-full"></div>
|
||||||
|
<span>Status: Unknown</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void refetchComparison()}
|
||||||
|
disabled={comparisonLoading}
|
||||||
|
className="hover:bg-accent ml-2 flex items-center justify-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title="Refresh comparison"
|
||||||
|
>
|
||||||
|
{comparisonLoading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="text-muted-foreground hover:text-foreground h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{scriptFilesData.files.length > 0 && (
|
{scriptFilesData.files.length > 0 && (
|
||||||
<div className="mt-2 text-xs text-muted-foreground break-words">
|
<div className="text-muted-foreground mt-2 text-xs break-words">
|
||||||
Files: {scriptFilesData.files.join(", ")}
|
Files: {scriptFilesData.files.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -574,17 +640,17 @@ export function ScriptDetailModal({
|
|||||||
|
|
||||||
{/* Load Message */}
|
{/* Load Message */}
|
||||||
{loadMessage && (
|
{loadMessage && (
|
||||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
|
||||||
{loadMessage}
|
{loadMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg">
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm sm:text-base text-muted-foreground">
|
<p className="text-muted-foreground text-sm sm:text-base">
|
||||||
{script.description}
|
{script.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -592,50 +658,50 @@ export function ScriptDetailModal({
|
|||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Slug
|
Slug
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-foreground">
|
<dd className="text-foreground font-mono text-sm">
|
||||||
{script.slug}
|
{script.slug}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Date Created
|
Date Created
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-foreground">
|
<dd className="text-foreground text-sm">
|
||||||
{script.date_created}
|
{script.date_created}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Categories
|
Categories
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-foreground">
|
<dd className="text-foreground text-sm">
|
||||||
{script.categories.join(", ")}
|
{script.categories.join(", ")}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{script.interface_port && (
|
{script.interface_port && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Interface Port
|
Interface Port
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-foreground">
|
<dd className="text-foreground text-sm">
|
||||||
{script.interface_port}
|
{script.interface_port}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script.config_path && (
|
{script.config_path && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Config Path
|
Config Path
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-foreground">
|
<dd className="text-foreground font-mono text-sm">
|
||||||
{script.config_path}
|
{script.config_path}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -644,13 +710,13 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||||
Links
|
Links
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
{script.website && (
|
{script.website && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Website
|
Website
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm">
|
<dd className="text-sm">
|
||||||
@@ -658,7 +724,7 @@ export function ScriptDetailModal({
|
|||||||
href={script.website}
|
href={script.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="break-all text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80 break-all"
|
||||||
>
|
>
|
||||||
{script.website}
|
{script.website}
|
||||||
</a>
|
</a>
|
||||||
@@ -667,7 +733,7 @@ export function ScriptDetailModal({
|
|||||||
)}
|
)}
|
||||||
{script.documentation && (
|
{script.documentation && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Documentation
|
Documentation
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm">
|
<dd className="text-sm">
|
||||||
@@ -675,7 +741,7 @@ export function ScriptDetailModal({
|
|||||||
href={script.documentation}
|
href={script.documentation}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="break-all text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80 break-all"
|
||||||
>
|
>
|
||||||
{script.documentation}
|
{script.documentation}
|
||||||
</a>
|
</a>
|
||||||
@@ -691,26 +757,26 @@ export function ScriptDetailModal({
|
|||||||
script.type !== "pve" &&
|
script.type !== "pve" &&
|
||||||
script.type !== "addon" && (
|
script.type !== "addon" && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||||
Install Methods
|
Install Methods
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{script.install_methods.map((method, index) => (
|
{script.install_methods.map((method, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
className="border-border bg-card rounded-lg border p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0">
|
||||||
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
<h4 className="text-foreground text-sm font-medium capitalize sm:text-base">
|
||||||
{method.type}
|
{method.type}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm">
|
||||||
{method.script}
|
{method.script}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="text-muted-foreground font-medium">
|
||||||
CPU
|
CPU
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">
|
||||||
@@ -718,7 +784,7 @@ export function ScriptDetailModal({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="text-muted-foreground font-medium">
|
||||||
RAM
|
RAM
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">
|
||||||
@@ -726,7 +792,7 @@ export function ScriptDetailModal({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="text-muted-foreground font-medium">
|
||||||
HDD
|
HDD
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">
|
||||||
@@ -734,7 +800,7 @@ export function ScriptDetailModal({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="text-muted-foreground font-medium">
|
||||||
OS
|
OS
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">
|
||||||
@@ -752,26 +818,26 @@ export function ScriptDetailModal({
|
|||||||
{(script.default_credentials.username ??
|
{(script.default_credentials.username ??
|
||||||
script.default_credentials.password) && (
|
script.default_credentials.password) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||||
Default Credentials
|
Default Credentials
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
{script.default_credentials.username && (
|
{script.default_credentials.username && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Username
|
Username
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-foreground">
|
<dd className="text-foreground font-mono text-sm">
|
||||||
{script.default_credentials.username}
|
{script.default_credentials.username}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script.default_credentials.password && (
|
{script.default_credentials.password && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Password
|
Password
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-foreground">
|
<dd className="text-foreground font-mono text-sm">
|
||||||
{script.default_credentials.password}
|
{script.default_credentials.password}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -783,7 +849,7 @@ export function ScriptDetailModal({
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{script.notes.length > 0 && (
|
{script.notes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="text-foreground mb-3 text-lg font-semibold">
|
||||||
Notes
|
Notes
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -798,14 +864,17 @@ export function ScriptDetailModal({
|
|||||||
key={index}
|
key={index}
|
||||||
className={`rounded-lg p-3 text-sm ${
|
className={`rounded-lg p-3 text-sm ${
|
||||||
noteType === "warning"
|
noteType === "warning"
|
||||||
? "border-l-4 border-warning bg-warning/10 text-warning"
|
? "border-warning bg-warning/10 text-warning border-l-4"
|
||||||
: noteType === "error"
|
: noteType === "error"
|
||||||
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
|
? "border-destructive bg-destructive/10 text-destructive border-l-4"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
|
<NoteBadge
|
||||||
|
noteType={noteType as "info" | "warning" | "error"}
|
||||||
|
className="mr-2 flex-shrink-0"
|
||||||
|
>
|
||||||
{noteType}
|
{noteType}
|
||||||
</NoteBadge>
|
</NoteBadge>
|
||||||
<span>{noteText}</span>
|
<span>{noteText}</span>
|
||||||
@@ -837,7 +906,13 @@ export function ScriptDetailModal({
|
|||||||
<TextViewer
|
<TextViewer
|
||||||
scriptName={
|
scriptName={
|
||||||
script.install_methods
|
script.install_methods
|
||||||
?.find((method) => method.script?.startsWith("ct/"))
|
?.find(
|
||||||
|
(method) =>
|
||||||
|
method.script &&
|
||||||
|
(method.script.startsWith("ct/") ||
|
||||||
|
method.script.startsWith("vm/") ||
|
||||||
|
method.script.startsWith("tools/")),
|
||||||
|
)
|
||||||
?.script?.split("/")
|
?.script?.split("/")
|
||||||
.pop() ?? `${script.slug}.sh`
|
.pop() ?? `${script.slug}.sh`
|
||||||
}
|
}
|
||||||
@@ -861,6 +936,7 @@ export function ScriptDetailModal({
|
|||||||
{script && (
|
{script && (
|
||||||
<ExecutionModeModal
|
<ExecutionModeModal
|
||||||
scriptName={script.name}
|
scriptName={script.name}
|
||||||
|
script={script}
|
||||||
isOpen={executionModeOpen}
|
isOpen={executionModeOpen}
|
||||||
onClose={() => setExecutionModeOpen(false)}
|
onClose={() => setExecutionModeOpen(false)}
|
||||||
onExecute={handleExecuteScript}
|
onExecute={handleExecuteScript}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface InstalledScript {
|
|||||||
container_status?: 'running' | 'stopped' | 'unknown';
|
container_status?: 'running' | 'stopped' | 'unknown';
|
||||||
web_ui_ip: string | null;
|
web_ui_ip: string | null;
|
||||||
web_ui_port: number | null;
|
web_ui_port: number | null;
|
||||||
|
is_vm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScriptInstallationCardProps {
|
interface ScriptInstallationCardProps {
|
||||||
@@ -45,6 +46,7 @@ interface ScriptInstallationCardProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
onBackup?: () => void;
|
onBackup?: () => void;
|
||||||
|
onClone?: () => void;
|
||||||
onShell: () => void;
|
onShell: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
@@ -70,6 +72,7 @@ export function ScriptInstallationCard({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onBackup,
|
onBackup,
|
||||||
|
onClone,
|
||||||
onShell,
|
onShell,
|
||||||
onDelete,
|
onDelete,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
@@ -300,7 +303,7 @@ export function ScriptInstallationCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||||
{script.container_id && (
|
{script.container_id && !script.is_vm && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onUpdate}
|
onClick={onUpdate}
|
||||||
disabled={containerStatus === 'stopped'}
|
disabled={containerStatus === 'stopped'}
|
||||||
@@ -318,6 +321,15 @@ export function ScriptInstallationCard({
|
|||||||
Backup
|
Backup
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && onClone && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onClone}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||||
|
>
|
||||||
|
Clone
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{script.container_id && script.execution_mode === 'ssh' && (
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onShell}
|
onClick={onShell}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import type { Script, ScriptInstallMethod } from '../../types/script';
|
import type { Script } from "../../types/script";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
|
|
||||||
interface ScriptVersionModalProps {
|
interface ScriptVersionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
|
|||||||
script: Script | null;
|
script: Script | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
|
export function ScriptVersionModal({
|
||||||
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelectVersion,
|
||||||
|
script,
|
||||||
|
}: ScriptVersionModalProps) {
|
||||||
|
useRegisterModal(isOpen, {
|
||||||
|
id: "script-version-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!isOpen || !script) return null;
|
if (!isOpen || !script) return null;
|
||||||
|
|
||||||
// Get available install methods
|
// Get available install methods
|
||||||
const installMethods = script.install_methods || [];
|
const installMethods = script.install_methods || [];
|
||||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
const defaultMethod = installMethods.find(
|
||||||
const alpineMethod = installMethods.find(method => method.type === 'alpine');
|
(method) => method.type === "default",
|
||||||
|
);
|
||||||
|
const alpineMethod = installMethods.find(
|
||||||
|
(method) => method.type === "alpine",
|
||||||
|
);
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (selectedVersion) {
|
if (selectedVersion) {
|
||||||
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="border-border flex items-center justify-between border-b p-6">
|
||||||
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
|
<h2 className="text-foreground text-xl font-bold">Select Version</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||||
Choose a version for "{script.name}"
|
Choose a version for "{script.name}"
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Select the version you want to install. Each version has different resource requirements.
|
Select the version you want to install. Each version has different
|
||||||
|
resource requirements.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
{/* Default Version */}
|
{/* Default Version */}
|
||||||
{defaultMethod && (
|
{defaultMethod && (
|
||||||
<div
|
<div
|
||||||
onClick={() => handleVersionSelect('default')}
|
onClick={() => handleVersionSelect("default")}
|
||||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||||
selectedVersion === 'default'
|
selectedVersion === "default"
|
||||||
? 'border-primary bg-primary/10'
|
? "border-primary bg-primary/10"
|
||||||
: 'border-border bg-card hover:border-primary/50'
|
: "border-border bg-card hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="mb-3 flex items-center space-x-3">
|
||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||||
selectedVersion === 'default'
|
selectedVersion === "default"
|
||||||
? 'border-primary bg-primary'
|
? "border-primary bg-primary"
|
||||||
: 'border-border'
|
: "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedVersion === 'default' && (
|
{selectedVersion === "default" && (
|
||||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="h-3 w-3 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
||||||
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||||
{defaultMethod.type}
|
{defaultMethod.type}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">CPU: </span>
|
<span className="text-muted-foreground">CPU: </span>
|
||||||
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{defaultMethod.resources.cpu} cores
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">RAM: </span>
|
<span className="text-muted-foreground">RAM: </span>
|
||||||
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{defaultMethod.resources.ram} MB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">HDD: </span>
|
<span className="text-muted-foreground">HDD: </span>
|
||||||
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{defaultMethod.resources.hdd} GB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">OS: </span>
|
<span className="text-muted-foreground">OS: </span>
|
||||||
<span className="text-foreground font-medium">
|
<span className="text-foreground font-medium">
|
||||||
{defaultMethod.resources.os} {defaultMethod.resources.version}
|
{defaultMethod.resources.os}{" "}
|
||||||
|
{defaultMethod.resources.version}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
{/* Alpine Version */}
|
{/* Alpine Version */}
|
||||||
{alpineMethod && (
|
{alpineMethod && (
|
||||||
<div
|
<div
|
||||||
onClick={() => handleVersionSelect('alpine')}
|
onClick={() => handleVersionSelect("alpine")}
|
||||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||||
selectedVersion === 'alpine'
|
selectedVersion === "alpine"
|
||||||
? 'border-primary bg-primary/10'
|
? "border-primary bg-primary/10"
|
||||||
: 'border-border bg-card hover:border-primary/50'
|
: "border-border bg-card hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="mb-3 flex items-center space-x-3">
|
||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||||
selectedVersion === 'alpine'
|
selectedVersion === "alpine"
|
||||||
? 'border-primary bg-primary'
|
? "border-primary bg-primary"
|
||||||
: 'border-border'
|
: "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedVersion === 'alpine' && (
|
{selectedVersion === "alpine" && (
|
||||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="h-3 w-3 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
||||||
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||||
{alpineMethod.type}
|
{alpineMethod.type}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">CPU: </span>
|
<span className="text-muted-foreground">CPU: </span>
|
||||||
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{alpineMethod.resources.cpu} cores
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">RAM: </span>
|
<span className="text-muted-foreground">RAM: </span>
|
||||||
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{alpineMethod.resources.ram} MB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">HDD: </span>
|
<span className="text-muted-foreground">HDD: </span>
|
||||||
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
|
<span className="text-foreground font-medium">
|
||||||
|
{alpineMethod.resources.hdd} GB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">OS: </span>
|
<span className="text-muted-foreground">OS: </span>
|
||||||
<span className="text-foreground font-medium">
|
<span className="text-foreground font-medium">
|
||||||
{alpineMethod.resources.os} {alpineMethod.resources.version}
|
{alpineMethod.resources.os}{" "}
|
||||||
|
{alpineMethod.resources.version}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
<Button
|
<Button onClick={onClose} variant="outline" size="default">
|
||||||
onClick={onClose}
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
disabled={!selectedVersion}
|
disabled={!selectedVersion}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
className={
|
||||||
|
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import type { CreateServerData } from '../../types/server';
|
import type { CreateServerData } from "../../types/server";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { SSHKeyInput } from './SSHKeyInput';
|
import { SSHKeyInput } from "./SSHKeyInput";
|
||||||
import { PublicKeyModal } from './PublicKeyModal';
|
import { PublicKeyModal } from "./PublicKeyModal";
|
||||||
import { Key } from 'lucide-react';
|
import { Key } from "lucide-react";
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -14,40 +14,47 @@ interface ServerFormProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
|
export function ServerForm({
|
||||||
|
onSubmit,
|
||||||
|
initialData,
|
||||||
|
isEditing = false,
|
||||||
|
onCancel,
|
||||||
|
}: ServerFormProps) {
|
||||||
const [formData, setFormData] = useState<CreateServerData>(
|
const [formData, setFormData] = useState<CreateServerData>(
|
||||||
initialData ?? {
|
initialData ?? {
|
||||||
name: '',
|
name: "",
|
||||||
ip: '',
|
ip: "",
|
||||||
user: '',
|
user: "",
|
||||||
password: '',
|
password: "",
|
||||||
auth_type: 'password',
|
auth_type: "password",
|
||||||
ssh_key: '',
|
ssh_key: "",
|
||||||
ssh_key_passphrase: '',
|
ssh_key_passphrase: "",
|
||||||
ssh_port: 22,
|
ssh_port: 22,
|
||||||
color: '#3b82f6',
|
color: "#3b82f6",
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
const [errors, setErrors] = useState<
|
||||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
Partial<Record<keyof CreateServerData, string>>
|
||||||
|
>({});
|
||||||
|
const [sshKeyError, setSshKeyError] = useState<string>("");
|
||||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||||
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||||
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
const [generatedPublicKey, setGeneratedPublicKey] = useState("");
|
||||||
const [, setIsGeneratedKey] = useState(false);
|
const [, setIsGeneratedKey] = useState(false);
|
||||||
const [, setGeneratedServerId] = useState<number | null>(null);
|
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColorCodingSetting = async () => {
|
const loadColorCodingSetting = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings/color-coding');
|
const response = await fetch("/api/settings/color-coding");
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setColorCodingEnabled(Boolean(data.enabled));
|
setColorCodingEnabled(Boolean(data.enabled));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading color coding setting:', error);
|
console.error("Error loading color coding setting:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadColorCodingSetting();
|
void loadColorCodingSetting();
|
||||||
@@ -58,15 +65,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
|
|
||||||
// IPv4 validation
|
// 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]?)$/;
|
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)) {
|
if (ipv4Regex.test(trimmed)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
|
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
|
||||||
let ipv6Address = trimmed;
|
let ipv6Address = trimmed;
|
||||||
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
|
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
|
||||||
if (zoneIdMatch) {
|
if (zoneIdMatch?.[1] && zoneIdMatch[2]) {
|
||||||
ipv6Address = zoneIdMatch[1];
|
ipv6Address = zoneIdMatch[1];
|
||||||
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
|
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
|
||||||
const zoneId = zoneIdMatch[2];
|
const zoneId = zoneIdMatch[2];
|
||||||
@@ -79,10 +87,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
|
// 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
|
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
|
||||||
// Simplified validation: check for valid hex segments separated by colons
|
// 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]?)$/;
|
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(ipv6Address)) {
|
if (ipv6Pattern.test(ipv6Address)) {
|
||||||
// Additional validation: ensure only one :: compression exists
|
// Additional validation: ensure only one :: compression exists
|
||||||
const compressionCount = (ipv6Address.match(/::/g) || []).length;
|
const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
|
||||||
if (compressionCount <= 1) {
|
if (compressionCount <= 1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -91,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
// FQDN/hostname validation (RFC 1123 compliant)
|
// FQDN/hostname validation (RFC 1123 compliant)
|
||||||
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
||||||
// Max length 253 characters, each label max 63 characters
|
// 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])?$/;
|
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) {
|
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
|
||||||
// Additional check: each label (between dots) must be max 63 chars
|
// Additional check: each label (between dots) must be max 63 chars
|
||||||
const labels = trimmed.split('.');
|
const labels = trimmed.split(".");
|
||||||
if (labels.every(label => label.length > 0 && label.length <= 63)) {
|
if (labels.every((label) => label.length > 0 && label.length <= 63)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also allow simple hostnames without dots (like 'localhost')
|
// Also allow simple hostnames without dots (like 'localhost')
|
||||||
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
const simpleHostnameRegex =
|
||||||
|
/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||||
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
|
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -113,42 +124,45 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
newErrors.name = 'Server name is required';
|
newErrors.name = "Server name is required";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.ip.trim()) {
|
if (!formData.ip.trim()) {
|
||||||
newErrors.ip = 'Server address is required';
|
newErrors.ip = "Server address is required";
|
||||||
} else {
|
} else {
|
||||||
if (!validateServerAddress(formData.ip)) {
|
if (!validateServerAddress(formData.ip)) {
|
||||||
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
|
newErrors.ip =
|
||||||
|
"Please enter a valid IP address (IPv4/IPv6) or hostname";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.user.trim()) {
|
if (!formData.user.trim()) {
|
||||||
newErrors.user = 'Username is required';
|
newErrors.user = "Username is required";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SSH port
|
// Validate SSH port
|
||||||
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
if (
|
||||||
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
formData.ssh_port !== undefined &&
|
||||||
|
(formData.ssh_port < 1 || formData.ssh_port > 65535)
|
||||||
|
) {
|
||||||
|
newErrors.ssh_port = "SSH port must be between 1 and 65535";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate authentication based on auth_type
|
// Validate authentication based on auth_type
|
||||||
const authType = formData.auth_type ?? 'password';
|
const authType = formData.auth_type ?? "password";
|
||||||
|
|
||||||
if (authType === 'password') {
|
if (authType === "password") {
|
||||||
if (!formData.password?.trim()) {
|
if (!formData.password?.trim()) {
|
||||||
newErrors.password = 'Password is required for password authentication';
|
newErrors.password = "Password is required for password authentication";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'key') {
|
if (authType === "key") {
|
||||||
if (!formData.ssh_key?.trim()) {
|
if (!formData.ssh_key?.trim()) {
|
||||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
newErrors.ssh_key = "SSH key is required for key authentication";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||||
};
|
};
|
||||||
@@ -159,156 +173,185 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
onSubmit(formData);
|
onSubmit(formData);
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: "",
|
||||||
ip: '',
|
ip: "",
|
||||||
user: '',
|
user: "",
|
||||||
password: '',
|
password: "",
|
||||||
auth_type: 'password',
|
auth_type: "password",
|
||||||
ssh_key: '',
|
ssh_key: "",
|
||||||
ssh_key_passphrase: '',
|
ssh_key_passphrase: "",
|
||||||
ssh_port: 22,
|
ssh_port: 22,
|
||||||
color: '#3b82f6'
|
color: "#3b82f6",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateServerData) => (
|
const handleChange =
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
(field: keyof CreateServerData) =>
|
||||||
) => {
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
// Special handling for numeric ssh_port: keep it strictly numeric
|
// Special handling for numeric ssh_port: keep it strictly numeric
|
||||||
if (field === 'ssh_port') {
|
if (field === "ssh_port") {
|
||||||
const raw = (e.target as HTMLInputElement).value ?? '';
|
const raw = (e.target as HTMLInputElement).value ?? "";
|
||||||
const digitsOnly = raw.replace(/\D+/g, '');
|
const digitsOnly = raw.replace(/\D+/g, "");
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
||||||
}));
|
}));
|
||||||
if (errors.ssh_port) {
|
if (errors.ssh_port) {
|
||||||
setErrors(prev => ({ ...prev, ssh_port: undefined }));
|
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: (e.target as HTMLInputElement).value,
|
||||||
|
}));
|
||||||
// Clear error when user starts typing
|
// Clear error when user starts typing
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset generated key state when switching auth types
|
// Reset generated key state when switching auth types
|
||||||
if (field === 'auth_type') {
|
if (field === "auth_type") {
|
||||||
setIsGeneratedKey(false);
|
setIsGeneratedKey(false);
|
||||||
setGeneratedPublicKey('');
|
setGeneratedPublicKey("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateKeyPair = async () => {
|
const handleGenerateKeyPair = async () => {
|
||||||
setIsGeneratingKey(true);
|
setIsGeneratingKey(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/servers/generate-keypair', {
|
const response = await fetch("/api/servers/generate-keypair", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to generate key pair');
|
throw new Error("Failed to generate key pair");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
|
const data = (await response.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
privateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
serverId?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const serverId = data.serverId ?? 0;
|
const serverId = data.serverId ?? 0;
|
||||||
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||||
|
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
ssh_key: data.privateKey ?? '',
|
ssh_key: data.privateKey ?? "",
|
||||||
ssh_key_path: keyPath,
|
ssh_key_path: keyPath,
|
||||||
key_generated: true
|
key_generated: true,
|
||||||
}));
|
}));
|
||||||
setGeneratedPublicKey(data.publicKey ?? '');
|
setGeneratedPublicKey(data.publicKey ?? "");
|
||||||
setGeneratedServerId(serverId);
|
setGeneratedServerId(serverId);
|
||||||
setIsGeneratedKey(true);
|
setIsGeneratedKey(true);
|
||||||
setShowPublicKeyModal(true);
|
setShowPublicKeyModal(true);
|
||||||
setSshKeyError('');
|
setSshKeyError("");
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error ?? 'Failed to generate key pair');
|
throw new Error(data.error ?? "Failed to generate key pair");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating key pair:', error);
|
console.error("Error generating key pair:", error);
|
||||||
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
setSshKeyError(
|
||||||
|
error instanceof Error ? error.message : "Failed to generate key pair",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingKey(false);
|
setIsGeneratingKey(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSSHKeyChange = (value: string) => {
|
const handleSSHKeyChange = (value: string) => {
|
||||||
setFormData(prev => ({ ...prev, ssh_key: value }));
|
setFormData((prev) => ({ ...prev, ssh_key: value }));
|
||||||
if (errors.ssh_key) {
|
if (errors.ssh_key) {
|
||||||
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
setErrors((prev) => ({ ...prev, ssh_key: undefined }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Server Name *
|
Server Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange('name')}
|
onChange={handleChange("name")}
|
||||||
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 ${
|
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||||
errors.name ? 'border-destructive' : 'border-border'
|
errors.name ? "border-destructive" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., Production Server"
|
placeholder="e.g., Production Server"
|
||||||
/>
|
/>
|
||||||
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
|
{errors.name && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="ip"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Host/IP Address *
|
Host/IP Address *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="ip"
|
id="ip"
|
||||||
value={formData.ip}
|
value={formData.ip}
|
||||||
onChange={handleChange('ip')}
|
onChange={handleChange("ip")}
|
||||||
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 ${
|
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||||
errors.ip ? 'border-destructive' : 'border-border'
|
errors.ip ? "border-destructive" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
|
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
|
||||||
/>
|
/>
|
||||||
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
|
{errors.ip && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="user"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Username *
|
Username *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="user"
|
id="user"
|
||||||
value={formData.user}
|
value={formData.user}
|
||||||
onChange={handleChange('user')}
|
onChange={handleChange("user")}
|
||||||
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 ${
|
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||||
errors.user ? 'border-destructive' : 'border-border'
|
errors.user ? "border-destructive" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., root"
|
placeholder="e.g., root"
|
||||||
/>
|
/>
|
||||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
{errors.user && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="ssh_port"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
SSH Port
|
SSH Port
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -318,26 +361,31 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={formData.ssh_port ?? 22}
|
value={formData.ssh_port ?? 22}
|
||||||
onChange={handleChange('ssh_port')}
|
onChange={handleChange("ssh_port")}
|
||||||
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 ${
|
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||||
errors.ssh_port ? 'border-destructive' : 'border-border'
|
errors.ssh_port ? "border-destructive" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
placeholder="22"
|
placeholder="22"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
/>
|
/>
|
||||||
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
{errors.ssh_port && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="auth_type"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Authentication Type *
|
Authentication Type *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="auth_type"
|
id="auth_type"
|
||||||
value={formData.auth_type ?? 'password'}
|
value={formData.auth_type ?? "password"}
|
||||||
onChange={handleChange('auth_type')}
|
onChange={handleChange("auth_type")}
|
||||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
className="bg-card text-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="password">Password Only</option>
|
<option value="password">Password Only</option>
|
||||||
<option value="key">SSH Key Only</option>
|
<option value="key">SSH Key Only</option>
|
||||||
@@ -346,18 +394,21 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
|
|
||||||
{colorCodingEnabled && (
|
{colorCodingEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="color"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Server Color
|
Server Color
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
id="color"
|
id="color"
|
||||||
value={formData.color ?? '#3b82f6'}
|
value={formData.color ?? "#3b82f6"}
|
||||||
onChange={handleChange('color')}
|
onChange={handleChange("color")}
|
||||||
className="w-20 h-10 rounded cursor-pointer border border-border"
|
className="border-border h-10 w-20 cursor-pointer rounded border"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Choose a color to identify this server
|
Choose a color to identify this server
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,31 +417,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Authentication */}
|
{/* Password Authentication */}
|
||||||
{formData.auth_type === 'password' && (
|
{formData.auth_type === "password" && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
Password *
|
Password *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
value={formData.password ?? ''}
|
value={formData.password ?? ""}
|
||||||
onChange={handleChange('password')}
|
onChange={handleChange("password")}
|
||||||
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 ${
|
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||||
errors.password ? 'border-destructive' : 'border-border'
|
errors.password ? "border-destructive" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
/>
|
/>
|
||||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
{errors.password && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SSH Key Authentication */}
|
{/* SSH Key Authentication */}
|
||||||
{formData.auth_type === 'key' && (
|
{formData.auth_type === "key" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<label className="block text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground block text-sm font-medium">
|
||||||
SSH Private Key *
|
SSH Private Key *
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
@@ -402,7 +458,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Key className="h-4 w-4" />
|
<Key className="h-4 w-4" />
|
||||||
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
{isGeneratingKey ? "Generating..." : "Generate Key Pair"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,24 +466,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
{!formData.key_generated && (
|
{!formData.key_generated && (
|
||||||
<>
|
<>
|
||||||
<SSHKeyInput
|
<SSHKeyInput
|
||||||
value={formData.ssh_key ?? ''}
|
value={formData.ssh_key ?? ""}
|
||||||
onChange={handleSSHKeyChange}
|
onChange={handleSSHKeyChange}
|
||||||
onError={setSshKeyError}
|
onError={setSshKeyError}
|
||||||
/>
|
/>
|
||||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
{errors.ssh_key && (
|
||||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
<p className="text-destructive mt-1 text-sm">
|
||||||
|
{errors.ssh_key}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sshKeyError && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">
|
||||||
|
{sshKeyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show generated key status */}
|
{/* Show generated key status */}
|
||||||
{formData.key_generated && (
|
{formData.key_generated && (
|
||||||
<div className="p-3 bg-success/10 border border-success/20 rounded-md">
|
<div className="bg-success/10 border-success/20 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
className="text-success h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-success-foreground">
|
<span className="text-success-foreground text-sm font-medium">
|
||||||
SSH key pair generated successfully
|
SSH key pair generated successfully
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,46 +510,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPublicKeyModal(true)}
|
onClick={() => setShowPublicKeyModal(true)}
|
||||||
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
|
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
|
||||||
>
|
>
|
||||||
<Key className="h-4 w-4" />
|
<Key className="h-4 w-4" />
|
||||||
View Public Key
|
View Public Key
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-success/80 mt-1">
|
<p className="text-success/80 mt-1 text-xs">
|
||||||
The private key has been generated and will be saved with the server.
|
The private key has been generated and will be saved with
|
||||||
|
the server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
htmlFor="ssh_key_passphrase"
|
||||||
|
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
SSH Key Passphrase (Optional)
|
SSH Key Passphrase (Optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="ssh_key_passphrase"
|
id="ssh_key_passphrase"
|
||||||
value={formData.ssh_key_passphrase ?? ''}
|
value={formData.ssh_key_passphrase ?? ""}
|
||||||
onChange={handleChange('ssh_key_passphrase')}
|
onChange={handleChange("ssh_key_passphrase")}
|
||||||
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 border-border"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder="Enter passphrase for encrypted key"
|
placeholder="Enter passphrase for encrypted key"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Only required if your SSH key is encrypted with a passphrase
|
Only required if your SSH key is encrypted with a passphrase
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
<div className="flex flex-col justify-end space-y-2 pt-4 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto order-2 sm:order-1"
|
className="order-2 w-full sm:order-1 sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -484,9 +562,9 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto order-1 sm:order-2"
|
className="order-1 w-full sm:order-2 sm:w-auto"
|
||||||
>
|
>
|
||||||
{isEditing ? 'Update Server' : 'Add Server'}
|
{isEditing ? "Update Server" : "Add Server"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -496,10 +574,9 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
isOpen={showPublicKeyModal}
|
isOpen={showPublicKeyModal}
|
||||||
onClose={() => setShowPublicKeyModal(false)}
|
onClose={() => setShowPublicKeyModal(false)}
|
||||||
publicKey={generatedPublicKey}
|
publicKey={generatedPublicKey}
|
||||||
serverName={formData.name || 'New Server'}
|
serverName={formData.name || "New Server"}
|
||||||
serverIp={formData.ip}
|
serverIp={formData.ip}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from "react";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
|
import {
|
||||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
Database,
|
||||||
import { api } from '~/trpc/react';
|
RefreshCw,
|
||||||
import { PBSCredentialsModal } from './PBSCredentialsModal';
|
CheckCircle,
|
||||||
import type { Storage } from '~/server/services/storageService';
|
Lock,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PBSCredentialsModal } from "./PBSCredentialsModal";
|
||||||
|
import type { Storage } from "~/server/services/storageService";
|
||||||
|
|
||||||
interface ServerStoragesModalProps {
|
interface ServerStoragesModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -19,30 +25,38 @@ export function ServerStoragesModal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
serverId,
|
serverId,
|
||||||
serverName
|
serverName,
|
||||||
}: ServerStoragesModalProps) {
|
}: ServerStoragesModalProps) {
|
||||||
const [forceRefresh, setForceRefresh] = useState(false);
|
const [forceRefresh, setForceRefresh] = useState(false);
|
||||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
|
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
const { data, isLoading, refetch } =
|
||||||
|
api.installedScripts.getBackupStorages.useQuery(
|
||||||
{ serverId, forceRefresh },
|
{ serverId, forceRefresh },
|
||||||
{ enabled: isOpen }
|
{ enabled: isOpen },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch all PBS credentials for this server to show status indicators
|
// Fetch all PBS credentials for this server to show status indicators
|
||||||
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
const { data: allCredentials } =
|
||||||
|
api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||||
{ serverId },
|
{ serverId },
|
||||||
{ enabled: isOpen }
|
{ enabled: isOpen },
|
||||||
);
|
);
|
||||||
|
|
||||||
const credentialsMap = new Map<string, boolean>();
|
const credentialsMap = new Map<string, boolean>();
|
||||||
if (allCredentials?.success) {
|
if (allCredentials?.success) {
|
||||||
allCredentials.credentials.forEach(c => {
|
allCredentials.credentials.forEach((c: { storage_name: string }) => {
|
||||||
credentialsMap.set(c.storage_name, true);
|
credentialsMap.set(String(c.storage_name), true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, {
|
||||||
|
id: "server-storages-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setForceRefresh(true);
|
setForceRefresh(true);
|
||||||
@@ -53,16 +67,16 @@ export function ServerStoragesModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const storages = data?.success ? data.storages : [];
|
const storages = data?.success ? data.storages : [];
|
||||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
const backupStorages = storages.filter((s) => s.supportsBackup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
|
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="border-border flex items-center justify-between border-b p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Database className="h-6 w-6 text-primary" />
|
<Database className="text-primary h-6 w-6" />
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">
|
<h2 className="text-card-foreground text-2xl font-bold">
|
||||||
Storages for {serverName}
|
Storages for {serverName}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +87,9 @@ export function ServerStoragesModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -82,8 +98,18 @@ export function ServerStoragesModal({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,35 +118,36 @@ export function ServerStoragesModal({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="py-8 text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
<div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||||
<p className="text-muted-foreground">Loading storages...</p>
|
<p className="text-muted-foreground">Loading storages...</p>
|
||||||
</div>
|
</div>
|
||||||
) : !data?.success ? (
|
) : !data?.success ? (
|
||||||
<div className="text-center py-8">
|
<div className="py-8 text-center">
|
||||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<p className="text-foreground mb-2">Failed to load storages</p>
|
<p className="text-foreground mb-2">Failed to load storages</p>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4 text-sm">
|
||||||
{data?.error ?? 'Unknown error occurred'}
|
{data?.error ?? "Unknown error occurred"}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : storages.length === 0 ? (
|
) : storages.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="py-8 text-center">
|
||||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<p className="text-foreground mb-2">No storages found</p>
|
<p className="text-foreground mb-2">No storages found</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Make sure your server has storages configured.
|
Make sure your server has storages configured.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{data.cached && (
|
{data.cached && (
|
||||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
<div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm">
|
||||||
Showing cached data. Click Refresh to fetch latest from server.
|
Showing cached data. Click Refresh to fetch latest from
|
||||||
|
server.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -131,57 +158,72 @@ export function ServerStoragesModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={storage.name}
|
key={storage.name}
|
||||||
className={`p-4 border rounded-lg ${
|
className={`rounded-lg border p-4 ${
|
||||||
isBackupCapable
|
isBackupCapable
|
||||||
? 'border-success/50 bg-success/5'
|
? "border-success/50 bg-success/5"
|
||||||
: 'border-border bg-card'
|
: "border-border bg-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
<h3 className="text-foreground font-medium">
|
||||||
|
{storage.name}
|
||||||
|
</h3>
|
||||||
{isBackupCapable && (
|
{isBackupCapable && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||||
<CheckCircle className="h-3 w-3" />
|
<CheckCircle className="h-3 w-3" />
|
||||||
Backup
|
Backup
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
<span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium">
|
||||||
{storage.type}
|
{storage.type}
|
||||||
</span>
|
</span>
|
||||||
{storage.type === 'pbs' && (
|
{storage.type === "pbs" &&
|
||||||
credentialsMap.has(storage.name) ? (
|
(credentialsMap.has(storage.name) ? (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||||
<CheckCircle className="h-3 w-3" />
|
<CheckCircle className="h-3 w-3" />
|
||||||
Credentials Configured
|
Credentials Configured
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1">
|
<span className="bg-warning/20 text-warning border-warning/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
Credentials Needed
|
Credentials Needed
|
||||||
</span>
|
</span>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-muted-foreground space-y-1 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
<span className="font-medium">Content:</span>{" "}
|
||||||
|
{storage.content.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
{storage.nodes && storage.nodes.length > 0 && (
|
{storage.nodes && storage.nodes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
<span className="font-medium">Nodes:</span>{" "}
|
||||||
|
{storage.nodes.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.entries(storage)
|
{Object.entries(storage)
|
||||||
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"content",
|
||||||
|
"supportsBackup",
|
||||||
|
"nodes",
|
||||||
|
].includes(key),
|
||||||
|
)
|
||||||
.map(([key, value]) => (
|
.map(([key, value]) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
|
<span className="font-medium capitalize">
|
||||||
|
{key.replace(/_/g, " ")}:
|
||||||
|
</span>{" "}
|
||||||
|
{String(value)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{storage.type === 'pbs' && (
|
{storage.type === "pbs" && (
|
||||||
<div className="mt-3 pt-3 border-t border-border">
|
<div className="border-border mt-3 border-t pt-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setSelectedPBSStorage(storage)}
|
onClick={() => setSelectedPBSStorage(storage)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -189,7 +231,10 @@ export function ServerStoragesModal({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4" />
|
||||||
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
|
{credentialsMap.has(storage.name)
|
||||||
|
? "Edit"
|
||||||
|
: "Configure"}{" "}
|
||||||
|
Credentials
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,9 +245,11 @@ export function ServerStoragesModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{backupStorages.length > 0 && (
|
{backupStorages.length > 0 && (
|
||||||
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
|
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4">
|
||||||
<p className="text-sm text-success font-medium">
|
<p className="text-success text-sm font-medium">
|
||||||
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
|
{backupStorages.length} storage
|
||||||
|
{backupStorages.length !== 1 ? "s" : ""} available for
|
||||||
|
backups
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -224,4 +271,3 @@ export function ServerStoragesModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ interface StorageSelectionModalProps {
|
|||||||
storages: Storage[];
|
storages: Storage[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
filterFn?: (storage: Storage) => boolean;
|
||||||
|
showBackupTag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageSelectionModal({
|
export function StorageSelectionModal({
|
||||||
@@ -21,7 +25,11 @@ export function StorageSelectionModal({
|
|||||||
onSelect,
|
onSelect,
|
||||||
storages,
|
storages,
|
||||||
isLoading,
|
isLoading,
|
||||||
onRefresh
|
onRefresh,
|
||||||
|
title = 'Select Storage',
|
||||||
|
description = 'Select a storage to use.',
|
||||||
|
filterFn,
|
||||||
|
showBackupTag = true
|
||||||
}: StorageSelectionModalProps) {
|
}: StorageSelectionModalProps) {
|
||||||
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
|
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
|
||||||
|
|
||||||
@@ -41,8 +49,8 @@ export function StorageSelectionModal({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter to show only backup-capable storages
|
// Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages
|
||||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
@@ -51,7 +59,7 @@ export function StorageSelectionModal({
|
|||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Database className="h-6 w-6 text-primary" />
|
<Database className="h-6 w-6 text-primary" />
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
|
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -72,7 +80,7 @@ export function StorageSelectionModal({
|
|||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||||
<p className="text-muted-foreground">Loading storages...</p>
|
<p className="text-muted-foreground">Loading storages...</p>
|
||||||
</div>
|
</div>
|
||||||
) : backupStorages.length === 0 ? (
|
) : filteredStorages.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-foreground mb-2">No backup-capable storages found</p>
|
<p className="text-foreground mb-2">No backup-capable storages found</p>
|
||||||
@@ -87,12 +95,12 @@ export function StorageSelectionModal({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Select a storage to use for the backup. Only storages that support backups are shown.
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Storage List */}
|
{/* Storage List */}
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
||||||
{backupStorages.map((storage) => (
|
{filteredStorages.map((storage) => (
|
||||||
<div
|
<div
|
||||||
key={storage.name}
|
key={storage.name}
|
||||||
onClick={() => setSelectedStorage(storage)}
|
onClick={() => setSelectedStorage(storage)}
|
||||||
@@ -106,9 +114,11 @@ export function StorageSelectionModal({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||||
|
{showBackupTag && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
||||||
Backup
|
Backup
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||||
{storage.type}
|
{storage.type}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ interface TerminalProps {
|
|||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
isShell?: boolean;
|
isShell?: boolean;
|
||||||
isBackup?: boolean;
|
isBackup?: boolean;
|
||||||
|
isClone?: boolean;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
storage?: string;
|
storage?: string;
|
||||||
backupStorage?: string;
|
backupStorage?: string;
|
||||||
|
executionId?: string;
|
||||||
|
cloneCount?: number;
|
||||||
|
hostnames?: string[];
|
||||||
|
containerType?: 'lxc' | 'vm';
|
||||||
|
envVars?: Record<string, string | number | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalMessage {
|
interface TerminalMessage {
|
||||||
@@ -24,7 +30,7 @@ interface TerminalMessage {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
|
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, isClone = false, containerId, storage, backupStorage, executionId: propExecutionId, cloneCount, hostnames, containerType, envVars }: TerminalProps) {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
@@ -39,7 +45,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
||||||
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
const [executionId, setExecutionId] = useState(() => propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
// Update executionId when propExecutionId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propExecutionId) {
|
||||||
|
setExecutionId(propExecutionId);
|
||||||
|
}
|
||||||
|
}, [propExecutionId]);
|
||||||
|
|
||||||
|
const effectiveExecutionId = propExecutionId ?? executionId;
|
||||||
const isConnectingRef = useRef<boolean>(false);
|
const isConnectingRef = useRef<boolean>(false);
|
||||||
const hasConnectedRef = useRef<boolean>(false);
|
const hasConnectedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -277,7 +292,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
const message = {
|
const message = {
|
||||||
action: 'input',
|
action: 'input',
|
||||||
executionId,
|
executionId: effectiveExecutionId,
|
||||||
input: data
|
input: data
|
||||||
};
|
};
|
||||||
wsRef.current.send(JSON.stringify(message));
|
wsRef.current.send(JSON.stringify(message));
|
||||||
@@ -325,9 +340,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
// Only auto-start on initial connection, not on reconnections
|
// Only auto-start on initial connection, not on reconnections
|
||||||
if (isInitialConnection && !isRunning) {
|
if (isInitialConnection && !isRunning) {
|
||||||
// Generate a new execution ID for the initial run
|
// Use propExecutionId if provided, otherwise generate a new one
|
||||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
if (!propExecutionId) {
|
||||||
setExecutionId(newExecutionId);
|
setExecutionId(newExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
action: 'start',
|
action: 'start',
|
||||||
@@ -338,9 +355,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
isUpdate,
|
isUpdate,
|
||||||
isShell,
|
isShell,
|
||||||
isBackup,
|
isBackup,
|
||||||
|
isClone,
|
||||||
containerId,
|
containerId,
|
||||||
storage,
|
storage,
|
||||||
backupStorage
|
backupStorage,
|
||||||
|
cloneCount,
|
||||||
|
hostnames,
|
||||||
|
containerType,
|
||||||
|
envVars
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -380,13 +402,15 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile, envVars]);
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
// Generate a new execution ID for each script run
|
// Generate a new execution ID for each script run (unless propExecutionId is provided)
|
||||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
if (!propExecutionId) {
|
||||||
setExecutionId(newExecutionId);
|
setExecutionId(newExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
setIsStopped(false);
|
setIsStopped(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
@@ -395,9 +419,17 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
executionId: newExecutionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
|
envVars,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
isShell,
|
isShell,
|
||||||
containerId
|
isBackup,
|
||||||
|
isClone,
|
||||||
|
containerId,
|
||||||
|
storage,
|
||||||
|
backupStorage,
|
||||||
|
cloneCount,
|
||||||
|
hostnames,
|
||||||
|
containerType
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { Button } from './ui/button';
|
import { Button } from "./ui/button";
|
||||||
import type { Script } from '../../types/script';
|
import type { Script } from "../../types/script";
|
||||||
|
|
||||||
interface TextViewerProps {
|
interface TextViewerProps {
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
@@ -14,154 +14,161 @@ interface TextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScriptContent {
|
interface ScriptContent {
|
||||||
ctScript?: string;
|
mainScript?: string;
|
||||||
installScript?: string;
|
installScript?: string;
|
||||||
alpineCtScript?: string;
|
alpineMainScript?: string;
|
||||||
alpineInstallScript?: string;
|
alpineInstallScript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
|
export function TextViewer({
|
||||||
|
scriptName,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
script,
|
||||||
|
}: TextViewerProps) {
|
||||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
const [activeTab, setActiveTab] = useState<"main" | "install">("main");
|
||||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
|
||||||
|
"default",
|
||||||
// Extract slug from script name (remove .sh extension)
|
|
||||||
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
|
// Extract slug from script name (remove .sh extension)
|
||||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, "");
|
||||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
|
||||||
|
// Get default and alpine install methods
|
||||||
|
const defaultMethod = script?.install_methods?.find(
|
||||||
|
(method) => method.type === "default",
|
||||||
|
);
|
||||||
|
const alpineMethod = script?.install_methods?.find(
|
||||||
|
(method) => method.type === "alpine",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if alpine variant exists
|
||||||
|
const hasAlpineVariant = !!alpineMethod;
|
||||||
|
|
||||||
|
// Get script paths from install_methods
|
||||||
|
const defaultScriptPath = defaultMethod?.script;
|
||||||
|
const alpineScriptPath = alpineMethod?.script;
|
||||||
|
|
||||||
|
// Determine if install scripts exist (only for ct/ scripts typically)
|
||||||
|
const hasInstallScript =
|
||||||
|
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
|
||||||
|
|
||||||
|
// Get script names for display
|
||||||
|
const defaultScriptName = scriptName.replace(/^alpine-/, "");
|
||||||
|
|
||||||
const loadScriptContent = useCallback(async () => {
|
const loadScriptContent = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build fetch requests for default version
|
// Build fetch requests based on actual script paths from install_methods
|
||||||
const requests: Promise<Response>[] = [];
|
const requests: Promise<Response>[] = [];
|
||||||
|
const requestTypes: Array<
|
||||||
|
"default-main" | "default-install" | "alpine-main" | "alpine-install"
|
||||||
|
> = [];
|
||||||
|
|
||||||
// Default CT script
|
// Default main script (ct/, vm/, tools/, etc.)
|
||||||
|
if (defaultScriptPath) {
|
||||||
requests.push(
|
requests.push(
|
||||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
fetch(
|
||||||
|
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
requestTypes.push("default-main");
|
||||||
|
}
|
||||||
|
|
||||||
// Tools, VM, VW scripts
|
// Default install script (only for ct/ scripts)
|
||||||
|
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
|
||||||
requests.push(
|
requests.push(
|
||||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
fetch(
|
||||||
);
|
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
|
||||||
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}` } }))}`)
|
|
||||||
);
|
);
|
||||||
|
requestTypes.push("default-install");
|
||||||
|
}
|
||||||
|
|
||||||
// Default install script
|
// Alpine main script
|
||||||
|
if (hasAlpineVariant && alpineScriptPath) {
|
||||||
requests.push(
|
requests.push(
|
||||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
fetch(
|
||||||
|
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
requestTypes.push("alpine-main");
|
||||||
|
}
|
||||||
|
|
||||||
// Alpine versions if variant exists
|
// Alpine install script (only for ct/ scripts)
|
||||||
if (hasAlpineVariant) {
|
if (
|
||||||
|
hasAlpineVariant &&
|
||||||
|
hasInstallScript &&
|
||||||
|
alpineScriptPath?.startsWith("ct/")
|
||||||
|
) {
|
||||||
requests.push(
|
requests.push(
|
||||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
fetch(
|
||||||
);
|
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
|
||||||
requests.push(
|
),
|
||||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
|
||||||
);
|
);
|
||||||
|
requestTypes.push("alpine-install");
|
||||||
}
|
}
|
||||||
|
|
||||||
const responses = await Promise.allSettled(requests);
|
const responses = await Promise.allSettled(requests);
|
||||||
|
|
||||||
const content: ScriptContent = {};
|
const content: ScriptContent = {};
|
||||||
let responseIndex = 0;
|
|
||||||
|
|
||||||
// Default CT script
|
// Process responses based on their types
|
||||||
const ctResponse = responses[responseIndex];
|
await Promise.all(
|
||||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
responses.map(async (response, index) => {
|
||||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
if (response.status === "fulfilled" && response.value.ok) {
|
||||||
if (ctData.result?.data?.json?.success) {
|
try {
|
||||||
content.ctScript = ctData.result.data.json.content;
|
const data = (await response.value.json()) as {
|
||||||
|
result?: {
|
||||||
|
data?: { json?: { success?: boolean; content?: string } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const type = requestTypes[index];
|
||||||
|
if (
|
||||||
|
data.result?.data?.json?.success &&
|
||||||
|
data.result.data.json.content
|
||||||
|
) {
|
||||||
|
switch (type) {
|
||||||
|
case "default-main":
|
||||||
|
content.mainScript = data.result.data.json.content;
|
||||||
|
break;
|
||||||
|
case "default-install":
|
||||||
|
content.installScript = data.result.data.json.content;
|
||||||
|
break;
|
||||||
|
case "alpine-main":
|
||||||
|
content.alpineMainScript = data.result.data.json.content;
|
||||||
|
break;
|
||||||
|
case "alpine-install":
|
||||||
|
content.alpineInstallScript = data.result.data.json.content;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
responseIndex++;
|
// Ignore errors
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
setScriptContent(content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load script content');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load script content",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
}, [
|
||||||
|
defaultScriptPath,
|
||||||
|
alpineScriptPath,
|
||||||
|
slug,
|
||||||
|
hasAlpineVariant,
|
||||||
|
hasInstallScript,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && scriptName) {
|
if (isOpen && scriptName) {
|
||||||
@@ -179,51 +186,63 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
|
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="border-border flex items-center justify-between border-b p-6">
|
||||||
<div className="flex items-center space-x-4 flex-1">
|
<div className="flex flex-1 items-center space-x-4">
|
||||||
<h2 className="text-2xl font-bold text-foreground">
|
<h2 className="text-foreground text-2xl font-bold">
|
||||||
Script Viewer: {defaultScriptName}
|
Script Viewer: {defaultScriptName}
|
||||||
</h2>
|
</h2>
|
||||||
{hasAlpineVariant && (
|
{hasAlpineVariant && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant={selectedVersion === 'default' ? 'default' : 'outline'}
|
variant={
|
||||||
onClick={() => setSelectedVersion('default')}
|
selectedVersion === "default" ? "default" : "outline"
|
||||||
|
}
|
||||||
|
onClick={() => setSelectedVersion("default")}
|
||||||
className="px-3 py-1 text-sm"
|
className="px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
Default
|
Default
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
|
variant={selectedVersion === "alpine" ? "default" : "outline"}
|
||||||
onClick={() => setSelectedVersion('alpine')}
|
onClick={() => setSelectedVersion("alpine")}
|
||||||
className="px-3 py-1 text-sm"
|
className="px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
Alpine
|
Alpine
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
{((selectedVersion === "default" &&
|
||||||
|
Boolean(
|
||||||
|
scriptContent.mainScript ?? scriptContent.installScript,
|
||||||
|
)) ||
|
||||||
|
(selectedVersion === "alpine" &&
|
||||||
|
Boolean(
|
||||||
|
scriptContent.alpineMainScript ??
|
||||||
|
scriptContent.alpineInstallScript,
|
||||||
|
))) && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
variant={activeTab === "main" ? "outline" : "ghost"}
|
||||||
onClick={() => setActiveTab('ct')}
|
onClick={() => setActiveTab("main")}
|
||||||
className="px-3 py-1 text-sm"
|
className="px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
CT Script
|
Script
|
||||||
</Button>
|
</Button>
|
||||||
|
{hasInstallScript && (
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
variant={activeTab === "install" ? "outline" : "ghost"}
|
||||||
onClick={() => setActiveTab('install')}
|
onClick={() => setActiveTab("install")}
|
||||||
className="px-3 py-1 text-sm"
|
className="px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
Install Script
|
Install Script
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,92 +250,108 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-lg text-muted-foreground">Loading script content...</div>
|
<div className="text-muted-foreground text-lg">
|
||||||
|
Loading script content...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-lg text-destructive">Error: {error}</div>
|
<div className="text-destructive text-lg">Error: {error}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{activeTab === 'ct' && (
|
{activeTab === "main" &&
|
||||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
(selectedVersion === "default" && scriptContent.mainScript ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="bash"
|
language="bash"
|
||||||
style={tomorrow}
|
style={tomorrow}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
fontSize: '14px',
|
fontSize: "14px",
|
||||||
lineHeight: '1.5',
|
lineHeight: "1.5",
|
||||||
minHeight: '100%'
|
minHeight: "100%",
|
||||||
}}
|
}}
|
||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
wrapLines={true}
|
wrapLines={true}
|
||||||
>
|
>
|
||||||
{scriptContent.ctScript}
|
{scriptContent.mainScript}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
) : selectedVersion === "alpine" &&
|
||||||
|
scriptContent.alpineMainScript ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="bash"
|
language="bash"
|
||||||
style={tomorrow}
|
style={tomorrow}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
fontSize: '14px',
|
fontSize: "14px",
|
||||||
lineHeight: '1.5',
|
lineHeight: "1.5",
|
||||||
minHeight: '100%'
|
minHeight: "100%",
|
||||||
}}
|
}}
|
||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
wrapLines={true}
|
wrapLines={true}
|
||||||
>
|
>
|
||||||
{scriptContent.alpineCtScript}
|
{scriptContent.alpineMainScript}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-lg text-muted-foreground">
|
<div className="text-muted-foreground text-lg">
|
||||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
{selectedVersion === "default"
|
||||||
|
? "Default script not found"
|
||||||
|
: "Alpine script not found"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
{activeTab === "install" &&
|
||||||
{activeTab === 'install' && (
|
(selectedVersion === "default" &&
|
||||||
selectedVersion === 'default' && scriptContent.installScript ? (
|
scriptContent.installScript ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="bash"
|
language="bash"
|
||||||
style={tomorrow}
|
style={tomorrow}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
fontSize: '14px',
|
fontSize: "14px",
|
||||||
lineHeight: '1.5',
|
lineHeight: "1.5",
|
||||||
minHeight: '100%'
|
minHeight: "100%",
|
||||||
}}
|
}}
|
||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
wrapLines={true}
|
wrapLines={true}
|
||||||
>
|
>
|
||||||
{scriptContent.installScript}
|
{scriptContent.installScript}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
|
) : selectedVersion === "alpine" &&
|
||||||
|
scriptContent.alpineInstallScript ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="bash"
|
language="bash"
|
||||||
style={tomorrow}
|
style={tomorrow}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
fontSize: '14px',
|
fontSize: "14px",
|
||||||
lineHeight: '1.5',
|
lineHeight: "1.5",
|
||||||
minHeight: '100%'
|
minHeight: "100%",
|
||||||
}}
|
}}
|
||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
wrapLines={true}
|
wrapLines={true}
|
||||||
@@ -324,13 +359,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
|||||||
{scriptContent.alpineInstallScript}
|
{scriptContent.alpineInstallScript}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-lg text-muted-foreground">
|
<div className="text-muted-foreground text-lg">
|
||||||
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
|
{selectedVersion === "default"
|
||||||
|
? "Default install script not found"
|
||||||
|
: "Alpine install script not found"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState, startTransition } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -31,9 +31,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||||
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
||||||
|
startTransition(() => {
|
||||||
setThemeState(savedTheme);
|
setThemeState(savedTheme);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
startTransition(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme to document element
|
// Apply theme to document element
|
||||||
|
|||||||
234
src/app/_components/UpdateConfirmationModal.tsx
Normal file
234
src/app/_components/UpdateConfirmationModal.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react";
|
||||||
|
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
interface UpdateConfirmationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
releaseInfo: {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateConfirmationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
releaseInfo,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
}: UpdateConfirmationModalProps) {
|
||||||
|
useRegisterModal(isOpen, {
|
||||||
|
id: "update-confirmation-modal",
|
||||||
|
allowEscape: true,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen || !releaseInfo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-border flex items-center justify-between border-b p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="text-warning h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-card-foreground text-2xl font-bold">
|
||||||
|
Confirm Update
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Review the changelog before proceeding with the update
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||||
|
{/* Version Info */}
|
||||||
|
<div className="bg-muted/50 border-border rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-card-foreground text-lg font-semibold">
|
||||||
|
{releaseInfo.name || releaseInfo.tagName}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={releaseInfo.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
<span>{releaseInfo.tagName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{new Date(releaseInfo.publishedAt).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<span>Updating from </span>
|
||||||
|
<span className="text-card-foreground font-medium">
|
||||||
|
v{currentVersion}
|
||||||
|
</span>
|
||||||
|
<span> to </span>
|
||||||
|
<span className="text-card-foreground font-medium">
|
||||||
|
v{latestVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changelog */}
|
||||||
|
{releaseInfo.body ? (
|
||||||
|
<div className="border-border bg-card rounded-lg border p-6">
|
||||||
|
<h4 className="text-md text-card-foreground mb-4 font-semibold">
|
||||||
|
Changelog
|
||||||
|
</h4>
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-card-foreground mt-4 mb-2 text-lg font-medium">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="text-card-foreground mb-3 leading-relaxed">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="text-card-foreground mb-3 list-inside list-disc space-y-1">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="text-card-foreground mb-3 list-inside list-decimal space-y-1">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li className="text-card-foreground">{children}</li>
|
||||||
|
),
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-info hover:text-info/80 underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="text-card-foreground font-semibold">
|
||||||
|
{children}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em className="text-card-foreground italic">
|
||||||
|
{children}
|
||||||
|
</em>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{releaseInfo.body}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-border bg-card rounded-lg border p-6">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No changelog available for this release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="bg-warning/10 border-warning/30 rounded-lg border p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||||
|
<div className="text-card-foreground text-sm">
|
||||||
|
<p className="mb-1 font-medium">Important:</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Please review the changelog above for any breaking changes
|
||||||
|
or important updates before proceeding. The server will
|
||||||
|
restart automatically after the update completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-border bg-muted/30 flex items-center justify-between border-t p-6">
|
||||||
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} variant="destructive" className="gap-2">
|
||||||
|
<span>Proceed with Update</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { api } from "~/trpc/react";
|
|||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
|
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
|
||||||
|
|
||||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
interface VersionDisplayProps {
|
interface VersionDisplayProps {
|
||||||
onOpenReleaseNotes?: () => void;
|
onOpenReleaseNotes?: () => void;
|
||||||
@@ -85,55 +86,233 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||||
const lastLogTimeRef = useRef<number>(Date.now());
|
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
||||||
|
const lastLogTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastLogTimeRef.current === 0) {
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const hasReloadedRef = useRef<boolean>(false);
|
||||||
|
const isUpdatingRef = useRef<boolean>(false);
|
||||||
|
const isNetworkErrorRef = useRef<boolean>(false);
|
||||||
|
const updateSessionIdRef = useRef<string | null>(null);
|
||||||
|
const updateStartTimeRef = useRef<number | null>(null);
|
||||||
|
const logFileModifiedTimeRef = useRef<number | null>(null);
|
||||||
|
const isCompleteProcessedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const executeUpdate = api.version.executeUpdate.useMutation({
|
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setUpdateResult({ success: result.success, message: result.message });
|
setUpdateResult({ success: result.success, message: result.message });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Start subscribing to update logs
|
// Start subscribing to update logs only if we're actually updating
|
||||||
|
if (isUpdatingRef.current) {
|
||||||
setShouldSubscribe(true);
|
setShouldSubscribe(true);
|
||||||
setUpdateLogs(['Update started...']);
|
setUpdateLogs(['Update started...']);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
setShouldSubscribe(false); // Reset subscription on failure
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setUpdateResult({ success: false, message: error.message });
|
setUpdateResult({ success: false, message: error.message });
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
setShouldSubscribe(false); // Reset subscription on error
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll for update logs
|
// Poll for update logs - only enabled when shouldSubscribe is true AND we're updating
|
||||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||||
enabled: shouldSubscribe,
|
enabled: shouldSubscribe && isUpdating,
|
||||||
refetchInterval: 1000, // Poll every second
|
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attempt to reconnect and reload page when server is back
|
||||||
|
// Memoized with useCallback to prevent recreation on every render
|
||||||
|
// Only depends on refs to avoid stale closures
|
||||||
|
const startReconnectAttempts = useCallback(() => {
|
||||||
|
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
||||||
|
// Only start if we're actually updating and haven't already started
|
||||||
|
// Double-check isUpdating state and session validity to prevent false triggers from stale data
|
||||||
|
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session age before starting reconnection attempts
|
||||||
|
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (sessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session is stale, don't start reconnection
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||||
|
|
||||||
|
reconnectIntervalRef.current = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
// Guard: Only proceed if we're still updating and in network error state
|
||||||
|
// Check refs directly to avoid stale closures
|
||||||
|
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
|
// Clear interval if we're no longer updating
|
||||||
|
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session is still valid
|
||||||
|
const currentSessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
if (currentSessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session expired, stop reconnection attempts
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to fetch the root path to check if server is back
|
||||||
|
const response = await fetch('/', { method: 'HEAD' });
|
||||||
|
if (response.ok || response.status === 200) {
|
||||||
|
// Double-check we're still updating and session is valid before reloading
|
||||||
|
if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final session validation
|
||||||
|
const finalSessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
if (finalSessionAge > MAX_SESSION_AGE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we're about to reload to prevent multiple reloads
|
||||||
|
hasReloadedRef.current = true;
|
||||||
|
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||||
|
|
||||||
|
// Clear interval
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set reload timeout
|
||||||
|
reloadTimeoutRef.current = setTimeout(() => {
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server still down, keep trying
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 2000);
|
||||||
|
}, []); // Empty deps - only uses refs which are stable
|
||||||
|
|
||||||
// Update logs when data changes
|
// Update logs when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (updateLogsData?.success && updateLogsData.logs) {
|
// CRITICAL: Only process update logs if we're actually updating
|
||||||
lastLogTimeRef.current = Date.now();
|
// This prevents stale isComplete data from triggering reloads when not updating
|
||||||
setUpdateLogs(updateLogsData.logs);
|
if (!isUpdating || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (updateLogsData.isComplete) {
|
// CRITICAL: Validate session - only process logs from current update session
|
||||||
|
// Check that update started within last 30 minutes (reasonable window for update)
|
||||||
|
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (sessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session is stale, reset everything
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
}, 0);
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateLogsData?.success && updateLogsData.logs) {
|
||||||
|
|
||||||
|
if (updateLogsData.logFileModifiedTime !== null && logFileModifiedTimeRef.current !== null) {
|
||||||
|
|
||||||
|
if (updateLogsData.logFileModifiedTime < logFileModifiedTimeRef.current) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (updateLogsData.logFileModifiedTime !== null && updateStartTimeRef.current) {
|
||||||
|
|
||||||
|
const timeDiff = updateLogsData.logFileModifiedTime - updateStartTimeRef.current;
|
||||||
|
if (timeDiff < -5000) {
|
||||||
|
|
||||||
|
}
|
||||||
|
logFileModifiedTimeRef.current = updateLogsData.logFileModifiedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0);
|
||||||
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
updateLogsData.isComplete &&
|
||||||
|
isUpdating &&
|
||||||
|
updateStartTimeRef.current &&
|
||||||
|
sessionAge < MAX_SESSION_AGE &&
|
||||||
|
!isCompleteProcessedRef.current
|
||||||
|
) {
|
||||||
|
// Mark as processed immediately to prevent multiple triggers
|
||||||
|
isCompleteProcessedRef.current = true;
|
||||||
|
|
||||||
|
// Stop polling immediately to prevent further stale data processing
|
||||||
|
setTimeout(() => setShouldSubscribe(false), 0);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||||
setIsNetworkError(true);
|
setIsNetworkError(true);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// Start reconnection attempts when we know update is complete
|
// Start reconnection attempts when we know update is complete
|
||||||
startReconnectAttempts();
|
setTimeout(() => startReconnectAttempts(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [updateLogsData]);
|
}, [updateLogsData, startReconnectAttempts, isUpdating]);
|
||||||
|
|
||||||
// Monitor for server connection loss and auto-reload (fallback only)
|
// Monitor for server connection loss and auto-reload (fallback only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldSubscribe) return;
|
// Early return: only run if we're actually updating
|
||||||
|
if (!shouldSubscribe || !isUpdating) return;
|
||||||
|
|
||||||
// Only use this as a fallback - the main trigger should be completion detection
|
// Only use this as a fallback - the main trigger should be completion detection
|
||||||
const checkInterval = setInterval(() => {
|
const checkInterval = setInterval(() => {
|
||||||
|
// Check refs first to ensure we're still updating
|
||||||
|
if (!isUpdatingRef.current || hasReloadedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||||
|
|
||||||
// Only start reconnection if we've been updating for at least 3 minutes
|
// Only start reconnection if we've been updating for at least 3 minutes
|
||||||
@@ -141,7 +320,10 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||||
|
|
||||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
// Additional guard: check refs again before triggering and validate session
|
||||||
|
const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
|
||||||
setIsNetworkError(true);
|
setIsNetworkError(true);
|
||||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||||
|
|
||||||
@@ -151,55 +333,130 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
}, 10000); // Check every 10 seconds
|
}, 10000); // Check every 10 seconds
|
||||||
|
|
||||||
return () => clearInterval(checkInterval);
|
return () => clearInterval(checkInterval);
|
||||||
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
|
||||||
|
|
||||||
// Attempt to reconnect and reload page when server is back
|
// Keep refs in sync with state
|
||||||
const startReconnectAttempts = () => {
|
useEffect(() => {
|
||||||
if (reconnectIntervalRef.current) return;
|
isUpdatingRef.current = isUpdating;
|
||||||
|
// CRITICAL: Reset shouldSubscribe immediately when isUpdating becomes false
|
||||||
|
// This prevents stale polling from continuing
|
||||||
|
if (!isUpdating) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
}, 0);
|
||||||
|
// Reset completion processing flag when update stops
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isUpdating]);
|
||||||
|
|
||||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
useEffect(() => {
|
||||||
|
isNetworkErrorRef.current = isNetworkError;
|
||||||
|
}, [isNetworkError]);
|
||||||
|
|
||||||
reconnectIntervalRef.current = setInterval(() => {
|
// Keep updateStartTime ref in sync
|
||||||
void (async () => {
|
useEffect(() => {
|
||||||
try {
|
updateStartTimeRef.current = updateStartTime;
|
||||||
// Try to fetch the root path to check if server is back
|
}, [updateStartTime]);
|
||||||
const response = await fetch('/', { method: 'HEAD' });
|
|
||||||
if (response.ok || response.status === 200) {
|
|
||||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
|
||||||
|
|
||||||
// Clear interval and reload
|
// Clear reconnect interval when update completes or component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
// If we're no longer updating, clear the reconnect interval and reset subscription
|
||||||
|
if (!isUpdating) {
|
||||||
if (reconnectIntervalRef.current) {
|
if (reconnectIntervalRef.current) {
|
||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
// Clear reload timeout if update stops
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset subscription to prevent stale polling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
setShouldSubscribe(false);
|
||||||
}, 1000);
|
}, 0);
|
||||||
|
// Reset completion processing flag
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
// Don't clear session refs here - they're cleared explicitly on unmount or new update
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Server still down, keep trying
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup reconnect interval on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
return () => {
|
||||||
if (reconnectIntervalRef.current) {
|
if (reconnectIntervalRef.current) {
|
||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isUpdating]);
|
||||||
|
|
||||||
|
// Cleanup on component unmount - reset all update-related state
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clear all intervals
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset all refs and state
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
hasReloadedRef.current = false;
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
isNetworkErrorRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
|
// Show confirmation modal instead of starting update directly
|
||||||
|
setShowUpdateConfirmation(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to generate secure random string
|
||||||
|
function getSecureRandomString(length: number): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
// Convert to base36 string (alphanumeric)
|
||||||
|
return Array.from(array, b => b.toString(36)).join('').substr(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmUpdate = () => {
|
||||||
|
// Close the confirmation modal
|
||||||
|
setShowUpdateConfirmation(false);
|
||||||
|
// Start the actual update process
|
||||||
|
const randomSuffix = getSecureRandomString(9);
|
||||||
|
const sessionId = `update_${Date.now()}_${randomSuffix}`;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setUpdateResult(null);
|
setUpdateResult(null);
|
||||||
setIsNetworkError(false);
|
setIsNetworkError(false);
|
||||||
setUpdateLogs([]);
|
setUpdateLogs([]);
|
||||||
setShouldSubscribe(false);
|
setShouldSubscribe(false); // Will be set to true in mutation onSuccess
|
||||||
setUpdateStartTime(Date.now());
|
setUpdateStartTime(startTime);
|
||||||
lastLogTimeRef.current = Date.now();
|
|
||||||
|
// Set refs for session tracking
|
||||||
|
updateSessionIdRef.current = sessionId;
|
||||||
|
updateStartTimeRef.current = startTime;
|
||||||
|
lastLogTimeRef.current = startTime;
|
||||||
|
logFileModifiedTimeRef.current = null; // Will be set when we first see log file
|
||||||
|
isCompleteProcessedRef.current = false; // Reset completion flag
|
||||||
|
hasReloadedRef.current = false; // Reset reload flag when starting new update
|
||||||
|
|
||||||
|
// Clear any existing reconnect interval and reload timeout
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
executeUpdate.mutate();
|
executeUpdate.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,6 +490,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||||
|
|
||||||
|
{/* Update Confirmation Modal */}
|
||||||
|
{versionStatus?.releaseInfo && (
|
||||||
|
<UpdateConfirmationModal
|
||||||
|
isOpen={showUpdateConfirmation}
|
||||||
|
onClose={() => setShowUpdateConfirmation(false)}
|
||||||
|
onConfirm={handleConfirmUpdate}
|
||||||
|
releaseInfo={versionStatus.releaseInfo}
|
||||||
|
currentVersion={versionStatus.currentVersion}
|
||||||
|
latestVersion={versionStatus.latestVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={isUpToDate ? "default" : "secondary"}
|
variant={isUpToDate ? "default" : "secondary"}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ export interface ToggleProps
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||||
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
({ className, checked, onCheckedChange, label, labelPosition = 'right', ...props }, ref) => {
|
||||||
return (
|
const toggleSwitch = (
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -29,7 +29,17 @@ const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
|||||||
className
|
className
|
||||||
)} />
|
)} />
|
||||||
</label>
|
</label>
|
||||||
{label && (
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{label && labelPosition === 'left' && (
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{toggleSwitch}
|
||||||
|
{label && labelPosition === 'right' && (
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const sessionDurationDays = authConfig.sessionDurationDays;
|
const sessionDurationDays = authConfig.sessionDurationDays;
|
||||||
const token = generateToken(username, sessionDurationDays);
|
const token = generateToken(username, sessionDurationDays);
|
||||||
|
|
||||||
|
// Calculate expiration time for client
|
||||||
|
const expirationTime = Date.now() + (sessionDurationDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
username
|
username,
|
||||||
|
expirationTime
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine if request is over HTTPS
|
// Determine if request is over HTTPS
|
||||||
@@ -54,7 +58,7 @@ export async function POST(request: NextRequest) {
|
|||||||
response.cookies.set('auth-token', token, {
|
response.cookies.set('auth-token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecure, // Only secure if actually over HTTPS
|
secure: isSecure, // Only secure if actually over HTTPS
|
||||||
sameSite: 'strict',
|
sameSite: 'lax', // Use lax for cross-origin navigation support
|
||||||
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
|
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
|
|||||||
import { getDatabase } from '../../../../../server/database-prisma';
|
import { getDatabase } from '../../../../../server/database-prisma';
|
||||||
import { getSSHService } from '../../../../../server/ssh-service';
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
|
|
||||||
|
interface ServerData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
ssh_key_path?: string | null;
|
||||||
|
key_generated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -18,7 +26,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = await db.getServerById(id);
|
const server = await db.getServerById(id) as ServerData | null;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -28,14 +36,14 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only allow viewing public key if it was generated by the system
|
// Only allow viewing public key if it was generated by the system
|
||||||
if (!(server as any).key_generated) {
|
if (!server.key_generated) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Public key not available for user-provided keys' },
|
{ error: 'Public key not available for user-provided keys' },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(server as any).ssh_key_path) {
|
if (!server.ssh_key_path) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'SSH key path not found' },
|
{ error: 'SSH key path not found' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
@@ -43,13 +51,13 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sshService = getSSHService();
|
const sshService = getSSHService();
|
||||||
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
const publicKey = sshService.getPublicKey(server.ssh_key_path);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
publicKey,
|
publicKey,
|
||||||
serverName: (server as any).name,
|
serverName: server.name,
|
||||||
serverIp: (server as any).ip
|
serverIp: server.ip
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error retrieving public key:', error);
|
console.error('Error retrieving public key:', error);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const POST = withApiLogging(async function POST(_request: NextRequest) {
|
|||||||
// Get the next available server ID for key file naming
|
// Get the next available server ID for key file naming
|
||||||
const serverId = await db.getNextServerId();
|
const serverId = await db.getNextServerId();
|
||||||
|
|
||||||
const keyPair = await sshService.generateKeyPair(serverId);
|
const keyPair = await sshService.generateKeyPair(Number(serverId));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { isValidCron } from 'cron-validator';
|
import { isValidCron } from 'cron-validator';
|
||||||
|
|
||||||
|
interface AutoSyncSettings {
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
syncIntervalType: string;
|
||||||
|
syncIntervalPredefined?: string;
|
||||||
|
syncIntervalCron?: string;
|
||||||
|
autoDownloadNew: boolean;
|
||||||
|
autoUpdateExisting: boolean;
|
||||||
|
notificationEnabled: boolean;
|
||||||
|
appriseUrls?: string[] | string;
|
||||||
|
lastAutoSync?: string;
|
||||||
|
lastAutoSyncError?: string;
|
||||||
|
lastAutoSyncErrorTime?: string;
|
||||||
|
testNotification?: boolean;
|
||||||
|
triggerManualSync?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const settings = await request.json();
|
const settings = await request.json() as AutoSyncSettings;
|
||||||
|
|
||||||
if (!settings || typeof settings !== 'object') {
|
if (!settings || typeof settings !== 'object') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -54,7 +70,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Validate predefined interval
|
// Validate predefined interval
|
||||||
if (settings.syncIntervalType === 'predefined') {
|
if (settings.syncIntervalType === 'predefined') {
|
||||||
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
|
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
|
||||||
if (!validIntervals.includes(settings.syncIntervalPredefined)) {
|
if (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid predefined interval' },
|
{ error: 'Invalid predefined interval' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -67,7 +83,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
|
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
|
||||||
// Fallback to predefined if custom is selected but no cron expression
|
// Fallback to predefined if custom is selected but no cron expression
|
||||||
settings.syncIntervalType = 'predefined';
|
settings.syncIntervalType = 'predefined';
|
||||||
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
|
settings.syncIntervalPredefined = settings.syncIntervalPredefined ?? '1hour';
|
||||||
settings.syncIntervalCron = '';
|
settings.syncIntervalCron = '';
|
||||||
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
|
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -109,7 +125,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid JSON format for Apprise URLs' },
|
{ error: 'Invalid JSON format for Apprise URLs' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -130,15 +146,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const autoSyncSettings = {
|
const autoSyncSettings = {
|
||||||
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
|
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
|
||||||
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
|
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
|
||||||
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
|
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '',
|
||||||
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
|
'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '',
|
||||||
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
|
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
|
||||||
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
|
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
|
||||||
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
|
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
|
||||||
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
|
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls ?? '[]'),
|
||||||
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
|
'LAST_AUTO_SYNC': settings.lastAutoSync ?? '',
|
||||||
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
|
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '',
|
||||||
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
|
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update or add each setting
|
// Update or add each setting
|
||||||
@@ -160,18 +176,27 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Reschedule auto-sync service with new settings
|
// Reschedule auto-sync service with new settings
|
||||||
try {
|
try {
|
||||||
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
|
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit');
|
||||||
let autoSyncService = getAutoSyncService();
|
let autoSyncService = getAutoSyncService();
|
||||||
|
|
||||||
// If no global instance exists, create one
|
// If no global instance exists, create one
|
||||||
if (!autoSyncService) {
|
if (!autoSyncService) {
|
||||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||||
autoSyncService = new AutoSyncService();
|
autoSyncService = new AutoSyncService();
|
||||||
setAutoSyncService(autoSyncService);
|
setAutoSyncService(autoSyncService);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the global service instance with new settings
|
// Update the global service instance with new settings
|
||||||
autoSyncService.saveSettings(settings);
|
// Normalize appriseUrls to always be an array
|
||||||
|
const normalizedSettings = {
|
||||||
|
...settings,
|
||||||
|
appriseUrls: Array.isArray(settings.appriseUrls)
|
||||||
|
? settings.appriseUrls
|
||||||
|
: settings.appriseUrls
|
||||||
|
? [settings.appriseUrls]
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
autoSyncService.saveSettings(normalizedSettings);
|
||||||
|
|
||||||
if (settings.autoSyncEnabled) {
|
if (settings.autoSyncEnabled) {
|
||||||
autoSyncService.scheduleAutoSync();
|
autoSyncService.scheduleAutoSync();
|
||||||
@@ -180,7 +205,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Ensure the service is completely stopped and won't restart
|
// Ensure the service is completely stopped and won't restart
|
||||||
autoSyncService.isRunning = false;
|
autoSyncService.isRunning = false;
|
||||||
// Also stop the global service instance if it exists
|
// Also stop the global service instance if it exists
|
||||||
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
|
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit');
|
||||||
stopGlobalAutoSync();
|
stopGlobalAutoSync();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -231,21 +256,21 @@ export async function GET() {
|
|||||||
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
|
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
|
||||||
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
|
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
|
||||||
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
|
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
|
||||||
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
|
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') ?? '',
|
||||||
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
|
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
|
||||||
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
|
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
|
||||||
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
|
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
|
||||||
appriseUrls: (() => {
|
appriseUrls: (() => {
|
||||||
try {
|
try {
|
||||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
|
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
|
||||||
return JSON.parse(urlsValue);
|
return JSON.parse(urlsValue) as string[];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
|
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') ?? '',
|
||||||
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
|
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') ?? null,
|
||||||
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
|
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json({ settings });
|
return NextResponse.json({ settings });
|
||||||
@@ -275,8 +300,8 @@ async function handleTestNotification() {
|
|||||||
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
|
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
|
||||||
const appriseUrls = (() => {
|
const appriseUrls = (() => {
|
||||||
try {
|
try {
|
||||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
|
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
|
||||||
return JSON.parse(urlsValue);
|
return JSON.parse(urlsValue) as string[];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -289,7 +314,7 @@ async function handleTestNotification() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appriseUrls || appriseUrls.length === 0) {
|
if (!appriseUrls?.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No Apprise URLs configured' },
|
{ error: 'No Apprise URLs configured' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -297,7 +322,7 @@ async function handleTestNotification() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send test notification using the auto-sync service
|
// Send test notification using the auto-sync service
|
||||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||||
const autoSyncService = new AutoSyncService();
|
const autoSyncService = new AutoSyncService();
|
||||||
const result = await autoSyncService.testNotification();
|
const result = await autoSyncService.testNotification();
|
||||||
|
|
||||||
@@ -345,11 +370,11 @@ async function handleManualSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger manual sync using the auto-sync service
|
// Trigger manual sync using the auto-sync service
|
||||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||||
const autoSyncService = new AutoSyncService();
|
const autoSyncService = new AutoSyncService();
|
||||||
const result = await autoSyncService.executeAutoSync() as any;
|
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null;
|
||||||
|
|
||||||
if (result && result.success) {
|
if (result?.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Manual sync completed successfully',
|
message: 'Manual sync completed successfully',
|
||||||
@@ -357,7 +382,7 @@ async function handleManualSync() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: result.message },
|
{ error: result?.message ?? 'Unknown error' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,7 +401,7 @@ function getEnvValue(envContent: string, key: string): string {
|
|||||||
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
|
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
|
||||||
let match = regex.exec(envContent);
|
let match = regex.exec(envContent);
|
||||||
|
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
let value = match[1];
|
let value = match[1];
|
||||||
// Remove extra quotes that might be around JSON values
|
// Remove extra quotes that might be around JSON values
|
||||||
if (value.startsWith('"') && value.endsWith('"')) {
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
@@ -388,7 +413,7 @@ function getEnvValue(envContent: string, key: string): string {
|
|||||||
// Try to match without quotes (fallback)
|
// Try to match without quotes (fallback)
|
||||||
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
|
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
|
||||||
match = regexNoQuotes.exec(envContent);
|
match = regexNoQuotes.exec(envContent);
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
285
src/app/page.tsx
285
src/app/page.tsx
@@ -1,51 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
'use client';
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { ScriptsGrid } from "./_components/ScriptsGrid";
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
|
||||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
|
||||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
import { BackupsTab } from "./_components/BackupsTab";
|
||||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
import { ResyncButton } from "./_components/ResyncButton";
|
||||||
import { BackupsTab } from './_components/BackupsTab';
|
import { Terminal } from "./_components/Terminal";
|
||||||
import { ResyncButton } from './_components/ResyncButton';
|
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
|
||||||
import { Terminal } from './_components/Terminal';
|
import { SettingsButton } from "./_components/SettingsButton";
|
||||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
import { HelpButton } from "./_components/HelpButton";
|
||||||
import { SettingsButton } from './_components/SettingsButton';
|
import { VersionDisplay } from "./_components/VersionDisplay";
|
||||||
import { HelpButton } from './_components/HelpButton';
|
import { ThemeToggle } from "./_components/ThemeToggle";
|
||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { Button } from "./_components/ui/button";
|
||||||
import { ThemeToggle } from './_components/ThemeToggle';
|
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
|
||||||
import { Button } from './_components/ui/button';
|
import {
|
||||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
ReleaseNotesModal,
|
||||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
getLastSeenVersion,
|
||||||
import { Footer } from './_components/Footer';
|
} from "./_components/ReleaseNotesModal";
|
||||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
|
import { Footer } from "./_components/Footer";
|
||||||
import { api } from '~/trpc/react';
|
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
|
||||||
import { useAuth } from './_components/AuthProvider';
|
import { api } from "~/trpc/react";
|
||||||
|
import { useAuth } from "./_components/AuthProvider";
|
||||||
|
import type { Server } from "~/types/server";
|
||||||
|
import type { ScriptCard } from "~/types/script";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
const [runningScript, setRunningScript] = useState<{
|
||||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
|
path: string;
|
||||||
if (typeof window !== 'undefined') {
|
name: string;
|
||||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
|
mode?: "local" | "ssh";
|
||||||
return savedTab || 'scripts';
|
server?: Server;
|
||||||
|
envVars?: Record<string, string | number | boolean>;
|
||||||
|
} | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"scripts" | "downloaded" | "installed" | "backups"
|
||||||
|
>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const savedTab = localStorage.getItem("activeTab") as
|
||||||
|
| "scripts"
|
||||||
|
| "downloaded"
|
||||||
|
| "installed"
|
||||||
|
| "backups";
|
||||||
|
return savedTab || "scripts";
|
||||||
}
|
}
|
||||||
return 'scripts';
|
return "scripts";
|
||||||
});
|
});
|
||||||
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
||||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fetch data for script counts
|
// Fetch data for script counts
|
||||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData } =
|
||||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
const { data: localScriptsData } =
|
||||||
|
api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
|
const { data: installedScriptsData } =
|
||||||
|
api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
||||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||||
|
|
||||||
// Save active tab to localStorage whenever it changes
|
// Save active tab to localStorage whenever it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem('activeTab', activeTab);
|
localStorage.setItem("activeTab", activeTab);
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
@@ -56,7 +77,10 @@ export default function Home() {
|
|||||||
const lastSeenVersion = getLastSeenVersion();
|
const lastSeenVersion = getLastSeenVersion();
|
||||||
|
|
||||||
// If we have a current version and either no last seen version or versions don't match
|
// If we have a current version and either no last seen version or versions don't match
|
||||||
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
|
if (
|
||||||
|
currentVersion &&
|
||||||
|
(!lastSeenVersion || currentVersion !== lastSeenVersion)
|
||||||
|
) {
|
||||||
setHighlightVersion(currentVersion);
|
setHighlightVersion(currentVersion);
|
||||||
setReleaseNotesOpen(true);
|
setReleaseNotesOpen(true);
|
||||||
}
|
}
|
||||||
@@ -79,9 +103,9 @@ export default function Home() {
|
|||||||
if (!scriptCardsData?.success) return 0;
|
if (!scriptCardsData?.success) return 0;
|
||||||
|
|
||||||
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||||
const scriptMap = new Map<string, any>();
|
const scriptMap = new Map<string, ScriptCard>();
|
||||||
|
|
||||||
scriptCardsData.cards?.forEach(script => {
|
scriptCardsData.cards?.forEach((script: ScriptCard) => {
|
||||||
if (script?.name && script?.slug) {
|
if (script?.name && script?.slug) {
|
||||||
// Use slug as unique identifier, only keep first occurrence
|
// Use slug as unique identifier, only keep first occurrence
|
||||||
if (!scriptMap.has(script.slug)) {
|
if (!scriptMap.has(script.slug)) {
|
||||||
@@ -95,10 +119,18 @@ export default function Home() {
|
|||||||
downloaded: (() => {
|
downloaded: (() => {
|
||||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||||
|
|
||||||
// First deduplicate GitHub scripts using Map by slug
|
// Helper to normalize identifiers for robust matching
|
||||||
const scriptMap = new Map<string, any>();
|
const normalizeId = (s?: string): string =>
|
||||||
|
(s ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
scriptCardsData.cards?.forEach(script => {
|
// First deduplicate GitHub scripts using Map by slug
|
||||||
|
const scriptMap = new Map<string, ScriptCard>();
|
||||||
|
|
||||||
|
scriptCardsData.cards?.forEach((script: ScriptCard) => {
|
||||||
if (script?.name && script?.slug) {
|
if (script?.name && script?.slug) {
|
||||||
if (!scriptMap.has(script.slug)) {
|
if (!scriptMap.has(script.slug)) {
|
||||||
scriptMap.set(script.slug, script);
|
scriptMap.set(script.slug, script);
|
||||||
@@ -107,21 +139,57 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||||
const localScripts = localScriptsData.scripts ?? [];
|
const localScripts = (localScriptsData.scripts ?? []) as Array<{
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||||
return deduplicatedGithubScripts.filter(script => {
|
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
|
||||||
|
return deduplicatedGithubScripts.filter((script) => {
|
||||||
if (!script?.name) return false;
|
if (!script?.name) return false;
|
||||||
return localScripts.some(local => {
|
|
||||||
|
// Check if there's a corresponding local script
|
||||||
|
return localScripts.some((local) => {
|
||||||
if (!local?.name) return false;
|
if (!local?.name) return false;
|
||||||
const localName = local.name.replace(/\.sh$/, '');
|
|
||||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
// Primary: Exact slug-to-slug matching (most reliable)
|
||||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
if (local.slug && script.slug) {
|
||||||
|
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
|
||||||
|
if (
|
||||||
|
normalizeId(local.slug ?? undefined) ===
|
||||||
|
normalizeId(script.slug ?? undefined)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||||
|
const normalizedLocal = normalizeId(local.name ?? undefined);
|
||||||
|
const matchesInstallBasename =
|
||||||
|
script.install_basenames?.some(
|
||||||
|
(base) => normalizeId(String(base)) === normalizedLocal,
|
||||||
|
) ?? false;
|
||||||
|
if (matchesInstallBasename) return true;
|
||||||
|
|
||||||
|
// Tertiary: Normalized filename to normalized slug matching
|
||||||
|
if (
|
||||||
|
script.slug &&
|
||||||
|
normalizeId(local.name ?? undefined) ===
|
||||||
|
normalizeId(script.slug ?? undefined)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}).length;
|
}).length;
|
||||||
})(),
|
})(),
|
||||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||||
backups: backupsData?.success ? backupsData.backups.length : 0
|
backups: backupsData?.success ? backupsData.backups.length : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToTerminal = () => {
|
const scrollToTerminal = () => {
|
||||||
@@ -132,13 +200,19 @@ export default function Home() {
|
|||||||
|
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: elementTop - offset,
|
top: elementTop - offset,
|
||||||
behavior: 'smooth'
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
const handleRunScript = (
|
||||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
scriptPath: string,
|
||||||
|
scriptName: string,
|
||||||
|
mode?: "local" | "ssh",
|
||||||
|
server?: Server,
|
||||||
|
envVars?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
|
setRunningScript({ path: scriptPath, name: scriptName, mode, server, envVars });
|
||||||
// Scroll to terminal after a short delay to ensure it's rendered
|
// Scroll to terminal after a short delay to ensure it's rendered
|
||||||
setTimeout(scrollToTerminal, 100);
|
setTimeout(scrollToTerminal, 100);
|
||||||
};
|
};
|
||||||
@@ -148,16 +222,16 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="bg-background min-h-screen">
|
||||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-6 sm:mb-8">
|
<div className="mb-6 text-center sm:mb-8">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
<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">
|
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
|
||||||
<span className="break-words">PVE Scripts Management</span>
|
<span className="break-words">PVE Scripts Management</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex-1 flex justify-end items-center gap-2">
|
<div className="flex flex-1 items-center justify-end gap-2">
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -173,8 +247,9 @@ export default function Home() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
|
||||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
Manage and execute Proxmox helper scripts locally with live output
|
||||||
|
streaming
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center px-2">
|
<div className="flex justify-center px-2">
|
||||||
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||||
@@ -183,7 +258,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
|
||||||
<ServerSettingsButton />
|
<ServerSettingsButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
<ResyncButton />
|
<ResyncButton />
|
||||||
@@ -193,72 +268,85 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="border-b border-border">
|
<div className="border-border border-b">
|
||||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('scripts')}
|
onClick={() => setActiveTab("scripts")}
|
||||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||||
activeTab === 'scripts'
|
activeTab === "scripts"
|
||||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Available Scripts</span>
|
<span className="hidden sm:inline">Available Scripts</span>
|
||||||
<span className="sm:hidden">Available</span>
|
<span className="sm:hidden">Available</span>
|
||||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||||
{scriptCounts.available}
|
{scriptCounts.available}
|
||||||
</span>
|
</span>
|
||||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
<ContextualHelpIcon
|
||||||
|
section="available-scripts"
|
||||||
|
tooltip="Help with Available Scripts"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('downloaded')}
|
onClick={() => setActiveTab("downloaded")}
|
||||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||||
activeTab === 'downloaded'
|
activeTab === "downloaded"
|
||||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||||
<span className="sm:hidden">Downloaded</span>
|
<span className="sm:hidden">Downloaded</span>
|
||||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||||
{scriptCounts.downloaded}
|
{scriptCounts.downloaded}
|
||||||
</span>
|
</span>
|
||||||
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
<ContextualHelpIcon
|
||||||
|
section="downloaded-scripts"
|
||||||
|
tooltip="Help with Downloaded Scripts"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('installed')}
|
onClick={() => setActiveTab("installed")}
|
||||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||||
activeTab === 'installed'
|
activeTab === "installed"
|
||||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Installed Scripts</span>
|
<span className="hidden sm:inline">Installed Scripts</span>
|
||||||
<span className="sm:hidden">Installed</span>
|
<span className="sm:hidden">Installed</span>
|
||||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||||
{scriptCounts.installed}
|
{scriptCounts.installed}
|
||||||
</span>
|
</span>
|
||||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
<ContextualHelpIcon
|
||||||
|
section="installed-scripts"
|
||||||
|
tooltip="Help with Installed Scripts"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('backups')}
|
onClick={() => setActiveTab("backups")}
|
||||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||||
activeTab === 'backups'
|
activeTab === "backups"
|
||||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Backups</span>
|
<span className="hidden sm:inline">Backups</span>
|
||||||
<span className="sm:hidden">Backups</span>
|
<span className="sm:hidden">Backups</span>
|
||||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||||
{scriptCounts.backups}
|
{scriptCounts.backups}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -266,8 +354,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Running Script Terminal */}
|
{/* Running Script Terminal */}
|
||||||
{runningScript && (
|
{runningScript && (
|
||||||
<div ref={terminalRef} className="mb-8">
|
<div ref={terminalRef} className="mb-8">
|
||||||
@@ -276,26 +362,23 @@ export default function Home() {
|
|||||||
onClose={handleCloseTerminal}
|
onClose={handleCloseTerminal}
|
||||||
mode={runningScript.mode}
|
mode={runningScript.mode}
|
||||||
server={runningScript.server}
|
server={runningScript.server}
|
||||||
|
envVars={runningScript.envVars}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
{activeTab === 'scripts' && (
|
{activeTab === "scripts" && (
|
||||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'downloaded' && (
|
{activeTab === "downloaded" && (
|
||||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'installed' && (
|
{activeTab === "installed" && <InstalledScriptsTab />}
|
||||||
<InstalledScriptsTab />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'backups' && (
|
{activeTab === "backups" && <BackupsTab />}
|
||||||
<BackupsTab />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function getAuthConfig(): {
|
|||||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
|
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
|
||||||
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
|
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
|
||||||
const sessionDurationDays = sessionDurationMatch
|
const sessionDurationDays = sessionDurationMatch
|
||||||
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
? parseInt(sessionDurationMatch[1]?.trim() ?? String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
||||||
: DEFAULT_JWT_EXPIRY_DAYS;
|
: DEFAULT_JWT_EXPIRY_DAYS;
|
||||||
|
|
||||||
const hasCredentials = !!(username && passwordHash);
|
const hasCredentials = !!(username && passwordHash);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
|
|||||||
storage_name: string;
|
storage_name: string;
|
||||||
storage_type: string;
|
storage_type: string;
|
||||||
discovered_at: Date;
|
discovered_at: Date;
|
||||||
|
server_id?: number;
|
||||||
server_name: string | null;
|
server_name: string | null;
|
||||||
server_color: string | null;
|
server_color: string | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -38,7 +39,7 @@ export const backupsRouter = createTRPCRouter({
|
|||||||
if (backups.length === 0) continue;
|
if (backups.length === 0) continue;
|
||||||
|
|
||||||
// Get hostname from first backup (all backups for same container should have same hostname)
|
// Get hostname from first backup (all backups for same container should have same hostname)
|
||||||
const hostname = backups[0]?.hostname || '';
|
const hostname = backups[0]?.hostname ?? '';
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
container_id: containerId,
|
container_id: containerId,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
credentials: credentials.map(c => ({
|
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
server_id: c.server_id,
|
server_id: c.server_id,
|
||||||
storage_name: c.storage_name,
|
storage_name: c.storage_name,
|
||||||
@@ -109,7 +109,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
|
|||||||
storage_name: input.storageName,
|
storage_name: input.storageName,
|
||||||
pbs_ip: input.pbs_ip,
|
pbs_ip: input.pbs_ip,
|
||||||
pbs_datastore: input.pbs_datastore,
|
pbs_datastore: input.pbs_datastore,
|
||||||
pbs_password: passwordToSave,
|
pbs_password: passwordToSave ?? '',
|
||||||
pbs_fingerprint: input.pbs_fingerprint,
|
pbs_fingerprint: input.pbs_fingerprint,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { scriptManager } from "~/server/lib/scripts";
|
import { scriptManager } from "~/server/lib/scripts";
|
||||||
@@ -6,7 +7,10 @@ import { localScriptsService } from "~/server/services/localScripts";
|
|||||||
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
|
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
|
||||||
import { AutoSyncService } from "~/server/services/autoSyncService";
|
import { AutoSyncService } from "~/server/services/autoSyncService";
|
||||||
import { repositoryService } from "~/server/services/repositoryService";
|
import { repositoryService } from "~/server/services/repositoryService";
|
||||||
|
import { getStorageService } from "~/server/services/storageService";
|
||||||
|
import { getDatabase } from "~/server/database-prisma";
|
||||||
import type { ScriptCard } from "~/types/script";
|
import type { ScriptCard } from "~/types/script";
|
||||||
|
import type { Server } from "~/types/server";
|
||||||
|
|
||||||
export const scriptsRouter = createTRPCRouter({
|
export const scriptsRouter = createTRPCRouter({
|
||||||
// Get all available scripts
|
// Get all available scripts
|
||||||
@@ -100,7 +104,7 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
getAllScripts: publicProcedure
|
getAllScripts: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const scripts = await githubJsonService.getAllScripts();
|
const scripts = await localScriptsService.getAllScripts();
|
||||||
return { success: true, scripts };
|
return { success: true, scripts };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -177,7 +181,7 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
const scripts = await localScriptsService.getAllScripts();
|
const scripts = await localScriptsService.getAllScripts();
|
||||||
|
|
||||||
// Create a set of enabled repository URLs for fast lookup
|
// Create a set of enabled repository URLs for fast lookup
|
||||||
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
|
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url));
|
||||||
|
|
||||||
// Create category ID to name mapping
|
// Create category ID to name mapping
|
||||||
const categoryMap: Record<number, string> = {};
|
const categoryMap: Record<number, string> = {};
|
||||||
@@ -188,7 +192,7 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enhance cards with category information and additional script data
|
// Enhance cards with category information and additional script data
|
||||||
const cardsWithCategories = cards.map(card => {
|
const cardsWithCategories = cards.map((card: ScriptCard) => {
|
||||||
const script = scripts.find(s => s.slug === card.slug);
|
const script = scripts.find(s => s.slug === card.slug);
|
||||||
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
||||||
|
|
||||||
@@ -225,7 +229,7 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Filter cards to only include scripts from enabled repositories
|
// Filter cards to only include scripts from enabled repositories
|
||||||
// For backward compatibility, include scripts without repository_url
|
// For backward compatibility, include scripts without repository_url
|
||||||
const filteredCards = cardsWithCategories.filter(card => {
|
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => {
|
||||||
const repoUrl = card.repository_url;
|
const repoUrl = card.repository_url;
|
||||||
|
|
||||||
// If script has no repository_url, include it for backward compatibility
|
// If script has no repository_url, include it for backward compatibility
|
||||||
@@ -636,5 +640,194 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
status: null
|
status: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get rootfs storages for a server (for container creation)
|
||||||
|
getRootfsStorages: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
serverId: z.number(),
|
||||||
|
forceRefresh: z.boolean().optional().default(false)
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = await db.getServerById(input.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
storages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server hostname to filter storages by node assignment
|
||||||
|
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshExecutionService = getSSHExecutionService();
|
||||||
|
let serverHostname = '';
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
server as Server,
|
||||||
|
'hostname',
|
||||||
|
(data: string) => {
|
||||||
|
serverHostname += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
reject(new Error(`Failed to get hostname: ${error}`));
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting server hostname:', error);
|
||||||
|
// Continue without filtering if hostname can't be retrieved
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||||
|
|
||||||
|
const storageService = getStorageService();
|
||||||
|
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
|
||||||
|
|
||||||
|
// Filter storages by node hostname matching and content type (rootdir for containers)
|
||||||
|
const rootfsStorages = allStorages.filter(storage => {
|
||||||
|
// Check content type - must have rootdir for containers
|
||||||
|
const hasRootdir = storage.content.includes('rootdir');
|
||||||
|
if (!hasRootdir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If storage has no nodes specified, it's available on all nodes
|
||||||
|
if (!storage.nodes || storage.nodes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't get hostname, include all storages (fallback)
|
||||||
|
if (!normalizedHostname) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
|
||||||
|
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||||
|
return normalizedNodes.includes(normalizedHostname);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
storages: rootfsStorages.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
content: s.content
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching rootfs storages:', error);
|
||||||
|
// Return empty array on error (as per plan requirement)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch storages',
|
||||||
|
storages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get template storages for a server (for template storage selection)
|
||||||
|
getTemplateStorages: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
serverId: z.number(),
|
||||||
|
forceRefresh: z.boolean().optional().default(false)
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = await db.getServerById(input.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
storages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server hostname to filter storages by node assignment
|
||||||
|
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshExecutionService = getSSHExecutionService();
|
||||||
|
let serverHostname = '';
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
server as Server,
|
||||||
|
'hostname',
|
||||||
|
(data: string) => {
|
||||||
|
serverHostname += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
reject(new Error(`Failed to get hostname: ${error}`));
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting server hostname:', error);
|
||||||
|
// Continue without filtering if hostname can't be retrieved
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||||
|
|
||||||
|
const storageService = getStorageService();
|
||||||
|
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
|
||||||
|
|
||||||
|
// Filter storages by node hostname matching and content type (vztmpl for templates)
|
||||||
|
const templateStorages = allStorages.filter(storage => {
|
||||||
|
// Check content type - must have vztmpl for templates
|
||||||
|
const hasVztmpl = storage.content.includes('vztmpl');
|
||||||
|
if (!hasVztmpl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If storage has no nodes specified, it's available on all nodes
|
||||||
|
if (!storage.nodes || storage.nodes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't get hostname, include all storages (fallback)
|
||||||
|
if (!normalizedHostname) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
|
||||||
|
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||||
|
return normalizedNodes.includes(normalizedHostname);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
storages: templateStorages.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
content: s.content
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching template storages:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch storages',
|
||||||
|
storages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { readFile, writeFile } from "fs/promises";
|
import { readFile, writeFile, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({
|
|||||||
tagName: release.tag_name,
|
tagName: release.tag_name,
|
||||||
name: release.name,
|
name: release.name,
|
||||||
publishedAt: release.published_at,
|
publishedAt: release.published_at,
|
||||||
htmlUrl: release.html_url
|
htmlUrl: release.html_url,
|
||||||
|
body: release.body
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,10 +176,21 @@ export const versionRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
logs: [],
|
logs: [],
|
||||||
isComplete: false
|
isComplete: false,
|
||||||
|
logFileModifiedTime: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get log file modification time for session validation
|
||||||
|
let logFileModifiedTime: number | null = null;
|
||||||
|
try {
|
||||||
|
const stats = await stat(logPath);
|
||||||
|
logFileModifiedTime = stats.mtimeMs;
|
||||||
|
} catch (statError) {
|
||||||
|
// If we can't get stats, continue without timestamp
|
||||||
|
console.warn('Could not get log file stats:', statError);
|
||||||
|
}
|
||||||
|
|
||||||
const logs = await readFile(logPath, 'utf-8');
|
const logs = await readFile(logPath, 'utf-8');
|
||||||
const logLines = logs.split('\n')
|
const logLines = logs.split('\n')
|
||||||
.filter(line => line.trim())
|
.filter(line => line.trim())
|
||||||
@@ -201,7 +213,8 @@ export const versionRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
logs: logLines,
|
logs: logLines,
|
||||||
isComplete
|
isComplete,
|
||||||
|
logFileModifiedTime
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading update logs:', error);
|
console.error('Error reading update logs:', error);
|
||||||
@@ -209,7 +222,8 @@ export const versionRouter = createTRPCRouter({
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||||
logs: [],
|
logs: [],
|
||||||
isComplete: false
|
isComplete: false,
|
||||||
|
logFileModifiedTime: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ class DatabaseServicePrisma {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Ensure data/ssh-keys directory exists
|
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
|
||||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
if (!existsSync(sshKeysDir)) {
|
if (!existsSync(sshKeysDir)) {
|
||||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,128 @@ import { join } from 'path';
|
|||||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import type { CreateServerData } from '../types/server';
|
import type { CreateServerData } from '../types/server';
|
||||||
|
import type { Prisma } from '../../prisma/generated/prisma/client';
|
||||||
|
|
||||||
|
// Type definitions based on Prisma schema
|
||||||
|
type Server = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
user: string;
|
||||||
|
password: string | null;
|
||||||
|
auth_type: string | null;
|
||||||
|
ssh_key: string | null;
|
||||||
|
ssh_key_passphrase: string | null;
|
||||||
|
ssh_port: number | null;
|
||||||
|
color: string | null;
|
||||||
|
created_at: Date | null;
|
||||||
|
updated_at: Date | null;
|
||||||
|
ssh_key_path: string | null;
|
||||||
|
key_generated: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstalledScript = {
|
||||||
|
id: number;
|
||||||
|
script_name: string;
|
||||||
|
script_path: string;
|
||||||
|
container_id: string | null;
|
||||||
|
server_id: number | null;
|
||||||
|
execution_mode: string;
|
||||||
|
installation_date: Date | null;
|
||||||
|
status: string;
|
||||||
|
output_log: string | null;
|
||||||
|
web_ui_ip: string | null;
|
||||||
|
web_ui_port: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstalledScriptWithServer = InstalledScript & {
|
||||||
|
server: Server | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LXCConfig = {
|
||||||
|
id: number;
|
||||||
|
installed_script_id: number;
|
||||||
|
arch: string | null;
|
||||||
|
cores: number | null;
|
||||||
|
memory: number | null;
|
||||||
|
hostname: string | null;
|
||||||
|
swap: number | null;
|
||||||
|
onboot: number | null;
|
||||||
|
ostype: string | null;
|
||||||
|
unprivileged: number | null;
|
||||||
|
net_name: string | null;
|
||||||
|
net_bridge: string | null;
|
||||||
|
net_hwaddr: string | null;
|
||||||
|
net_ip_type: string | null;
|
||||||
|
net_ip: string | null;
|
||||||
|
net_gateway: string | null;
|
||||||
|
net_type: string | null;
|
||||||
|
net_vlan: number | null;
|
||||||
|
rootfs_storage: string | null;
|
||||||
|
rootfs_size: string | null;
|
||||||
|
feature_keyctl: number | null;
|
||||||
|
feature_nesting: number | null;
|
||||||
|
feature_fuse: number | null;
|
||||||
|
feature_mount: string | null;
|
||||||
|
tags: string | null;
|
||||||
|
advanced_config: string | null;
|
||||||
|
synced_at: Date | null;
|
||||||
|
config_hash: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Backup = {
|
||||||
|
id: number;
|
||||||
|
container_id: string;
|
||||||
|
server_id: number;
|
||||||
|
hostname: string;
|
||||||
|
backup_name: string;
|
||||||
|
backup_path: string;
|
||||||
|
size: bigint | null;
|
||||||
|
created_at: Date | null;
|
||||||
|
storage_name: string;
|
||||||
|
storage_type: string;
|
||||||
|
discovered_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackupWithServer = Backup & {
|
||||||
|
server: Server | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PBSStorageCredential = {
|
||||||
|
id: number;
|
||||||
|
server_id: number;
|
||||||
|
storage_name: string;
|
||||||
|
pbs_ip: string;
|
||||||
|
pbs_datastore: string;
|
||||||
|
pbs_password: string;
|
||||||
|
pbs_fingerprint: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LXCConfigInput = Partial<Omit<LXCConfig, 'id' | 'installed_script_id' | 'created_at' | 'updated_at'>>;
|
||||||
|
|
||||||
class DatabaseServicePrisma {
|
class DatabaseServicePrisma {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init(): void {
|
||||||
// Ensure data/ssh-keys directory exists
|
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
|
||||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
if (!existsSync(sshKeysDir)) {
|
if (!existsSync(sshKeysDir)) {
|
||||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server CRUD operations
|
// Server CRUD operations
|
||||||
async createServer(serverData: CreateServerData) {
|
async createServer(serverData: CreateServerData): Promise<Server> {
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
||||||
|
|
||||||
let ssh_key_path = null;
|
let ssh_key_path: string | null = null;
|
||||||
|
|
||||||
// If using SSH key authentication, create persistent key file
|
// If using SSH key authentication, create persistent key file
|
||||||
if (auth_type === 'key' && ssh_key) {
|
if (auth_type === 'key' && ssh_key) {
|
||||||
@@ -30,7 +132,7 @@ class DatabaseServicePrisma {
|
|||||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.server.create({
|
const result = await prisma.server.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
ip,
|
ip,
|
||||||
@@ -45,27 +147,30 @@ class DatabaseServicePrisma {
|
|||||||
color,
|
color,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as Server;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllServers() {
|
async getAllServers(): Promise<Server[]> {
|
||||||
return await prisma.server.findMany({
|
const result = await prisma.server.findMany({
|
||||||
orderBy: { created_at: 'desc' }
|
orderBy: { created_at: 'desc' }
|
||||||
});
|
});
|
||||||
|
return result as Server[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerById(id: number) {
|
async getServerById(id: number): Promise<Server | null> {
|
||||||
return await prisma.server.findUnique({
|
const result = await prisma.server.findUnique({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
|
return result as Server | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateServer(id: number, serverData: CreateServerData) {
|
async updateServer(id: number, serverData: CreateServerData): Promise<Server> {
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
|
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
|
||||||
|
|
||||||
// Get existing server to check for key changes
|
// Get existing server to check for key changes
|
||||||
const existingServer = await this.getServerById(id);
|
const existingServer = await this.getServerById(id);
|
||||||
let ssh_key_path = existingServer?.ssh_key_path;
|
let ssh_key_path = existingServer?.ssh_key_path ?? null;
|
||||||
|
|
||||||
// Handle SSH key changes
|
// Handle SSH key changes
|
||||||
if (auth_type === 'key' && ssh_key) {
|
if (auth_type === 'key' && ssh_key) {
|
||||||
@@ -101,7 +206,7 @@ class DatabaseServicePrisma {
|
|||||||
ssh_key_path = null;
|
ssh_key_path = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.server.update({
|
const result = await prisma.server.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
@@ -117,9 +222,10 @@ class DatabaseServicePrisma {
|
|||||||
color,
|
color,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as Server;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteServer(id: number) {
|
async deleteServer(id: number): Promise<Server> {
|
||||||
// Get server info before deletion to clean up key files
|
// Get server info before deletion to clean up key files
|
||||||
const server = await this.getServerById(id);
|
const server = await this.getServerById(id);
|
||||||
|
|
||||||
@@ -136,9 +242,10 @@ class DatabaseServicePrisma {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.server.delete({
|
const result = await prisma.server.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
|
return result as Server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installed Scripts CRUD operations
|
// Installed Scripts CRUD operations
|
||||||
@@ -152,10 +259,10 @@ class DatabaseServicePrisma {
|
|||||||
output_log?: string;
|
output_log?: string;
|
||||||
web_ui_ip?: string;
|
web_ui_ip?: string;
|
||||||
web_ui_port?: number;
|
web_ui_port?: number;
|
||||||
}) {
|
}): Promise<InstalledScript> {
|
||||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||||
|
|
||||||
return await prisma.installedScript.create({
|
const result = await prisma.installedScript.create({
|
||||||
data: {
|
data: {
|
||||||
script_name,
|
script_name,
|
||||||
script_path,
|
script_path,
|
||||||
@@ -168,34 +275,40 @@ class DatabaseServicePrisma {
|
|||||||
web_ui_port: web_ui_port ?? null,
|
web_ui_port: web_ui_port ?? null,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as InstalledScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllInstalledScripts() {
|
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
||||||
return await prisma.installedScript.findMany({
|
const result = await prisma.installedScript.findMany({
|
||||||
include: {
|
include: {
|
||||||
server: true
|
server: true,
|
||||||
|
lxc_config: true
|
||||||
},
|
},
|
||||||
orderBy: { installation_date: 'desc' }
|
orderBy: { installation_date: 'desc' }
|
||||||
});
|
});
|
||||||
|
return result as InstalledScriptWithServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstalledScriptById(id: number) {
|
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> {
|
||||||
return await prisma.installedScript.findUnique({
|
const result = await prisma.installedScript.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
server: true
|
server: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as InstalledScriptWithServer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstalledScriptsByServer(server_id: number) {
|
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> {
|
||||||
return await prisma.installedScript.findMany({
|
const result = await prisma.installedScript.findMany({
|
||||||
where: { server_id },
|
where: { server_id },
|
||||||
include: {
|
include: {
|
||||||
server: true
|
server: true,
|
||||||
|
lxc_config: true
|
||||||
},
|
},
|
||||||
orderBy: { installation_date: 'desc' }
|
orderBy: { installation_date: 'desc' }
|
||||||
});
|
});
|
||||||
|
return result as InstalledScriptWithServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateInstalledScript(id: number, updateData: {
|
async updateInstalledScript(id: number, updateData: {
|
||||||
@@ -205,17 +318,10 @@ class DatabaseServicePrisma {
|
|||||||
output_log?: string;
|
output_log?: string;
|
||||||
web_ui_ip?: string;
|
web_ui_ip?: string;
|
||||||
web_ui_port?: number;
|
web_ui_port?: number;
|
||||||
}) {
|
}): Promise<InstalledScript | { changes: number }> {
|
||||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||||
|
|
||||||
const updateFields: {
|
const updateFields: Prisma.InstalledScriptUpdateInput = {};
|
||||||
script_name?: string;
|
|
||||||
container_id?: string;
|
|
||||||
status?: 'in_progress' | 'success' | 'failed';
|
|
||||||
output_log?: string;
|
|
||||||
web_ui_ip?: string;
|
|
||||||
web_ui_port?: number;
|
|
||||||
} = {};
|
|
||||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||||
if (status !== undefined) updateFields.status = status;
|
if (status !== undefined) updateFields.status = status;
|
||||||
@@ -227,33 +333,36 @@ class DatabaseServicePrisma {
|
|||||||
return { changes: 0 };
|
return { changes: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.installedScript.update({
|
const result = await prisma.installedScript.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateFields
|
data: updateFields
|
||||||
});
|
});
|
||||||
|
return result as InstalledScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteInstalledScript(id: number) {
|
async deleteInstalledScript(id: number): Promise<InstalledScript> {
|
||||||
return await prisma.installedScript.delete({
|
const result = await prisma.installedScript.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
|
return result as InstalledScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteInstalledScriptsByServer(server_id: number) {
|
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> {
|
||||||
return await prisma.installedScript.deleteMany({
|
const result = await prisma.installedScript.deleteMany({
|
||||||
where: { server_id }
|
where: { server_id }
|
||||||
});
|
});
|
||||||
|
return result as { count: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextServerId() {
|
async getNextServerId(): Promise<number> {
|
||||||
const result = await prisma.server.findFirst({
|
const result = await prisma.server.findFirst({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
});
|
});
|
||||||
return (result?.id ?? 0) + 1;
|
return ((result as { id: number } | null)?.id ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
createSSHKeyFile(serverId: number, sshKey: string) {
|
createSSHKeyFile(serverId: number, sshKey: string): string {
|
||||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
@@ -266,17 +375,18 @@ class DatabaseServicePrisma {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LXC Config CRUD operations
|
// LXC Config CRUD operations
|
||||||
async createLXCConfig(scriptId: number, configData: any) {
|
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
||||||
return await prisma.lXCConfig.create({
|
const result = await prisma.lXCConfig.create({
|
||||||
data: {
|
data: {
|
||||||
installed_script_id: scriptId,
|
installed_script_id: scriptId,
|
||||||
...configData
|
...configData
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as LXCConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLXCConfig(scriptId: number, configData: any) {
|
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
||||||
return await prisma.lXCConfig.upsert({
|
const result = await prisma.lXCConfig.upsert({
|
||||||
where: { installed_script_id: scriptId },
|
where: { installed_script_id: scriptId },
|
||||||
update: configData,
|
update: configData,
|
||||||
create: {
|
create: {
|
||||||
@@ -284,16 +394,18 @@ class DatabaseServicePrisma {
|
|||||||
...configData
|
...configData
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return result as LXCConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLXCConfigByScriptId(scriptId: number) {
|
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> {
|
||||||
return await prisma.lXCConfig.findUnique({
|
const result = await prisma.lXCConfig.findUnique({
|
||||||
where: { installed_script_id: scriptId }
|
where: { installed_script_id: scriptId }
|
||||||
});
|
});
|
||||||
|
return result as LXCConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteLXCConfig(scriptId: number) {
|
async deleteLXCConfig(scriptId: number): Promise<void> {
|
||||||
return await prisma.lXCConfig.delete({
|
await prisma.lXCConfig.delete({
|
||||||
where: { installed_script_id: scriptId }
|
where: { installed_script_id: scriptId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,7 +421,7 @@ class DatabaseServicePrisma {
|
|||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
storage_name: string;
|
storage_name: string;
|
||||||
storage_type: 'local' | 'storage' | 'pbs';
|
storage_type: 'local' | 'storage' | 'pbs';
|
||||||
}) {
|
}): Promise<Backup> {
|
||||||
// Find existing backup by container_id, server_id, and backup_path
|
// Find existing backup by container_id, server_id, and backup_path
|
||||||
const existing = await prisma.backup.findFirst({
|
const existing = await prisma.backup.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -317,11 +429,11 @@ class DatabaseServicePrisma {
|
|||||||
server_id: backupData.server_id,
|
server_id: backupData.server_id,
|
||||||
backup_path: backupData.backup_path,
|
backup_path: backupData.backup_path,
|
||||||
},
|
},
|
||||||
});
|
}) as Backup | null;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update existing backup
|
// Update existing backup
|
||||||
return await prisma.backup.update({
|
const result = await prisma.backup.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
hostname: backupData.hostname,
|
hostname: backupData.hostname,
|
||||||
@@ -333,9 +445,10 @@ class DatabaseServicePrisma {
|
|||||||
discovered_at: new Date(),
|
discovered_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as Backup;
|
||||||
} else {
|
} else {
|
||||||
// Create new backup
|
// Create new backup
|
||||||
return await prisma.backup.create({
|
const result = await prisma.backup.create({
|
||||||
data: {
|
data: {
|
||||||
container_id: backupData.container_id,
|
container_id: backupData.container_id,
|
||||||
server_id: backupData.server_id,
|
server_id: backupData.server_id,
|
||||||
@@ -349,11 +462,12 @@ class DatabaseServicePrisma {
|
|||||||
discovered_at: new Date(),
|
discovered_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as Backup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllBackups() {
|
async getAllBackups(): Promise<BackupWithServer[]> {
|
||||||
return await prisma.backup.findMany({
|
const result = await prisma.backup.findMany({
|
||||||
include: {
|
include: {
|
||||||
server: true,
|
server: true,
|
||||||
},
|
},
|
||||||
@@ -362,58 +476,43 @@ class DatabaseServicePrisma {
|
|||||||
{ created_at: 'desc' },
|
{ created_at: 'desc' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return result as BackupWithServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBackupById(id: number) {
|
async getBackupById(id: number): Promise<BackupWithServer | null> {
|
||||||
return await prisma.backup.findUnique({
|
const result = await prisma.backup.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
server: true,
|
server: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as BackupWithServer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBackupsByContainerId(containerId: string) {
|
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> {
|
||||||
return await prisma.backup.findMany({
|
const result = await prisma.backup.findMany({
|
||||||
where: { container_id: containerId },
|
where: { container_id: containerId },
|
||||||
include: {
|
include: {
|
||||||
server: true,
|
server: true,
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
});
|
});
|
||||||
|
return result as BackupWithServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBackupsForContainer(containerId: string, serverId: number) {
|
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> {
|
||||||
return await prisma.backup.deleteMany({
|
const result = await prisma.backup.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
container_id: containerId,
|
container_id: containerId,
|
||||||
server_id: serverId,
|
server_id: serverId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as { count: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
|
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> {
|
||||||
id: number;
|
|
||||||
container_id: string;
|
|
||||||
server_id: number;
|
|
||||||
hostname: string;
|
|
||||||
backup_name: string;
|
|
||||||
backup_path: string;
|
|
||||||
size: bigint | null;
|
|
||||||
created_at: Date | null;
|
|
||||||
storage_name: string;
|
|
||||||
storage_type: string;
|
|
||||||
discovered_at: Date;
|
|
||||||
server: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
user: string;
|
|
||||||
color: string | null;
|
|
||||||
} | null;
|
|
||||||
}>>> {
|
|
||||||
const backups = await this.getAllBackups();
|
const backups = await this.getAllBackups();
|
||||||
const grouped = new Map<string, typeof backups>();
|
const grouped = new Map<string, BackupWithServer[]>();
|
||||||
|
|
||||||
for (const backup of backups) {
|
for (const backup of backups) {
|
||||||
const key = backup.container_id;
|
const key = backup.container_id;
|
||||||
@@ -434,8 +533,8 @@ class DatabaseServicePrisma {
|
|||||||
pbs_datastore: string;
|
pbs_datastore: string;
|
||||||
pbs_password: string;
|
pbs_password: string;
|
||||||
pbs_fingerprint: string;
|
pbs_fingerprint: string;
|
||||||
}) {
|
}): Promise<PBSStorageCredential> {
|
||||||
return await prisma.pBSStorageCredential.upsert({
|
const result = await prisma.pBSStorageCredential.upsert({
|
||||||
where: {
|
where: {
|
||||||
server_id_storage_name: {
|
server_id_storage_name: {
|
||||||
server_id: credentialData.server_id,
|
server_id: credentialData.server_id,
|
||||||
@@ -458,10 +557,11 @@ class DatabaseServicePrisma {
|
|||||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as PBSStorageCredential;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPBSCredential(serverId: number, storageName: string) {
|
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> {
|
||||||
return await prisma.pBSStorageCredential.findUnique({
|
const result = await prisma.pBSStorageCredential.findUnique({
|
||||||
where: {
|
where: {
|
||||||
server_id_storage_name: {
|
server_id_storage_name: {
|
||||||
server_id: serverId,
|
server_id: serverId,
|
||||||
@@ -469,17 +569,19 @@ class DatabaseServicePrisma {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as PBSStorageCredential | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPBSCredentialsByServer(serverId: number) {
|
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> {
|
||||||
return await prisma.pBSStorageCredential.findMany({
|
const result = await prisma.pBSStorageCredential.findMany({
|
||||||
where: { server_id: serverId },
|
where: { server_id: serverId },
|
||||||
orderBy: { storage_name: 'asc' },
|
orderBy: { storage_name: 'asc' },
|
||||||
});
|
});
|
||||||
|
return result as PBSStorageCredential[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePBSCredential(serverId: number, storageName: string) {
|
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> {
|
||||||
return await prisma.pBSStorageCredential.delete({
|
const result = await prisma.pBSStorageCredential.delete({
|
||||||
where: {
|
where: {
|
||||||
server_id_storage_name: {
|
server_id_storage_name: {
|
||||||
server_id: serverId,
|
server_id: serverId,
|
||||||
@@ -487,9 +589,10 @@ class DatabaseServicePrisma {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return result as PBSStorageCredential;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close(): Promise<void> {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +600,7 @@ class DatabaseServicePrisma {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let dbInstance: DatabaseServicePrisma | null = null;
|
let dbInstance: DatabaseServicePrisma | null = null;
|
||||||
|
|
||||||
export function getDatabase() {
|
export function getDatabase(): DatabaseServicePrisma {
|
||||||
dbInstance ??= new DatabaseServicePrisma();
|
dbInstance ??= new DatabaseServicePrisma();
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../../prisma/generated/prisma/client.ts'
|
||||||
|
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
|
||||||
const globalForPrisma = globalThis;
|
const globalForPrisma = globalThis;
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
// Ensure database directory exists before initializing Prisma
|
||||||
|
// DATABASE_URL format: file:/path/to/database.db
|
||||||
|
const dbUrl = process.env.DATABASE_URL || 'file:./data/settings.db';
|
||||||
|
const dbPath = dbUrl.replace(/^file:/, '');
|
||||||
|
const dbDir = dirname(dbPath);
|
||||||
|
|
||||||
|
if (!existsSync(dbDir)) {
|
||||||
|
console.log(`Creating database directory: ${dbDir}`);
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../../prisma/generated/prisma/client'
|
||||||
|
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as { prisma?: PrismaClient };
|
||||||
prisma: PrismaClient | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
// Ensure database directory exists before initializing Prisma
|
||||||
|
// DATABASE_URL format: file:/path/to/database.db
|
||||||
|
const dbUrl = process.env.DATABASE_URL || 'file:./data/settings.db';
|
||||||
|
const dbPath = dbUrl.replace(/^file:/, '');
|
||||||
|
const dbDir = dirname(dbPath);
|
||||||
|
|
||||||
|
if (!existsSync(dbDir)) {
|
||||||
|
console.log(`Creating database directory: ${dbDir}`);
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! });
|
||||||
|
|
||||||
|
export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
adapter,
|
||||||
log: ['warn', 'error']
|
log: ['warn', 'error']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { AutoSyncService } from '../services/autoSyncService.js';
|
import { AutoSyncService } from '../services/autoSyncService.js';
|
||||||
import { repositoryService } from '../services/repositoryService.ts';
|
import { repositoryService } from '../services/repositoryService.js';
|
||||||
|
|
||||||
|
/** @type {AutoSyncService | null} */
|
||||||
let autoSyncService = null;
|
let autoSyncService = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize default repositories
|
* Initialize default repositories
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function initializeRepositories() {
|
export async function initializeRepositories() {
|
||||||
try {
|
try {
|
||||||
console.log('Initializing default repositories...');
|
console.log('Initializing default repositories...');
|
||||||
|
if (repositoryService && repositoryService.initializeDefaultRepositories) {
|
||||||
await repositoryService.initializeDefaultRepositories();
|
await repositoryService.initializeDefaultRepositories();
|
||||||
console.log('Default repositories initialized successfully');
|
console.log('Default repositories initialized successfully');
|
||||||
|
} else {
|
||||||
|
console.warn('Repository service not available, skipping repository initialization');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize repositories:', error);
|
console.error('Failed to initialize repositories:', error);
|
||||||
console.error('Error stack:', error.stack);
|
console.error('Error stack:', error.stack);
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { AutoSyncService } from '~/server/services/autoSyncService';
|
import { AutoSyncService } from '~/server/services/autoSyncService';
|
||||||
|
import { repositoryService } from '~/server/services/repositoryService';
|
||||||
|
|
||||||
let autoSyncService: AutoSyncService | null = null;
|
let autoSyncService: AutoSyncService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize default repositories
|
||||||
|
*/
|
||||||
|
export async function initializeRepositories(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Initializing default repositories...');
|
||||||
|
await repositoryService.initializeDefaultRepositories();
|
||||||
|
console.log('Default repositories initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize repositories:', error);
|
||||||
|
console.error('Error stack:', (error as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize auto-sync service and schedule cron job if enabled
|
* Initialize auto-sync service and schedule cron job if enabled
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -272,6 +272,12 @@ export class AutoSyncService {
|
|||||||
|
|
||||||
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
|
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
|
||||||
|
|
||||||
|
/** @type {any} */
|
||||||
|
const cronOptions = {
|
||||||
|
scheduled: true,
|
||||||
|
timezone: 'UTC'
|
||||||
|
};
|
||||||
|
|
||||||
this.cronJob = cron.schedule(cronExpression, async () => {
|
this.cronJob = cron.schedule(cronExpression, async () => {
|
||||||
// Check global lock first
|
// Check global lock first
|
||||||
if (globalAutoSyncLock) {
|
if (globalAutoSyncLock) {
|
||||||
@@ -300,10 +306,7 @@ export class AutoSyncService {
|
|||||||
|
|
||||||
console.log('Starting scheduled auto-sync...');
|
console.log('Starting scheduled auto-sync...');
|
||||||
await this.executeAutoSync();
|
await this.executeAutoSync();
|
||||||
}, {
|
}, cronOptions);
|
||||||
scheduled: true,
|
|
||||||
timezone: 'UTC'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Auto-sync cron job scheduled successfully');
|
console.log('Auto-sync cron job scheduled successfully');
|
||||||
}
|
}
|
||||||
@@ -373,7 +376,7 @@ export class AutoSyncService {
|
|||||||
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
|
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
|
||||||
|
|
||||||
// Get scripts only for the synced files
|
// Get scripts only for the synced files
|
||||||
const localScriptsService = await import('./localScripts.js');
|
const localScriptsService = await import('./localScripts');
|
||||||
const syncedScripts = [];
|
const syncedScripts = [];
|
||||||
|
|
||||||
for (const filename of syncResult.syncedFiles) {
|
for (const filename of syncResult.syncedFiles) {
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ class BackupService {
|
|||||||
let hostname = '';
|
let hostname = '';
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
'hostname',
|
'hostname',
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
hostname += data;
|
hostname += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(_error: string) => {
|
||||||
reject(new Error(`Failed to get hostname: ${error}`));
|
reject(new Error(`Failed to get hostname: ${_error}`));
|
||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(_exitCode: number) => {
|
||||||
if (exitCode === 0) {
|
if (_exitCode === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
reject(new Error(`hostname command failed with exit code ${_exitCode}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -61,17 +61,19 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
findCommand,
|
findCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
findOutput += data;
|
findOutput += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
|
console.error('Error getting hostname:', error);
|
||||||
// Ignore errors - directory might not exist
|
// Ignore errors - directory might not exist
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(exitCode: number) => {
|
||||||
|
console.error('Error getting find command:', exitCode);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -96,7 +98,7 @@ class BackupService {
|
|||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
statCommand,
|
statCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -112,11 +114,11 @@ class BackupService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statParts = statOutput.trim().split('|');
|
const statParts = statOutput.trim().split('|');
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
|
|
||||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||||
const size = BigInt(statParts[0] || '0');
|
const size = BigInt(statParts[0] ?? '0');
|
||||||
const mtime = parseInt(statParts[1] || '0', 10);
|
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||||
|
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
@@ -144,8 +146,9 @@ class BackupService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error processing backup:', error);
|
||||||
// Still try to add the backup even if stat fails
|
// Still try to add the backup even if stat fails
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
server_id: server.id,
|
server_id: server.id,
|
||||||
@@ -182,17 +185,18 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
findCommand,
|
findCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
findOutput += data;
|
findOutput += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
// Ignore errors - storage might not be mounted
|
console.error('Error getting stat command:', error);
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(exitCode: number) => {
|
||||||
|
console.error('Error getting stat command:', exitCode);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -218,7 +222,7 @@ class BackupService {
|
|||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
statCommand,
|
statCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -234,11 +238,11 @@ class BackupService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statParts = statOutput.trim().split('|');
|
const statParts = statOutput.trim().split('|');
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
|
|
||||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||||
const size = BigInt(statParts[0] || '0');
|
const size = BigInt(statParts[0] ?? '0');
|
||||||
const mtime = parseInt(statParts[1] || '0', 10);
|
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||||
|
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
@@ -270,7 +274,7 @@ class BackupService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing backup ${backupPath}:`, error);
|
console.error(`Error processing backup ${backupPath}:`, error);
|
||||||
// Still try to add the backup even if stat fails
|
// Still try to add the backup even if stat fails
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
server_id: server.id,
|
server_id: server.id,
|
||||||
@@ -310,8 +314,8 @@ class BackupService {
|
|||||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||||
|
|
||||||
// Use IP and datastore from credentials (they override config if different)
|
// Use IP and datastore from credentials (they override config if different)
|
||||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||||
|
|
||||||
if (!pbsIp || !pbsDatastore) {
|
if (!pbsIp || !pbsDatastore) {
|
||||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||||
@@ -339,7 +343,7 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
fullCommand,
|
fullCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -405,8 +409,8 @@ class BackupService {
|
|||||||
|
|
||||||
const storageService = getStorageService();
|
const storageService = getStorageService();
|
||||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||||
|
|
||||||
if (!pbsIp || !pbsDatastore) {
|
if (!pbsIp || !pbsDatastore) {
|
||||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||||
@@ -425,8 +429,8 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
// Add timeout to prevent hanging
|
// Add timeout to prevent hanging
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
command,
|
command,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -468,7 +472,7 @@ class BackupService {
|
|||||||
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
||||||
continue; // Skip header row
|
continue; // Skip header row
|
||||||
}
|
}
|
||||||
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
|
if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) {
|
||||||
continue; // Skip table separator lines
|
continue; // Skip table separator lines
|
||||||
}
|
}
|
||||||
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
||||||
@@ -489,7 +493,7 @@ class BackupService {
|
|||||||
|
|
||||||
// Extract snapshot name (last part after /)
|
// Extract snapshot name (last part after /)
|
||||||
const snapshotParts = snapshotPath.split('/');
|
const snapshotParts = snapshotPath.split('/');
|
||||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
|
||||||
|
|
||||||
if (!snapshotName) {
|
if (!snapshotName) {
|
||||||
continue; // Skip if no snapshot name
|
continue; // Skip if no snapshot name
|
||||||
@@ -497,11 +501,12 @@ class BackupService {
|
|||||||
|
|
||||||
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
||||||
let createdAt: Date | undefined;
|
let createdAt: Date | undefined;
|
||||||
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
|
const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName);
|
||||||
if (dateMatch && dateMatch[1]) {
|
if (dateMatch?.[1]) {
|
||||||
try {
|
try {
|
||||||
createdAt = new Date(dateMatch[1]);
|
createdAt = new Date(dateMatch[1]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('Error parsing date:', e);
|
||||||
// Invalid date, leave undefined
|
// Invalid date, leave undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,8 +514,8 @@ class BackupService {
|
|||||||
// Parse size (convert MiB/GiB to bytes)
|
// Parse size (convert MiB/GiB to bytes)
|
||||||
let size: bigint | undefined;
|
let size: bigint | undefined;
|
||||||
if (sizeStr) {
|
if (sizeStr) {
|
||||||
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
|
const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr);
|
||||||
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
|
if (sizeMatch?.[1] && sizeMatch[2]) {
|
||||||
const sizeValue = parseFloat(sizeMatch[1]);
|
const sizeValue = parseFloat(sizeMatch[1]);
|
||||||
const unit = sizeMatch[2].toUpperCase();
|
const unit = sizeMatch[2].toUpperCase();
|
||||||
let bytes = sizeValue;
|
let bytes = sizeValue;
|
||||||
@@ -640,18 +645,18 @@ class BackupService {
|
|||||||
if (!script.container_id || !script.server_id || !script.server) continue;
|
if (!script.container_id || !script.server_id || !script.server) continue;
|
||||||
|
|
||||||
const containerId = script.container_id;
|
const containerId = script.container_id;
|
||||||
const serverId = script.server_id;
|
|
||||||
const server = script.server as Server;
|
const server = script.server as Server;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get hostname from LXC config if available, otherwise use script name
|
// Get hostname from LXC config if available, otherwise use script name
|
||||||
let hostname = script.script_name || `CT-${script.container_id}`;
|
let hostname = script.script_name ?? `CT-${script.container_id}`;
|
||||||
try {
|
try {
|
||||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||||
if (lxcConfig?.hostname) {
|
if (lxcConfig?.hostname) {
|
||||||
hostname = lxcConfig.hostname;
|
hostname = lxcConfig.hostname;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error getting LXC config:', error);
|
||||||
// LXC config might not exist, use script name
|
// LXC config might not exist, use script name
|
||||||
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
||||||
}
|
}
|
||||||
@@ -682,9 +687,7 @@ class BackupService {
|
|||||||
let backupServiceInstance: BackupService | null = null;
|
let backupServiceInstance: BackupService | null = null;
|
||||||
|
|
||||||
export function getBackupService(): BackupService {
|
export function getBackupService(): BackupService {
|
||||||
if (!backupServiceInstance) {
|
backupServiceInstance ??= new BackupService();
|
||||||
backupServiceInstance = new BackupService();
|
|
||||||
}
|
|
||||||
return backupServiceInstance;
|
return backupServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,428 @@
|
|||||||
// JavaScript wrapper for githubJsonService.ts
|
// JavaScript wrapper for githubJsonService (for use with node server.js)
|
||||||
// This allows the JavaScript autoSyncService.js to import the TypeScript service
|
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { repositoryService } from './repositoryService.js';
|
||||||
|
|
||||||
import { githubJsonService } from './githubJsonService.ts';
|
// Get environment variables
|
||||||
|
const getEnv = () => ({
|
||||||
|
REPO_BRANCH: process.env.REPO_BRANCH || 'main',
|
||||||
|
JSON_FOLDER: process.env.JSON_FOLDER || 'json',
|
||||||
|
REPO_URL: process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE',
|
||||||
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
export { githubJsonService };
|
class GitHubJsonService {
|
||||||
|
constructor() {
|
||||||
|
this.branch = null;
|
||||||
|
this.jsonFolder = null;
|
||||||
|
this.localJsonDirectory = null;
|
||||||
|
this.scriptCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeConfig() {
|
||||||
|
if (this.branch === null) {
|
||||||
|
const env = getEnv();
|
||||||
|
this.branch = env.REPO_BRANCH;
|
||||||
|
this.jsonFolder = env.JSON_FOLDER;
|
||||||
|
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseUrl(repoUrl) {
|
||||||
|
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||||
|
if (!urlMatch) {
|
||||||
|
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, owner, repo] = urlMatch;
|
||||||
|
return `https://api.github.com/repos/${owner}/${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractRepoPath(repoUrl) {
|
||||||
|
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Invalid GitHub repository URL');
|
||||||
|
}
|
||||||
|
return `${match[1]}/${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchFromGitHub(repoUrl, endpoint) {
|
||||||
|
const baseUrl = this.getBaseUrl(repoUrl);
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'PVEScripts-Local/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.GITHUB_TOKEN) {
|
||||||
|
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}${endpoint}`, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
|
||||||
|
error.name = 'RateLimitError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadJsonFile(repoUrl, filePath) {
|
||||||
|
this.initializeConfig();
|
||||||
|
const repoPath = this.extractRepoPath(repoUrl);
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch}/${filePath}`;
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'PVEScripts-Local/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.GITHUB_TOKEN) {
|
||||||
|
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(rawUrl, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits.`);
|
||||||
|
error.name = 'RateLimitError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
script.repository_url = repoUrl;
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJsonFiles(repoUrl) {
|
||||||
|
this.initializeConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await this.fetchFromGitHub(
|
||||||
|
repoUrl,
|
||||||
|
`/contents/${this.jsonFolder}?ref=${this.branch}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return files.filter(file => file.name.endsWith('.json'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
|
||||||
|
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllScripts(repoUrl) {
|
||||||
|
try {
|
||||||
|
const jsonFiles = await this.getJsonFiles(repoUrl);
|
||||||
|
const scripts = [];
|
||||||
|
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
try {
|
||||||
|
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||||
|
scripts.push(script);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to download script ${file.name} from ${repoUrl}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scripts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching all scripts from ${repoUrl}:`, error);
|
||||||
|
throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptCards(repoUrl) {
|
||||||
|
try {
|
||||||
|
const scripts = await this.getAllScripts(repoUrl);
|
||||||
|
|
||||||
|
return scripts.map(script => ({
|
||||||
|
name: script.name,
|
||||||
|
slug: script.slug,
|
||||||
|
description: script.description,
|
||||||
|
logo: script.logo,
|
||||||
|
type: script.type,
|
||||||
|
updateable: script.updateable,
|
||||||
|
website: script.website,
|
||||||
|
repository_url: script.repository_url,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating script cards from ${repoUrl}:`, error);
|
||||||
|
throw new Error(`Failed to create script cards from repository: ${repoUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptBySlug(slug, repoUrl) {
|
||||||
|
try {
|
||||||
|
const localScript = await this.getScriptFromLocal(slug);
|
||||||
|
if (localScript) {
|
||||||
|
if (repoUrl && localScript.repository_url !== repoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repoUrl) {
|
||||||
|
try {
|
||||||
|
this.initializeConfig();
|
||||||
|
const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder}/${slug}.json`);
|
||||||
|
return script;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||||
|
for (const repo of enabledRepos) {
|
||||||
|
try {
|
||||||
|
this.initializeConfig();
|
||||||
|
const script = await this.downloadJsonFile(repo.url, `${this.jsonFolder}/${slug}.json`);
|
||||||
|
return script;
|
||||||
|
} catch {
|
||||||
|
// Continue to next repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching script by slug:', error);
|
||||||
|
throw new Error(`Failed to fetch script: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptFromLocal(slug) {
|
||||||
|
try {
|
||||||
|
if (this.scriptCache.has(slug)) {
|
||||||
|
return this.scriptCache.get(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializeConfig();
|
||||||
|
const filePath = join(this.localJsonDirectory, `${slug}.json`);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
|
||||||
|
if (!script.repository_url) {
|
||||||
|
const env = getEnv();
|
||||||
|
script.repository_url = env.REPO_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scriptCache.set(slug, script);
|
||||||
|
|
||||||
|
return script;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncJsonFilesForRepo(repoUrl) {
|
||||||
|
try {
|
||||||
|
console.log(`Starting JSON sync from repository: ${repoUrl}`);
|
||||||
|
|
||||||
|
const githubFiles = await this.getJsonFiles(repoUrl);
|
||||||
|
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
|
||||||
|
|
||||||
|
const localFiles = await this.getLocalJsonFiles();
|
||||||
|
console.log(`Found ${localFiles.length} local JSON files`);
|
||||||
|
|
||||||
|
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
||||||
|
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
||||||
|
|
||||||
|
if (filesToSync.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `All JSON files are up to date for repository: ${repoUrl}`,
|
||||||
|
count: 0,
|
||||||
|
syncedFiles: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
|
||||||
|
count: syncedFiles.length,
|
||||||
|
syncedFiles
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
count: 0,
|
||||||
|
syncedFiles: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncJsonFiles() {
|
||||||
|
try {
|
||||||
|
console.log('Starting multi-repository JSON sync...');
|
||||||
|
|
||||||
|
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||||
|
|
||||||
|
if (enabledRepos.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'No enabled repositories found',
|
||||||
|
count: 0,
|
||||||
|
syncedFiles: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
||||||
|
|
||||||
|
const allSyncedFiles = [];
|
||||||
|
const processedSlugs = new Set();
|
||||||
|
let totalSynced = 0;
|
||||||
|
|
||||||
|
for (const repo of enabledRepos) {
|
||||||
|
try {
|
||||||
|
console.log(`Syncing from repository: ${repo.url} (priority: ${repo.priority})`);
|
||||||
|
|
||||||
|
const result = await this.syncJsonFilesForRepo(repo.url);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newFiles = result.syncedFiles.filter(file => {
|
||||||
|
const slug = file.replace('.json', '');
|
||||||
|
if (processedSlugs.has(slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
processedSlugs.add(slug);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
allSyncedFiles.push(...newFiles);
|
||||||
|
totalSynced += newFiles.length;
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to sync from ${repo.url}: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing from ${repo.url}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateExistingFilesWithRepositoryUrl();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
|
||||||
|
count: totalSynced,
|
||||||
|
syncedFiles: allSyncedFiles
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Multi-repository JSON sync failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
count: 0,
|
||||||
|
syncedFiles: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExistingFilesWithRepositoryUrl() {
|
||||||
|
try {
|
||||||
|
this.initializeConfig();
|
||||||
|
const files = await this.getLocalJsonFiles();
|
||||||
|
const env = getEnv();
|
||||||
|
const mainRepoUrl = env.REPO_URL;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const filePath = join(this.localJsonDirectory, file);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
|
||||||
|
if (!script.repository_url) {
|
||||||
|
script.repository_url = mainRepoUrl;
|
||||||
|
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||||
|
console.log(`Updated ${file} with repository_url: ${mainRepoUrl}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating ${file}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating existing files with repository_url:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalJsonFiles() {
|
||||||
|
this.initializeConfig();
|
||||||
|
try {
|
||||||
|
const files = await readdir(this.localJsonDirectory);
|
||||||
|
return files.filter(f => f.endsWith('.json'));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
|
||||||
|
const filesToSync = [];
|
||||||
|
|
||||||
|
for (const ghFile of githubFiles) {
|
||||||
|
const localFilePath = join(this.localJsonDirectory, ghFile.name);
|
||||||
|
|
||||||
|
let needsSync = false;
|
||||||
|
|
||||||
|
if (!localFiles.includes(ghFile.name)) {
|
||||||
|
needsSync = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const content = await readFile(localFilePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
|
||||||
|
if (!script.repository_url || script.repository_url !== repoUrl) {
|
||||||
|
needsSync = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
needsSync = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSync) {
|
||||||
|
filesToSync.push(ghFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesToSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncSpecificFiles(repoUrl, filesToSync) {
|
||||||
|
this.initializeConfig();
|
||||||
|
const syncedFiles = [];
|
||||||
|
|
||||||
|
await mkdir(this.localJsonDirectory, { recursive: true });
|
||||||
|
|
||||||
|
for (const file of filesToSync) {
|
||||||
|
try {
|
||||||
|
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||||
|
const filename = `${script.slug}.json`;
|
||||||
|
const filePath = join(this.localJsonDirectory, filename);
|
||||||
|
|
||||||
|
script.repository_url = repoUrl;
|
||||||
|
|
||||||
|
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||||
|
syncedFiles.push(filename);
|
||||||
|
|
||||||
|
this.scriptCache.delete(script.slug);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncedFiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const githubJsonService = new GitHubJsonService();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
|
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
|
||||||
import { repositoryService } from './repositoryService.ts';
|
import { repositoryService } from './repositoryService';
|
||||||
|
|
||||||
export class GitHubJsonService {
|
export class GitHubJsonService {
|
||||||
private branch: string | null = null;
|
private branch: string | null = null;
|
||||||
@@ -64,7 +64,8 @@ export class GitHubJsonService {
|
|||||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
const data = await response.json();
|
||||||
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
||||||
@@ -214,9 +215,7 @@ export class GitHubJsonService {
|
|||||||
const script = JSON.parse(content) as Script;
|
const script = JSON.parse(content) as Script;
|
||||||
|
|
||||||
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
||||||
if (!script.repository_url) {
|
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||||
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the script
|
// Cache the script
|
||||||
this.scriptCache.set(slug, script);
|
this.scriptCache.set(slug, script);
|
||||||
@@ -397,7 +396,6 @@ export class GitHubJsonService {
|
|||||||
const filesToSync: GitHubFile[] = [];
|
const filesToSync: GitHubFile[] = [];
|
||||||
|
|
||||||
for (const ghFile of githubFiles) {
|
for (const ghFile of githubFiles) {
|
||||||
const slug = ghFile.name.replace('.json', '');
|
|
||||||
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
|
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
|
||||||
|
|
||||||
let needsSync = false;
|
let needsSync = false;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
// JavaScript wrapper for localScripts.ts
|
|
||||||
// This allows the JavaScript autoSyncService.js to import the TypeScript service
|
|
||||||
|
|
||||||
import { localScriptsService } from './localScripts.ts';
|
|
||||||
|
|
||||||
export { localScriptsService };
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
|
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import type { Script, ScriptCard } from '~/types/script';
|
import type { Script, ScriptCard } from '~/types/script';
|
||||||
@@ -95,7 +96,7 @@ export class LocalScriptsService {
|
|||||||
let foundRepo: string | null = null;
|
let foundRepo: string | null = null;
|
||||||
for (const repo of enabledRepos) {
|
for (const repo of enabledRepos) {
|
||||||
try {
|
try {
|
||||||
const { githubJsonService } = await import('./githubJsonService.js');
|
const { githubJsonService } = await import('./githubJsonService');
|
||||||
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
|
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
|
||||||
if (repoScript) {
|
if (repoScript) {
|
||||||
foundRepo = repo.url;
|
foundRepo = repo.url;
|
||||||
|
|||||||
216
src/server/services/repositoryService.js
Normal file
216
src/server/services/repositoryService.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// JavaScript wrapper for repositoryService (for use with node server.js)
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
|
||||||
|
class RepositoryService {
|
||||||
|
/**
|
||||||
|
* Initialize default repositories if they don't exist
|
||||||
|
*/
|
||||||
|
async initializeDefaultRepositories() {
|
||||||
|
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
|
||||||
|
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
|
||||||
|
|
||||||
|
// Check if repositories already exist
|
||||||
|
const existingRepos = await prisma.repository.findMany({
|
||||||
|
where: {
|
||||||
|
url: {
|
||||||
|
in: [mainRepoUrl, devRepoUrl]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingUrls = new Set(existingRepos.map((r) => r.url));
|
||||||
|
|
||||||
|
// Create main repo if it doesn't exist
|
||||||
|
if (!existingUrls.has(mainRepoUrl)) {
|
||||||
|
await prisma.repository.create({
|
||||||
|
data: {
|
||||||
|
url: mainRepoUrl,
|
||||||
|
enabled: true,
|
||||||
|
is_default: true,
|
||||||
|
is_removable: false,
|
||||||
|
priority: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Initialized main repository:', mainRepoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dev repo if it doesn't exist
|
||||||
|
if (!existingUrls.has(devRepoUrl)) {
|
||||||
|
await prisma.repository.create({
|
||||||
|
data: {
|
||||||
|
url: devRepoUrl,
|
||||||
|
enabled: false,
|
||||||
|
is_default: true,
|
||||||
|
is_removable: false,
|
||||||
|
priority: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Initialized dev repository:', devRepoUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all repositories, sorted by priority
|
||||||
|
*/
|
||||||
|
async getAllRepositories() {
|
||||||
|
return await prisma.repository.findMany({
|
||||||
|
orderBy: [
|
||||||
|
{ priority: 'asc' },
|
||||||
|
{ created_at: 'asc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled repositories, sorted by priority
|
||||||
|
*/
|
||||||
|
async getEnabledRepositories() {
|
||||||
|
return await prisma.repository.findMany({
|
||||||
|
where: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ priority: 'asc' },
|
||||||
|
{ created_at: 'asc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository by URL
|
||||||
|
*/
|
||||||
|
async getRepositoryByUrl(url) {
|
||||||
|
return await prisma.repository.findUnique({
|
||||||
|
where: { url }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new repository
|
||||||
|
*/
|
||||||
|
async createRepository(data) {
|
||||||
|
// Validate GitHub URL
|
||||||
|
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||||
|
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
const existing = await this.getRepositoryByUrl(data.url);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Repository already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get max priority for user-added repos
|
||||||
|
const maxPriority = await prisma.repository.aggregate({
|
||||||
|
_max: {
|
||||||
|
priority: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.repository.create({
|
||||||
|
data: {
|
||||||
|
url: data.url,
|
||||||
|
enabled: data.enabled ?? true,
|
||||||
|
is_default: false,
|
||||||
|
is_removable: true,
|
||||||
|
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update repository
|
||||||
|
*/
|
||||||
|
async updateRepository(id, data) {
|
||||||
|
// If updating URL, validate it
|
||||||
|
if (data.url) {
|
||||||
|
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||||
|
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates (excluding current repo)
|
||||||
|
const existing = await prisma.repository.findFirst({
|
||||||
|
where: {
|
||||||
|
url: data.url,
|
||||||
|
id: { not: id }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Repository URL already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.repository.update({
|
||||||
|
where: { id },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete repository and associated JSON files
|
||||||
|
*/
|
||||||
|
async deleteRepository(id) {
|
||||||
|
const repo = await prisma.repository.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error('Repository not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repo.is_removable) {
|
||||||
|
throw new Error('Cannot delete default repository');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete associated JSON files
|
||||||
|
await this.deleteRepositoryJsonFiles(repo.url);
|
||||||
|
|
||||||
|
// Delete repository
|
||||||
|
await prisma.repository.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all JSON files associated with a repository
|
||||||
|
*/
|
||||||
|
async deleteRepositoryJsonFiles(repoUrl) {
|
||||||
|
const { readdir, unlink, readFile } = await import('fs/promises');
|
||||||
|
const { join } = await import('path');
|
||||||
|
|
||||||
|
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir(jsonDirectory);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.json')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = join(jsonDirectory, file);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
|
||||||
|
// If script has repository_url matching the repo, delete it
|
||||||
|
if (script.repository_url === repoUrl) {
|
||||||
|
await unlink(filePath);
|
||||||
|
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Skip files that can't be read or parsed
|
||||||
|
console.error(`Error processing file ${file}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Directory might not exist, which is fine
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('Error deleting repository JSON files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const repositoryService = new RepositoryService();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '../db.ts';
|
/* eslint-disable @typescript-eslint/prefer-regexp-exec */
|
||||||
|
import { prisma } from '../db';
|
||||||
|
|
||||||
export class RepositoryService {
|
export class RepositoryService {
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +18,7 @@ export class RepositoryService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingUrls = new Set(existingRepos.map(r => r.url));
|
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url));
|
||||||
|
|
||||||
// Create main repo if it doesn't exist
|
// Create main repo if it doesn't exist
|
||||||
if (!existingUrls.has(mainRepoUrl)) {
|
if (!existingUrls.has(mainRepoUrl)) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/no-empty-function */
|
||||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||||
import { getBackupService } from './backupService';
|
import { getBackupService } from './backupService';
|
||||||
import { getStorageService } from './storageService';
|
import { getStorageService } from './storageService';
|
||||||
import { getDatabase } from '../database-prisma';
|
import { getDatabase } from '../database-prisma';
|
||||||
import type { Server } from '~/types/server';
|
import type { Server } from '~/types/server';
|
||||||
import type { Storage } from './storageService';
|
import type { Storage } from './storageService';
|
||||||
import { writeFile, readFile } from 'fs/promises';
|
import { writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync } from 'fs';
|
|
||||||
|
|
||||||
export interface RestoreProgress {
|
export interface RestoreProgress {
|
||||||
step: string;
|
step: string;
|
||||||
@@ -76,7 +76,7 @@ class RestoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Try fallback to database
|
// Try fallback to database
|
||||||
try {
|
try {
|
||||||
const installedScripts = await db.getAllInstalledScripts();
|
const installedScripts = await db.getAllInstalledScripts();
|
||||||
@@ -90,7 +90,7 @@ class RestoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch {
|
||||||
// Ignore database error
|
// Ignore database error
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -231,7 +231,6 @@ class RestoreService {
|
|||||||
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
|
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
|
||||||
|
|
||||||
// Determine file extension - try common extensions
|
// Determine file extension - try common extensions
|
||||||
const extensions = ['.tar', '.tar.zst', '.pxar'];
|
|
||||||
let downloadedPath = '';
|
let downloadedPath = '';
|
||||||
let downloadSuccess = false;
|
let downloadSuccess = false;
|
||||||
|
|
||||||
@@ -408,7 +407,7 @@ class RestoreService {
|
|||||||
const clearLogFile = async () => {
|
const clearLogFile = async () => {
|
||||||
try {
|
try {
|
||||||
await writeFile(logPath, '', 'utf-8');
|
await writeFile(logPath, '', 'utf-8');
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore log file errors
|
// Ignore log file errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -418,7 +417,7 @@ class RestoreService {
|
|||||||
try {
|
try {
|
||||||
const logLine = `${message}\n`;
|
const logLine = `${message}\n`;
|
||||||
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
|
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore log file errors
|
// Ignore log file errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -452,10 +451,12 @@ class RestoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get server details
|
// Get server details
|
||||||
const server = await db.getServerById(serverId);
|
const serverData = await db.getServerById(serverId);
|
||||||
if (!server) {
|
if (!serverData) {
|
||||||
throw new Error(`Server with ID ${serverId} not found`);
|
throw new Error(`Server with ID ${serverId} not found`);
|
||||||
}
|
}
|
||||||
|
// Cast to Server type (Prisma returns nullable fields as null, Server uses undefined)
|
||||||
|
const server = serverData as unknown as Server;
|
||||||
|
|
||||||
// Get rootfs storage
|
// Get rootfs storage
|
||||||
await addProgress('reading_config', 'Reading container configuration...');
|
await addProgress('reading_config', 'Reading container configuration...');
|
||||||
@@ -489,7 +490,7 @@ class RestoreService {
|
|||||||
await addProgress('stopping', 'Stopping container...');
|
await addProgress('stopping', 'Stopping container...');
|
||||||
try {
|
try {
|
||||||
await this.stopContainer(server, containerId);
|
await this.stopContainer(server, containerId);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Continue even if stop fails
|
// Continue even if stop fails
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +498,7 @@ class RestoreService {
|
|||||||
await addProgress('destroying', 'Destroying container...');
|
await addProgress('destroying', 'Destroying container...');
|
||||||
try {
|
try {
|
||||||
await this.destroyContainer(server, containerId);
|
await this.destroyContainer(server, containerId);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Container might not exist, which is fine - continue with restore
|
// Container might not exist, which is fine - continue with restore
|
||||||
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||||
}
|
}
|
||||||
@@ -559,3 +560,4 @@ export function getRestoreService(): RestoreService {
|
|||||||
return restoreServiceInstance;
|
return restoreServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
|
|||||||
|
|
||||||
export class ScriptDownloaderService {
|
export class ScriptDownloaderService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.scriptsDirectory = null;
|
/** @type {string} */
|
||||||
this.repoUrl = null;
|
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||||
|
/** @type {string} */
|
||||||
|
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeConfig() {
|
initializeConfig() {
|
||||||
if (this.scriptsDirectory === null) {
|
// Re-initialize if needed (for environment changes)
|
||||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||||
// Get REPO_URL from environment or use default
|
|
||||||
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a directory path doesn't contain nested directories with the same name
|
* Validates that a directory path doesn't contain nested directories with the same name
|
||||||
* (e.g., prevents ct/ct or install/install)
|
* (e.g., prevents ct/ct or install/install)
|
||||||
|
* @param {string} dirPath - The directory path to validate
|
||||||
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
validateDirectoryPath(dirPath) {
|
validateDirectoryPath(dirPath) {
|
||||||
const normalizedPath = dirPath.replace(/\\/g, '/');
|
const normalizedPath = dirPath.replace(/\\/g, '/');
|
||||||
@@ -36,6 +38,9 @@ export class ScriptDownloaderService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
|
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
|
||||||
|
* @param {string} targetDir - The base target directory
|
||||||
|
* @param {string} finalTargetDir - The final target directory to validate
|
||||||
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
validateTargetDir(targetDir, finalTargetDir) {
|
validateTargetDir(targetDir, finalTargetDir) {
|
||||||
// Check if finalTargetDir contains nested directory names
|
// Check if finalTargetDir contains nested directory names
|
||||||
@@ -53,6 +58,11 @@ export class ScriptDownloaderService {
|
|||||||
return finalTargetDir;
|
return finalTargetDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a directory exists, creating it if necessary
|
||||||
|
* @param {string} dirPath - The directory path to ensure exists
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async ensureDirectoryExists(dirPath) {
|
async ensureDirectoryExists(dirPath) {
|
||||||
// Validate the directory path to prevent nested directories with the same name
|
// Validate the directory path to prevent nested directories with the same name
|
||||||
this.validateDirectoryPath(dirPath);
|
this.validateDirectoryPath(dirPath);
|
||||||
@@ -61,7 +71,7 @@ export class ScriptDownloaderService {
|
|||||||
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
|
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
|
||||||
await mkdir(dirPath, { recursive: true });
|
await mkdir(dirPath, { recursive: true });
|
||||||
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
|
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
|
||||||
} catch (error) {
|
} catch (/** @type {any} */ error) {
|
||||||
if (error.code !== 'EEXIST') {
|
if (error.code !== 'EEXIST') {
|
||||||
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
|
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -71,6 +81,11 @@ export class ScriptDownloaderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract repository path from GitHub URL
|
||||||
|
* @param {string} repoUrl - The GitHub repository URL
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
extractRepoPath(repoUrl) {
|
extractRepoPath(repoUrl) {
|
||||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -79,6 +94,13 @@ export class ScriptDownloaderService {
|
|||||||
return `${match[1]}/${match[2]}`;
|
return `${match[1]}/${match[2]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from GitHub
|
||||||
|
* @param {string} repoUrl - The GitHub repository URL
|
||||||
|
* @param {string} filePath - The file path within the repository
|
||||||
|
* @param {string} [branch] - The branch to download from
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
|
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
if (!repoUrl) {
|
if (!repoUrl) {
|
||||||
@@ -88,6 +110,7 @@ export class ScriptDownloaderService {
|
|||||||
const repoPath = this.extractRepoPath(repoUrl);
|
const repoPath = this.extractRepoPath(repoUrl);
|
||||||
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
|
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
const headers = {
|
const headers = {
|
||||||
'User-Agent': 'PVEScripts-Local/1.0',
|
'User-Agent': 'PVEScripts-Local/1.0',
|
||||||
};
|
};
|
||||||
@@ -106,6 +129,11 @@ export class ScriptDownloaderService {
|
|||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository URL for a script
|
||||||
|
* @param {import('~/types/script').Script} script - The script object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
getRepoUrlForScript(script) {
|
getRepoUrlForScript(script) {
|
||||||
// Use repository_url from script if available, otherwise fallback to env or default
|
// Use repository_url from script if available, otherwise fallback to env or default
|
||||||
if (script.repository_url) {
|
if (script.repository_url) {
|
||||||
@@ -115,6 +143,11 @@ export class ScriptDownloaderService {
|
|||||||
return this.repoUrl;
|
return this.repoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify script content to use local paths
|
||||||
|
* @param {string} content - The script content
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
modifyScriptContent(content) {
|
modifyScriptContent(content) {
|
||||||
// Replace the build.func source line
|
// Replace the build.func source line
|
||||||
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
|
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
|
||||||
@@ -123,9 +156,15 @@ export class ScriptDownloaderService {
|
|||||||
return content.replace(oldPattern, newPattern);
|
return content.replace(oldPattern, newPattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a script by downloading its files
|
||||||
|
* @param {import('~/types/script').Script} script - The script to load
|
||||||
|
* @returns {Promise<{success: boolean, message: string, files: string[], error?: string}>}
|
||||||
|
*/
|
||||||
async loadScript(script) {
|
async loadScript(script) {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
try {
|
try {
|
||||||
|
/** @type {string[]} */
|
||||||
const files = [];
|
const files = [];
|
||||||
const repoUrl = this.getRepoUrlForScript(script);
|
const repoUrl = this.getRepoUrlForScript(script);
|
||||||
const branch = process.env.REPO_BRANCH || 'main';
|
const branch = process.env.REPO_BRANCH || 'main';
|
||||||
@@ -266,6 +305,11 @@ export class ScriptDownloaderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a script is downloaded
|
||||||
|
* @param {import('~/types/script').Script} script - The script to check
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
async isScriptDownloaded(script) {
|
async isScriptDownloaded(script) {
|
||||||
if (!script.install_methods?.length) return false;
|
if (!script.install_methods?.length) return false;
|
||||||
|
|
||||||
@@ -318,6 +362,11 @@ export class ScriptDownloaderService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which script files exist locally
|
||||||
|
* @param {import('~/types/script').Script} script - The script to check
|
||||||
|
* @returns {Promise<{ctExists: boolean, installExists: boolean, files: string[]}>}
|
||||||
|
*/
|
||||||
async checkScriptExists(script) {
|
async checkScriptExists(script) {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -416,6 +465,11 @@ export class ScriptDownloaderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a script's local files
|
||||||
|
* @param {import('~/types/script').Script} script - The script to delete
|
||||||
|
* @returns {Promise<{success: boolean, message: string, deletedFiles: string[]}>}
|
||||||
|
*/
|
||||||
async deleteScript(script) {
|
async deleteScript(script) {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
const deletedFiles = [];
|
const deletedFiles = [];
|
||||||
@@ -467,8 +521,14 @@ export class ScriptDownloaderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare local script content with remote
|
||||||
|
* @param {import('~/types/script').Script} script - The script to compare
|
||||||
|
* @returns {Promise<{hasDifferences: boolean, differences: string[], error?: string}>}
|
||||||
|
*/
|
||||||
async compareScriptContent(script) {
|
async compareScriptContent(script) {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
|
/** @type {string[]} */
|
||||||
const differences = [];
|
const differences = [];
|
||||||
let hasDifferences = false;
|
let hasDifferences = false;
|
||||||
const repoUrl = this.getRepoUrlForScript(script);
|
const repoUrl = this.getRepoUrlForScript(script);
|
||||||
@@ -519,13 +579,16 @@ export class ScriptDownloaderService {
|
|||||||
comparisonPromises.push(
|
comparisonPromises.push(
|
||||||
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
|
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||||
|
}
|
||||||
if (result.hasDifferences) {
|
if (result.hasDifferences) {
|
||||||
hasDifferences = true;
|
hasDifferences = true;
|
||||||
differences.push(result.filePath);
|
differences.push(result.filePath);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
// Don't add to differences if there's an error reading files
|
console.error(`[Comparison] Promise error for ${scriptPath}:`, error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -541,13 +604,16 @@ export class ScriptDownloaderService {
|
|||||||
comparisonPromises.push(
|
comparisonPromises.push(
|
||||||
this.compareSingleFile(script, installScriptPath, installScriptPath)
|
this.compareSingleFile(script, installScriptPath, installScriptPath)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||||
|
}
|
||||||
if (result.hasDifferences) {
|
if (result.hasDifferences) {
|
||||||
hasDifferences = true;
|
hasDifferences = true;
|
||||||
differences.push(result.filePath);
|
differences.push(result.filePath);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
// Don't add to differences if there's an error reading files
|
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -567,13 +633,16 @@ export class ScriptDownloaderService {
|
|||||||
comparisonPromises.push(
|
comparisonPromises.push(
|
||||||
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
|
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||||
|
}
|
||||||
if (result.hasDifferences) {
|
if (result.hasDifferences) {
|
||||||
hasDifferences = true;
|
hasDifferences = true;
|
||||||
differences.push(result.filePath);
|
differences.push(result.filePath);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
// Don't add to differences if there's an error reading files
|
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -584,29 +653,42 @@ export class ScriptDownloaderService {
|
|||||||
// Wait for all comparisons to complete
|
// Wait for all comparisons to complete
|
||||||
await Promise.all(comparisonPromises);
|
await Promise.all(comparisonPromises);
|
||||||
|
|
||||||
|
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
|
||||||
return { hasDifferences, differences };
|
return { hasDifferences, differences };
|
||||||
} catch (error) {
|
} catch (/** @type {any} */ error) {
|
||||||
console.error('Error comparing script content:', error);
|
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
|
||||||
return { hasDifferences: false, differences: [] };
|
return { hasDifferences: false, differences: [], error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a single file with remote
|
||||||
|
* @param {import('~/types/script').Script} script - The script object
|
||||||
|
* @param {string} remotePath - The remote file path
|
||||||
|
* @param {string} filePath - The local file path
|
||||||
|
* @returns {Promise<{hasDifferences: boolean, filePath: string, error?: string}>}
|
||||||
|
*/
|
||||||
async compareSingleFile(script, remotePath, filePath) {
|
async compareSingleFile(script, remotePath, filePath) {
|
||||||
try {
|
try {
|
||||||
const localPath = join(this.scriptsDirectory, filePath);
|
const localPath = join(this.scriptsDirectory, filePath);
|
||||||
const repoUrl = this.getRepoUrlForScript(script);
|
const repoUrl = this.getRepoUrlForScript(script);
|
||||||
const branch = process.env.REPO_BRANCH || 'main';
|
const branch = process.env.REPO_BRANCH || 'main';
|
||||||
|
|
||||||
|
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
|
||||||
|
|
||||||
// Read local content
|
// Read local content
|
||||||
const localContent = await readFile(localPath, 'utf-8');
|
const localContent = await readFile(localPath, 'utf-8');
|
||||||
|
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
|
||||||
|
|
||||||
// Download remote content from the script's repository
|
// Download remote content from the script's repository
|
||||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
||||||
|
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
|
||||||
|
|
||||||
// Apply modification only for CT scripts, not for other script types
|
// Apply modification only for CT scripts, not for other script types
|
||||||
let modifiedRemoteContent;
|
let modifiedRemoteContent;
|
||||||
if (remotePath.startsWith('ct/')) {
|
if (remotePath.startsWith('ct/')) {
|
||||||
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||||
|
console.log(`[Comparison] Applied CT script modifications`);
|
||||||
} else {
|
} else {
|
||||||
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
||||||
}
|
}
|
||||||
@@ -614,13 +696,26 @@ export class ScriptDownloaderService {
|
|||||||
// Compare content
|
// Compare content
|
||||||
const hasDifferences = localContent !== modifiedRemoteContent;
|
const hasDifferences = localContent !== modifiedRemoteContent;
|
||||||
|
|
||||||
|
if (hasDifferences) {
|
||||||
|
console.log(`[Comparison] Differences found in ${filePath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Comparison] No differences in ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { hasDifferences, filePath };
|
return { hasDifferences, filePath };
|
||||||
} catch (error) {
|
} catch (/** @type {any} */ error) {
|
||||||
console.error(`Error comparing file ${filePath}:`, error);
|
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
|
||||||
return { hasDifferences: false, filePath };
|
// Return error information so it can be handled upstream
|
||||||
|
return { hasDifferences: false, filePath, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get diff between local and remote script
|
||||||
|
* @param {import('~/types/script').Script} script - The script object
|
||||||
|
* @param {string} filePath - The file path to diff
|
||||||
|
* @returns {Promise<{diff: string|null, localContent: string|null, remoteContent: string|null}>}
|
||||||
|
*/
|
||||||
async getScriptDiff(script, filePath) {
|
async getScriptDiff(script, filePath) {
|
||||||
this.initializeConfig();
|
this.initializeConfig();
|
||||||
try {
|
try {
|
||||||
@@ -680,6 +775,12 @@ export class ScriptDownloaderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a simple line-by-line diff
|
||||||
|
* @param {string} localContent - The local file content
|
||||||
|
* @param {string} remoteContent - The remote file content
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
generateDiff(localContent, remoteContent) {
|
generateDiff(localContent, remoteContent) {
|
||||||
const localLines = localContent.split('\n');
|
const localLines = localContent.split('\n');
|
||||||
const remoteLines = remoteContent.split('\n');
|
const remoteLines = remoteContent.split('\n');
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ class StorageService {
|
|||||||
|
|
||||||
let currentStorage: Partial<Storage> | null = null;
|
let currentStorage: Partial<Storage> | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (const rawLine of lines) {
|
||||||
const rawLine = lines[i];
|
|
||||||
if (!rawLine) continue;
|
if (!rawLine) continue;
|
||||||
|
|
||||||
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
||||||
@@ -44,10 +43,10 @@ class StorageService {
|
|||||||
// Check if this is a storage definition line (format: "type: name")
|
// Check if this is a storage definition line (format: "type: name")
|
||||||
// Storage definitions are NOT indented
|
// Storage definitions are NOT indented
|
||||||
if (!isIndented) {
|
if (!isIndented) {
|
||||||
const storageMatch = line.match(/^(\w+):\s*(.+)$/);
|
const storageMatch = /^(\w+):\s*(.+)$/.exec(line);
|
||||||
if (storageMatch && storageMatch[1] && storageMatch[2]) {
|
if (storageMatch?.[1] && storageMatch[2]) {
|
||||||
// Save previous storage if exists
|
// Save previous storage if exists
|
||||||
if (currentStorage && currentStorage.name) {
|
if (currentStorage?.name) {
|
||||||
storages.push(this.finalizeStorage(currentStorage));
|
storages.push(this.finalizeStorage(currentStorage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +64,9 @@ class StorageService {
|
|||||||
// Parse storage properties (indented lines - can be tabs or spaces)
|
// Parse storage properties (indented lines - can be tabs or spaces)
|
||||||
if (currentStorage && isIndented) {
|
if (currentStorage && isIndented) {
|
||||||
// Split on first whitespace (space or tab) to separate key and value
|
// Split on first whitespace (space or tab) to separate key and value
|
||||||
const match = line.match(/^(\S+)\s+(.+)$/);
|
const match = /^(\S+)\s+(.+)$/.exec(line);
|
||||||
|
|
||||||
if (match && match[1] && match[2]) {
|
if (match?.[1] && match[2]) {
|
||||||
const key = match[1];
|
const key = match[1];
|
||||||
const value = match[2].trim();
|
const value = match[2].trim();
|
||||||
|
|
||||||
@@ -92,7 +91,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last storage
|
// Don't forget the last storage
|
||||||
if (currentStorage && currentStorage.name) {
|
if (currentStorage?.name) {
|
||||||
storages.push(this.finalizeStorage(currentStorage));
|
storages.push(this.finalizeStorage(currentStorage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +105,8 @@ class StorageService {
|
|||||||
return {
|
return {
|
||||||
name: storage.name!,
|
name: storage.name!,
|
||||||
type: storage.type!,
|
type: storage.type!,
|
||||||
content: storage.content || [],
|
content: storage.content ?? [],
|
||||||
supportsBackup: storage.supportsBackup || false,
|
supportsBackup: storage.supportsBackup ?? false,
|
||||||
nodes: storage.nodes,
|
nodes: storage.nodes,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(storage).filter(([key]) =>
|
Object.entries(storage).filter(([key]) =>
|
||||||
@@ -138,7 +137,7 @@ class StorageService {
|
|||||||
let configContent = '';
|
let configContent = '';
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
'cat /etc/pve/storage.cfg',
|
'cat /etc/pve/storage.cfg',
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -191,8 +190,8 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pbs_ip: (storage as any).server || null,
|
pbs_ip: (storage as any).server ?? null,
|
||||||
pbs_datastore: (storage as any).datastore || null,
|
pbs_datastore: (storage as any).datastore ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +214,7 @@ class StorageService {
|
|||||||
let storageServiceInstance: StorageService | null = null;
|
let storageServiceInstance: StorageService | null = null;
|
||||||
|
|
||||||
export function getStorageService(): StorageService {
|
export function getStorageService(): StorageService {
|
||||||
if (!storageServiceInstance) {
|
storageServiceInstance ??= new StorageService();
|
||||||
storageServiceInstance = new StorageService();
|
|
||||||
}
|
|
||||||
return storageServiceInstance;
|
return storageServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ class SSHExecutionService {
|
|||||||
* @param {Function} onData - Callback for data output
|
* @param {Function} onData - Callback for data output
|
||||||
* @param {Function} onError - Callback for errors
|
* @param {Function} onError - Callback for errors
|
||||||
* @param {Function} onExit - Callback for process exit
|
* @param {Function} onExit - Callback for process exit
|
||||||
|
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
async executeScript(server, scriptPath, onData, onError, onExit, envVars = {}) {
|
||||||
try {
|
try {
|
||||||
await this.transferScriptsFolder(server, onData, onError);
|
await this.transferScriptsFolder(server, onData, onError);
|
||||||
|
|
||||||
@@ -98,8 +99,43 @@ class SSHExecutionService {
|
|||||||
// Build SSH command based on authentication type
|
// Build SSH command based on authentication type
|
||||||
const { command, args } = this.buildSSHCommand(server);
|
const { command, args } = this.buildSSHCommand(server);
|
||||||
|
|
||||||
|
// Format environment variables as var_name=value pairs
|
||||||
|
const envVarsString = Object.entries(envVars)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
// Escape special characters in values
|
||||||
|
const escapedValue = String(value).replace(/'/g, "'\\''");
|
||||||
|
return `${key}='${escapedValue}'`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
// Build the command with environment variables
|
||||||
|
let scriptCommand = `cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1`;
|
||||||
|
|
||||||
|
if (envVarsString) {
|
||||||
|
scriptCommand += ` && ${envVarsString} bash ${relativeScriptPath}`;
|
||||||
|
} else {
|
||||||
|
scriptCommand += ` && bash ${relativeScriptPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the full command that will be executed
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log(`[SSH Execution] Executing on host: ${server.ip} (${server.name || 'Unnamed'})`);
|
||||||
|
console.log(`[SSH Execution] Script path: ${scriptPath}`);
|
||||||
|
console.log(`[SSH Execution] Relative script path: ${relativeScriptPath}`);
|
||||||
|
if (Object.keys(envVars).length > 0) {
|
||||||
|
console.log(`[SSH Execution] Environment variables (${Object.keys(envVars).length} vars):`);
|
||||||
|
Object.entries(envVars).forEach(([key, value]) => {
|
||||||
|
console.log(` ${key}=${String(value)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`[SSH Execution] No environment variables provided`);
|
||||||
|
}
|
||||||
|
console.log(`[SSH Execution] Full command:`);
|
||||||
|
console.log(scriptCommand);
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
|
||||||
// Add the script execution command to the args
|
// Add the script execution command to the args
|
||||||
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
args.push(scriptCommand);
|
||||||
|
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
const sshCommand = ptySpawn(command, args, {
|
const sshCommand = ptySpawn(command, args, {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
59
update.sh
59
update.sh
@@ -851,6 +851,59 @@ rollback() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check installed Node.js version and upgrade if needed
|
||||||
|
check_node_version() {
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
log_error "Node.js is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local current major_version
|
||||||
|
|
||||||
|
current=$(node -v 2>/dev/null | tr -d 'v')
|
||||||
|
major_version=${current%%.*}
|
||||||
|
|
||||||
|
log "Detected Node.js version: $current"
|
||||||
|
|
||||||
|
if (( major_version < 24 )); then
|
||||||
|
log_warning "Node.js < 24 detected → upgrading to Node.js 24 LTS..."
|
||||||
|
upgrade_node_to_24
|
||||||
|
elif (( major_version > 24 )); then
|
||||||
|
log_warning "Node.js > 24 detected → script tested only up to Node 24"
|
||||||
|
log "Continuing anyway…"
|
||||||
|
else
|
||||||
|
log_success "Node.js 24 already installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upgrade Node.js to version 24
|
||||||
|
upgrade_node_to_24() {
|
||||||
|
log "Preparing Node.js 24 upgrade…"
|
||||||
|
|
||||||
|
# Remove old nodesource repo if it exists
|
||||||
|
if [ -f /etc/apt/sources.list.d/nodesource.list ]; then
|
||||||
|
rm -f /etc/apt/sources.list.d/nodesource.list
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install NodeSource repo for Node.js 24
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/node24_setup.sh
|
||||||
|
if ! bash /tmp/node24_setup.sh > /tmp/node24_setup.log 2>&1; then
|
||||||
|
log_error "Failed to configure Node.js 24 repository"
|
||||||
|
tail -20 /tmp/node24_setup.log | while read -r line; do log_error "$line"; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Installing Node.js 24…"
|
||||||
|
if ! apt-get install -y nodejs >> "$LOG_FILE" 2>&1; then
|
||||||
|
log_error "Failed to install Node.js 24"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local new_ver
|
||||||
|
new_ver=$(node -v 2>/dev/null || true)
|
||||||
|
log_success "Node.js successfully upgraded to $new_ver"
|
||||||
|
}
|
||||||
|
|
||||||
# Main update process
|
# Main update process
|
||||||
main() {
|
main() {
|
||||||
# Check if this is the relocated/detached version first
|
# Check if this is the relocated/detached version first
|
||||||
@@ -914,6 +967,12 @@ main() {
|
|||||||
# Stop the application before updating
|
# Stop the application before updating
|
||||||
stop_application
|
stop_application
|
||||||
|
|
||||||
|
# Check Node.js version
|
||||||
|
check_node_version
|
||||||
|
|
||||||
|
#Update Node.js to 24
|
||||||
|
upgrade_node_to_24
|
||||||
|
|
||||||
# Download and extract release
|
# Download and extract release
|
||||||
local source_dir
|
local source_dir
|
||||||
source_dir=$(download_release "$release_info")
|
source_dir=$(download_release "$release_info")
|
||||||
|
|||||||
Reference in New Issue
Block a user