feat: Add Load Script functionality to script detail modal
- Create ScriptDownloaderService to download and modify script files from GitHub - Add tRPC routes for loading scripts and checking file existence - Add Load Script button to ScriptDetailModal with loading states - Implement sed replacement for build.func source line in CT scripts - Download CT scripts to scripts/ct/ and install scripts to scripts/install/ - Add visual indicators for script file availability - Show success/error messages for script loading operations
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import type { Script } from '~/types/script';
|
||||
|
||||
interface ScriptDetailModalProps {
|
||||
@@ -11,6 +12,33 @@ interface ScriptDetailModalProps {
|
||||
|
||||
export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModalProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
||||
|
||||
// Check if script files exist locally
|
||||
const { data: scriptFilesData } = api.scripts.checkScriptFiles.useQuery(
|
||||
{ slug: script?.slug ?? '' },
|
||||
{ enabled: !!script && isOpen }
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
const loadScriptMutation = api.scripts.loadScript.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsLoading(false);
|
||||
if (data.success) {
|
||||
setLoadMessage(`✅ ${data.message}`);
|
||||
} else {
|
||||
setLoadMessage(`❌ ${data.error}`);
|
||||
}
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLoading(false);
|
||||
setLoadMessage(`❌ Error: ${error.message}`);
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
@@ -24,6 +52,14 @@ export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModal
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadScript = async () => {
|
||||
if (!script) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadMessage(null);
|
||||
loadScriptMutation.mutate({ slug: script.slug });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
@@ -70,16 +106,70 @@ export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<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 className="flex items-center space-x-4">
|
||||
{/* Load Script Button */}
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-blue-800 text-sm">
|
||||
{loadMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Files Status */}
|
||||
{scriptFilesData?.success && (
|
||||
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${scriptFilesData.ctExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
|
||||
<span>CT Script: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
|
||||
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
Files: {scriptFilesData.files.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { scriptManager } from "~/server/lib/scripts";
|
||||
import { gitManager } from "~/server/lib/git";
|
||||
import { githubService } from "~/server/services/github";
|
||||
import { localScriptsService } from "~/server/services/localScripts";
|
||||
import { scriptDownloaderService } from "~/server/services/scriptDownloader";
|
||||
|
||||
export const scriptsRouter = createTRPCRouter({
|
||||
// Get all available scripts
|
||||
@@ -137,5 +138,66 @@ export const scriptsRouter = createTRPCRouter({
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Load script files from GitHub
|
||||
loadScript: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Get the script details
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
|
||||
// Load the script files
|
||||
const result = await scriptDownloaderService.loadScript(script);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in loadScript:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load script',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Check if script files exist locally
|
||||
checkScriptFiles: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
ctExists: false,
|
||||
installExists: false,
|
||||
files: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scriptDownloaderService.checkScriptExists(script);
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in checkScriptFiles:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check script files',
|
||||
ctExists: false,
|
||||
installExists: false,
|
||||
files: []
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
158
src/server/services/scriptDownloader.ts
Normal file
158
src/server/services/scriptDownloader.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import type { Script } from '~/types/script';
|
||||
|
||||
export class ScriptDownloaderService {
|
||||
private scriptsDirectory: string;
|
||||
private repoUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
this.repoUrl = env.REPO_URL || '';
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
// Directory might already exist, ignore error
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFileFromGitHub(filePath: string): Promise<string> {
|
||||
if (!this.repoUrl) {
|
||||
throw new Error('REPO_URL environment variable is not set');
|
||||
}
|
||||
|
||||
const url = `https://raw.githubusercontent.com/${this.extractRepoPath()}/main/${filePath}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private extractRepoPath(): string {
|
||||
const match = this.repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
private modifyScriptContent(content: string): string {
|
||||
// Replace the build.func source line
|
||||
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
|
||||
const newPattern = 'this SCRIPT_DIR="$(dirname "$0")" source "$SCRIPT_DIR/../core/build.func"';
|
||||
|
||||
return content.replace(oldPattern, newPattern);
|
||||
}
|
||||
|
||||
async loadScript(script: Script): Promise<{ success: boolean; message: string; files: string[] }> {
|
||||
try {
|
||||
const files: string[] = [];
|
||||
|
||||
// Ensure directories exist
|
||||
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
|
||||
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
|
||||
|
||||
// Download and save CT script
|
||||
if (script.install_methods && script.install_methods.length > 0) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script && method.script.startsWith('ct/')) {
|
||||
const scriptPath = method.script;
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
if (fileName) {
|
||||
// Download from GitHub
|
||||
const content = await this.downloadFileFromGitHub(scriptPath);
|
||||
|
||||
// Modify the content
|
||||
const modifiedContent = this.modifyScriptContent(content);
|
||||
|
||||
// Save to local directory
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
await writeFile(localPath, modifiedContent, 'utf-8');
|
||||
|
||||
files.push(`ct/${fileName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download and save install script
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
try {
|
||||
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
|
||||
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
|
||||
await writeFile(localInstallPath, installContent, 'utf-8');
|
||||
files.push(`install/${installScriptName}`);
|
||||
} catch (error) {
|
||||
// Install script might not exist, that's okay
|
||||
console.log(`Install script not found for ${script.slug}: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
|
||||
files
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to load script',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> {
|
||||
const files: string[] = [];
|
||||
let ctExists = false;
|
||||
let installExists = false;
|
||||
|
||||
try {
|
||||
// Check CT script
|
||||
if (script.install_methods && script.install_methods.length > 0) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script && method.script.startsWith('ct/')) {
|
||||
const fileName = method.script.split('/').pop();
|
||||
if (fileName) {
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
try {
|
||||
await readFile(localPath, 'utf-8');
|
||||
ctExists = true;
|
||||
files.push(`ct/${fileName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check install script
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
|
||||
try {
|
||||
await readFile(localInstallPath, 'utf-8');
|
||||
installExists = true;
|
||||
files.push(`install/${installScriptName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
return { ctExists, installExists, files };
|
||||
} catch (error) {
|
||||
console.error('Error checking script existence:', error);
|
||||
return { ctExists: false, installExists: false, files: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const scriptDownloaderService = new ScriptDownloaderService();
|
||||
Reference in New Issue
Block a user