Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
ddfdb5e575 chore: add VERSION v0.4.12 2025-11-20 17:59:37 +00:00
88 changed files with 9451 additions and 22606 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [24.x] node-version: [22.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
View File

@@ -16,9 +16,6 @@
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/

View File

@@ -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 /opt/PVESciptslocal cd PVESciptslocal
# Install dependencies and build # Install dependencies and build
npm install npm install

View File

@@ -1 +1 @@
0.5.4 0.4.12

View File

@@ -1,23 +1,15 @@
import eslintPluginNext from "@next/eslint-plugin-next"; import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks"; const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config( export default tseslint.config(
{ {
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"], ignores: [".next"],
},
{
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: [

View File

@@ -18,25 +18,31 @@ const config = {
}, },
], ],
}, },
// Allow cross-origin requests from local network in dev mode // Allow cross-origin requests from local network ranges
// Note: In Next.js 16, we disable this check entirely for dev allowedDevOrigins: [
async headers() { 'http://localhost:3000',
return [ 'http://127.0.0.1:3000',
{ 'http://[::1]:3000',
source: '/:path*', 'http://10.*',
headers: [ 'http://172.16.*',
{ key: 'Access-Control-Allow-Origin', value: '*' }, 'http://172.17.*',
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' }, 'http://172.18.*',
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, 'http://172.19.*',
'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 = {
@@ -44,18 +50,15 @@ 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;
}, },
// TypeScript errors will fail the build // Ignore ESLint errors during build (they can be fixed separately)
eslint: {
ignoreDuringBuilds: true,
},
// Ignore TypeScript errors during build (they can be fixed separately)
typescript: { typescript: {
ignoreBuildErrors: false, ignoreBuildErrors: true,
}, },
}; };

4512
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,17 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "prisma generate && next build --webpack", "build": "next build",
"check": "eslint . && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --webpack", "dev": "next dev",
"dev:server": "node --import tsx server.js", "dev:server": "node server.js",
"dev:next": "next dev --webpack", "dev:next": "next dev",
"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",
"generate": "prisma generate", "lint": "next lint",
"lint": "eslint .", "lint:fix": "next lint --fix",
"lint:fix": "eslint --fix .",
"migrate": "prisma migrate dev",
"preview": "next build && next start", "preview": "next build && next start",
"postinstall": "prisma generate", "start": "node server.js",
"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",
@@ -25,82 +22,76 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.2.0", "@prisma/client": "^6.18.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.8.1", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.6.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.11.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^5.5.0",
"axios": "^1.13.2", "axios": "^1.7.9",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.2",
"better-sqlite3": "^12.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cron-validator": "^1.4.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.553.0",
"next": "^16.1.1", "next": "^15.1.6",
"node-cron": "^4.2.1", "node-cron": "^3.0.3",
"node-pty": "^1.1.0", "node-pty": "^1.0.0",
"react": "^19.2.3", "react": "^19.0.0",
"react-dom": "^19.2.3", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^15.6.6",
"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.6", "superjson": "^2.2.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.3.1",
"ws": "^8.19.0", "ws": "^8.18.3",
"zod": "^4.3.5" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@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.13", "@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.4", "@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "^19.2.8", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^4.0.17", "@vitest/ui": "^3.2.4",
"baseline-browser-mapping": "^2.9.14", "eslint": "^9.39.1",
"eslint": "^9.39.2", "eslint-config-next": "^15.1.6",
"eslint-config-next": "^16.1.1", "jsdom": "^27.2.0",
"jsdom": "^27.4.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.2.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.53.0",
"vitest": "^4.0.17"
}, },
"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"
} }

View File

@@ -1,20 +0,0 @@
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 })
},
},
})

View File

@@ -1,10 +1,10 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client-js"
output = "./generated/prisma"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = env("DATABASE_URL")
} }
model InstalledScript { model InstalledScript {

View File

@@ -1,4 +1,4 @@
# Copyright (c) 2021-2026 community-scripts ORG # Copyright (c) 2021-2025 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,65 +6,33 @@
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 [ "${IPV6_METHOD:-}" = "disable" ]; then if [ "$DISABLEIPV6" == "yes" ]; 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
$STD sysctl -w net.ipv6.conf.default.disable_ipv6=1 echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
$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
} }
set -Eeuo pipefail # This function catches errors and handles them with the error handler function
trap 'error_handler $? $LINENO "$BASH_COMMAND"' ERR catch_errors() {
trap on_exit EXIT set -Eeuo pipefail
trap on_interrupt INT trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
trap on_terminate TERM }
# This function handles errors
error_handler() { 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"
}
on_exit() {
local exit_code="$?" local exit_code="$?"
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile" local line_number="$1"
exit "$exit_code" 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\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
@@ -93,10 +61,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
ipv4_status="${GN}✔${CL} IPv4" msg_ok "Internet Connected"
else else
ipv4_status="${RD}✖${CL} IPv4" msg_error "Internet NOT Connected"
read -r -p "Internet NOT connected. Continue anyway? <y/N> " prompt read -r -p "Would you like to 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
@@ -105,11 +73,7 @@ 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 if [[ -z "$RESOLVEDIP" ]]; then msg_error "DNS Lookup Failure"; else msg_ok "DNS Resolved github.com to ${BL}$RESOLVEDIP${CL}"; fi
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
} }
@@ -118,7 +82,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 "$(dirname "${BASH_SOURCE[0]}")/tools.func" #source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
msg_ok "Updated Container OS" msg_ok "Updated Container OS"
} }
@@ -190,4 +154,10 @@ 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
} }

View File

@@ -1,507 +0,0 @@
#!/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)"
}

View File

@@ -1,154 +1,7 @@
# Copyright (c) 2021-2026 community-scripts ORG # Copyright (c) 2021-2025 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
@@ -177,6 +30,7 @@ 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",
@@ -185,26 +39,14 @@ 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
@@ -239,6 +81,7 @@ 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",
@@ -247,6 +90,7 @@ 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" \
@@ -254,54 +98,19 @@ 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}"
if [[ "$status" == "failed" ]]; then local error="${2:-No error message}"
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
@@ -312,6 +121,7 @@ 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

View File

@@ -1,505 +0,0 @@
#!/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

View File

@@ -0,0 +1,699 @@
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
}

View File

@@ -1,35 +1,13 @@
#!/usr/bin/env bash # Copyright (c) 2021-2025 community-scripts ORG
# 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
# ============================================================================== # ------------------------------------------------------------------------------
# CORE FUNCTIONS - LXC CONTAINER UTILITIES # Loads core utility groups once (colors, formatting, icons, defaults).
# ============================================================================== # ------------------------------------------------------------------------------
#
# 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
@@ -38,14 +16,58 @@ 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
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# color() # Sets ANSI color codes used for styled terminal output.
#
# - 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")
@@ -58,14 +80,7 @@ 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'
@@ -73,12 +88,7 @@ color_spinner() {
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# formatting() # Defines formatting helpers like tab, bold, and line reset sequences.
#
# - 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"
@@ -89,11 +99,7 @@ formatting() {
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# icons() # Sets symbolic icons used throughout user feedback and prompts.
#
# - 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}"
@@ -123,31 +129,22 @@ 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}"
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# default_vars() # Sets default retry and wait variables used for system actions.
#
# - 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" ]]
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# set_std_mode() # Sets default verbose mode for script and os execution.
#
# - 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
@@ -155,338 +152,138 @@ set_std_mode() {
else else
STD="silent" STD="silent"
fi fi
# Enable bash tracing if trace mode active
if [[ "${DEV_MODE_TRACE:-false}" == "true" ]]; then
set -x
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
fi
} }
# ------------------------------------------------------------------------------ # Silent execution function
# parse_dev_mode()
#
# - Parses comma-separated dev_mode variable (e.g., "motd,keep,trace")
# - Sets global flags for each mode:
# * DEV_MODE_MOTD: Setup SSH/MOTD before installation
# * DEV_MODE_KEEP: Never delete container on failure
# * DEV_MODE_TRACE: Enable bash set -x tracing
# * 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
# Parse comma-separated modes
if [[ -n "${dev_mode:-}" ]]; then
IFS=',' read -ra MODES <<<"$dev_mode"
for mode in "${MODES[@]}"; do
mode="$(echo "$mode" | xargs)" # Trim whitespace
case "$mode" in
motd) export DEV_MODE_MOTD=true ;;
keep) export DEV_MODE_KEEP=true ;;
trace) export DEV_MODE_TRACE=true ;;
pause) export DEV_MODE_PAUSE=true ;;
breakpoint) export DEV_MODE_BREAKPOINT=true ;;
logs) export DEV_MODE_LOGS=true ;;
dryrun) export DEV_MODE_DRYRUN=true ;;
*)
if declare -f msg_warn >/dev/null 2>&1; then
msg_warn "Unknown dev_mode: '$mode' (ignored)"
else
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
}
# ------------------------------------------------------------------------------
# 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
# ------------------------------------------------------------------------------
root_check() {
if [[ "$(id -u)" -ne 0 || $(ps -o comm= -p $PPID) == "sudo" ]]; then
clear
msg_error "Please run this script as root."
echo -e "\nExiting..."
sleep 2
exit
fi
}
# ------------------------------------------------------------------------------
# pve_check()
#
# - Validates Proxmox VE version compatibility
# - Supported: PVE 8.0-8.9 and PVE 9.0-9.1
# - Exits with error message if unsupported version detected
# ------------------------------------------------------------------------------
pve_check() {
local PVE_VER
PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')"
# Check for Proxmox VE 8.x: allow 8.08.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.09.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
}
# ------------------------------------------------------------------------------
# arch_check()
#
# - 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() { silent() {
local cmd="$*" "$@" >/dev/null 2>&1
local caller_line="${BASH_LINENO[0]:-unknown}" }
local logfile="$(get_active_logfile)"
# Dryrun mode: Show command without executing # Function to download & save header files
if [[ "${DEV_MODE_DRYRUN:-false}" == "true" ]]; then get_header() {
if declare -f msg_custom >/dev/null 2>&1; then local app_name=$(echo "${APP,,}" | tr -d ' ')
msg_custom "🔍" "${BL}" "[DRYRUN] $cmd" local app_type=${APP_TYPE:-ct}
else local header_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/${app_type}/headers/${app_name}"
echo "[DRYRUN] $cmd" >&2 local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}"
fi
return 0
fi
set +Eeuo pipefail mkdir -p "$(dirname "$local_header_path")"
trap - ERR
"$@" >>"$logfile" 2>&1 if [ ! -s "$local_header_path" ]; then
local rc=$? if ! curl -fsSL "$header_url" -o "$local_header_path"; then
return 1
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
fi fi
exit "$rc" 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
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() {
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() {
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 ]]
}
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# spinner() # Handles specific curl error codes and displays descriptive messages.
#
# - 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
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
__curl_err_handler() {
local exit_code="$1"
local target="$2"
local curl_msg="$3"
case $exit_code in
1) msg_error "Unsupported protocol: $target" ;;
2) msg_error "Curl init failed: $target" ;;
3) msg_error "Malformed URL: $target" ;;
5) msg_error "Proxy resolution failed: $target" ;;
6) msg_error "Host resolution failed: $target" ;;
7) msg_error "Connection failed: $target" ;;
9) msg_error "Access denied: $target" ;;
18) msg_error "Partial file transfer: $target" ;;
22) msg_error "HTTP error (e.g. 400/404): $target" ;;
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
exit 1
}
fatal() {
msg_error "$1"
kill -INT $$
}
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}${msg}${CS_CL}" printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${SPINNER_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)
@@ -504,19 +301,6 @@ 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
@@ -533,12 +317,6 @@ 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
@@ -547,68 +325,29 @@ 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
echo -e "$CM ${GN}${msg}${CL}" printf "%s %b\n" "$CM" "${GN}${msg}${CL}" >&2
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}" >&2 echo -e "${BFR:-} ${CROSS:-✖️} ${RD}${msg}${CL}"
} }
# ------------------------------------------------------------------------------
# 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}" >&2 echo -e "${BFR:-} ${INFO:-} ${YWB}${msg}${CL}"
} }
# ------------------------------------------------------------------------------
# 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"}"
@@ -618,181 +357,17 @@ msg_custom() {
echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}" echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}"
} }
# ------------------------------------------------------------------------------ run_container_safe() {
# msg_debug() local ct="$1"
# shift
# - Displays debug message with timestamp when var_full_verbose=1 local cmd="$*"
# - Automatically enables var_verbose if not already set
# - Shows date/time prefix for log correlation lxc-attach -n "$ct" -- bash -euo pipefail -c "
# - Uses bright yellow color for debug output trap 'echo Aborted in container; exit 130' SIGINT SIGTERM
# ------------------------------------------------------------------------------ $cmd
msg_debug() { " || __handle_general_error "lxc-attach to CT $ct"
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"
@@ -809,15 +384,22 @@ 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
# Node.js npm - directly remove cache directory # Truncate writable log files silently (permission errors ignored)
# npm cache clean/verify can fail with ENOTEMPTY errors, so we skip them if command -v truncate >/dev/null 2>&1; then
if command -v npm &>/dev/null; then find /var/log -type f -writable -print0 2>/dev/null |
rm -rf /root/.npm/_cacache /root/.npm/_logs 2>/dev/null || true xargs -0 -n1 truncate -s 0 2>/dev/null || true
fi fi
# Python pip
if command -v pip &>/dev/null; then $STD pip cache purge || true; fi
# Python uv
if command -v uv &>/dev/null; then $STD uv cache clear || 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 yarn cache clean &>/dev/null || true; fi if command -v yarn &>/dev/null; then $STD yarn cache clean || true; fi
# Node.js pnpm # Node.js pnpm
if command -v pnpm &>/dev/null; then pnpm store prune &>/dev/null || true; fi if command -v pnpm &>/dev/null; then $STD pnpm store prune || 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
@@ -825,21 +407,15 @@ 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 COMPOSER_ALLOW_SUPERUSER=1 $STD composer clear-cache || true; fi if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
if command -v journalctl &>/dev/null; then
$STD journalctl --rotate || true
$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"
@@ -878,8 +454,7 @@ check_or_create_swap() {
fi fi
} }
# ==============================================================================
# SIGNAL TRAPS
# ==============================================================================
trap 'stop_spinner' EXIT INT TERM trap 'stop_spinner' EXIT INT TERM
# Initialize functions when core.func is sourced
load_functions

380
scripts/core/create_lxc.sh Executable file
View File

@@ -0,0 +1,380 @@
#!/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."

View File

@@ -1,322 +0,0 @@
#!/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
}

View File

@@ -1,79 +1,48 @@
# Copyright (c) 2021-2026 community-scripts ORG # Copyright (c) 2021-2025 michelroegl-brunner
# Author: tteck (tteckster) # Author: michelroegl-brunner
# Co-Author: MickLesk # License: MIT
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE # 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 update >/dev/null 2>&1 apt-get update >/dev/null 2>&1
apt install -y curl >/dev/null 2>&1 apt-get install -y curl >/dev/null 2>&1
fi fi
source "$(dirname "${BASH_SOURCE[0]}")/core.func" # core.func is included in FUNCTIONS_FILE_PATH
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
# ==============================================================================
# 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 [ "${IPV6_METHOD:-}" = "disable" ]; then if [ "$DISABLEIPV6" == "yes" ]; then
msg_info "Disabling IPv6 (this may affect some services)" echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
mkdir -p /etc/sysctl.d $STD sysctl -p
$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
# setting_up_container() catch_errors() {
# set -Eeuo pipefail
# - Verifies network connectivity via hostname -I trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
# - Retries up to RETRY_NUM times with RETRY_EVERY seconds delay }
# - Removes Python EXTERNALLY-MANAGED restrictions
# - Disables systemd-networkd-wait-online.service for faster boot # This function handles errors
# - Exits with error if network unavailable after retries error_handler() {
# ------------------------------------------------------------------------------ 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
@@ -95,17 +64,8 @@ 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
# network_check() # This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
#
# - 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
@@ -165,19 +125,7 @@ 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
@@ -197,37 +145,29 @@ 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"
source "$(dirname "${BASH_SOURCE[0]}")/tools.func" # tools.func is included in FUNCTIONS_FILE_PATH
} }
# ============================================================================== # 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}\$(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}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${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"
@@ -240,19 +180,7 @@ 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

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../core/build.func"
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
APP="Debian"
var_tags="${var_tags:-os}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_info "Updating $APP LXC"
$STD apt update
$STD apt -y upgrade
msg_ok "Updated $APP LXC"
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}"

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
cleanup_lxc

576
server.js
View File

@@ -8,12 +8,9 @@ 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)
@@ -74,15 +71,7 @@ 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 {
@@ -90,28 +79,15 @@ 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({
noServer: true server,
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
@@ -300,21 +276,19 @@ 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, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType, envVars } = message; const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
switch (action) { switch (action) {
case 'start': case 'start':
if (scriptPath && executionId) { if (scriptPath && executionId) {
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) { if (isBackup && containerId && storage) {
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, envVars); await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
} }
} else { } else {
this.sendMessage(ws, { this.sendMessage(ws, {
@@ -352,9 +326,8 @@ 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, envVars = {}) { async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) {
/** @type {number|null} */ /** @type {number|null} */
let installationId = null; let installationId = null;
@@ -383,7 +356,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, envVars); await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId);
return; return;
} }
@@ -409,32 +382,19 @@ 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: envWithVars env: {
...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
@@ -537,9 +497,8 @@ 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, envVars = {}) { async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
// Send start message // Send start message
@@ -628,8 +587,7 @@ 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
@@ -749,7 +707,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 = undefined) { startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -855,432 +813,16 @@ 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|undefined} server * @param {ServerInfo|null} 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 = undefined, backupStorage = undefined) { async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
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) {
@@ -1610,7 +1152,6 @@ class ScriptExecutionHandler {
// TerminalHandler removed - not used by current application // TerminalHandler removed - not used by current application
app.prepare().then(() => { app.prepare().then(() => {
console.log('> Next.js app prepared successfully');
const httpServer = createServer(async (req, res) => { const httpServer = createServer(async (req, res) => {
try { try {
// Be sure to pass `true` as the second argument to `url.parse`. // Be sure to pass `true` as the second argument to `url.parse`.
@@ -1618,22 +1159,12 @@ app.prepare().then(() => {
const parsedUrl = parse(req.url || '', true); const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl; const { pathname, query } = parsedUrl;
// Check if this is a WebSocket upgrade request if (pathname === '/ws/script-execution') {
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: // Let Next.js handle all other requests including HMR
// - 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);
@@ -1644,33 +1175,6 @@ 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
@@ -1682,43 +1186,13 @@ 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
if (typeof autoSyncModule.initializeRepositories === 'function') { await initializeRepositories();
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
if (typeof autoSyncModule.initializeAutoSync === 'function') { initializeAutoSync();
console.log('Calling initializeAutoSync...');
autoSyncModule.initializeAutoSync();
}
// Setup graceful shutdown handlers // Setup graceful shutdown handlers
if (typeof autoSyncModule.setupGracefulShutdown === 'function') { setupGracefulShutdown();
console.log('Setting up graceful shutdown...');
autoSyncModule.setupGracefulShutdown();
}
}); });
}).catch((err) => {
console.error('> Failed to start server:', err.message);
console.error('> If you see "Could not find a production build", run: npm run build');
console.error('> Full error:', err);
process.exit(1);
}); });

View File

@@ -1,13 +1,6 @@
"use client"; 'use client';
import { import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -34,12 +27,9 @@ 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 { const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
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) {
@@ -52,11 +42,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;
@@ -78,7 +68,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);
@@ -99,49 +89,44 @@ export function AuthProvider({ children }: AuthProviderProps) {
return checkAuthInternal(0); return checkAuthInternal(0);
}, []); }, []);
const login = async ( const login = async (username: string, password: string): Promise<boolean> => {
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 { const data = await response.json() as { username: string };
username: string;
expirationTime?: number;
};
setIsAuthenticated(true); setIsAuthenticated(true);
setUsername(data.username); setUsername(data.username);
// Set expiration time from login response if available
if (data.expirationTime) { // Check auth again to get expiration time
setExpirationTime(data.expirationTime); // Add a small delay to ensure the httpOnly cookie is available
} await new Promise<void>((resolve) => {
// Don't call checkAuth after login - we already know we're authenticated setTimeout(() => {
// The cookie is set by the server response void checkAuth().then(() => resolve());
}, 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 = document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null); setExpirationTime(null);
@@ -171,7 +156,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;
} }

View File

@@ -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,43 +13,33 @@ interface BackupWarningModalProps {
export function BackupWarningModal({ export function BackupWarningModal({
isOpen, isOpen,
onClose, onClose,
onProceed, onProceed
}: BackupWarningModalProps) { }: BackupWarningModalProps) {
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
id: "backup-warning-modal",
allowEscape: true,
onClose,
});
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-center border-b p-6"> <div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="text-warning h-8 w-8" /> <AlertTriangle className="h-8 w-8 text-warning" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
Backup Failed
</h2>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-muted-foreground mb-6 text-sm"> <p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you The backup failed, but you can still proceed with the update if you wish.
wish. <br /><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.
<br />
<strong className="text-foreground">Warning:</strong> Proceeding
without a backup means you won&apos;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 justify-end gap-3 sm:flex-row"> <div className="flex flex-col sm:flex-row justify-end gap-3">
<Button <Button
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
@@ -62,7 +52,7 @@ export function BackupWarningModal({
onClick={onProceed} onClick={onProceed}
variant="default" variant="default"
size="default" size="default"
className="bg-warning hover:bg-warning/90 w-full sm:w-auto" className="w-full sm:w-auto bg-warning hover:bg-warning/90"
> >
Proceed Anyway Proceed Anyway
</Button> </Button>
@@ -72,3 +62,6 @@ export function BackupWarningModal({
</div> </div>
); );
} }

View File

@@ -1,27 +1,18 @@
"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 { import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
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;
@@ -32,7 +23,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;
} }
@@ -44,25 +35,16 @@ interface ContainerBackups {
} }
export function BackupsTab() { export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>( const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
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<{ const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
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 { const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
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();
@@ -70,14 +52,11 @@ export function BackupsTab() {
}); });
// Poll for restore progress // Poll for restore progress
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery( const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
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(() => {
@@ -88,12 +67,11 @@ 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 = const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? ""; if (lastLog.includes('Restore completed successfully')) {
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);
} }
@@ -105,7 +83,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);
}, },
@@ -115,12 +93,7 @@ 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 = const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
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);
@@ -128,10 +101,8 @@ 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( setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
result.progress?.map((p) => p.message) ?? restoreProgress,
);
setRestoreSuccess(false); setRestoreSuccess(false);
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
@@ -141,7 +112,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([]);
@@ -149,17 +120,16 @@ export function BackupsTab() {
}); });
// Update progress text in modal based on current progress // Update progress text in modal based on current progress
const currentProgressText = const currentProgressText = restoreProgress.length > 0
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?.length) { if (!backupsData.backups || backupsData.backups.length === 0) {
void handleDiscoverBackups(); handleDiscoverBackups();
} }
setHasAutoDiscovered(true); setHasAutoDiscovered(true);
} }
@@ -187,7 +157,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 ?? 0, serverId: selectedBackup.backup.server_id,
}); });
}; };
@@ -202,41 +172,39 @@ 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 = ( const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
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';
} }
}; };
@@ -248,8 +216,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-foreground text-2xl font-bold">Backups</h2> <h2 className="text-2xl font-bold text-foreground">Backups</h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
Discovered backups grouped by container ID Discovered backups grouped by container ID
</p> </p>
</div> </div>
@@ -258,38 +226,31 @@ export function BackupsTab() {
disabled={isDiscovering} disabled={isDiscovering}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<RefreshCw <RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`} {isDiscovering ? 'Discovering...' : 'Discover Backups'}
/>
{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 border-border rounded-lg border p-8 text-center"> <div className="bg-card rounded-lg border border-border p-8 text-center">
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" /> <RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<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 border-border rounded-lg border p-8 text-center"> <div className="bg-card rounded-lg border border-border p-8 text-center">
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-foreground mb-2 text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
No backups found
</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Click &quot;Discover Backups&quot; to scan for backups on your Click "Discover Backups" to scan for backups on your servers.
servers.
</p> </p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}> <Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw <RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
Discover Backups Discover Backups
</Button> </Button>
</div> </div>
@@ -305,35 +266,33 @@ export function BackupsTab() {
return ( return (
<div <div
key={container.container_id} key={container.container_id}
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm" className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
> >
{/* Container header - collapsible */} {/* Container header - collapsible */}
<button <button
onClick={() => toggleContainer(container.container_id)} onClick={() => toggleContainer(container.container_id)}
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors" className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex items-center gap-3 flex-1 min-w-0">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" /> <ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
) : ( ) : (
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" /> <ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)} )}
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-foreground font-semibold"> <span className="font-semibold text-foreground">
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"> <span className="text-muted-foreground">{container.hostname}</span>
{container.hostname}
</span>
</> </>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
{backupCount} {backupCount === 1 ? "backup" : "backups"} {backupCount} {backupCount === 1 ? 'backup' : 'backups'}
</p> </p>
</div> </div>
</div> </div>
@@ -341,30 +300,28 @@ export function BackupsTab() {
{/* Container content - backups list */} {/* Container content - backups list */}
{isExpanded && ( {isExpanded && (
<div className="border-border border-t"> <div className="border-t border-border">
<div className="space-y-3 p-4"> <div className="p-4 space-y-3">
{container.backups.map((backup) => ( {container.backups.map((backup) => (
<div <div
key={backup.id} key={backup.id}
className="bg-muted/50 border-border/50 rounded-lg border p-4" className="bg-muted/50 rounded-lg p-4 border border-border/50"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-foreground font-medium break-all"> <span className="font-medium text-foreground break-all">
{backup.backup_name} {backup.backup_name}
</span> </span>
<Badge <Badge
variant={getStorageTypeBadgeVariant( variant={getStorageTypeBadgeVariant(backup.storage_type)}
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="text-muted-foreground flex flex-wrap items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{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" />
@@ -382,7 +339,7 @@ export function BackupsTab() {
)} )}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<code className="text-muted-foreground text-xs break-all"> <code className="text-xs text-muted-foreground break-all">
{backup.backup_path} {backup.backup_path}
</code> </code>
</div> </div>
@@ -393,19 +350,14 @@ export function BackupsTab() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
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" 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"
> >
Actions Actions
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border w-48"> <DropdownMenuContent className="w-48 bg-card border-border">
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() => handleRestoreClick(backup, container.container_id)}
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"
> >
@@ -434,9 +386,9 @@ export function BackupsTab() {
{/* Error state */} {/* Error state */}
{backupsData && !backupsData.success && ( {backupsData && !backupsData.success && (
<div className="bg-destructive/10 border-destructive rounded-lg border p-4"> <div className="bg-destructive/10 border border-destructive rounded-lg 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>
)} )}
@@ -460,8 +412,7 @@ export function BackupsTab() {
)} )}
{/* Restore Progress Modal */} {/* Restore Progress Modal */}
{(restoreMutation.isPending || {(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
(restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal <LoadingModal
isOpen={true} isOpen={true}
action={currentProgressText} action={currentProgressText}
@@ -477,13 +428,11 @@ export function BackupsTab() {
{/* Restore Success */} {/* Restore Success */}
{restoreSuccess && ( {restoreSuccess && (
<div className="bg-success/10 border-success/20 rounded-lg border p-4"> <div className="bg-success/10 border border-success/20 rounded-lg p-4">
<div className="mb-2 flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="text-success h-5 w-5" /> <CheckCircle className="h-5 w-5 text-success" />
<span className="text-success font-medium"> <span className="font-medium text-success">Restore Completed Successfully</span>
Restore Completed Successfully
</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -497,7 +446,7 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
The container has been restored from backup. The container has been restored from backup.
</p> </p>
</div> </div>
@@ -505,11 +454,11 @@ export function BackupsTab() {
{/* Restore Error */} {/* Restore Error */}
{restoreError && ( {restoreError && (
<div className="bg-error/10 border-error/20 rounded-lg border p-4"> <div className="bg-error/10 border border-error/20 rounded-lg p-4">
<div className="mb-2 flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="text-error h-5 w-5" /> <AlertCircle className="h-5 w-5 text-error" />
<span className="text-error font-medium">Restore Failed</span> <span className="font-medium text-error">Restore Failed</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -523,11 +472,13 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-sm">{restoreError}</p> <p className="text-sm text-muted-foreground">
{restoreError}
</p>
{restoreProgress.length > 0 && ( {restoreProgress.length > 0 && (
<div className="mt-2 space-y-1"> <div className="space-y-1 mt-2">
{restoreProgress.map((message, index) => ( {restoreProgress.map((message, index) => (
<p key={index} className="text-muted-foreground text-sm"> <p key={index} className="text-sm text-muted-foreground">
{message} {message}
</p> </p>
))} ))}
@@ -549,3 +500,4 @@ export function BackupsTab() {
</div> </div>
); );
} }

View File

@@ -187,10 +187,9 @@ export function CategorySidebar({
'Miscellaneous': 'box' 'Miscellaneous': 'box'
}; };
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically // Sort categories by count (descending) and then 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);

View File

@@ -1,129 +0,0 @@
'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>
);
}

View File

@@ -1,899 +0,0 @@
'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>
);
}

View File

@@ -1,53 +1,41 @@
"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 type { Server } from "~/types/server"; import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
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?: Server, server?: any,
) => void; ) => void;
} }
export function DownloadedScriptsTab({ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
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 { const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
data: scriptCardsData, const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
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
@@ -55,7 +43,7 @@ export function DownloadedScriptsTab({
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();
@@ -65,11 +53,9 @@ export function DownloadedScriptsTab({
// 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()) as { const filtersData = await filtersResponse.json();
filters?: Partial<FilterState>;
};
if (filtersData.filters) { if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters)); setFilters(mergeFiltersWithDefaults(filtersData.filters));
} }
@@ -77,20 +63,16 @@ export function DownloadedScriptsTab({
} }
// 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 ( if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
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);
} }
@@ -105,15 +87,15 @@ export function DownloadedScriptsTab({
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);
} }
}; };
@@ -128,15 +110,15 @@ export function DownloadedScriptsTab({
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);
} }
}; };
@@ -147,14 +129,13 @@ export function DownloadedScriptsTab({
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
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)
@@ -164,13 +145,13 @@ export function DownloadedScriptsTab({
// 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: ScriptCardType) => { scriptCardsData.cards?.forEach(script => {
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
}); });
@@ -184,22 +165,20 @@ export function DownloadedScriptsTab({
// 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 => const normalizeId = (s?: string): string => (s ?? '')
(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 = const hasLocalVersion = localScriptsData?.scripts?.some(local => {
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)
@@ -212,10 +191,7 @@ export function DownloadedScriptsTab({
// 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 = const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
(script as any)?.install_basenames?.some(
(base: string) => normalizeId(base) === normalizedLocal,
) ?? false;
return matchesInstallBasename; return matchesInstallBasename;
}) ?? false; }) ?? false;
@@ -224,7 +200,7 @@ export function DownloadedScriptsTab({
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)
@@ -239,15 +215,11 @@ export function DownloadedScriptsTab({
}); });
// 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 ( if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
typeof categoryName === "string" &&
counts[categoryName] !== undefined &&
!countedCategories.has(categoryName)
) {
countedCategories.add(categoryName); countedCategories.add(categoryName);
counts[categoryName]++; counts[categoryName]++;
} }
@@ -267,13 +239,13 @@ export function DownloadedScriptsTab({
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);
}); });
@@ -282,7 +254,7 @@ export function DownloadedScriptsTab({
// 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
@@ -292,7 +264,7 @@ export function DownloadedScriptsTab({
// 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;
@@ -301,22 +273,20 @@ export function DownloadedScriptsTab({
// 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( return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
(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;
@@ -337,13 +307,13 @@ export function DownloadedScriptsTab({
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) {
@@ -357,15 +327,15 @@ export function DownloadedScriptsTab({
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;
@@ -373,9 +343,7 @@ export function DownloadedScriptsTab({
// Calculate filter counts for FilterBar // Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => { const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter( const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
(script) => script?.updateable,
).length;
return { installedCount: downloadedScripts.length, updatableCount }; return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]); }, [downloadedScripts]);
@@ -395,9 +363,9 @@ export function DownloadedScriptsTab({
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);
@@ -419,38 +387,22 @@ export function DownloadedScriptsTab({
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="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="text-muted-foreground ml-2"> <span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
Loading downloaded scripts...
</span>
</div> </div>
); );
} }
if (githubError || localError) { if (githubError || localError) {
return ( return (
<div className="py-12 text-center"> <div className="text-center py-12">
<div className="text-error mb-4"> <div className="text-error mb-4">
<svg <svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-2 h-12 w-12" <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" />
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"> <p className="text-lg font-medium">Failed to load downloaded scripts</p>
Failed to load downloaded scripts <p className="text-sm text-muted-foreground mt-1">
</p> {githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
<p className="text-muted-foreground mt-1 text-sm">
{githubError?.message ??
localError?.message ??
"Unknown error occurred"}
</p> </p>
</div> </div>
<Button <Button
@@ -467,25 +419,14 @@ export function DownloadedScriptsTab({
if (!downloadedScripts?.length) { if (!downloadedScripts?.length) {
return ( return (
<div className="py-12 text-center"> <div className="text-center py-12">
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<svg <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-4 h-12 w-12" <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" />
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-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
Scripts tab to download some scripts.
</p> </p>
</div> </div>
</div> </div>
@@ -494,9 +435,12 @@ export function DownloadedScriptsTab({
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="order-2 flex-shrink-0 lg:order-1"> <div className="flex-shrink-0 order-2 lg:order-1">
<CategorySidebar <CategorySidebar
categories={categories} categories={categories}
categoryCounts={categoryCounts} categoryCounts={categoryCounts}
@@ -507,7 +451,7 @@ export function DownloadedScriptsTab({
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}> <div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */} {/* Enhanced Filter Bar */}
<FilterBar <FilterBar
filters={filters} filters={filters}
@@ -520,41 +464,26 @@ export function DownloadedScriptsTab({
/> />
{/* View Toggle */} {/* View Toggle */}
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} /> <ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Scripts Grid */} {/* Scripts Grid */}
{filteredScripts.length === 0 && {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
(filters.searchQuery || <div className="text-center py-12">
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 <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-4 h-12 w-12" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
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"> <p className="text-lg font-medium">No matching downloaded scripts found</p>
No matching downloaded scripts found <p className="text-sm text-muted-foreground mt-1">
</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="mt-4 flex justify-center gap-2"> <div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
handleFiltersChange({ ...filters, searchQuery: "" })
}
variant="default" variant="default"
size="default" size="default"
> >
@@ -573,16 +502,17 @@ export function DownloadedScriptsTab({
</div> </div>
</div> </div>
</div> </div>
) : viewMode === "card" ? ( ) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> viewMode === 'card' ? (
<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
@@ -597,12 +527,12 @@ export function DownloadedScriptsTab({
<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
@@ -613,6 +543,7 @@ export function DownloadedScriptsTab({
); );
})} })}
</div> </div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -2,31 +2,26 @@
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, envVars?: EnvVars) => void; onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
scriptName: string; scriptName: string;
script?: Script | null;
} }
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) { export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: 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) {
@@ -69,25 +64,19 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
} }
}; };
const handleConfigModeSelect = (mode: 'default' | 'advanced') => { const handleExecute = () => {
if (!selectedServer) { if (!selectedServer) {
setError('Please select a server first'); setError('Please select a server for SSH execution');
return; return;
} }
setConfigMode(mode);
setConfigModalOpen(true);
};
const handleConfigConfirm = (envVars: EnvVars) => { onExecute('ssh', selectedServer);
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
}; };
@@ -175,31 +164,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
</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
@@ -209,6 +173,13 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
> >
Cancel Cancel
</Button> </Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
Install
</Button>
</div> </div>
</div> </div>
) : ( ) : (
@@ -233,33 +204,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
/> />
</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
@@ -269,6 +213,15 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
> >
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>
)} )}
@@ -281,16 +234,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
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}
/>
</> </>
); );
} }

View File

@@ -3,17 +3,7 @@
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 { import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
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";
@@ -63,7 +53,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 = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -108,33 +98,29 @@ export function FilterBar({
}; };
return ( return (
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6"> <div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* 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="text-muted-foreground flex items-center space-x-2 text-sm"> <div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary 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-foreground text-lg font-medium"> <h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
Filter Scripts
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
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="text-muted-foreground hover:text-foreground h-8 w-8" className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"} title={isMinimized ? "Expand filters" : "Minimize filters"}
> >
<svg <svg
@@ -160,10 +146,10 @@ export function FilterBar({
<> <>
{/* Search Bar */} {/* Search Bar */}
<div className="mb-4"> <div className="mb-4">
<div className="relative w-full max-w-md"> <div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg <svg
className="text-muted-foreground h-5 w-5" className="h-5 w-5 text-muted-foreground"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -181,13 +167,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="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" 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"
/> />
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => updateFilters({ searchQuery: "" })} onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3" className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -208,7 +194,7 @@ export function FilterBar({
</div> </div>
{/* Filter Buttons */} {/* Filter Buttons */}
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3"> <div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */} {/* Updateable Filter */}
<Button <Button
onClick={() => { onClick={() => {
@@ -222,12 +208,12 @@ export function FilterBar({
}} }}
variant="outline" variant="outline"
size="default" size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${ className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true : filters.showUpdatable === true
? "border-success/20 bg-success/10 text-success border" ? "border border-success/20 bg-success/10 text-success"
: "border-destructive/20 bg-destructive/10 text-destructive border" : "border border-destructive/20 bg-destructive/10 text-destructive"
}`} }`}
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
@@ -240,10 +226,10 @@ export function FilterBar({
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)} onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline" variant="outline"
size="default" size="default"
className={`flex w-full items-center justify-center space-x-2 ${ className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0 filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border-primary/20 bg-primary/10 text-primary border" : "border border-primary/20 bg-primary/10 text-primary"
}`} }`}
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
@@ -264,14 +250,14 @@ export function FilterBar({
</Button> </Button>
{isTypeDropdownOpen && ( {isTypeDropdownOpen && (
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg"> <div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2"> <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="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2" className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
> >
<input <input
type="checkbox" type="checkbox"
@@ -292,17 +278,17 @@ export function FilterBar({
}); });
} }
}} }}
className="border-input text-primary focus:ring-primary rounded" className="rounded border-input text-primary focus:ring-primary"
/> />
<IconComponent className="h-4 w-4" /> <IconComponent className="h-4 w-4" />
<span className="text-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
{type.label} {type.label}
</span> </span>
</label> </label>
); );
})} })}
</div> </div>
<div className="border-border border-t p-2"> <div className="border-t border-border p-2">
<Button <Button
onClick={() => { onClick={() => {
updateFilters({ selectedTypes: [] }); updateFilters({ selectedTypes: [] });
@@ -310,7 +296,7 @@ export function FilterBar({
}} }}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start" className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
> >
Clear all Clear all
</Button> </Button>
@@ -320,11 +306,8 @@ 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.length > 1 && enabledRepos.map((repo) => {
enabledRepos.map((repo: { id: number; url: string }) => { const isSelected = filters.selectedRepositories.includes(repo.url);
const repoUrl = String(repo.url);
const isSelected =
filters.selectedRepositories.includes(repoUrl);
return ( return (
<Button <Button
key={repo.id} key={repo.id}
@@ -333,27 +316,25 @@ export function FilterBar({
if (isSelected) { if (isSelected) {
// Remove repository from selection // Remove repository from selection
updateFilters({ updateFilters({
selectedRepositories: currentSelected.filter( selectedRepositories: currentSelected.filter(url => url !== repo.url)
(url) => url !== repoUrl,
),
}); });
} else { } else {
// Add repository to selection // Add repository to selection
updateFilters({ updateFilters({
selectedRepositories: [...currentSelected, repoUrl], selectedRepositories: [...currentSelected, repo.url]
}); });
} }
}} }}
variant="outline" variant="outline"
size="default" size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${ className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
isSelected isSelected
? "border-primary/20 bg-primary/10 text-primary border" ? "border border-primary/20 bg-primary/10 text-primary"
: "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(repoUrl)}</span> <span>{getRepoName(repo.url)}</span>
</Button> </Button>
); );
})} })}
@@ -364,16 +345,14 @@ export function FilterBar({
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)} onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline" variant="outline"
size="default" size="default"
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" className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
> >
{filters.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> <span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</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"
@@ -390,17 +369,15 @@ export function FilterBar({
</Button> </Button>
{isSortDropdownOpen && ( {isSortDropdownOpen && (
<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="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2"> <div className="p-2">
<button <button
onClick={() => { onClick={() => {
updateFilters({ sortBy: "name" }); updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false); setIsSortDropdownOpen(false);
}} }}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${ className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`} }`}
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -411,10 +388,8 @@ export function FilterBar({
updateFilters({ sortBy: "created" }); updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false); setIsSortDropdownOpen(false);
}} }}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${ className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`} }`}
> >
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@@ -434,7 +409,7 @@ export function FilterBar({
} }
variant="outline" variant="outline"
size="default" size="default"
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" className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
> >
{filters.sortOrder === "asc" ? ( {filters.sortOrder === "asc" ? (
<> <>
@@ -479,16 +454,18 @@ export function FilterBar({
</div> </div>
{/* Filter Summary and Clear All */} {/* Filter Summary and Clear All */}
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm"> <div className="text-sm text-muted-foreground">
{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="text-info font-medium">(filtered)</span> <span className="font-medium text-info">
(filtered)
</span>
)} )}
</span> </span>
)} )}
@@ -496,17 +473,9 @@ export function FilterBar({
{/* Filter Persistence Status */} {/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && ( {!isLoadingFilters && saveFiltersEnabled && (
<div className="text-success flex items-center space-x-1 text-xs"> <div className="flex items-center space-x-1 text-xs text-success">
<svg <svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
className="h-3 w-3" <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" />
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>
@@ -518,7 +487,7 @@ export function FilterBar({
onClick={clearAllFilters} onClick={clearAllFilters}
variant="ghost" variant="ghost"
size="sm" size="sm"
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" 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"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"

View File

@@ -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>© 2026 PVE Scripts Local</span> <span>© 2024 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

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, startTransition } 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 { Input } from './ui/input'; import { Input } from './ui/input';
@@ -159,13 +159,9 @@ 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]);

View File

@@ -1,45 +1,34 @@
"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({ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
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, { useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
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 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<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"> <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">
{/* Close button - only show when complete */} {/* Close button - only show when complete */}
{isComplete && onClose && ( {isComplete && onClose && (
<Button <Button
@@ -55,31 +44,27 @@ export function LoadingModal({
<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="text-success h-12 w-12" /> <CheckCircle className="h-12 w-12 text-success" />
) : ( ) : (
<> <>
<Loader2 className="text-primary h-12 w-12 animate-spin" /> <Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div> <div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</> </>
)} )}
</div> </div>
{/* Action text - displayed prominently */}
{action && (
<p className="text-foreground text-base font-medium">{action}</p>
)}
{/* Static title text */} {/* Static title text */}
{title && <p className="text-muted-foreground text-sm">{title}</p>} {title && (
<p className="text-sm text-muted-foreground">
{title}
</p>
)}
{/* Log output */} {/* Log output */}
{logs.length > 0 && ( {logs.length > 0 && (
<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"> <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">
{logs.map((log, index) => ( {logs.map((log, index) => (
<div <div key={index} className="mb-1 whitespace-pre-wrap break-words">
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log} {log}
</div> </div>
))} ))}
@@ -89,15 +74,9 @@ export function LoadingModal({
{!isComplete && ( {!isComplete && (
<div className="flex space-x-1"> <div className="flex space-x-1">
<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"></div>
<div <div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
className="bg-primary h-2 w-2 animate-bounce rounded-full" <div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
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>
@@ -105,3 +84,4 @@ export function LoadingModal({
</div> </div>
); );
} }

View File

@@ -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,25 +19,23 @@ 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 { server?: string }).server ?? null; const pbsIpFromStorage = (storage as any).server || null;
const pbsDatastoreFromStorage = const pbsDatastoreFromStorage = (storage as any).datastore || null;
(storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials // Fetch existing credentials
const { data: credentialData, refetch } = const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
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
@@ -45,18 +43,16 @@ export function PBSCredentialsModal({
if (isOpen) { if (isOpen) {
if (credentialData?.success && credentialData.credential) { if (credentialData?.success && credentialData.credential) {
// Load existing credentials // Load existing credentials
setPbsIp(String(credentialData.credential.pbs_ip)); setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(String(credentialData.credential.pbs_datastore)); setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(""); // Don't show password setPbsPassword(''); // Don't show password
setPbsFingerprint( setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
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]);
@@ -67,7 +63,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}`);
}, },
}); });
@@ -78,22 +74,18 @@ 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, { useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
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;
} }
@@ -114,11 +106,7 @@ export function PBSCredentialsModal({
}; };
const handleDelete = async () => { const handleDelete = async () => {
if ( if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
!confirm(
"Are you sure you want to delete the PBS credentials for this storage?",
)
) {
return; return;
} }
@@ -138,13 +126,13 @@ export function PBSCredentialsModal({
const hasCredentials = credentialData?.success && credentialData.credential; const hasCredentials = credentialData?.success && credentialData.credential;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <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">
<Lock className="text-primary h-6 w-6" /> <Lock className="h-6 w-6 text-primary" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">
PBS Credentials - {storage.name} PBS Credentials - {storage.name}
</h2> </h2>
</div> </div>
@@ -154,18 +142,8 @@ export function PBSCredentialsModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-5 w-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
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>
@@ -175,10 +153,7 @@ 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 <label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
htmlFor="storage-name"
className="text-foreground mb-1 block text-sm font-medium"
>
Storage Name Storage Name
</label> </label>
<input <input
@@ -186,16 +161,13 @@ export function PBSCredentialsModal({
id="storage-name" id="storage-name"
value={storage.name} value={storage.name}
disabled disabled
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm" className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
/> />
</div> </div>
{/* PBS IP */} {/* PBS IP */}
<div> <div>
<label <label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
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
@@ -205,20 +177,17 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsIp(e.target.value)} onChange={(e) => setPbsIp(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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"
placeholder="e.g., 10.10.10.226" placeholder="e.g., 10.10.10.226"
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
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 <label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
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
@@ -228,22 +197,18 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsDatastore(e.target.value)} onChange={(e) => setPbsDatastore(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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"
placeholder="e.g., NAS03-ISCSI-BACKUP" placeholder="e.g., NAS03-ISCSI-BACKUP"
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
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 <label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
htmlFor="pbs-password" Password {!hasCredentials && <span className="text-error">*</span>}
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"
@@ -252,24 +217,17 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsPassword(e.target.value)} onChange={(e) => setPbsPassword(e.target.value)}
required={!hasCredentials} required={!hasCredentials}
disabled={isLoading} disabled={isLoading}
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" 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"
placeholder={ placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
hasCredentials
? "Enter new password (leave empty to keep existing)"
: "Enter PBS password"
}
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
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 <label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
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
@@ -279,37 +237,35 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsFingerprint(e.target.value)} onChange={(e) => setPbsFingerprint(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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"
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="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
Server fingerprint for auto-acceptance. You can find this on Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
your PBS dashboard by clicking the &quot;Show Fingerprint&quot;
button.
</p> </p>
</div> </div>
{/* Status indicator */} {/* Status indicator */}
{hasCredentials && ( {hasCredentials && (
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3"> <div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
<CheckCircle className="text-success h-4 w-4" /> <CheckCircle className="h-4 w-4 text-success" />
<span className="text-success text-sm font-medium"> <span className="text-sm text-success 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 justify-end gap-3 pt-4 sm:flex-row"> <div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
{hasCredentials && ( {hasCredentials && (
<Button <Button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="order-3 w-full sm:w-auto" className="w-full sm:w-auto order-3"
> >
<AlertCircle className="mr-2 h-4 w-4" /> <AlertCircle className="h-4 w-4 mr-2" />
Delete Credentials Delete Credentials
</Button> </Button>
)} )}
@@ -318,7 +274,7 @@ export function PBSCredentialsModal({
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="order-2 w-full sm:w-auto" className="w-full sm:w-auto order-2"
> >
Cancel Cancel
</Button> </Button>
@@ -326,13 +282,9 @@ export function PBSCredentialsModal({
type="submit" type="submit"
variant="default" variant="default"
disabled={isLoading} disabled={isLoading}
className="order-1 w-full sm:w-auto" className="w-full sm:w-auto order-1"
> >
{isLoading {isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
? "Saving..."
: hasCredentials
? "Update Credentials"
: "Save Credentials"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -341,3 +293,4 @@ export function PBSCredentialsModal({
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, startTransition } 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';
@@ -47,9 +47,7 @@ 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]);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState } 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,10 +9,6 @@ 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) => {
@@ -20,87 +16,29 @@ 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');
// Only reload if this was triggered by user action // Reload the page after successful sync
if (isUserInitiatedRef.current && !hasReloadedRef.current) { setTimeout(() => {
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
if (messageTimeoutRef.current) { setTimeout(() => setSyncMessage(null), 3000);
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}`);
if (messageTimeoutRef.current) { setTimeout(() => setSyncMessage(null), 3000);
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">

View File

@@ -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,12 +12,7 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCard({ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -32,8 +27,8 @@ export function ScriptCard({
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ""; if (!url) return '';
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -42,36 +37,32 @@ export function ScriptCard({
return ( return (
<div <div
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" className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
onClick={() => onClick(script)} 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={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${ className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
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="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path <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" />
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="flex flex-1 flex-col p-6"> <div className="p-6 flex-1 flex flex-col">
{/* Header with logo and name */} {/* Header with logo and name */}
<div className="mb-4 flex items-start space-x-4"> <div className="flex items-start space-x-4 mb-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
@@ -79,31 +70,28 @@ export function ScriptCard({
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={48} width={48}
height={48} height={48}
className="h-12 w-12 rounded-lg object-contain" className="w-12 h-12 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg"> <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<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="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<h3 className="text-foreground truncate text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground truncate">
{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 flex-wrap items-center gap-1 space-x-2"> <div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? "unknown"} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span <span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
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>
)} )}
@@ -111,17 +99,13 @@ export function ScriptCard({
{/* Download Status */} {/* Download Status */}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div <div className={`w-2 h-2 rounded-full ${
className={`h-2 w-2 rounded-full ${ script.isDownloaded ? 'bg-success' : 'bg-error'
script.isDownloaded ? "bg-success" : "bg-error" }`}></div>
}`} <span className={`text-xs font-medium ${
></div> script.isDownloaded ? 'text-success' : 'text-error'
<span }`}>
className={`text-xs font-medium ${ {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -129,8 +113,8 @@ export function ScriptCard({
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm"> <p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || "No description available"} {script.description || 'No description available'}
</p> </p>
{/* Footer with website link */} {/* Footer with website link */}
@@ -140,22 +124,12 @@ export function ScriptCard({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium" className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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>

View File

@@ -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,12 +12,7 @@ interface ScriptCardListProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCardList({ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardListProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -32,27 +27,26 @@ export function ScriptCardList({
}; };
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) if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
return "Uncategorized"; return script.categoryNames.join(', ');
return script.categoryNames.join(", ");
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ""; if (!url) return '';
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -61,34 +55,30 @@ export function ScriptCardList({
return ( return (
<div <div
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md" className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
onClick={() => onClick(script)} 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={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${ className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
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="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path <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" />
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">
@@ -98,49 +88,42 @@ export function ScriptCardList({
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={56} width={56}
height={56} height={56}
className="h-14 w-14 rounded-lg object-contain" className="w-14 h-14 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg"> <div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
<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="min-w-0 flex-1"> <div className="flex-1 min-w-0">
{/* Header Row */} {/* Header Row */}
<div className="mb-3 flex items-start justify-between"> <div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<h3 className="text-foreground mb-2 truncate text-xl font-semibold"> <h3 className="text-xl font-semibold text-foreground truncate mb-2">
{script.name || "Unnamed Script"} {script.name || 'Unnamed Script'}
</h3> </h3>
<div className="flex flex-wrap items-center gap-2 space-x-3"> <div className="flex items-center space-x-3 flex-wrap gap-2">
<TypeBadge type={script.type ?? "unknown"} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span <span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
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 <div className={`w-2 h-2 rounded-full ${
className={`h-2 w-2 rounded-full ${ script.isDownloaded ? 'bg-success' : 'bg-error'
script.isDownloaded ? "bg-success" : "bg-error" }`}></div>
}`} <span className={`text-sm font-medium ${
></div> script.isDownloaded ? 'text-success' : 'text-error'
<span }`}>
className={`text-sm font-medium ${ {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -152,128 +135,68 @@ export function ScriptCardList({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium" className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-4 w-4" <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" />
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 mb-4 line-clamp-2 text-sm"> <p className="text-muted-foreground text-sm mb-4 line-clamp-2">
{script.description || "No description available"} {script.description || 'No description available'}
</p> </p>
{/* Metadata Row */} {/* Metadata Row */}
<div className="text-muted-foreground flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<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 <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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 <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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 <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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.charAt(0).toUpperCase() + script.os.slice(1)
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 <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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 <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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>

View File

@@ -4,20 +4,14 @@ 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 { import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
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;
@@ -27,8 +21,7 @@ interface ScriptDetailModalProps {
scriptPath: string, scriptPath: string,
scriptName: string, scriptName: string,
mode?: "local" | "ssh", mode?: "local" | "ssh",
server?: Server, server?: any,
envVars?: Record<string, string | number | boolean>,
) => void; ) => void;
} }
@@ -38,11 +31,7 @@ export function ScriptDetailModal({
onClose, onClose,
onInstallScript, onInstallScript,
}: ScriptDetailModalProps) { }: ScriptDetailModalProps) {
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
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);
@@ -51,9 +40,7 @@ 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>( const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
null,
);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -74,11 +61,7 @@ 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
@@ -156,9 +139,8 @@ 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 = const hasMultipleVariants = installMethods.filter(method =>
installMethods.filter( method.type === 'default' || method.type === 'alpine'
(method) => method.type === "default" || method.type === "alpine",
).length > 1; ).length > 1;
if (hasMultipleVariants) { if (hasMultipleVariants) {
@@ -167,13 +149,9 @@ 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( const defaultMethod = installMethods.find(method => method.type === 'default');
(method) => method.type === "default",
);
const firstMethod = installMethods[0]; const firstMethod = installMethods[0];
setSelectedVersionType( setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
defaultMethod?.type ?? firstMethod?.type ?? "default",
);
setExecutionModeOpen(true); setExecutionModeOpen(true);
} }
}; };
@@ -184,22 +162,23 @@ export function ScriptDetailModal({
setExecutionModeOpen(true); setExecutionModeOpen(true);
}; };
const handleExecuteScript = (mode: "local" | "ssh", server?: Server, envVars?: Record<string, string | number | boolean>) => { const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
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 = const scriptMethod = script.install_methods?.find(
script.install_methods?.find(
(method) => method.type === versionType && method.script, (method) => method.type === versionType && method.script,
) ?? script.install_methods?.find((method) => method.script); ) || script.install_methods?.find(
(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, server info, and envVars to the parent // Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server, envVars); onInstallScript(scriptPath, scriptName, mode, server);
onClose(); // Close the modal when starting installation onClose(); // Close the modal when starting installation
} }
@@ -224,31 +203,31 @@ export function ScriptDetailModal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-card 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"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6"> <div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4"> <div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
src={script.logo} src={script.logo}
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={64} width={64}
height={64} height={64}
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16" className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16"> <div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
<span className="text-muted-foreground text-lg font-semibold sm:text-2xl"> <span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()} {script.name.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl"> <h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
{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">
@@ -260,13 +239,11 @@ export function ScriptDetailModal({
href={script.repository_url} href={script.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
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" 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"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`} title={`Source: ${script.repository_url}`}
> >
{/github\.com\/([^\/]+)\/([^\/]+)/ {script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
.exec(script.repository_url)?.[0]
?.replace("https://", "") ?? script.repository_url}
</a> </a>
)} )}
</div> </div>
@@ -274,12 +251,12 @@ export function ScriptDetailModal({
{/* Interface Port*/} {/* Interface Port*/}
{script.interface_port && ( {script.interface_port && (
<div className="ml-3 flex-shrink-0 sm:ml-4"> <div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2"> <div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm"> <span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
Port: Port:
</span> </span>
<span className="text-foreground font-mono text-sm font-semibold sm:text-base"> <span className="text-sm sm:text-base font-semibold text-foreground font-mono">
{script.interface_port} {script.interface_port}
</span> </span>
</div> </div>
@@ -292,7 +269,7 @@ export function ScriptDetailModal({
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0" className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
> >
<svg <svg
className="h-5 w-5 sm:h-6 sm:w-6" className="h-5 w-5 sm:h-6 sm:w-6"
@@ -311,7 +288,7 @@ export function ScriptDetailModal({
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<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"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
{/* Install Button - only show if script files exist */} {/* Install Button - only show if script files exist */}
{scriptFilesData?.success && {scriptFilesData?.success &&
scriptFilesData.ctExists && scriptFilesData.ctExists &&
@@ -320,7 +297,7 @@ export function ScriptDetailModal({
onClick={handleInstallScript} onClick={handleInstallScript}
variant="outline" variant="outline"
size="default" size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto" className="w-full sm:w-auto flex items-center justify-center space-x-2"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -346,7 +323,7 @@ export function ScriptDetailModal({
onClick={handleViewScript} onClick={handleViewScript}
variant="outline" variant="outline"
size="default" size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto" className="w-full sm:w-auto flex items-center justify-center space-x-2"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -388,7 +365,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
? "bg-muted text-muted-foreground cursor-not-allowed" ? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-success text-success-foreground hover:bg-success/90" : "bg-success text-success-foreground hover:bg-success/90"
}`} }`}
> >
@@ -422,7 +399,7 @@ export function ScriptDetailModal({
return ( return (
<button <button
disabled disabled
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" 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"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -448,7 +425,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
? "bg-muted text-muted-foreground cursor-not-allowed" ? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-warning text-warning-foreground hover:bg-warning/90" : "bg-warning text-warning-foreground hover:bg-warning/90"
}`} }`}
> >
@@ -488,7 +465,7 @@ export function ScriptDetailModal({
disabled={isDeleting} disabled={isDeleting}
variant="destructive" variant="destructive"
size="default" size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto" className="w-full sm:w-auto flex items-center justify-center space-x-2"
> >
{isDeleting ? ( {isDeleting ? (
<> <>
@@ -518,12 +495,12 @@ export function ScriptDetailModal({
</div> </div>
{/* Content */} {/* Content */}
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6"> <div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* Script Files Status */} {/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && ( {(scriptFilesLoading || comparisonLoading) && (
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Loading script status...</span> <span>Loading script status...</span>
</div> </div>
</div> </div>
@@ -546,8 +523,8 @@ export function ScriptDetailModal({
} }
return ( return (
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<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 flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`} className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
@@ -570,10 +547,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>
@@ -583,54 +560,11 @@ 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="text-muted-foreground mt-2 text-xs break-words"> <div className="mt-2 text-xs text-muted-foreground break-words">
Files: {scriptFilesData.files.join(", ")} Files: {scriptFilesData.files.join(", ")}
</div> </div>
)} )}
@@ -640,17 +574,17 @@ export function ScriptDetailModal({
{/* Load Message */} {/* Load Message */}
{loadMessage && ( {loadMessage && (
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage} {loadMessage}
</div> </div>
)} )}
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg"> <h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
Description Description
</h3> </h3>
<p className="text-muted-foreground text-sm sm:text-base"> <p className="text-sm sm:text-base text-muted-foreground">
{script.description} {script.description}
</p> </p>
</div> </div>
@@ -658,50 +592,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="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Basic Information Basic Information
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Slug Slug
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.slug} {script.slug}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Date Created Date Created
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.date_created} {script.date_created}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Categories Categories
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.categories.join(", ")} {script.categories.join(", ")}
</dd> </dd>
</div> </div>
{script.interface_port && ( {script.interface_port && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Interface Port Interface Port
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.interface_port} {script.interface_port}
</dd> </dd>
</div> </div>
)} )}
{script.config_path && ( {script.config_path && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Config Path Config Path
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.config_path} {script.config_path}
</dd> </dd>
</div> </div>
@@ -710,13 +644,13 @@ export function ScriptDetailModal({
</div> </div>
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Links Links
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.website && ( {script.website && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Website Website
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -724,7 +658,7 @@ export function ScriptDetailModal({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:text-primary/80 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.website} {script.website}
</a> </a>
@@ -733,7 +667,7 @@ export function ScriptDetailModal({
)} )}
{script.documentation && ( {script.documentation && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Documentation Documentation
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -741,7 +675,7 @@ export function ScriptDetailModal({
href={script.documentation} href={script.documentation}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:text-primary/80 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.documentation} {script.documentation}
</a> </a>
@@ -757,26 +691,26 @@ export function ScriptDetailModal({
script.type !== "pve" && script.type !== "pve" &&
script.type !== "addon" && ( script.type !== "addon" && (
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Install Methods Install Methods
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div <div
key={index} key={index}
className="border-border bg-card rounded-lg border p-3 sm:p-4" className="rounded-lg border border-border bg-card p-3 sm:p-4"
> >
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0"> <div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
<h4 className="text-foreground text-sm font-medium capitalize sm:text-base"> <h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
{method.type} {method.type}
</h4> </h4>
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm"> <span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
{method.script} {method.script}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
CPU CPU
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -784,7 +718,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
RAM RAM
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -792,7 +726,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
HDD HDD
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -800,7 +734,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
OS OS
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -818,26 +752,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ?? {(script.default_credentials.username ??
script.default_credentials.password) && ( script.default_credentials.password) && (
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Default Credentials Default Credentials
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.default_credentials.username && ( {script.default_credentials.username && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Username Username
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{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-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Password Password
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.default_credentials.password} {script.default_credentials.password}
</dd> </dd>
</div> </div>
@@ -849,7 +783,7 @@ export function ScriptDetailModal({
{/* Notes */} {/* Notes */}
{script.notes.length > 0 && ( {script.notes.length > 0 && (
<div> <div>
<h3 className="text-foreground mb-3 text-lg font-semibold"> <h3 className="mb-3 text-lg font-semibold text-foreground">
Notes Notes
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@@ -864,17 +798,14 @@ 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-warning bg-warning/10 text-warning border-l-4" ? "border-l-4 border-warning bg-warning/10 text-warning"
: noteType === "error" : noteType === "error"
? "border-destructive bg-destructive/10 text-destructive border-l-4" ? "border-l-4 border-destructive bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">
<NoteBadge <NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
noteType={noteType as "info" | "warning" | "error"}
className="mr-2 flex-shrink-0"
>
{noteType} {noteType}
</NoteBadge> </NoteBadge>
<span>{noteText}</span> <span>{noteText}</span>
@@ -906,13 +837,7 @@ export function ScriptDetailModal({
<TextViewer <TextViewer
scriptName={ scriptName={
script.install_methods script.install_methods
?.find( ?.find((method) => method.script?.startsWith("ct/"))
(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`
} }
@@ -936,7 +861,6 @@ 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}

View File

@@ -33,7 +33,6 @@ 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 {
@@ -46,7 +45,6 @@ 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;
@@ -72,7 +70,6 @@ export function ScriptInstallationCard({
onCancel, onCancel,
onUpdate, onUpdate,
onBackup, onBackup,
onClone,
onShell, onShell,
onDelete, onDelete,
isUpdating, isUpdating,
@@ -303,7 +300,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.is_vm && ( {script.container_id && (
<DropdownMenuItem <DropdownMenuItem
onClick={onUpdate} onClick={onUpdate}
disabled={containerStatus === 'stopped'} disabled={containerStatus === 'stopped'}
@@ -321,15 +318,6 @@ 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}

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import type { Script } from "../../types/script"; import type { Script, ScriptInstallMethod } 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,29 +12,16 @@ interface ScriptVersionModalProps {
script: Script | null; script: Script | null;
} }
export function ScriptVersionModal({ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
isOpen, useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
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( const defaultMethod = installMethods.find(method => method.type === 'default');
(method) => method.type === "default", const alpineMethod = installMethods.find(method => method.type === 'alpine');
);
const alpineMethod = installMethods.find(
(method) => method.type === "alpine",
);
const handleConfirm = () => { const handleConfirm = () => {
if (selectedVersion) { if (selectedVersion) {
@@ -48,29 +35,19 @@ export function ScriptVersionModal({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-foreground text-xl font-bold">Select Version</h2> <h2 className="text-xl font-bold text-foreground">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 <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-6 w-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
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>
@@ -78,12 +55,11 @@ export function ScriptVersionModal({
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-foreground mb-2 text-lg font-medium"> <h3 className="text-lg font-medium text-foreground mb-2">
Choose a version for &quot;{script.name}&quot; Choose a version for &quot;{script.name}&quot;
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different Select the version you want to install. Each version has different resource requirements.
resource requirements.
</p> </p>
</div> </div>
@@ -91,29 +67,25 @@ export function ScriptVersionModal({
{/* 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="mb-3 flex items-center space-x-3"> <div className="flex items-center space-x-3 mb-3">
<div <div
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${ className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === "default" selectedVersion === 'default'
? "border-primary bg-primary" ? 'border-primary bg-primary'
: "border-border" : 'border-border'
}`} }`}
> >
{selectedVersion === "default" && ( {selectedVersion === 'default' && (
<svg <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
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"
@@ -122,34 +94,27 @@ export function ScriptVersionModal({
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-foreground text-base font-semibold capitalize"> <h4 className="text-base font-semibold text-foreground capitalize">
{defaultMethod.type} {defaultMethod.type}
</h4> </h4>
</div> </div>
<div className="ml-8 grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
{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"> <span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
{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"> <span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
{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.os} {defaultMethod.resources.version}
{defaultMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -161,29 +126,25 @@ export function ScriptVersionModal({
{/* 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="mb-3 flex items-center space-x-3"> <div className="flex items-center space-x-3 mb-3">
<div <div
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${ className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === "alpine" selectedVersion === 'alpine'
? "border-primary bg-primary" ? 'border-primary bg-primary'
: "border-border" : 'border-border'
}`} }`}
> >
{selectedVersion === "alpine" && ( {selectedVersion === 'alpine' && (
<svg <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
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"
@@ -192,34 +153,27 @@ export function ScriptVersionModal({
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-foreground text-base font-semibold capitalize"> <h4 className="text-base font-semibold text-foreground capitalize">
{alpineMethod.type} {alpineMethod.type}
</h4> </h4>
</div> </div>
<div className="ml-8 grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
{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"> <span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
{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"> <span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
{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.os} {alpineMethod.resources.version}
{alpineMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -230,8 +184,12 @@ export function ScriptVersionModal({
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="mt-6 flex justify-end space-x-3"> <div className="flex justify-end space-x-3 mt-6">
<Button onClick={onClose} variant="outline" size="default"> <Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -239,9 +197,7 @@ export function ScriptVersionModal({
disabled={!selectedVersion} disabled={!selectedVersion}
variant="default" variant="default"
size="default" size="default"
className={ className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
}
> >
Continue Continue
</Button> </Button>
@@ -251,3 +207,4 @@ export function ScriptVersionModal({
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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,47 +14,40 @@ interface ServerFormProps {
onCancel?: () => void; onCancel?: () => void;
} }
export function ServerForm({ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
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< const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
Partial<Record<keyof CreateServerData, string>> const [sshKeyError, setSshKeyError] = useState<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();
@@ -65,16 +58,15 @@ export function ServerForm({
if (!trimmed) return false; if (!trimmed) return false;
// IPv4 validation // IPv4 validation
const ipv4Regex = 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]?)$/;
/^(?:(?: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 = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed); const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
if (zoneIdMatch?.[1] && zoneIdMatch[2]) { if (zoneIdMatch) {
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];
@@ -87,11 +79,10 @@ export function ServerForm({
// 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 = 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]?)$/;
/^(?:[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;
} }
@@ -100,19 +91,17 @@ export function ServerForm({
// 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 = 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])?$/;
/^([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 = const simpleHostnameRegex = /^[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 (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) { if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true; return true;
} }
@@ -124,45 +113,42 @@ export function ServerForm({
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 = newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
"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 ( if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
formData.ssh_port !== undefined && newErrors.ssh_port = 'SSH port must be between 1 and 65535';
(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;
}; };
@@ -173,185 +159,156 @@ export function ServerForm({
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 = const handleChange = (field: keyof CreateServerData) => (
(field: keyof CreateServerData) => e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
(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) => ({ setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
...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 { const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
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( setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
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 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label <label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
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={`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 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? "border-destructive" : "border-border" errors.name ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., Production Server" placeholder="e.g., Production Server"
/> />
{errors.name && ( {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
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={`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 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? "border-destructive" : "border-border" 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 && ( {errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
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={`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 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? "border-destructive" : "border-border" errors.user ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., root" placeholder="e.g., root"
/> />
{errors.user && ( {errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
htmlFor="ssh_port"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Port SSH Port
</label> </label>
<input <input
@@ -361,31 +318,26 @@ export function ServerForm({
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={`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 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.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 && ( {errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
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="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" 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"
> >
<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>
@@ -394,21 +346,18 @@ export function ServerForm({
{colorCodingEnabled && ( {colorCodingEnabled && (
<div> <div>
<label <label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
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="border-border h-10 w-20 cursor-pointer rounded border" className="w-20 h-10 rounded cursor-pointer border border-border"
/> />
<span className="text-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Choose a color to identify this server Choose a color to identify this server
</span> </span>
</div> </div>
@@ -417,36 +366,31 @@ export function ServerForm({
</div> </div>
{/* Password Authentication */} {/* Password Authentication */}
{formData.auth_type === "password" && ( {formData.auth_type === 'password' && (
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
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={`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 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? "border-destructive" : "border-border" errors.password ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="Enter password" placeholder="Enter password"
/> />
{errors.password && ( {errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
<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="mb-1 flex items-center justify-between"> <div className="flex items-center justify-between mb-1">
<label className="text-muted-foreground block text-sm font-medium"> <label className="block text-sm font-medium text-muted-foreground">
SSH Private Key * SSH Private Key *
</label> </label>
<Button <Button
@@ -458,7 +402,7 @@ export function ServerForm({
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>
@@ -466,42 +410,24 @@ export function ServerForm({
{!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 && ( {errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
<p className="text-destructive mt-1 text-sm"> {sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
{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="bg-success/10 border-success/20 rounded-md border p-3"> <div className="p-3 bg-success/10 border border-success/20 rounded-md">
<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 <svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="text-success h-4 w-4" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
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-success-foreground text-sm font-medium"> <span className="text-sm font-medium text-success-foreground">
SSH key pair generated successfully SSH key pair generated successfully
</span> </span>
</div> </div>
@@ -510,50 +436,46 @@ export function ServerForm({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowPublicKeyModal(true)} onClick={() => setShowPublicKeyModal(true)}
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2" className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
> >
<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-success/80 mt-1 text-xs"> <p className="text-xs text-success/80 mt-1">
The private key has been generated and will be saved with The private key has been generated and will be saved with the server.
the server.
</p> </p>
</div> </div>
)} )}
</div> </div>
<div> <div>
<label <label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
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="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" 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"
placeholder="Enter passphrase for encrypted key" placeholder="Enter passphrase for encrypted key"
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
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 justify-end space-y-2 pt-4 sm:flex-row sm:space-y-0 sm:space-x-3"> <div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && ( {isEditing && onCancel && (
<Button <Button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
variant="outline" variant="outline"
size="default" size="default"
className="order-2 w-full sm:order-1 sm:w-auto" className="w-full sm:w-auto order-2 sm:order-1"
> >
Cancel Cancel
</Button> </Button>
@@ -562,9 +484,9 @@ export function ServerForm({
type="submit" type="submit"
variant="default" variant="default"
size="default" size="default"
className="order-1 w-full sm:order-2 sm:w-auto" className="w-full sm:w-auto order-1 sm:order-2"
> >
{isEditing ? "Update Server" : "Add Server"} {isEditing ? 'Update Server' : 'Add Server'}
</Button> </Button>
</div> </div>
</form> </form>
@@ -574,9 +496,10 @@ export function ServerForm({
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}
/> />
</> </>
); );
} }

View File

@@ -1,18 +1,12 @@
"use client"; 'use client';
import { useState } from "react"; import { useState, useEffect } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
Database, import { useRegisterModal } from './modal/ModalStackProvider';
RefreshCw, import { api } from '~/trpc/react';
CheckCircle, import { PBSCredentialsModal } from './PBSCredentialsModal';
Lock, import type { Storage } from '~/server/services/storageService';
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;
@@ -25,38 +19,30 @@ 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>( const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
null,
);
const { data, isLoading, refetch } = const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
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 } = const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
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: { storage_name: string }) => { allCredentials.credentials.forEach(c => {
credentialsMap.set(String(c.storage_name), true); credentialsMap.set(c.storage_name, true);
}); });
} }
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
id: "server-storages-modal",
allowEscape: true,
onClose,
});
const handleRefresh = () => { const handleRefresh = () => {
setForceRefresh(true); setForceRefresh(true);
@@ -67,16 +53,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 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <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="text-primary h-6 w-6" /> <Database className="h-6 w-6 text-primary" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">
Storages for {serverName} Storages for {serverName}
</h2> </h2>
</div> </div>
@@ -87,9 +73,7 @@ export function ServerStoragesModal({
size="sm" size="sm"
disabled={isLoading} disabled={isLoading}
> >
<RefreshCw <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
<Button <Button
@@ -98,18 +82,8 @@ export function ServerStoragesModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-5 w-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
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>
@@ -118,36 +92,35 @@ 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="py-8 text-center"> <div className="text-center py-8">
<div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></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>
) : !data?.success ? ( ) : !data?.success ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<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-muted-foreground mb-4 text-sm"> <p className="text-sm text-muted-foreground mb-4">
{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="mr-2 h-4 w-4" /> <RefreshCw className="h-4 w-4 mr-2" />
Try Again Try Again
</Button> </Button>
</div> </div>
) : storages.length === 0 ? ( ) : storages.length === 0 ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No storages found</p> <p className="text-foreground mb-2">No storages found</p>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Make sure your server has storages configured. Make sure your server has storages configured.
</p> </p>
</div> </div>
) : ( ) : (
<> <>
{data.cached && ( {data.cached && (
<div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
Showing cached data. Click Refresh to fetch latest from Showing cached data. Click Refresh to fetch latest from server.
server.
</div> </div>
)} )}
@@ -158,72 +131,57 @@ export function ServerStoragesModal({
return ( return (
<div <div
key={storage.name} key={storage.name}
className={`rounded-lg border p-4 ${ className={`p-4 border rounded-lg ${
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="mb-2 flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-foreground font-medium"> <h3 className="font-medium text-foreground">{storage.name}</h3>
{storage.name}
</h3>
{isBackupCapable && ( {isBackupCapable && (
<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"> <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">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Backup Backup
</span> </span>
)} )}
<span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"> <span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type} {storage.type}
</span> </span>
{storage.type === "pbs" && {storage.type === 'pbs' && (
(credentialsMap.has(storage.name) ? ( credentialsMap.has(storage.name) ? (
<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"> <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">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Credentials Configured Credentials Configured
</span> </span>
) : ( ) : (
<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"> <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">
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
Credentials Needed Credentials Needed
</span> </span>
))} )
)}
</div> </div>
<div className="text-muted-foreground space-y-1 text-sm"> <div className="text-sm text-muted-foreground space-y-1">
<div> <div>
<span className="font-medium">Content:</span>{" "} <span className="font-medium">Content:</span> {storage.content.join(', ')}
{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>{" "} <span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
{storage.nodes.join(", ")}
</div> </div>
)} )}
{Object.entries(storage) {Object.entries(storage)
.filter( .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
([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"> <span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
{key.replace(/_/g, " ")}:
</span>{" "}
{String(value)}
</div> </div>
))} ))}
</div> </div>
{storage.type === "pbs" && ( {storage.type === 'pbs' && (
<div className="border-border mt-3 border-t pt-3"> <div className="mt-3 pt-3 border-t border-border">
<Button <Button
onClick={() => setSelectedPBSStorage(storage)} onClick={() => setSelectedPBSStorage(storage)}
variant="outline" variant="outline"
@@ -231,10 +189,7 @@ 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) {credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
? "Edit"
: "Configure"}{" "}
Credentials
</Button> </Button>
</div> </div>
)} )}
@@ -245,11 +200,9 @@ export function ServerStoragesModal({
</div> </div>
{backupStorages.length > 0 && ( {backupStorages.length > 0 && (
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4"> <div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
<p className="text-success text-sm font-medium"> <p className="text-sm text-success font-medium">
{backupStorages.length} storage {backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
{backupStorages.length !== 1 ? "s" : ""} available for
backups
</p> </p>
</div> </div>
)} )}
@@ -271,3 +224,4 @@ export function ServerStoragesModal({
</div> </div>
); );
} }

View File

@@ -13,10 +13,6 @@ 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({
@@ -25,11 +21,7 @@ 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);
@@ -49,8 +41,8 @@ export function StorageSelectionModal({
onClose(); onClose();
}; };
// Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages // Filter to show only backup-capable storages
const filteredStorages = filterFn ? storages.filter(filterFn) : 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 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
@@ -59,7 +51,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">{title}</h2> <h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
</div> </div>
<Button <Button
onClick={handleClose} onClick={handleClose}
@@ -80,7 +72,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>
) : filteredStorages.length === 0 ? ( ) : backupStorages.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>
@@ -95,12 +87,12 @@ export function StorageSelectionModal({
) : ( ) : (
<> <>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{description} Select a storage to use for the backup. Only storages that support backups are shown.
</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">
{filteredStorages.map((storage) => ( {backupStorages.map((storage) => (
<div <div
key={storage.name} key={storage.name}
onClick={() => setSelectedStorage(storage)} onClick={() => setSelectedStorage(storage)}
@@ -114,11 +106,9 @@ 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>

View File

@@ -13,15 +13,9 @@ 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 {
@@ -30,7 +24,7 @@ interface TerminalMessage {
timestamp: number; timestamp: number;
} }
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) { export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: 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);
@@ -45,16 +39,7 @@ 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(() => propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); const [executionId, setExecutionId] = useState(() => `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);
@@ -292,7 +277,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: effectiveExecutionId, executionId,
input: data input: data
}; };
wsRef.current.send(JSON.stringify(message)); wsRef.current.send(JSON.stringify(message));
@@ -340,11 +325,9 @@ 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) {
// Use propExecutionId if provided, otherwise generate a new one // Generate a new execution ID for the initial run
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!propExecutionId) {
setExecutionId(newExecutionId); setExecutionId(newExecutionId);
}
const message = { const message = {
action: 'start', action: 'start',
@@ -355,14 +338,9 @@ 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));
} }
@@ -402,15 +380,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); wsRef.current.close();
} }
}; };
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile, envVars]); }, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
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 (unless propExecutionId is provided) // Generate a new execution ID for each script run
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newExecutionId = `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({
@@ -419,17 +395,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
executionId: newExecutionId, executionId: newExecutionId,
mode, mode,
server, server,
envVars,
isUpdate, isUpdate,
isShell, isShell,
isBackup, containerId
isClone,
containerId,
storage,
backupStorage,
cloneCount,
hostnames,
containerType
})); }));
} }
}; };

View File

@@ -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,161 +14,154 @@ interface TextViewerProps {
} }
interface ScriptContent { interface ScriptContent {
mainScript?: string; ctScript?: string;
installScript?: string; installScript?: string;
alpineMainScript?: string; alpineCtScript?: string;
alpineInstallScript?: string; alpineInstallScript?: string;
} }
export function TextViewer({ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
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<"main" | "install">("main"); const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">( const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
"default",
);
// Extract slug from script name (remove .sh extension) // Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, ""); const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// 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 // Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod; const hasAlpineVariant = script?.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
// Get script paths from install_methods // Get script names for default and alpine versions
const defaultScriptPath = defaultMethod?.script; const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptPath = alpineMethod?.script; const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
// 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 based on actual script paths from install_methods // Build fetch requests for default version
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
const requestTypes: Array<
"default-main" | "default-install" | "alpine-main" | "alpine-install"
> = [];
// Default main script (ct/, vm/, tools/, etc.) // Default CT script
if (defaultScriptPath) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
),
); );
requestTypes.push("default-main");
}
// Default install script (only for ct/ scripts) // Tools, VM, VW scripts
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
`/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");
}
// Alpine main script // Default install script
if (hasAlpineVariant && alpineScriptPath) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
),
); );
requestTypes.push("alpine-main");
}
// Alpine install script (only for ct/ scripts) // Alpine versions if variant exists
if ( if (hasAlpineVariant) {
hasAlpineVariant &&
hasInstallScript &&
alpineScriptPath?.startsWith("ct/")
) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
`/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 = {};
// Process responses based on their types const content: ScriptContent = {};
await Promise.all( let responseIndex = 0;
responses.map(async (response, index) => {
if (response.status === "fulfilled" && response.value.ok) { // Default CT script
try { const ctResponse = responses[responseIndex];
const data = (await response.value.json()) as { if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
result?: { const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
data?: { json?: { success?: boolean; content?: string } }; if (ctData.result?.data?.json?.success) {
}; content.ctScript = ctData.result.data.json.content;
};
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 {
// Ignore errors responseIndex++;
// Tools script
const toolsResponse = responses[responseIndex];
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (toolsData.result?.data?.json?.success) {
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
}
}
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( setError(err instanceof Error ? err.message : 'Failed to load script content');
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) {
@@ -186,63 +179,51 @@ export function TextViewer({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm" className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex flex-1 items-center space-x-4"> <div className="flex items-center space-x-4 flex-1">
<h2 className="text-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-foreground">
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={ variant={selectedVersion === 'default' ? 'default' : 'outline'}
selectedVersion === "default" ? "default" : "outline" onClick={() => setSelectedVersion('default')}
}
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>
)} )}
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
{((selectedVersion === "default" && (selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
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 === "main" ? "outline" : "ghost"} variant={activeTab === 'ct' ? 'outline' : 'ghost'}
onClick={() => setActiveTab("main")} onClick={() => setActiveTab('ct')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Script CT 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>
@@ -250,108 +231,92 @@ export function TextViewer({
onClick={onClose} onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<svg <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-6 w-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
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 flex-1 flex-col overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">Loading script content...</div>
Loading script content...
</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-destructive text-lg">Error: {error}</div> <div className="text-lg text-destructive">Error: {error}</div>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === "main" && {activeTab === 'ct' && (
(selectedVersion === "default" && scriptContent.mainScript ? ( selectedVersion === 'default' && scriptContent.ctScript ? (
<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.mainScript} {scriptContent.ctScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === "alpine" && ) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
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.alpineMainScript} {scriptContent.alpineCtScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">
{selectedVersion === "default" {selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
? "Default script not found"
: "Alpine script not found"}
</div> </div>
</div> </div>
))} )
{activeTab === "install" && )}
(selectedVersion === "default" && {activeTab === 'install' && (
scriptContent.installScript ? ( selectedVersion === 'default' && 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" && ) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
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}
@@ -359,14 +324,13 @@ export function TextViewer({
{scriptContent.alpineInstallScript} {scriptContent.alpineInstallScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">
{selectedVersion === "default" {selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
? "Default install script not found"
: "Alpine install script not found"}
</div> </div>
</div> </div>
))} )
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, startTransition } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
@@ -31,13 +31,9 @@ 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

View File

@@ -1,234 +0,0 @@
"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>
);
}

View File

@@ -4,10 +4,9 @@ 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, useCallback } from "react"; import { useState, useEffect, useRef } from "react";
interface VersionDisplayProps { interface VersionDisplayProps {
onOpenReleaseNotes?: () => void; onOpenReleaseNotes?: () => void;
@@ -86,233 +85,55 @@ 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 [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false); const lastLogTimeRef = useRef<number>(Date.now());
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 only if we're actually updating // Start subscribing to update logs
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 - only enabled when shouldSubscribe is true AND we're updating // Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, { const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe && isUpdating, enabled: shouldSubscribe,
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data refetchIntervalInBackground: true,
}); });
// 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(() => {
// CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating || !updateStartTimeRef.current) {
return;
}
// 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?.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(); lastLogTimeRef.current = Date.now();
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0); setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
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
setTimeout(() => startReconnectAttempts(), 0); startReconnectAttempts();
} }
} }
}, [updateLogsData, startReconnectAttempts, isUpdating]); }, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only) // Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => { useEffect(() => {
// Early return: only run if we're actually updating if (!shouldSubscribe) return;
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
@@ -320,10 +141,7 @@ 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
// Additional guard: check refs again before triggering and validate session if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
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...']);
@@ -333,130 +151,55 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
}, 10000); // Check every 10 seconds }, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval); return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]); }, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Keep refs in sync with state // Attempt to reconnect and reload page when server is back
useEffect(() => { const startReconnectAttempts = () => {
isUpdatingRef.current = isUpdating; if (reconnectIntervalRef.current) return;
// 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]);
useEffect(() => { setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]);
// Keep updateStartTime ref in sync reconnectIntervalRef.current = setInterval(() => {
useEffect(() => { void (async () => {
updateStartTimeRef.current = updateStartTime; try {
}, [updateStartTime]); // Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) {
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear reconnect interval when update completes or component unmounts // Clear interval and reload
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(() => {
setShouldSubscribe(false);
}, 0);
// Reset completion processing flag
isCompleteProcessedRef.current = false;
// Don't clear session refs here - they're cleared explicitly on unmount or new update
} }
return () => { setTimeout(() => {
if (reconnectIntervalRef.current) { window.location.reload();
clearInterval(reconnectIntervalRef.current); }, 1000);
reconnectIntervalRef.current = null;
} }
if (reloadTimeoutRef.current) { } catch {
clearTimeout(reloadTimeoutRef.current); // Server still down, keep trying
reloadTimeoutRef.current = null;
} }
})();
}, 2000);
}; };
}, [isUpdating]);
// Cleanup on component unmount - reset all update-related state // Cleanup reconnect interval on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
// Clear all intervals
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(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); // Will be set to true in mutation onSuccess setShouldSubscribe(false);
setUpdateStartTime(startTime); setUpdateStartTime(Date.now());
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();
}; };
@@ -490,18 +233,6 @@ 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"}

View File

@@ -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, labelPosition = 'right', ...props }, ref) => { ({ className, checked, onCheckedChange, label, ...props }, ref) => {
const toggleSwitch = ( return (
<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,17 +29,7 @@ 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>

View File

@@ -41,14 +41,10 @@ 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
@@ -58,7 +54,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: 'lax', // Use lax for cross-origin navigation support sameSite: 'strict',
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/', path: '/',
}); });

View File

@@ -3,14 +3,6 @@ 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 }> }
@@ -26,7 +18,7 @@ export async function GET(
} }
const db = getDatabase(); const db = getDatabase();
const server = await db.getServerById(id) as ServerData | null; const server = await db.getServerById(id);
if (!server) { if (!server) {
return NextResponse.json( return NextResponse.json(
@@ -36,14 +28,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.key_generated) { if (!(server as any).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.ssh_key_path) { if (!(server as any).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 }
@@ -51,13 +43,13 @@ export async function GET(
} }
const sshService = getSSHService(); const sshService = getSSHService();
const publicKey = sshService.getPublicKey(server.ssh_key_path); const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
publicKey, publicKey,
serverName: server.name, serverName: (server as any).name,
serverIp: server.ip serverIp: (server as any).ip
}); });
} catch (error) { } catch (error) {
console.error('Error retrieving public key:', error); console.error('Error retrieving public key:', error);

View File

@@ -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(Number(serverId)); const keyPair = await sshService.generateKeyPair(serverId);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -4,25 +4,9 @@ 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() as AutoSyncSettings; const settings = await request.json();
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== 'object') {
return NextResponse.json( return NextResponse.json(
@@ -70,7 +54,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 (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) { if (!validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid predefined interval' }, { error: 'Invalid predefined interval' },
{ status: 400 } { status: 400 }
@@ -83,7 +67,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(
@@ -125,7 +109,7 @@ export async function POST(request: NextRequest) {
); );
} }
} }
} catch { } catch (parseError) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' }, { error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 } { status: 400 }
@@ -146,15 +130,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
@@ -176,27 +160,18 @@ 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'); const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
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'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
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
// Normalize appriseUrls to always be an array autoSyncService.saveSettings(settings);
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();
@@ -205,7 +180,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'); const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
stopGlobalAutoSync(); stopGlobalAutoSync();
} }
} catch (error) { } catch (error) {
@@ -256,21 +231,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) as string[]; return JSON.parse(urlsValue);
} 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 });
@@ -300,8 +275,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) as string[]; return JSON.parse(urlsValue);
} catch { } catch {
return []; return [];
} }
@@ -314,7 +289,7 @@ async function handleTestNotification() {
); );
} }
if (!appriseUrls?.length) { if (!appriseUrls || appriseUrls.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'No Apprise URLs configured' }, { error: 'No Apprise URLs configured' },
{ status: 400 } { status: 400 }
@@ -322,7 +297,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'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService(); const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification(); const result = await autoSyncService.testNotification();
@@ -370,11 +345,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'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService(); const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null; const result = await autoSyncService.executeAutoSync() as any;
if (result?.success) { if (result && result.success) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Manual sync completed successfully', message: 'Manual sync completed successfully',
@@ -382,7 +357,7 @@ async function handleManualSync() {
}); });
} else { } else {
return NextResponse.json( return NextResponse.json(
{ error: result?.message ?? 'Unknown error' }, { error: result.message },
{ status: 500 } { status: 500 }
); );
} }
@@ -401,7 +376,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?.[1]) { if (match && 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('"')) {
@@ -413,7 +388,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?.[1]) { if (match && match[1]) {
return match[1]; return match[1];
} }

View File

@@ -1,72 +1,51 @@
"use client";
import { useState, useRef, useEffect } from "react"; 'use client';
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab"; import { useState, useRef, useEffect } from 'react';
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab"; import { ScriptsGrid } from './_components/ScriptsGrid';
import { BackupsTab } from "./_components/BackupsTab"; import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { ResyncButton } from "./_components/ResyncButton"; import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { Terminal } from "./_components/Terminal"; import { BackupsTab } from './_components/BackupsTab';
import { ServerSettingsButton } from "./_components/ServerSettingsButton"; import { ResyncButton } from './_components/ResyncButton';
import { SettingsButton } from "./_components/SettingsButton"; import { Terminal } from './_components/Terminal';
import { HelpButton } from "./_components/HelpButton"; import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { VersionDisplay } from "./_components/VersionDisplay"; import { SettingsButton } from './_components/SettingsButton';
import { ThemeToggle } from "./_components/ThemeToggle"; import { HelpButton } from './_components/HelpButton';
import { Button } from "./_components/ui/button"; import { VersionDisplay } from './_components/VersionDisplay';
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon"; import { ThemeToggle } from './_components/ThemeToggle';
import { import { Button } from './_components/ui/button';
ReleaseNotesModal, import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
getLastSeenVersion, import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
} from "./_components/ReleaseNotesModal"; import { Footer } from './_components/Footer';
import { Footer } from "./_components/Footer"; import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react"; import { api } from '~/trpc/react';
import { api } from "~/trpc/react"; import { useAuth } from './_components/AuthProvider';
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<{ const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
path: string; const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
name: string; if (typeof window !== 'undefined') {
mode?: "local" | "ssh"; const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
server?: Server; return savedTab || 'scripts';
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>( const [highlightVersion, setHighlightVersion] = useState<string | undefined>(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 } = const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
api.scripts.getScriptCardsWithCategories.useQuery(); const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: localScriptsData } = const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
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]);
@@ -77,10 +56,7 @@ 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 ( if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion); setHighlightVersion(currentVersion);
setReleaseNotesOpen(true); setReleaseNotesOpen(true);
} }
@@ -103,9 +79,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, ScriptCard>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script: ScriptCard) => { scriptCardsData.cards?.forEach(script => {
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)) {
@@ -119,18 +95,10 @@ export default function Home() {
downloaded: (() => { downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
// First deduplicate GitHub scripts using Map by slug // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, ScriptCard>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script: ScriptCard) => { scriptCardsData.cards?.forEach(script => {
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);
@@ -139,57 +107,21 @@ export default function Home() {
}); });
const deduplicatedGithubScripts = Array.from(scriptMap.values()); const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = (localScriptsData.scripts ?? []) as Array<{ const localScripts = localScriptsData.scripts ?? [];
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
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid return deduplicatedGithubScripts.filter(script => {
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$/, '');
// Primary: Exact slug-to-slug matching (most reliable) return localName.toLowerCase() === script.name.toLowerCase() ||
if (local.slug && script.slug) { localName.toLowerCase() === (script.slug ?? '').toLowerCase();
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 = () => {
@@ -200,19 +132,13 @@ export default function Home() {
window.scrollTo({ window.scrollTo({
top: elementTop - offset, top: elementTop - offset,
behavior: "smooth", behavior: 'smooth'
}); });
} }
}; };
const handleRunScript = ( const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
scriptPath: string, setRunningScript({ path: scriptPath, name: scriptName, mode, server });
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);
}; };
@@ -222,16 +148,16 @@ export default function Home() {
}; };
return ( return (
<main className="bg-background min-h-screen"> <main className="min-h-screen bg-background">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8"> <div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */} {/* Header */}
<div className="mb-6 text-center sm:mb-8"> <div className="text-center mb-6 sm:mb-8">
<div className="mb-2 flex items-start justify-between"> <div className="flex justify-between items-start mb-2">
<div className="flex-1"></div> <div className="flex-1"></div>
<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"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span> <span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<div className="flex flex-1 items-center justify-end gap-2"> <div className="flex-1 flex justify-end items-center gap-2">
{isAuthenticated && ( {isAuthenticated && (
<Button <Button
variant="ghost" variant="ghost"
@@ -247,9 +173,8 @@ export default function Home() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base"> <p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output Manage and execute Proxmox helper scripts locally with live output streaming
streaming
</p> </p>
<div className="flex justify-center px-2"> <div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} /> <VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -258,7 +183,7 @@ export default function Home() {
{/* Controls */} {/* Controls */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<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"> <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">
<ServerSettingsButton /> <ServerSettingsButton />
<SettingsButton /> <SettingsButton />
<ResyncButton /> <ResyncButton />
@@ -268,85 +193,72 @@ 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-border border-b"> <div className="border-b border-border">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1"> <nav className="-mb-px flex flex-col sm:flex-row space-y-2 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={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "scripts" activeTab === 'scripts'
? "bg-accent text-accent-foreground 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="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available} {scriptCounts.available}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
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={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "downloaded" activeTab === 'downloaded'
? "bg-accent text-accent-foreground 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="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded} {scriptCounts.downloaded}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
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={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "installed" activeTab === 'installed'
? "bg-accent text-accent-foreground 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="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed} {scriptCounts.installed}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
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={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.backups} {scriptCounts.backups}
</span> </span>
</Button> </Button>
@@ -354,6 +266,8 @@ 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">
@@ -362,23 +276,26 @@ 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" && <InstalledScriptsTab />} {activeTab === 'installed' && (
<InstalledScriptsTab />
)}
{activeTab === "backups" && <BackupsTab />} {activeTab === 'backups' && (
<BackupsTab />
)}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -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);

View File

@@ -29,7 +29,6 @@ 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;
}>; }>;
@@ -39,7 +38,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

View File

@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
return { return {
success: true, success: true,
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({ credentials: credentials.map(c => ({
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,
}); });

View File

@@ -1,4 +1,3 @@
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";
@@ -7,10 +6,7 @@ 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
@@ -104,7 +100,7 @@ export const scriptsRouter = createTRPCRouter({
getAllScripts: publicProcedure getAllScripts: publicProcedure
.query(async () => { .query(async () => {
try { try {
const scripts = await localScriptsService.getAllScripts(); const scripts = await githubJsonService.getAllScripts();
return { success: true, scripts }; return { success: true, scripts };
} catch (error) { } catch (error) {
return { return {
@@ -181,7 +177,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: { url: string }) => repo.url)); const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
// Create category ID to name mapping // Create category ID to name mapping
const categoryMap: Record<number, string> = {}; const categoryMap: Record<number, string> = {};
@@ -192,7 +188,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: ScriptCard) => { const cardsWithCategories = cards.map(card => {
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') ?? [];
@@ -229,7 +225,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: ScriptCard) => { const filteredCards = cardsWithCategories.filter(card => {
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
@@ -640,194 +636,5 @@ 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: []
};
}
}) })
}); });

View File

@@ -1,5 +1,5 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile, stat } from "fs/promises"; import { readFile, writeFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { env } from "~/env"; import { env } from "~/env";
@@ -111,8 +111,7 @@ 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) {
@@ -176,21 +175,10 @@ 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())
@@ -213,8 +201,7 @@ 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);
@@ -222,8 +209,7 @@ 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
}; };
} }
}), }),
@@ -238,27 +224,6 @@ export const versionRouter = createTRPCRouter({
// Clear/create the log file // Clear/create the log file
await writeFile(logPath, '', 'utf-8'); await writeFile(logPath, '', 'utf-8');
// Always fetch the latest update.sh from GitHub before running
// This ensures we always use the newest update script, avoiding
// the "chicken-and-egg" problem where old scripts can't update properly
const updateScriptUrl = 'https://raw.githubusercontent.com/community-scripts/ProxmoxVE-Local/main/update.sh';
try {
const response = await fetch(updateScriptUrl);
if (response.ok) {
const latestScript = await response.text();
await writeFile(updateScriptPath, latestScript, { mode: 0o755 });
// Log that we fetched the latest script
await writeFile(logPath, '[INFO] Fetched latest update.sh from GitHub\n', { flag: 'a' });
} else {
// If fetch fails, log warning but continue with local script
await writeFile(logPath, `[WARNING] Could not fetch latest update.sh (HTTP ${response.status}), using local version\n`, { flag: 'a' });
}
} catch (fetchError) {
// If fetch fails, log warning but continue with local script
const errorMsg = fetchError instanceof Error ? fetchError.message : 'Unknown error';
await writeFile(logPath, `[WARNING] Could not fetch latest update.sh: ${errorMsg}, using local version\n`, { flag: 'a' });
}
// Spawn the update script as a detached process using nohup // Spawn the update script as a detached process using nohup
// This allows it to run independently and kill the parent Node.js process // This allows it to run independently and kill the parent Node.js process
// Redirect output to log file // Redirect output to log file

View File

@@ -9,10 +9,10 @@ class DatabaseServicePrisma {
} }
init() { init() {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs) // Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) { if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 }); mkdirSync(sshKeysDir, { mode: 0o700 });
} }
} }

View File

@@ -3,128 +3,26 @@ 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(): void { init() {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs) // Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) { if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 }); mkdirSync(sshKeysDir, { mode: 0o700 });
} }
} }
// Server CRUD operations // Server CRUD operations
async createServer(serverData: CreateServerData): Promise<Server> { async createServer(serverData: CreateServerData) {
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: string | null = null; let ssh_key_path = 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) {
@@ -132,7 +30,7 @@ class DatabaseServicePrisma {
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key); ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
} }
const result = await prisma.server.create({ return await prisma.server.create({
data: { data: {
name, name,
ip, ip,
@@ -147,30 +45,27 @@ class DatabaseServicePrisma {
color, color,
} }
}); });
return result as Server;
} }
async getAllServers(): Promise<Server[]> { async getAllServers() {
const result = await prisma.server.findMany({ return await prisma.server.findMany({
orderBy: { created_at: 'desc' } orderBy: { created_at: 'desc' }
}); });
return result as Server[];
} }
async getServerById(id: number): Promise<Server | null> { async getServerById(id: number) {
const result = await prisma.server.findUnique({ return await prisma.server.findUnique({
where: { id } where: { id }
}); });
return result as Server | null;
} }
async updateServer(id: number, serverData: CreateServerData): Promise<Server> { async updateServer(id: number, serverData: CreateServerData) {
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 ?? null; let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes // Handle SSH key changes
if (auth_type === 'key' && ssh_key) { if (auth_type === 'key' && ssh_key) {
@@ -206,7 +101,7 @@ class DatabaseServicePrisma {
ssh_key_path = null; ssh_key_path = null;
} }
const result = await prisma.server.update({ return await prisma.server.update({
where: { id }, where: { id },
data: { data: {
name, name,
@@ -222,10 +117,9 @@ class DatabaseServicePrisma {
color, color,
} }
}); });
return result as Server;
} }
async deleteServer(id: number): Promise<Server> { async deleteServer(id: number) {
// 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);
@@ -242,10 +136,9 @@ class DatabaseServicePrisma {
} }
} }
const result = await prisma.server.delete({ return await prisma.server.delete({
where: { id } where: { id }
}); });
return result as Server;
} }
// Installed Scripts CRUD operations // Installed Scripts CRUD operations
@@ -259,10 +152,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;
const result = await prisma.installedScript.create({ return await prisma.installedScript.create({
data: { data: {
script_name, script_name,
script_path, script_path,
@@ -275,40 +168,34 @@ class DatabaseServicePrisma {
web_ui_port: web_ui_port ?? null, web_ui_port: web_ui_port ?? null,
} }
}); });
return result as InstalledScript;
} }
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> { async getAllInstalledScripts() {
const result = await prisma.installedScript.findMany({ return 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): Promise<InstalledScriptWithServer | null> { async getInstalledScriptById(id: number) {
const result = await prisma.installedScript.findUnique({ return await prisma.installedScript.findUnique({
where: { id }, where: { id },
include: { include: {
server: true server: true
} }
}); });
return result as InstalledScriptWithServer | null;
} }
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> { async getInstalledScriptsByServer(server_id: number) {
const result = await prisma.installedScript.findMany({ return 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: {
@@ -318,10 +205,17 @@ 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: Prisma.InstalledScriptUpdateInput = {}; const updateFields: {
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;
@@ -333,36 +227,33 @@ class DatabaseServicePrisma {
return { changes: 0 }; return { changes: 0 };
} }
const result = await prisma.installedScript.update({ return await prisma.installedScript.update({
where: { id }, where: { id },
data: updateFields data: updateFields
}); });
return result as InstalledScript;
} }
async deleteInstalledScript(id: number): Promise<InstalledScript> { async deleteInstalledScript(id: number) {
const result = await prisma.installedScript.delete({ return await prisma.installedScript.delete({
where: { id } where: { id }
}); });
return result as InstalledScript;
} }
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> { async deleteInstalledScriptsByServer(server_id: number) {
const result = await prisma.installedScript.deleteMany({ return await prisma.installedScript.deleteMany({
where: { server_id } where: { server_id }
}); });
return result as { count: number };
} }
async getNextServerId(): Promise<number> { async getNextServerId() {
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 as { id: number } | null)?.id ?? 0) + 1; return (result?.id ?? 0) + 1;
} }
createSSHKeyFile(serverId: number, sshKey: string): string { createSSHKeyFile(serverId: number, sshKey: 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`);
@@ -375,18 +266,17 @@ class DatabaseServicePrisma {
} }
// LXC Config CRUD operations // LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> { async createLXCConfig(scriptId: number, configData: any) {
const result = await prisma.lXCConfig.create({ return 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: LXCConfigInput): Promise<LXCConfig> { async updateLXCConfig(scriptId: number, configData: any) {
const result = await prisma.lXCConfig.upsert({ return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId }, where: { installed_script_id: scriptId },
update: configData, update: configData,
create: { create: {
@@ -394,18 +284,16 @@ class DatabaseServicePrisma {
...configData ...configData
} }
}); });
return result as LXCConfig;
} }
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> { async getLXCConfigByScriptId(scriptId: number) {
const result = await prisma.lXCConfig.findUnique({ return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId } where: { installed_script_id: scriptId }
}); });
return result as LXCConfig | null;
} }
async deleteLXCConfig(scriptId: number): Promise<void> { async deleteLXCConfig(scriptId: number) {
await prisma.lXCConfig.delete({ return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId } where: { installed_script_id: scriptId }
}); });
} }
@@ -421,7 +309,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: {
@@ -429,11 +317,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
const result = await prisma.backup.update({ return await prisma.backup.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
hostname: backupData.hostname, hostname: backupData.hostname,
@@ -445,10 +333,9 @@ class DatabaseServicePrisma {
discovered_at: new Date(), discovered_at: new Date(),
}, },
}); });
return result as Backup;
} else { } else {
// Create new backup // Create new backup
const result = await prisma.backup.create({ return 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,
@@ -462,12 +349,11 @@ class DatabaseServicePrisma {
discovered_at: new Date(), discovered_at: new Date(),
}, },
}); });
return result as Backup;
} }
} }
async getAllBackups(): Promise<BackupWithServer[]> { async getAllBackups() {
const result = await prisma.backup.findMany({ return await prisma.backup.findMany({
include: { include: {
server: true, server: true,
}, },
@@ -476,43 +362,58 @@ class DatabaseServicePrisma {
{ created_at: 'desc' }, { created_at: 'desc' },
], ],
}); });
return result as BackupWithServer[];
} }
async getBackupById(id: number): Promise<BackupWithServer | null> { async getBackupById(id: number) {
const result = await prisma.backup.findUnique({ return await prisma.backup.findUnique({
where: { id }, where: { id },
include: { include: {
server: true, server: true,
}, },
}); });
return result as BackupWithServer | null;
} }
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> { async getBackupsByContainerId(containerId: string) {
const result = await prisma.backup.findMany({ return 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): Promise<{ count: number }> { async deleteBackupsForContainer(containerId: string, serverId: number) {
const result = await prisma.backup.deleteMany({ return 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, BackupWithServer[]>> { async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
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, BackupWithServer[]>(); const grouped = new Map<string, typeof backups>();
for (const backup of backups) { for (const backup of backups) {
const key = backup.container_id; const key = backup.container_id;
@@ -533,8 +434,8 @@ class DatabaseServicePrisma {
pbs_datastore: string; pbs_datastore: string;
pbs_password: string; pbs_password: string;
pbs_fingerprint: string; pbs_fingerprint: string;
}): Promise<PBSStorageCredential> { }) {
const result = await prisma.pBSStorageCredential.upsert({ return await prisma.pBSStorageCredential.upsert({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: credentialData.server_id, server_id: credentialData.server_id,
@@ -557,11 +458,10 @@ class DatabaseServicePrisma {
pbs_fingerprint: credentialData.pbs_fingerprint, pbs_fingerprint: credentialData.pbs_fingerprint,
}, },
}); });
return result as PBSStorageCredential;
} }
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> { async getPBSCredential(serverId: number, storageName: string) {
const result = await prisma.pBSStorageCredential.findUnique({ return await prisma.pBSStorageCredential.findUnique({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: serverId, server_id: serverId,
@@ -569,19 +469,17 @@ class DatabaseServicePrisma {
}, },
}, },
}); });
return result as PBSStorageCredential | null;
} }
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> { async getPBSCredentialsByServer(serverId: number) {
const result = await prisma.pBSStorageCredential.findMany({ return 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): Promise<PBSStorageCredential> { async deletePBSCredential(serverId: number, storageName: string) {
const result = await prisma.pBSStorageCredential.delete({ return await prisma.pBSStorageCredential.delete({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: serverId, server_id: serverId,
@@ -589,10 +487,9 @@ class DatabaseServicePrisma {
}, },
}, },
}); });
return result as PBSStorageCredential;
} }
async close(): Promise<void> { async close() {
await prisma.$disconnect(); await prisma.$disconnect();
} }
} }
@@ -600,7 +497,7 @@ class DatabaseServicePrisma {
// Singleton instance // Singleton instance
let dbInstance: DatabaseServicePrisma | null = null; let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase(): DatabaseServicePrisma { export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma(); dbInstance ??= new DatabaseServicePrisma();
return dbInstance; return dbInstance;
} }

View File

@@ -1,24 +1,7 @@
import 'dotenv/config' import { PrismaClient } from '@prisma/client';
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;
// Ensure database directory exists before initializing Prisma export const prisma = globalForPrisma.prisma ?? new PrismaClient();
// 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;

View File

@@ -1,26 +1,10 @@
import 'dotenv/config' import { PrismaClient } from '@prisma/client';
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 { prisma?: PrismaClient }; const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Ensure database directory exists before initializing Prisma export const prisma = globalForPrisma.prisma ?? new PrismaClient({
// 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']
}); });

View File

@@ -1,23 +1,17 @@
import { AutoSyncService } from '../services/autoSyncService.js'; import { AutoSyncService } from '../services/autoSyncService.js';
import { repositoryService } from '../services/repositoryService.js'; import { repositoryService } from '../services/repositoryService.ts';
/** @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);

View File

@@ -1,22 +1,7 @@
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
*/ */

View File

@@ -272,12 +272,6 @@ 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) {
@@ -306,7 +300,10 @@ 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');
} }
@@ -376,7 +373,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'); const localScriptsService = await import('./localScripts.js');
const syncedScripts = []; const syncedScripts = [];
for (const filename of syncResult.syncedFiles) { for (const filename of syncResult.syncedFiles) {

View File

@@ -25,20 +25,20 @@ class BackupService {
let hostname = ''; let hostname = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshService.executeCommand( 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,19 +61,17 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( 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();
} }
); );
@@ -98,7 +96,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -114,11 +112,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,
@@ -146,9 +144,8 @@ 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,
@@ -185,18 +182,17 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
findCommand, findCommand,
(data: string) => { (data: string) => {
findOutput += data; findOutput += data;
}, },
(error: string) => { (error: string) => {
console.error('Error getting stat command:', error); // Ignore errors - storage might not be mounted
resolve(); resolve();
}, },
(exitCode: number) => { (exitCode: number) => {
console.error('Error getting stat command:', exitCode);
resolve(); resolve();
} }
); );
@@ -222,7 +218,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -238,11 +234,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,
@@ -274,7 +270,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,
@@ -314,8 +310,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}`);
@@ -343,7 +339,7 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
fullCommand, fullCommand,
(data: string) => { (data: string) => {
@@ -409,8 +405,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}`);
@@ -429,8 +425,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) => { new Promise<void>((resolve, reject) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
command, command,
(data: string) => { (data: string) => {
@@ -472,7 +468,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('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) { if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
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')) {
@@ -493,7 +489,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
@@ -501,12 +497,11 @@ 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 = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName); const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
if (dateMatch?.[1]) { if (dateMatch && 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
} }
} }
@@ -514,8 +509,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 = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr); const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
if (sizeMatch?.[1] && sizeMatch[2]) { if (sizeMatch && 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;
@@ -645,18 +640,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`);
} }
@@ -687,7 +682,9 @@ class BackupService {
let backupServiceInstance: BackupService | null = null; let backupServiceInstance: BackupService | null = null;
export function getBackupService(): BackupService { export function getBackupService(): BackupService {
backupServiceInstance ??= new BackupService(); if (!backupServiceInstance) {
backupServiceInstance = new BackupService();
}
return backupServiceInstance; return backupServiceInstance;
} }

View File

@@ -1,428 +1,6 @@
// JavaScript wrapper for githubJsonService (for use with node server.js) // JavaScript wrapper for githubJsonService.ts
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; // This allows the JavaScript autoSyncService.js to import the TypeScript service
import { join } from 'path';
import { repositoryService } from './repositoryService.js';
// Get environment variables import { githubJsonService } from './githubJsonService.ts';
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
});
class GitHubJsonService { export { 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();

View File

@@ -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'; import { repositoryService } from './repositoryService.ts';
export class GitHubJsonService { export class GitHubJsonService {
private branch: string | null = null; private branch: string | null = null;
@@ -64,8 +64,7 @@ export class GitHubJsonService {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); return response.json() as Promise<T>;
return data as T;
} }
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> { private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
@@ -215,7 +214,9 @@ 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)
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE'; if (!script.repository_url) {
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);
@@ -396,6 +397,7 @@ 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;

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -1,4 +1,3 @@
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';
@@ -96,7 +95,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'); const { githubJsonService } = await import('./githubJsonService.js');
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;

View File

@@ -1,216 +0,0 @@
// 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();

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-regexp-exec */ import { prisma } from '../db.ts';
import { prisma } from '../db';
export class RepositoryService { export class RepositoryService {
/** /**
@@ -18,7 +17,7 @@ export class RepositoryService {
} }
}); });
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url)); const existingUrls = new Set(existingRepos.map(r => 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)) {

View File

@@ -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 } from 'fs/promises'; import { writeFile, readFile } 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 { } catch (error) {
// 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 { } catch (dbError) {
// Ignore database error // Ignore database error
} }
return null; return null;
@@ -231,6 +231,7 @@ 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;
@@ -407,7 +408,7 @@ class RestoreService {
const clearLogFile = async () => { const clearLogFile = async () => {
try { try {
await writeFile(logPath, '', 'utf-8'); await writeFile(logPath, '', 'utf-8');
} catch { } catch (error) {
// Ignore log file errors // Ignore log file errors
} }
}; };
@@ -417,7 +418,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 { } catch (error) {
// Ignore log file errors // Ignore log file errors
} }
}; };
@@ -451,12 +452,10 @@ class RestoreService {
} }
// Get server details // Get server details
const serverData = await db.getServerById(serverId); const server = await db.getServerById(serverId);
if (!serverData) { if (!server) {
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...');
@@ -490,7 +489,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 { } catch (error) {
// Continue even if stop fails // Continue even if stop fails
} }
@@ -498,7 +497,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 { } catch (error) {
// 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...');
} }
@@ -560,4 +559,3 @@ export function getRestoreService(): RestoreService {
return restoreServiceInstance; return restoreServiceInstance;
} }

View File

@@ -4,23 +4,21 @@ import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService { export class ScriptDownloaderService {
constructor() { constructor() {
/** @type {string} */ this.scriptsDirectory = null;
this.scriptsDirectory = join(process.cwd(), 'scripts'); this.repoUrl = null;
/** @type {string} */
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
} }
initializeConfig() { initializeConfig() {
// Re-initialize if needed (for environment changes) if (this.scriptsDirectory === null) {
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, '/');
@@ -38,9 +36,6 @@ 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
@@ -58,11 +53,6 @@ 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);
@@ -71,7 +61,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 (/** @type {any} */ error) { } catch (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;
@@ -81,11 +71,6 @@ 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) {
@@ -94,13 +79,6 @@ 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) {
@@ -110,7 +88,6 @@ 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',
}; };
@@ -129,11 +106,6 @@ 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) {
@@ -143,11 +115,6 @@ 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;
@@ -156,15 +123,9 @@ 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';
@@ -305,11 +266,6 @@ 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;
@@ -362,11 +318,6 @@ 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 = [];
@@ -465,11 +416,6 @@ 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 = [];
@@ -521,14 +467,8 @@ 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);
@@ -579,16 +519,13 @@ 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((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${scriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} }
@@ -604,16 +541,13 @@ 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((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} }
@@ -633,16 +567,13 @@ 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((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} catch { } catch {
@@ -653,42 +584,29 @@ 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 (/** @type {any} */ error) { } catch (error) {
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error); console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [], error: error.message }; return { hasDifferences: false, differences: [] };
} }
} }
/**
* 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
} }
@@ -696,26 +614,13 @@ 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 (/** @type {any} */ error) { } catch (error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message); console.error(`Error comparing file ${filePath}:`, error);
// Return error information so it can be handled upstream return { hasDifferences: false, filePath };
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 {
@@ -775,12 +680,6 @@ 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');

View File

@@ -28,7 +28,8 @@ class StorageService {
let currentStorage: Partial<Storage> | null = null; let currentStorage: Partial<Storage> | null = null;
for (const rawLine of lines) { for (let i = 0; i < lines.length; i++) {
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
@@ -43,10 +44,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 = /^(\w+):\s*(.+)$/.exec(line); const storageMatch = line.match(/^(\w+):\s*(.+)$/);
if (storageMatch?.[1] && storageMatch[2]) { if (storageMatch && storageMatch[1] && storageMatch[2]) {
// Save previous storage if exists // Save previous storage if exists
if (currentStorage?.name) { if (currentStorage && currentStorage.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -64,9 +65,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 = /^(\S+)\s+(.+)$/.exec(line); const match = line.match(/^(\S+)\s+(.+)$/);
if (match?.[1] && match[2]) { if (match && match[1] && match[2]) {
const key = match[1]; const key = match[1];
const value = match[2].trim(); const value = match[2].trim();
@@ -91,7 +92,7 @@ class StorageService {
} }
// Don't forget the last storage // Don't forget the last storage
if (currentStorage?.name) { if (currentStorage && currentStorage.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -105,8 +106,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]) =>
@@ -137,7 +138,7 @@ class StorageService {
let configContent = ''; let configContent = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
'cat /etc/pve/storage.cfg', 'cat /etc/pve/storage.cfg',
(data: string) => { (data: string) => {
@@ -190,8 +191,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,
}; };
} }
@@ -214,7 +215,9 @@ class StorageService {
let storageServiceInstance: StorageService | null = null; let storageServiceInstance: StorageService | null = null;
export function getStorageService(): StorageService { export function getStorageService(): StorageService {
storageServiceInstance ??= new StorageService(); if (!storageServiceInstance) {
storageServiceInstance = new StorageService();
}
return storageServiceInstance; return storageServiceInstance;
} }

View File

@@ -85,10 +85,9 @@ 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, envVars = {}) { async executeScript(server, scriptPath, onData, onError, onExit) {
try { try {
await this.transferScriptsFolder(server, onData, onError); await this.transferScriptsFolder(server, onData, onError);
@@ -99,43 +98,8 @@ 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(scriptCommand); 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}`);
// 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, {

View File

@@ -22,7 +22,7 @@
"noEmit": true, "noEmit": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "react-jsx", "jsx": "preserve",
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"

141
update.sh
View File

@@ -72,7 +72,7 @@ load_github_token() {
# Initialize log file # Initialize log file
init_log() { init_log() {
# Clear/create log file # Clear/create log file
>"$LOG_FILE" > "$LOG_FILE"
log "Starting ProxmoxVE-Local update process..." log "Starting ProxmoxVE-Local update process..."
log "Log file: $LOG_FILE" log "Log file: $LOG_FILE"
} }
@@ -100,19 +100,19 @@ check_dependencies() {
local missing_deps=() local missing_deps=()
if ! command -v curl &>/dev/null; then if ! command -v curl &> /dev/null; then
missing_deps+=("curl") missing_deps+=("curl")
fi fi
if ! command -v jq &>/dev/null; then if ! command -v jq &> /dev/null; then
missing_deps+=("jq") missing_deps+=("jq")
fi fi
if ! command -v npm &>/dev/null; then if ! command -v npm &> /dev/null; then
missing_deps+=("npm") missing_deps+=("npm")
fi fi
if ! command -v node &>/dev/null; then if ! command -v node &> /dev/null; then
missing_deps+=("node") missing_deps+=("node")
fi fi
@@ -418,12 +418,6 @@ restore_backup_files() {
verify_database_restored() { verify_database_restored() {
log "Verifying database was restored correctly..." log "Verifying database was restored correctly..."
# Ensure data directory exists (will be auto-created by app if needed)
if [ ! -d "data" ]; then
log "Creating data directory..."
mkdir -p data
fi
# Check for both possible database filenames # Check for both possible database filenames
local db_file="" local db_file=""
if [ -f "data/database.sqlite" ]; then if [ -f "data/database.sqlite" ]; then
@@ -431,10 +425,8 @@ verify_database_restored() {
elif [ -f "data/settings.db" ]; then elif [ -f "data/settings.db" ]; then
db_file="data/settings.db" db_file="data/settings.db"
else else
# Database doesn't exist yet - this is OK for new installations log_error "Database file not found after restore! (checked database.sqlite and settings.db)"
# The app will create it automatically via Prisma migrations return 1
log_warning "No existing database file found - will be created automatically on first start"
return 0
fi fi
local db_size=$(stat -f%z "$db_file" 2>/dev/null || stat -c%s "$db_file" 2>/dev/null) local db_size=$(stat -f%z "$db_file" 2>/dev/null || stat -c%s "$db_file" 2>/dev/null)
@@ -469,9 +461,9 @@ ensure_database_url() {
# Add DATABASE_URL to .env file # Add DATABASE_URL to .env file
log "Adding DATABASE_URL to .env file..." log "Adding DATABASE_URL to .env file..."
echo "" >>.env echo "" >> .env
echo "# Database" >>.env echo "# Database" >> .env
echo "DATABASE_URL=\"file:./data/settings.db\"" >>.env echo "DATABASE_URL=\"file:./data/settings.db\"" >> .env
log_success "DATABASE_URL added to .env file" log_success "DATABASE_URL added to .env file"
} }
@@ -489,9 +481,11 @@ check_service() {
fi fi
} }
# Stop the application before updating # Stop the application before updating
stop_application() { stop_application() {
# Change to the application directory if we're not already there # Change to the application directory if we're not already there
local app_dir local app_dir
if [ -f "package.json" ] && [ -f "server.js" ]; then if [ -f "package.json" ] && [ -f "server.js" ]; then
@@ -583,7 +577,7 @@ update_files() {
# Create a temporary file list to avoid process substitution issues # Create a temporary file list to avoid process substitution issues
local file_list="/tmp/file_list_$$.txt" local file_list="/tmp/file_list_$$.txt"
find "$actual_source_dir" -type f >"$file_list" find "$actual_source_dir" -type f > "$file_list"
while IFS= read -r file; do while IFS= read -r file; do
local rel_path="${file#$actual_source_dir/}" local rel_path="${file#$actual_source_dir/}"
@@ -612,7 +606,7 @@ update_files() {
else else
files_excluded=$((files_excluded + 1)) files_excluded=$((files_excluded + 1))
fi fi
done <"$file_list" done < "$file_list"
# Clean up temporary file # Clean up temporary file
rm -f "$file_list" rm -f "$file_list"
@@ -630,6 +624,7 @@ update_files() {
log_success "Application files updated successfully ($files_copied files)" log_success "Application files updated successfully ($files_copied files)"
} }
# Install dependencies and build # Install dependencies and build
install_and_build() { install_and_build() {
log "Installing dependencies..." log "Installing dependencies..."
@@ -652,7 +647,7 @@ install_and_build() {
export NODE_ENV=development export NODE_ENV=development
# Run npm install to get ALL dependencies including devDependencies # Run npm install to get ALL dependencies including devDependencies
if ! npm install --include=dev >"$npm_log" 2>&1; then if ! npm install --include=dev > "$npm_log" 2>&1; then
log_error "Failed to install dependencies" log_error "Failed to install dependencies"
log_error "npm install output (last 30 lines):" log_error "npm install output (last 30 lines):"
tail -30 "$npm_log" | while read -r line; do tail -30 "$npm_log" | while read -r line; do
@@ -674,7 +669,7 @@ install_and_build() {
# Generate Prisma client # Generate Prisma client
log "Generating Prisma client..." log "Generating Prisma client..."
if ! npx prisma generate >"$npm_log" 2>&1; then if ! npx prisma generate > "$npm_log" 2>&1; then
log_error "Failed to generate Prisma client" log_error "Failed to generate Prisma client"
log_error "Prisma generate output:" log_error "Prisma generate output:"
cat "$npm_log" | while read -r line; do cat "$npm_log" | while read -r line; do
@@ -696,7 +691,7 @@ install_and_build() {
# Run Prisma migrations # Run Prisma migrations
log "Running Prisma migrations..." log "Running Prisma migrations..."
if ! npx prisma migrate deploy >"$npm_log" 2>&1; then if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
log_warning "Prisma migrations failed or no migrations to run" log_warning "Prisma migrations failed or no migrations to run"
log "Prisma migrate output:" log "Prisma migrate output:"
cat "$npm_log" | while read -r line; do cat "$npm_log" | while read -r line; do
@@ -710,14 +705,11 @@ install_and_build() {
log "Building application..." log "Building application..."
# Set NODE_ENV to production for build # Set NODE_ENV to production for build
export NODE_ENV=production export NODE_ENV=production
# Unset TURBOPACK to prevent "Multiple bundler flags" error with --webpack
unset TURBOPACK 2>/dev/null || true
export TURBOPACK=''
# Create temporary file for npm build output # Create temporary file for npm build output
local build_log="/tmp/npm_build_$$.log" local build_log="/tmp/npm_build_$$.log"
if ! TURBOPACK='' npm run build >"$build_log" 2>&1; then if ! npm run build > "$build_log" 2>&1; then
log_error "Failed to build application" log_error "Failed to build application"
log_error "npm run build output:" log_error "npm run build output:"
cat "$build_log" | while read -r line; do cat "$build_log" | while read -r line; do
@@ -771,7 +763,7 @@ start_with_npm() {
log "Starting application with npm start..." log "Starting application with npm start..."
# Start in background # Start in background
nohup npm start >server.log 2>&1 & nohup npm start > server.log 2>&1 &
local npm_pid=$! local npm_pid=$!
# Wait a moment and check if it started # Wait a moment and check if it started
@@ -784,23 +776,6 @@ start_with_npm() {
fi fi
} }
# Re-enable the systemd service on failure to prevent users from being locked out
re_enable_service_on_failure() {
if check_service; then
log "Re-enabling systemd service after failure..."
if systemctl enable pvescriptslocal.service 2>/dev/null; then
log_success "Service re-enabled"
if systemctl start pvescriptslocal.service 2>/dev/null; then
log_success "Service started"
else
log_warning "Failed to start service - manual intervention may be required"
fi
else
log_warning "Failed to re-enable service - manual intervention may be required"
fi
fi
}
# Rollback function # Rollback function
rollback() { rollback() {
log_warning "Rolling back to previous version..." log_warning "Rolling back to previous version..."
@@ -872,83 +847,10 @@ rollback() {
log_error "No backup directory found for rollback" log_error "No backup directory found for rollback"
fi fi
# Re-enable the service so users aren't locked out
re_enable_service_on_failure
log_error "Update failed. Please check the logs and try again." log_error "Update failed. Please check the logs and try again."
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_success "Node.js 24 already installed"
elif ((major_version < 24)); then
log_warning "Node.js < 24 detected → upgrading to Node.js 24 LTS..."
upgrade_node_to_24
else
log_warning "Node.js > 24 detected → script tested only up to Node 24"
log "Continuing anyway…"
fi
}
# Upgrade Node.js to version 24
upgrade_node_to_24() {
log "Preparing Node.js 24 upgrade…"
# Remove old nodesource repo files if they exist
if [ -f /etc/apt/sources.list.d/nodesource.list ]; then
log "Removing old nodesource.list file..."
rm -f /etc/apt/sources.list.d/nodesource.list
fi
if [ -f /etc/apt/sources.list.d/nodesource.sources ]; then
log "Removing old nodesource.sources file..."
rm -f /etc/apt/sources.list.d/nodesource.sources
fi
# Update apt cache first
log "Updating apt cache..."
apt-get update >>"$LOG_FILE" 2>&1 || true
# Install NodeSource repo for Node.js 24
log "Downloading Node.js 24 setup script..."
if ! curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/node24_setup.sh; then
log_error "Failed to download Node.js 24 setup script"
re_enable_service_on_failure
exit 1
fi
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
re_enable_service_on_failure
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"
re_enable_service_on_failure
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
@@ -1012,9 +914,6 @@ main() {
# Stop the application before updating # Stop the application before updating
stop_application stop_application
# Check Node.js version
check_node_version
# 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")