From 6ad18e185e4d24742055038c0dec5898eefdc4f3 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 7 Nov 2025 14:44:34 +0100 Subject: [PATCH 1/2] feat: Add alpine variant support for LXC scripts - Download alpine install scripts (alpine-{slug}-install.sh) when alpine variant exists - Add ScriptVersionModal component for version selection (default/alpine) - Update ScriptDetailModal to show version selection before server selection - Update script execution to use selected version type - Support downloading both default and alpine variants of scripts --- src/app/_components/ScriptDetailModal.tsx | 42 ++++- src/app/_components/ScriptVersionModal.tsx | 210 +++++++++++++++++++++ src/server/services/scriptDownloader.js | 75 ++++++++ 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/app/_components/ScriptVersionModal.tsx diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 4bd36dd..bceba7a 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer"; import { TextViewer } from "./TextViewer"; import { ExecutionModeModal } from "./ExecutionModeModal"; import { ConfirmationModal } from "./ConfirmationModal"; +import { ScriptVersionModal } from "./ScriptVersionModal"; import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge"; import { Button } from "./ui/button"; import { useRegisterModal } from './modal/ModalStackProvider'; @@ -38,6 +39,8 @@ export function ScriptDetailModal({ const [selectedDiffFile, setSelectedDiffFile] = useState(null); const [textViewerOpen, setTextViewerOpen] = useState(false); const [executionModeOpen, setExecutionModeOpen] = useState(false); + const [versionModalOpen, setVersionModalOpen] = useState(false); + const [selectedVersionType, setSelectedVersionType] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -133,16 +136,43 @@ export function ScriptDetailModal({ const handleInstallScript = () => { if (!script) return; + + // Check if script has multiple variants (default and alpine) + const installMethods = script.install_methods || []; + const hasMultipleVariants = installMethods.filter(method => + method.type === 'default' || method.type === 'alpine' + ).length > 1; + + if (hasMultipleVariants) { + // Show version selection modal first + setVersionModalOpen(true); + } else { + // Only one variant, proceed directly to execution mode + // Use the first available method or default to 'default' type + const defaultMethod = installMethods.find(method => method.type === 'default'); + const firstMethod = installMethods[0]; + setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default'); + setExecutionModeOpen(true); + } + }; + + const handleVersionSelect = (versionType: string) => { + setSelectedVersionType(versionType); + setVersionModalOpen(false); setExecutionModeOpen(true); }; const handleExecuteScript = (mode: "local" | "ssh", server?: any) => { if (!script || !onInstallScript) return; - // Find the script path (CT or tools) + // Find the script path based on selected version type + const versionType = selectedVersionType || 'default'; const scriptMethod = script.install_methods?.find( + (method) => method.type === versionType && method.script, + ) || script.install_methods?.find( (method) => method.script, ); + if (scriptMethod?.script) { const scriptPath = `scripts/${scriptMethod.script}`; const scriptName = script.name; @@ -804,6 +834,16 @@ export function ScriptDetailModal({ /> )} + {/* Version Selection Modal */} + {script && ( + setVersionModalOpen(false)} + onSelectVersion={handleVersionSelect} + /> + )} + {/* Execution Mode Modal */} {script && ( void; + onSelectVersion: (versionType: string) => void; + script: Script | null; +} + +export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) { + useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose }); + const [selectedVersion, setSelectedVersion] = useState(null); + + if (!isOpen || !script) return null; + + // Get available install methods + const installMethods = script.install_methods || []; + const defaultMethod = installMethods.find(method => method.type === 'default'); + const alpineMethod = installMethods.find(method => method.type === 'alpine'); + + const handleConfirm = () => { + if (selectedVersion) { + onSelectVersion(selectedVersion); + onClose(); + } + }; + + const handleVersionSelect = (versionType: string) => { + setSelectedVersion(versionType); + }; + + return ( +
+
+ {/* Header */} +
+

Select Version

+ +
+ + {/* Content */} +
+
+

+ Choose a version for "{script.name}" +

+

+ Select the version you want to install. Each version has different resource requirements. +

+
+ +
+ {/* Default Version */} + {defaultMethod && ( +
handleVersionSelect('default')} + className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ + selectedVersion === 'default' + ? 'border-primary bg-primary/10' + : 'border-border bg-card hover:border-primary/50' + }`} + > +
+
+
+
+ {selectedVersion === 'default' && ( + + + + )} +
+

+ {defaultMethod.type} +

+
+
+
+ CPU: + {defaultMethod.resources.cpu} cores +
+
+ RAM: + {defaultMethod.resources.ram} MB +
+
+ HDD: + {defaultMethod.resources.hdd} GB +
+
+ OS: + + {defaultMethod.resources.os} {defaultMethod.resources.version} + +
+
+
+
+
+ )} + + {/* Alpine Version */} + {alpineMethod && ( +
handleVersionSelect('alpine')} + className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ + selectedVersion === 'alpine' + ? 'border-primary bg-primary/10' + : 'border-border bg-card hover:border-primary/50' + }`} + > +
+
+
+
+ {selectedVersion === 'alpine' && ( + + + + )} +
+

+ {alpineMethod.type} +

+
+
+
+ CPU: + {alpineMethod.resources.cpu} cores +
+
+ RAM: + {alpineMethod.resources.ram} MB +
+
+ HDD: + {alpineMethod.resources.hdd} GB +
+
+ OS: + + {alpineMethod.resources.os} {alpineMethod.resources.version} + +
+
+
+
+
+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} + diff --git a/src/server/services/scriptDownloader.js b/src/server/services/scriptDownloader.js index ad49e58..6b6f40b 100644 --- a/src/server/services/scriptDownloader.js +++ b/src/server/services/scriptDownloader.js @@ -145,6 +145,35 @@ export class ScriptDownloaderService { } } + // Download alpine install script if alpine variant exists (only for CT scripts) + const hasAlpineCtVariant = script.install_methods?.some( + method => method.type === 'alpine' && method.script?.startsWith('ct/') + ); + console.log(`[${script.slug}] Checking for alpine variant:`, { + hasAlpineCtVariant, + installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script })) + }); + + if (hasAlpineCtVariant) { + const alpineInstallScriptName = `alpine-${script.slug}-install.sh`; + try { + console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`); + const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`); + const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName); + await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8'); + files.push(`install/${alpineInstallScriptName}`); + console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`); + } catch (error) { + // Alpine install script might not exist, that's okay + console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error); + if (error instanceof Error) { + console.error(`[${script.slug}] Error details:`, error.message, error.stack); + } + } + } else { + console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`); + } + return { success: true, message: `Successfully loaded ${files.length} script(s) for ${script.name}`, @@ -286,6 +315,23 @@ export class ScriptDownloaderService { } } + // Check alpine install script if alpine variant exists (only for CT scripts) + const hasAlpineCtVariant = script.install_methods?.some( + method => method.type === 'alpine' && method.script?.startsWith('ct/') + ); + if (hasAlpineCtVariant) { + const alpineInstallScriptName = `alpine-${script.slug}-install.sh`; + const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName); + + try { + await access(alpineInstallPath); + files.push(`install/${alpineInstallScriptName}`); + installExists = true; // Mark as exists if alpine install script exists + } catch { + // File doesn't exist + } + } + return { ctExists, installExists, files }; } catch (error) { console.error('Error checking script existence:', error); @@ -427,6 +473,35 @@ export class ScriptDownloaderService { ); } + // Compare alpine install script if alpine variant exists (only for CT scripts) + const hasAlpineCtVariant = script.install_methods?.some( + method => method.type === 'alpine' && method.script?.startsWith('ct/') + ); + if (hasAlpineCtVariant) { + const alpineInstallScriptName = `alpine-${script.slug}-install.sh`; + const alpineInstallScriptPath = `install/${alpineInstallScriptName}`; + const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath); + + // Check if alpine install script exists locally + try { + await access(localAlpineInstallPath); + comparisonPromises.push( + this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath) + .then(result => { + if (result.hasDifferences) { + hasDifferences = true; + differences.push(result.filePath); + } + }) + .catch(() => { + // Don't add to differences if there's an error reading files + }) + ); + } catch { + // Alpine install script doesn't exist locally, skip comparison + } + } + // Wait for all comparisons to complete await Promise.all(comparisonPromises); From cf3f9a54798802a650500202c13c756ce86892d3 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 7 Nov 2025 14:51:12 +0100 Subject: [PATCH 2/2] feat: Add version toggle in TextViewer for default/alpine variants - Add version selection toggle (Default/Alpine) in TextViewer component - Load both default and alpine versions of CT and install scripts - Display correct script content based on selected version - Pass script object to TextViewer to detect alpine variants - Show toggle buttons only when alpine variant exists --- src/app/_components/ScriptDetailModal.tsx | 1 + src/app/_components/TextViewer.tsx | 251 +++++++++++++++++----- 2 files changed, 197 insertions(+), 55 deletions(-) diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index bceba7a..9911b21 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -829,6 +829,7 @@ export function ScriptDetailModal({ ?.script?.split("/") .pop() ?? `${script.slug}.sh` } + script={script} isOpen={textViewerOpen} onClose={() => setTextViewerOpen(false)} /> diff --git a/src/app/_components/TextViewer.tsx b/src/app/_components/TextViewer.tsx index 9635f8d..20785a6 100644 --- a/src/app/_components/TextViewer.tsx +++ b/src/app/_components/TextViewer.tsx @@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Button } from './ui/button'; +import type { Script } from '../../types/script'; interface TextViewerProps { scriptName: string; isOpen: boolean; onClose: () => void; + script?: Script | null; } interface ScriptContent { ctScript?: string; installScript?: string; + alpineCtScript?: string; + alpineInstallScript?: string; } -export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) { +export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) { const [scriptContent, setScriptContent] = useState({}); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct'); + const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default'); // Extract slug from script name (remove .sh extension) - const slug = scriptName.replace(/\.sh$/, ''); + const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, ''); + + // Check if alpine variant exists + const hasAlpineVariant = script?.install_methods?.some( + method => method.type === 'alpine' && method.script?.startsWith('ct/') + ); + + // Get script names for default and alpine versions + const defaultScriptName = scriptName.replace(/^alpine-/, ''); + const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`; const loadScriptContent = useCallback(async () => { setIsLoading(true); setError(null); try { - // Try to load from different possible locations - const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([ - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`), - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`), - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`), - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`), + // Build fetch requests for default version + const requests: Promise[] = []; + + // Default CT script + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`) + ); + + // Tools, VM, VW scripts + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`) + ); + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`) + ); + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`) + ); + + // Default install script + requests.push( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) - ]); + ); + + // Alpine versions if variant exists + if (hasAlpineVariant) { + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`) + ); + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`) + ); + } + + const responses = await Promise.allSettled(requests); const content: ScriptContent = {}; + let responseIndex = 0; - if (ctResponse.status === 'fulfilled' && ctResponse.value.ok) { + // Default CT script + const ctResponse = responses[responseIndex]; + if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) { const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; if (ctData.result?.data?.json?.success) { content.ctScript = ctData.result.data.json.content; } } - if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) { + responseIndex++; + // Tools script + const toolsResponse = responses[responseIndex]; + if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) { const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; if (toolsData.result?.data?.json?.success) { content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too } } - if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) { + responseIndex++; + // VM script + const vmResponse = responses[responseIndex]; + if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) { const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; if (vmData.result?.data?.json?.success) { content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too } } - if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) { + responseIndex++; + // VW script + const vwResponse = responses[responseIndex]; + if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) { const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; if (vwData.result?.data?.json?.success) { content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too } } - if (installResponse.status === 'fulfilled' && installResponse.value.ok) { + responseIndex++; + // Default install script + const installResponse = responses[responseIndex]; + if (installResponse?.status === 'fulfilled' && installResponse.value.ok) { const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; if (installData.result?.data?.json?.success) { content.installScript = installData.result.data.json.content; } } + responseIndex++; + // Alpine CT script + if (hasAlpineVariant) { + const alpineCtResponse = responses[responseIndex]; + if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) { + const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; + if (alpineCtData.result?.data?.json?.success) { + content.alpineCtScript = alpineCtData.result.data.json.content; + } + } + responseIndex++; + } + + // Alpine install script + if (hasAlpineVariant) { + const alpineInstallResponse = responses[responseIndex]; + if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) { + const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; + if (alpineInstallData.result?.data?.json?.success) { + content.alpineInstallScript = alpineInstallData.result.data.json.content; + } + } + } setScriptContent(content); } catch (err) { @@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) { } finally { setIsLoading(false); } - }, [scriptName, slug]); + }, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]); useEffect(() => { if (isOpen && scriptName) { @@ -106,11 +185,30 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
{/* Header */}
-
+

- Script Viewer: {scriptName} + Script Viewer: {defaultScriptName}

- {scriptContent.ctScript && scriptContent.installScript && ( + {hasAlpineVariant && ( +
+ + +
+ )} + {((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) || + (selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (