Merge pull request #354 from community-scripts/bugfixing_bumps

Add TypeScript Runtime Support and add Prisma 7 Compatibility
This commit is contained in:
Michel Roegl-Brunner
2025-11-28 14:56:43 +01:00
committed by GitHub
53 changed files with 9562 additions and 4985 deletions

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
db.sqlite
data/settings.db
# prisma generated client
/prisma/generated/
# ssh keys (sensitive)
data/ssh-keys/

View File

@@ -1,20 +1,23 @@
import eslintPluginNext from "@next/eslint-plugin-next";
import tseslint from "typescript-eslint";
import { createRequire } from "module";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
// Import Next.js config directly (it's already in flat config format)
const nextConfig = require("eslint-config-next/core-web-vitals");
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
export default tseslint.config(
{
ignores: [".next", "node_modules"],
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"],
},
{
plugins: {
"@next/next": eslintPluginNext,
"react": reactPlugin,
"react-hooks": reactHooksPlugin,
},
rules: {
...eslintPluginNext.configs.recommended.rules,
...eslintPluginNext.configs["core-web-vitals"].rules,
},
},
...nextConfig,
{
files: ["**/*.ts", "**/*.tsx"],
extends: [

View File

@@ -18,30 +18,20 @@ const config = {
},
],
},
// Allow cross-origin requests from local network ranges
allowedDevOrigins: [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://[::1]:3000',
'http://10.*',
'http://172.16.*',
'http://172.17.*',
'http://172.18.*',
'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.*',
],
// Allow cross-origin requests from local network in dev mode
// Note: In Next.js 16, we disable this check entirely for dev
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
];
},
turbopack: {
// Disable Turbopack and use Webpack instead for compatibility

1380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,20 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build --webpack",
"check": "npm run lint && tsc --noEmit",
"build": "prisma generate && next build --webpack",
"check": "eslint . && tsc --noEmit",
"dev": "next dev --webpack",
"dev:server": "node server.js",
"dev:server": "node --import tsx server.js",
"dev:next": "next dev --webpack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"generate": "prisma generate",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"migrate": "prisma migrate dev",
"preview": "next build && next start",
"start": "node server.js",
"postinstall": "prisma generate",
"start": "node --import tsx server.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
@@ -22,71 +25,73 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.19.0",
"@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1",
"better-sqlite3": "^12.4.6",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@tanstack/react-query": "^5.90.11",
"@trpc/client": "^11.7.2",
"@trpc/react-query": "^11.7.2",
"@trpc/server": "^11.7.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.9",
"bcryptjs": "^3.0.2",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-validator": "^1.2.0",
"cron-validator": "^1.4.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.554.0",
"next": "^16.0.4",
"lucide-react": "^0.555.0",
"next": "^16.0.5",
"node-cron": "^4.2.1",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"refractor": "^5.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.3",
"tailwind-merge": "^3.3.1",
"superjson": "^2.2.6",
"tailwind-merge": "^3.4.0",
"ws": "^8.18.3",
"zod": "^4.1.12"
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^4.0.13",
"@vitest/ui": "^4.0.13",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.4",
"eslint-config-next": "^16.0.5",
"jsdom": "^27.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"postcss": "^8.5.6",
"prettier": "^3.7.1",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.19.0",
"prisma": "^7.0.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.2",
"vitest": "^4.0.13"
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"tsx": "^4.19.4",
"vitest": "^4.0.14"
},
"ct3aMetadata": {
"initVersion": "7.39.3"

20
prisma.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'dotenv/config'
import path from 'path'
import { defineConfig } from 'prisma/config'
// Resolve database path
const dbPath = process.env.DATABASE_URL ?? `file:${path.join(process.cwd(), 'data', 'pve-scripts.db')}`
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: dbPath,
},
// @ts-expect-error - Prisma 7 config types are incomplete
studio: {
adapter: async () => {
const { PrismaBetterSqlite3 } = await import('@prisma/adapter-better-sqlite3')
return new PrismaBetterSqlite3({ url: dbPath })
},
},
})

View File

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

255
server.js
View File

@@ -2,14 +2,21 @@ import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { WebSocketServer } from 'ws';
import { spawn } from 'child_process';
import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
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
dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import)
function registerGlobalErrorHandlers() {
if (registerGlobalErrorHandlers._registered) return;
registerGlobalErrorHandlers._registered = true;
@@ -27,9 +34,11 @@ const hostname = '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
const app = next({ dev, hostname, port });
// Register global handlers once at bootstrap
registerGlobalErrorHandlers();
const handle = app.getRequestHandler();
// WebSocket handler for script execution
/**
* @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket
*/
@@ -76,6 +85,8 @@ class ScriptExecutionHandler {
* @param {import('http').Server} 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({
noServer: true
});
@@ -85,6 +96,7 @@ class ScriptExecutionHandler {
}
/**
* Handle WebSocket upgrade for our endpoint
* @param {import('http').IncomingMessage} request
* @param {import('stream').Duplex} socket
* @param {Buffer} head
@@ -96,33 +108,48 @@ class ScriptExecutionHandler {
}
/**
* @param {string} output
* @returns {string|null}
* Parse Container ID from terminal output
* @param {string} output - Terminal output to parse
* @returns {string|null} - Container ID if found, null otherwise
*/
parseContainerId(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for various patterns that Proxmox scripts might use
const patterns = [
// Primary pattern - the exact format from the output
/🆔\s+Container\s+ID:\s+(\d+)/i,
// Standard patterns with flexible spacing
/🆔\s*Container\s*ID:\s*(\d+)/i,
/Container\s*ID:\s*(\d+)/i,
/CT\s*ID:\s*(\d+)/i,
/Container\s*(\d+)/i,
// Alternative patterns
/CT\s*(\d+)/i,
/Container\s*created\s*with\s*ID\s*(\d+)/i,
/Created\s*container\s*(\d+)/i,
/Container\s*(\d+)\s*created/i,
/ID:\s*(\d+)/i,
// Patterns with different spacing and punctuation
/Container\s*ID\s*:\s*(\d+)/i,
/CT\s*ID\s*:\s*(\d+)/i,
/Container\s*#\s*(\d+)/i,
/CT\s*#\s*(\d+)/i,
// Patterns that might appear in success messages
/Successfully\s*created\s*container\s*(\d+)/i,
/Container\s*(\d+)\s*is\s*ready/i,
/Container\s*(\d+)\s*started/i,
// Generic number patterns that might be container IDs (3-4 digits)
/(?:^|\s)(\d{3,4})(?:\s|$)/m,
];
// Try patterns on both original and cleaned output
const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) {
@@ -130,6 +157,7 @@ class ScriptExecutionHandler {
const match = testOutput.match(pattern);
if (match && match[1]) {
const containerId = match[1];
// Additional validation: container IDs are typically 3-4 digits
if (containerId.length >= 3 && containerId.length <= 4) {
return containerId;
}
@@ -137,24 +165,34 @@ class ScriptExecutionHandler {
}
}
return null;
}
/**
* @param {string} output
* @returns {{ip: string, port: number}|null}
* Parse Web UI URL from terminal output
* @param {string} output - Terminal output to parse
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
*/
parseWebUIUrl(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for URL patterns with any valid IP address (private or public)
const patterns = [
// HTTP/HTTPS URLs with IP and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
// URLs without explicit port (assume default ports)
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
// URLs with trailing slash and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
// URLs with just IP and port (no protocol)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
// URLs with just IP (no protocol, no port)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
];
// Try patterns on both original and cleaned output
const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) {
@@ -165,6 +203,7 @@ class ScriptExecutionHandler {
const ip = match[1];
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
// Validate IP address format
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return {
ip: ip,
@@ -180,11 +219,12 @@ class ScriptExecutionHandler {
}
/**
* @param {string} scriptName
* @param {string} scriptPath
* @param {string} executionMode
* @param {number|null} serverId
* @returns {Promise<number|null>}
* Create installation record
* @param {string} scriptName - Name of the script
* @param {string} scriptPath - Path to the script
* @param {string} executionMode - 'local' or 'ssh'
* @param {number|null} serverId - Server ID for SSH executions
* @returns {Promise<number|null>} - Installation record ID
*/
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
try {
@@ -205,8 +245,9 @@ class ScriptExecutionHandler {
}
/**
* @param {number} installationId
* @param {Object} updateData
* Update installation record
* @param {number} installationId - Installation record ID
* @param {Object} updateData - Data to update
*/
async updateInstallationRecord(installationId, updateData) {
try {
@@ -218,6 +259,8 @@ class ScriptExecutionHandler {
setupWebSocket() {
this.wss.on('connection', (ws, request) => {
// Set connection metadata
/** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now();
/** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown';
@@ -308,6 +351,8 @@ class ScriptExecutionHandler {
let installationId = null;
try {
// Check if execution is already running
if (this.activeExecutions.has(executionId)) {
this.sendMessage(ws, {
type: 'error',
@@ -317,7 +362,10 @@ class ScriptExecutionHandler {
return;
}
// Extract script name from path
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
// Create installation record
const serverId = server ? (server.id ?? null) : null;
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
@@ -325,14 +373,17 @@ class ScriptExecutionHandler {
console.error('Failed to create installation record');
}
// Handle SSH execution
if (mode === 'ssh' && server) {
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId);
return;
}
if (mode === 'ssh' && !server) {
// SSH mode requested but no server provided, falling back to local execution
}
// Basic validation for local execution
const scriptsDir = join(process.cwd(), 'scripts');
const resolvedPath = resolve(scriptPath);
@@ -343,12 +394,14 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
// Update installation record with failure
if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
return;
}
// Start script execution with pty for proper TTY support
const childProcess = ptySpawn('bash', [resolvedPath], {
cwd: scriptsDir,
name: 'xterm-256color',
@@ -356,13 +409,16 @@ class ScriptExecutionHandler {
rows: 24,
env: {
...process.env,
TERM: 'xterm-256color',
FORCE_ANSI: 'true',
COLUMNS: '80',
LINES: '24'
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
// Store the execution with installation ID
this.activeExecutions.set(executionId, {
process: childProcess,
ws,
@@ -370,28 +426,34 @@ class ScriptExecutionHandler {
outputBuffer: ''
});
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting execution of ${scriptPath}`,
timestamp: Date.now()
});
childProcess.onData(/** @param {string} data */ async (data) => {
// Handle pty data (both stdout and stderr combined)
childProcess.onData(async (data) => {
const output = data.toString();
// Store output in buffer for logging
const execution = this.activeExecutions.get(executionId);
if (execution) {
execution.outputBuffer += output;
// Keep only last 1000 characters to avoid memory issues
if (execution.outputBuffer.length > 1000) {
execution.outputBuffer = execution.outputBuffer.slice(-1000);
}
}
// Parse for Container ID
const containerId = this.parseContainerId(output);
if (containerId && installationId) {
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(output);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
@@ -410,10 +472,12 @@ class ScriptExecutionHandler {
});
});
// Handle process exit
childProcess.onExit((e) => {
const execution = this.activeExecutions.get(executionId);
const isSuccess = e.exitCode === 0;
// Update installation record with final status and output
if (installationId && execution) {
this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed',
@@ -427,6 +491,7 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
});
@@ -437,6 +502,7 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
// Update installation record with failure
if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
@@ -444,6 +510,7 @@ class ScriptExecutionHandler {
}
/**
* Start SSH script execution
* @param {ExtendedWebSocket} ws
* @param {string} scriptPath
* @param {string} executionId
@@ -453,6 +520,7 @@ class ScriptExecutionHandler {
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) {
const sshService = getSSHExecutionService();
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`,
@@ -460,23 +528,27 @@ class ScriptExecutionHandler {
});
try {
const execution = await sshService.executeScript(
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
server,
scriptPath,
/** @param {string} data */ async (data) => {
// Store output in buffer for logging
const exec = this.activeExecutions.get(executionId);
if (exec) {
exec.outputBuffer += data;
// Keep only last 1000 characters to avoid memory issues
if (exec.outputBuffer.length > 1000) {
exec.outputBuffer = exec.outputBuffer.slice(-1000);
}
}
// Parse for Container ID
const containerId = this.parseContainerId(data);
if (containerId && installationId) {
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(data);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
@@ -488,6 +560,7 @@ class ScriptExecutionHandler {
}
}
// Handle data output
this.sendMessage(ws, {
type: 'output',
data: data,
@@ -495,14 +568,17 @@ class ScriptExecutionHandler {
});
},
/** @param {string} error */ (error) => {
// Store error in buffer for logging
const exec = this.activeExecutions.get(executionId);
if (exec) {
exec.outputBuffer += error;
// Keep only last 1000 characters to avoid memory issues
if (exec.outputBuffer.length > 1000) {
exec.outputBuffer = exec.outputBuffer.slice(-1000);
}
}
// Handle errors
this.sendMessage(ws, {
type: 'error',
data: error,
@@ -513,6 +589,7 @@ class ScriptExecutionHandler {
const exec = this.activeExecutions.get(executionId);
const isSuccess = code === 0;
// Update installation record with final status and output
if (installationId && exec) {
await this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed',
@@ -520,18 +597,21 @@ class ScriptExecutionHandler {
});
}
// Handle process exit
this.sendMessage(ws, {
type: 'end',
data: `SSH script execution finished with code: ${code}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
}
);
));
// Store the execution with installation ID
this.activeExecutions.set(executionId, {
process: /** @type {ExecutionResult} */ (execution).process,
process: execution.process,
ws,
installationId,
outputBuffer: ''
@@ -544,6 +624,7 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
// Update installation record with failure
if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
@@ -583,7 +664,7 @@ class ScriptExecutionHandler {
* @param {any} message
*/
sendMessage(ws, message) {
if (ws.readyState === 1) {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(JSON.stringify(message));
}
}
@@ -601,6 +682,7 @@ class ScriptExecutionHandler {
}
/**
* Start backup execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -610,6 +692,7 @@ class ScriptExecutionHandler {
*/
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
try {
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting backup for container ${containerId} to storage ${storage}...`,
@@ -635,19 +718,22 @@ class ScriptExecutionHandler {
}
/**
* Start SSH backup execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} storage
* @param {ServerInfo} server
* @param {Function|null} [onComplete]
* @param {Function} [onComplete] - Optional callback when backup completes
*/
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
const sshService = getSSHExecutionService();
return new Promise((resolve, reject) => {
try {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
// Wrap the onExit callback to resolve our promise
let promiseResolved = false;
sshService.executeCommand(
@@ -671,6 +757,8 @@ class ScriptExecutionHandler {
},
/** @param {number} code */
(code) => {
// Don't send 'end' message here if this is part of a backup+update flow
// The update flow will handle completion messages
const success = code === 0;
if (!success) {
@@ -681,6 +769,7 @@ class ScriptExecutionHandler {
});
}
// Send a completion message (but not 'end' type to avoid stopping terminal)
this.sendMessage(ws, {
type: 'output',
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
@@ -689,10 +778,14 @@ class ScriptExecutionHandler {
if (onComplete) onComplete(success);
// Resolve the promise when backup completes
// Use setImmediate to ensure resolution happens in the right execution context
if (!promiseResolved) {
promiseResolved = true;
const result = { success, code };
// Use setImmediate to ensure promise resolution happens in the next tick
// This ensures the await in startUpdateExecution can properly resume
setImmediate(() => {
try {
resolve(result);
@@ -706,10 +799,12 @@ class ScriptExecutionHandler {
this.activeExecutions.delete(executionId);
}
).then((execution) => {
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Note: Don't resolve here - wait for onExit callback
}).catch((error) => {
console.error('Error starting backup execution:', error);
this.sendMessage(ws, {
@@ -738,15 +833,17 @@ class ScriptExecutionHandler {
}
/**
* Start update execution (pct enter + update command)
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
* @param {string|null} [backupStorage]
* @param {ServerInfo|undefined} server
* @param {string} [backupStorage] - Optional storage to backup to before update
*/
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
try {
// If backup storage is provided, run backup first
if (backupStorage && mode === 'ssh' && server) {
this.sendMessage(ws, {
type: 'start',
@@ -754,8 +851,10 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
// Create a separate execution ID for backup
const backupExecutionId = `backup_${executionId}`;
// Run backup and wait for it to complete
try {
const backupResult = await this.startSSHBackupExecution(
ws,
@@ -765,13 +864,16 @@ class ScriptExecutionHandler {
server
);
// Backup completed (successfully or not)
if (!backupResult || !backupResult.success) {
// Backup failed, but we'll still allow update (per requirement 1b)
this.sendMessage(ws, {
type: 'output',
data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
timestamp: Date.now()
});
} else {
// Backup succeeded
this.sendMessage(ws, {
type: 'output',
data: '\n✅ Backup completed successfully. Starting update...\n',
@@ -780,6 +882,7 @@ class ScriptExecutionHandler {
}
} catch (error) {
console.error('Backup error before update:', error);
// Backup failed to start, but allow update to proceed
this.sendMessage(ws, {
type: 'output',
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
@@ -787,9 +890,11 @@ class ScriptExecutionHandler {
});
}
// Small delay before starting update
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Send start message for update (only if we're actually starting an update)
this.sendMessage(ws, {
type: 'start',
data: `Starting update for container ${containerId}...`,
@@ -812,6 +917,7 @@ class ScriptExecutionHandler {
}
/**
* Start local update execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -819,6 +925,7 @@ class ScriptExecutionHandler {
async startLocalUpdateExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty');
// Create a shell process that will run pct enter and then update
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color',
cols: 80,
@@ -827,11 +934,13 @@ class ScriptExecutionHandler {
env: process.env
});
// Store the execution
this.activeExecutions.set(executionId, {
process: childProcess,
ws
});
// Handle pty data
childProcess.onData((data) => {
this.sendMessage(ws, {
type: 'output',
@@ -840,10 +949,12 @@ class ScriptExecutionHandler {
});
});
// Send the update command after a delay to ensure we're in the container
setTimeout(() => {
childProcess.write('update\n');
}, 4000);
// Handle process exit
childProcess.onExit((e) => {
this.sendMessage(ws, {
type: 'end',
@@ -856,6 +967,7 @@ class ScriptExecutionHandler {
}
/**
* Start SSH update execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -896,11 +1008,13 @@ class ScriptExecutionHandler {
}
);
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Send the update command after a delay to ensure we're in the container
setTimeout(() => {
/** @type {any} */ (execution).process.write('update\n');
}, 4000);
@@ -915,6 +1029,7 @@ class ScriptExecutionHandler {
}
/**
* Start shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -923,6 +1038,8 @@ class ScriptExecutionHandler {
*/
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
try {
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting shell session for container ${containerId}...`,
@@ -945,6 +1062,7 @@ class ScriptExecutionHandler {
}
/**
* Start local shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -952,6 +1070,7 @@ class ScriptExecutionHandler {
async startLocalShellExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty');
// Create a shell process that will run pct enter
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color',
cols: 80,
@@ -960,11 +1079,13 @@ class ScriptExecutionHandler {
env: process.env
});
// Store the execution
this.activeExecutions.set(executionId, {
process: childProcess,
ws
});
// Handle pty data
childProcess.onData((data) => {
this.sendMessage(ws, {
type: 'output',
@@ -973,6 +1094,9 @@ class ScriptExecutionHandler {
});
});
// Note: No automatic command is sent - user can type commands interactively
// Handle process exit
childProcess.onExit((e) => {
this.sendMessage(ws, {
type: 'end',
@@ -985,6 +1109,7 @@ class ScriptExecutionHandler {
}
/**
* Start SSH shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
@@ -1025,11 +1150,14 @@ class ScriptExecutionHandler {
}
);
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Note: No automatic command is sent - user can type commands interactively
} catch (error) {
this.sendMessage(ws, {
type: 'error',
@@ -1040,24 +1168,32 @@ class ScriptExecutionHandler {
}
}
// TerminalHandler removed - not used by current application
app.prepare().then(() => {
const httpServer = createServer(async (req, res) => {
try {
// Be sure to pass `true` as the second argument to `url.parse`.
// This tells it to parse the query portion of the URL.
const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl;
// Check if this is a WebSocket upgrade request
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
// Only intercept WebSocket upgrades for /ws/script-execution
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
// WebSocket upgrade will be handled by the WebSocket server
// Don't call handle() for this path - let WebSocketServer handle it
return;
}
if (isWebSocketUpgrade) {
return;
}
// Let Next.js handle all other requests including:
// - HTTP requests to /ws/script-execution (non-WebSocket)
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
// - All static assets (_next routes)
// - All other routes
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
@@ -1066,19 +1202,36 @@ app.prepare().then(() => {
}
});
// Create WebSocket handlers
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);
return;
} 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);
}
}
}
socket.destroy();
});
// Note: TerminalHandler removed as it's not being used by the current application
httpServer
.once('error', (err) => {
@@ -1089,10 +1242,38 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
await initializeRepositories();
// 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;
}
}
initializeAutoSync();
// Initialize default repositories
if (typeof autoSyncModule.initializeRepositories === 'function') {
console.log('Calling initializeRepositories...');
await autoSyncModule.initializeRepositories();
} else {
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
}
setupGracefulShutdown();
// Initialize auto-sync service
if (typeof autoSyncModule.initializeAutoSync === 'function') {
console.log('Calling initializeAutoSync...');
autoSyncModule.initializeAutoSync();
}
// Setup graceful shutdown handlers
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
console.log('Setting up graceful shutdown...');
autoSyncModule.setupGracefulShutdown();
}
});
});

View File

@@ -1,6 +1,13 @@
'use client';
"use client";
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
interface AuthContextType {
isAuthenticated: boolean;
@@ -27,9 +34,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
const checkAuthInternal = async (retryCount = 0) => {
try {
// 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) {
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
const setupData = (await setupResponse.json()) as {
setupCompleted: boolean;
enabled: boolean;
};
// If setup is not completed or auth is disabled, don't verify
if (!setupData.setupCompleted || !setupData.enabled) {
@@ -42,11 +52,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
// Only verify authentication if setup is completed and auth is enabled
const response = await fetch('/api/auth/verify', {
credentials: 'include', // Ensure cookies are sent
const response = await fetch("/api/auth/verify", {
credentials: "include", // Ensure cookies are sent
});
if (response.ok) {
const data = await response.json() as {
const data = (await response.json()) as {
username: string;
expirationTime?: number | null;
timeUntilExpiration?: number | null;
@@ -68,7 +78,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
}
} catch (error) {
console.error('Error checking auth:', error);
console.error("Error checking auth:", error);
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
@@ -89,44 +99,49 @@ export function AuthProvider({ children }: AuthProviderProps) {
return checkAuthInternal(0);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
const login = async (
username: string,
password: string,
): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
credentials: 'include', // Ensure cookies are received
credentials: "include", // Ensure cookies are received
});
if (response.ok) {
const data = await response.json() as { username: string };
const data = (await response.json()) as {
username: string;
expirationTime?: number;
};
setIsAuthenticated(true);
setUsername(data.username);
// Check auth again to get expiration time
// Add a small delay to ensure the httpOnly cookie is available
await new Promise<void>((resolve) => {
setTimeout(() => {
void checkAuth().then(() => resolve());
}, 150);
});
// Set expiration time from login response if available
if (data.expirationTime) {
setExpirationTime(data.expirationTime);
}
// Don't call checkAuth after login - we already know we're authenticated
// The cookie is set by the server response
return true;
} else {
const errorData = await response.json();
console.error('Login failed:', errorData.error);
console.error("Login failed:", errorData.error);
return false;
}
} catch (error) {
console.error('Login error:', error);
console.error("Login error:", error);
return false;
}
};
const logout = () => {
// Clear the auth cookie by setting it to expire
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie =
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
@@ -156,7 +171,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
export function useAuth() {
const context = useContext(AuthContext);
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;
}

View File

@@ -1,8 +1,8 @@
'use client';
"use client";
import { Button } from './ui/button';
import { AlertTriangle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { Button } from "./ui/button";
import { AlertTriangle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
interface BackupWarningModalProps {
isOpen: boolean;
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
export function BackupWarningModal({
isOpen,
onClose,
onProceed
onProceed,
}: BackupWarningModalProps) {
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "backup-warning-modal",
allowEscape: true,
onClose,
});
if (!isOpen) return null;
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">
<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 w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-warning" />
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
<AlertTriangle className="text-warning h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
Backup Failed
</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you wish.
<br /><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 className="text-muted-foreground mb-6 text-sm">
The backup failed, but you can still proceed with the update if you
wish.
<br />
<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>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<div className="flex flex-col justify-end gap-3 sm:flex-row">
<Button
onClick={onClose}
variant="outline"
@@ -52,7 +62,7 @@ export function BackupWarningModal({
onClick={onProceed}
variant="default"
size="default"
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
className="bg-warning hover:bg-warning/90 w-full sm:w-auto"
>
Proceed Anyway
</Button>
@@ -62,6 +72,3 @@ export function BackupWarningModal({
</div>
);
}

View File

@@ -1,18 +1,27 @@
'use client';
"use client";
import { useState, useEffect, useCallback, startTransition } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
import { useState, useEffect } from "react";
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import {
RefreshCw,
ChevronDown,
ChevronRight,
HardDrive,
Database,
Server,
CheckCircle,
AlertCircle,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { ConfirmationModal } from './ConfirmationModal';
import { LoadingModal } from './LoadingModal';
} from "./ui/dropdown-menu";
import { ConfirmationModal } from "./ConfirmationModal";
import { LoadingModal } from "./LoadingModal";
interface Backup {
id: number;
@@ -28,19 +37,32 @@ interface Backup {
server_color: string | null;
}
interface ContainerBackups {
container_id: string;
hostname: string;
backups: Backup[];
}
export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(
new Set(),
);
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
const [selectedBackup, setSelectedBackup] = useState<{
backup: Backup;
containerId: string;
} | null>(null);
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
const [restoreSuccess, setRestoreSuccess] = useState(false);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [shouldPollRestore, setShouldPollRestore] = useState(false);
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
const {
data: backupsData,
refetch: refetchBackups,
isLoading,
} = api.backups.getAllBackupsGrouped.useQuery();
const discoverMutation = api.backups.discoverBackups.useMutation({
onSuccess: () => {
void refetchBackups();
@@ -48,32 +70,34 @@ export function BackupsTab() {
});
// Poll for restore progress
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
enabled: shouldPollRestore,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
undefined,
{
enabled: shouldPollRestore,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
},
);
// Update restore progress when log data changes
useEffect(() => {
if (restoreLogsData?.success && restoreLogsData.logs) {
startTransition(() => {
setRestoreProgress(restoreLogsData.logs);
setRestoreProgress(restoreLogsData.logs);
// Stop polling when restore is complete
if (restoreLogsData.isComplete) {
setShouldPollRestore(false);
// Check if restore was successful or failed
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? '';
if (lastLog.includes('Restore completed successfully')) {
setRestoreSuccess(true);
setRestoreError(null);
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
setRestoreError(lastLog);
setRestoreSuccess(false);
}
// Stop polling when restore is complete
if (restoreLogsData.isComplete) {
setShouldPollRestore(false);
// Check if restore was successful or failed
const lastLog =
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? "";
if (lastLog.includes("Restore completed successfully")) {
setRestoreSuccess(true);
setRestoreError(null);
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
setRestoreError(lastLog);
setRestoreSuccess(false);
}
});
}
}
}, [restoreLogsData]);
@@ -81,7 +105,7 @@ export function BackupsTab() {
onMutate: () => {
// Start polling for progress
setShouldPollRestore(true);
setRestoreProgress(['Starting restore...']);
setRestoreProgress(["Starting restore..."]);
setRestoreError(null);
setRestoreSuccess(false);
},
@@ -91,7 +115,12 @@ export function BackupsTab() {
if (result.success) {
// Update progress with all messages from backend (fallback if polling didn't work)
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) ?? ['Restore completed successfully']);
const progressMessages =
restoreProgress.length > 0
? restoreProgress
: (result.progress?.map((p) => p.message) ?? [
"Restore completed successfully",
]);
setRestoreProgress(progressMessages);
setRestoreSuccess(true);
setRestoreError(null);
@@ -99,8 +128,10 @@ export function BackupsTab() {
setSelectedBackup(null);
// Keep success message visible - user can dismiss manually
} else {
setRestoreError(result.error ?? 'Restore failed');
setRestoreProgress(result.progress?.map(p => p.message) ?? restoreProgress);
setRestoreError(result.error ?? "Restore failed");
setRestoreProgress(
result.progress?.map((p) => p.message) ?? restoreProgress,
);
setRestoreSuccess(false);
setRestoreConfirmOpen(false);
setSelectedBackup(null);
@@ -110,7 +141,7 @@ export function BackupsTab() {
onError: (error) => {
// Stop polling on error
setShouldPollRestore(false);
setRestoreError(error.message ?? 'Restore failed');
setRestoreError(error.message ?? "Restore failed");
setRestoreConfirmOpen(false);
setSelectedBackup(null);
setRestoreProgress([]);
@@ -118,26 +149,25 @@ export function BackupsTab() {
});
// Update progress text in modal based on current progress
const currentProgressText = restoreProgress.length > 0
? restoreProgress[restoreProgress.length - 1]
: 'Restoring backup...';
const handleDiscoverBackups = useCallback(() => {
discoverMutation.mutate();
}, [discoverMutation]);
const currentProgressText =
restoreProgress.length > 0
? restoreProgress[restoreProgress.length - 1]
: "Restoring backup...";
// Auto-discover backups when tab is first opened
useEffect(() => {
if (!hasAutoDiscovered && !isLoading && backupsData) {
// Only auto-discover if there are no backups yet
if (!backupsData.backups || backupsData.backups.length === 0) {
handleDiscoverBackups();
if (!backupsData.backups?.length) {
void handleDiscoverBackups();
}
startTransition(() => {
setHasAutoDiscovered(true);
});
setHasAutoDiscovered(true);
}
}, [hasAutoDiscovered, isLoading, backupsData, handleDiscoverBackups]);
}, [hasAutoDiscovered, isLoading, backupsData]);
const handleDiscoverBackups = () => {
discoverMutation.mutate();
};
const handleRestoreClick = (backup: Backup, containerId: string) => {
setSelectedBackup({ backup, containerId });
@@ -150,12 +180,6 @@ export function BackupsTab() {
const handleRestoreConfirm = () => {
if (!selectedBackup) return;
// Ensure server_id is available
if (!selectedBackup.backup.server_id) {
setRestoreError('Server ID is required for restore operation');
return;
}
setRestoreConfirmOpen(false);
setRestoreError(null);
setRestoreSuccess(false);
@@ -163,7 +187,7 @@ export function BackupsTab() {
restoreMutation.mutate({
backupId: selectedBackup.backup.id,
containerId: selectedBackup.containerId,
serverId: selectedBackup.backup.server_id,
serverId: selectedBackup.backup.server_id ?? 0,
});
};
@@ -178,39 +202,41 @@ export function BackupsTab() {
};
const formatFileSize = (bytes: bigint | null): string => {
if (!bytes) return 'Unknown size';
if (!bytes) return "Unknown size";
const b = Number(bytes);
if (b === 0) return '0 B';
if (b === 0) return "0 B";
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));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatDate = (date: Date | null): string => {
if (!date) return 'Unknown date';
if (!date) return "Unknown date";
return new Date(date).toLocaleString();
};
const getStorageTypeIcon = (type: string) => {
switch (type) {
case 'pbs':
case "pbs":
return <Database className="h-4 w-4" />;
case 'local':
case "local":
return <HardDrive className="h-4 w-4" />;
default:
return <Server className="h-4 w-4" />;
}
};
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
const getStorageTypeBadgeVariant = (
type: string,
): "default" | "secondary" | "outline" => {
switch (type) {
case 'pbs':
return 'default';
case 'local':
return 'secondary';
case "pbs":
return "default";
case "local":
return "secondary";
default:
return 'outline';
return "outline";
}
};
@@ -222,8 +248,8 @@ export function BackupsTab() {
{/* Header with refresh button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
<p className="text-sm text-muted-foreground mt-1">
<h2 className="text-foreground text-2xl font-bold">Backups</h2>
<p className="text-muted-foreground mt-1 text-sm">
Discovered backups grouped by container ID
</p>
</div>
@@ -232,31 +258,38 @@ export function BackupsTab() {
disabled={isDiscovering}
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
<RefreshCw
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
{isDiscovering ? "Discovering..." : "Discover Backups"}
</Button>
</div>
{/* Loading state */}
{(isLoading || isDiscovering) && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<div className="bg-card border-border rounded-lg border p-8 text-center">
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
<p className="text-muted-foreground">
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
{isDiscovering ? "Discovering backups..." : "Loading backups..."}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !isDiscovering && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
<div className="bg-card border-border rounded-lg border p-8 text-center">
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="text-foreground mb-2 text-lg font-semibold">
No backups found
</h3>
<p className="text-muted-foreground mb-4">
Click &quot;Discover Backups&quot; to scan for backups on your servers.
Click &quot;Discover Backups&quot; to scan for backups on your
servers.
</p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
<RefreshCw
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
Discover Backups
</Button>
</div>
@@ -265,40 +298,42 @@ export function BackupsTab() {
{/* Backups list */}
{!isLoading && backups.length > 0 && (
<div className="space-y-4">
{backups.map((container) => {
{backups.map((container: ContainerBackups) => {
const isExpanded = expandedContainers.has(container.container_id);
const backupCount = container.backups.length;
return (
<div
key={container.container_id}
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm"
>
{/* Container header - collapsible */}
<button
onClick={() => toggleContainer(container.container_id)}
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground font-semibold">
CT {container.container_id}
</span>
{container.hostname && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{container.hostname}</span>
<span className="text-muted-foreground">
{container.hostname}
</span>
</>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
<p className="text-muted-foreground mt-1 text-sm">
{backupCount} {backupCount === 1 ? "backup" : "backups"}
</p>
</div>
</div>
@@ -306,28 +341,30 @@ export function BackupsTab() {
{/* Container content - backups list */}
{isExpanded && (
<div className="border-t border-border">
<div className="p-4 space-y-3">
<div className="border-border border-t">
<div className="space-y-3 p-4">
{container.backups.map((backup) => (
<div
key={backup.id}
className="bg-muted/50 rounded-lg p-4 border border-border/50"
className="bg-muted/50 border-border/50 rounded-lg border p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="font-medium text-foreground break-all">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-foreground font-medium break-all">
{backup.backup_name}
</span>
<Badge
variant={getStorageTypeBadgeVariant(backup.storage_type)}
variant={getStorageTypeBadgeVariant(
backup.storage_type,
)}
className="flex items-center gap-1"
>
{getStorageTypeIcon(backup.storage_type)}
{backup.storage_name}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm">
{backup.size && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
@@ -345,7 +382,7 @@ export function BackupsTab() {
)}
</div>
<div className="mt-2">
<code className="text-xs text-muted-foreground break-all">
<code className="text-muted-foreground text-xs break-all">
{backup.backup_path}
</code>
</div>
@@ -356,14 +393,19 @@ export function BackupsTab() {
<Button
variant="outline"
size="sm"
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border">
<DropdownMenuContent className="bg-card border-border w-48">
<DropdownMenuItem
onClick={() => handleRestoreClick(backup, container.container_id)}
onClick={() =>
handleRestoreClick(
backup,
container.container_id,
)
}
disabled={restoreMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
@@ -392,9 +434,9 @@ export function BackupsTab() {
{/* Error state */}
{backupsData && !backupsData.success && (
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<div className="bg-destructive/10 border-destructive rounded-lg border p-4">
<p className="text-destructive">
Error loading backups: {backupsData.error ?? 'Unknown error'}
Error loading backups: {backupsData.error ?? "Unknown error"}
</p>
</div>
)}
@@ -418,10 +460,11 @@ export function BackupsTab() {
)}
{/* Restore Progress Modal */}
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
{(restoreMutation.isPending ||
(restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal
isOpen={true}
action={currentProgressText ?? ""}
action={currentProgressText}
logs={restoreProgress}
isComplete={restoreSuccess}
title="Restore in progress"
@@ -434,11 +477,13 @@ export function BackupsTab() {
{/* Restore Success */}
{restoreSuccess && (
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="bg-success/10 border-success/20 rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-success" />
<span className="font-medium text-success">Restore Completed Successfully</span>
<CheckCircle className="text-success h-5 w-5" />
<span className="text-success font-medium">
Restore Completed Successfully
</span>
</div>
<Button
variant="ghost"
@@ -452,7 +497,7 @@ export function BackupsTab() {
×
</Button>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
The container has been restored from backup.
</p>
</div>
@@ -460,11 +505,11 @@ export function BackupsTab() {
{/* Restore Error */}
{restoreError && (
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="bg-error/10 border-error/20 rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-error" />
<span className="font-medium text-error">Restore Failed</span>
<AlertCircle className="text-error h-5 w-5" />
<span className="text-error font-medium">Restore Failed</span>
</div>
<Button
variant="ghost"
@@ -478,13 +523,11 @@ export function BackupsTab() {
×
</Button>
</div>
<p className="text-sm text-muted-foreground">
{restoreError}
</p>
<p className="text-muted-foreground text-sm">{restoreError}</p>
{restoreProgress.length > 0 && (
<div className="space-y-1 mt-2">
<div className="mt-2 space-y-1">
{restoreProgress.map((message, index) => (
<p key={index} className="text-sm text-muted-foreground">
<p key={index} className="text-muted-foreground text-sm">
{message}
</p>
))}
@@ -506,4 +549,3 @@ export function BackupsTab() {
</div>
);
}

View File

@@ -1,41 +1,53 @@
'use client';
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script';
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
import React, { useState, useRef, useEffect } from "react";
import { api } from "~/trpc/react";
import { ScriptCard } from "./ScriptCard";
import { ScriptCardList } from "./ScriptCardList";
import { ScriptDetailModal } from "./ScriptDetailModal";
import { CategorySidebar } from "./CategorySidebar";
import { FilterBar, type FilterState } from "./FilterBar";
import { ViewToggle } from "./ViewToggle";
import { Button } from "./ui/button";
import type { ScriptCard as ScriptCardType } from "~/types/script";
import type { Server } from "~/types/server";
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
interface DownloadedScriptsTabProps {
onInstallScript?: (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
server?: Server,
) => void;
}
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
export function DownloadedScriptsTab({
onInstallScript,
}: DownloadedScriptsTabProps) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
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 [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
const {
data: scriptCardsData,
isLoading: githubLoading,
error: githubError,
refetch,
} = api.scripts.getScriptCardsWithCategories.useQuery();
const {
data: localScriptsData,
isLoading: localLoading,
error: localError,
} = api.scripts.getAllDownloadedScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
{ slug: selectedSlug ?? "" },
{ enabled: !!selectedSlug },
);
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
@@ -43,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const loadSettings = async () => {
try {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
const saveFilterResponse = await fetch("/api/settings/save-filter");
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
@@ -53,9 +65,11 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
const filtersResponse = await fetch("/api/settings/filters");
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json() as { filters?: Partial<FilterState> };
const filtersData = (await filtersResponse.json()) as {
filters?: Partial<FilterState>;
};
if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters));
}
@@ -63,16 +77,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
const viewModeResponse = await fetch("/api/settings/view-mode");
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
if (
viewMode &&
typeof viewMode === "string" &&
(viewMode === "card" || viewMode === "list")
) {
setViewMode(viewMode);
}
}
} catch (error) {
console.error('Error loading settings:', error);
console.error("Error loading settings:", error);
} finally {
setIsLoadingFilters(false);
}
@@ -87,15 +105,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
await fetch("/api/settings/filters", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
console.error("Error saving filters:", error);
}
};
@@ -110,15 +128,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
await fetch("/api/settings/view-mode", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
console.error("Error saving view mode:", error);
}
};
@@ -129,13 +147,14 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories)
return [];
return (scriptCardsData.metadata.categories as any[])
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
.sort((a, b) => a.sort_order - b.sort_order)
.map((cat) => cat.name as string)
.filter((name): name is string => typeof name === 'string');
.filter((name): name is string => typeof name === "string");
}, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated)
@@ -145,15 +164,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Use Map to deduplicate by slug/name
const scriptMap = new Map<string, ScriptCardType>();
scriptCardsData.cards?.forEach(script => {
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, {
...script,
source: 'github' as const,
source: "github" as const,
isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
});
}
}
@@ -165,42 +184,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
// Helper to normalize identifiers so underscores vs hyphens don't break matches
const normalizeId = (s?: string): string => (s ?? '')
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return combinedScripts
.map(script => {
.map((script) => {
if (!script?.name) {
return script; // Return as-is if invalid
}
// Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
if (!local?.name) return false;
const hasLocalVersion =
localScriptsData?.scripts?.some((local) => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
}
}
// 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
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
return matchesInstallBasename;
}) ?? false;
// 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
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename =
(script as any)?.install_basenames?.some(
(base: string) => normalizeId(base) === normalizedLocal,
) ?? false;
return matchesInstallBasename;
}) ?? false;
return {
...script,
isDownloaded: hasLocalVersion,
};
})
.filter(script => script.isDownloaded); // Only show downloaded scripts
.filter((script) => script.isDownloaded); // Only show downloaded scripts
}, [combinedScripts, localScriptsData]);
// Count scripts per category (using downloaded scripts only)
@@ -215,11 +239,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
});
// Count each unique downloaded script only once per category
downloadedScripts.forEach(script => {
downloadedScripts.forEach((script) => {
if (script.categoryNames && script.slug) {
const countedCategories = new Set<string>();
script.categoryNames.forEach((categoryName: unknown) => {
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
if (
typeof categoryName === "string" &&
counts[categoryName] !== undefined &&
!countedCategories.has(categoryName)
) {
countedCategories.add(categoryName);
counts[categoryName]++;
}
@@ -239,13 +267,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const query = filters.searchQuery.toLowerCase().trim();
if (query.length >= 1) {
scripts = scripts.filter(script => {
if (!script || typeof script !== 'object') {
scripts = scripts.filter((script) => {
if (!script || typeof script !== "object") {
return false;
}
const name = (script.name ?? '').toLowerCase();
const slug = (script.slug ?? '').toLowerCase();
const name = (script.name ?? "").toLowerCase();
const slug = (script.slug ?? "").toLowerCase();
return name.includes(query) ?? slug.includes(query);
});
@@ -254,7 +282,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by category using real category data from downloaded scripts
if (selectedCategory) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
// Check if the downloaded script has categoryNames that include the selected category
@@ -264,7 +292,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by updateable status
if (filters.showUpdatable !== null) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable;
@@ -273,20 +301,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by script types
if (filters.selectedTypes.length > 0) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const scriptType = (script.type ?? '').toLowerCase();
const scriptType = (script.type ?? "").toLowerCase();
// Map non-standard types to standard categories
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
return filters.selectedTypes.some(
(type) => type.toLowerCase() === mappedType,
);
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const repoUrl = script.repository_url;
@@ -307,13 +337,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
let compareValue = 0;
switch (filters.sortBy) {
case 'name':
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
case "name":
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
break;
case 'created':
case "created":
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
const aCreated = a?.date_created ?? '';
const bCreated = b?.date_created ?? '';
const aCreated = a?.date_created ?? "";
const bCreated = b?.date_created ?? "";
// If both have dates, compare them directly
if (aCreated && bCreated) {
@@ -327,15 +357,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
compareValue = 1;
} else {
// Both have no dates, fallback to name comparison
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
}
break;
default:
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
}
// Apply sort order
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
return filters.sortOrder === "asc" ? compareValue : -compareValue;
});
return scripts;
@@ -343,7 +373,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
const updatableCount = downloadedScripts.filter(
(script) => script?.updateable,
).length;
return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]);
@@ -363,9 +395,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
behavior: "smooth",
block: "start",
inline: "nearest",
});
}, 100);
@@ -387,22 +419,38 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (githubLoading || localLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
<span className="text-muted-foreground ml-2">
Loading downloaded scripts...
</span>
</div>
);
}
if (githubError || localError) {
return (
<div className="text-center py-12">
<div className="py-12 text-center">
<div className="text-error mb-4">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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
className="mx-auto mb-2 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
<p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
<p className="text-lg font-medium">
Failed to load downloaded scripts
</p>
<p className="text-muted-foreground mt-1 text-sm">
{githubError?.message ??
localError?.message ??
"Unknown error occurred"}
</p>
</div>
<Button
@@ -419,14 +467,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (!downloadedScripts?.length) {
return (
<div className="text-center py-12">
<div className="py-12 text-center">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" 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
className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
<p className="text-muted-foreground mt-1 text-sm">
You haven&apos;t downloaded any scripts yet. Visit the Available
Scripts tab to download some scripts.
</p>
</div>
</div>
@@ -435,12 +494,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1">
<div className="order-2 flex-shrink-0 lg:order-1">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
@@ -451,7 +507,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}
@@ -464,26 +520,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
{/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12">
{filteredScripts.length === 0 &&
(filters.searchQuery ||
selectedCategory ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0) ? (
<div className="py-12 text-center">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" 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
className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<p className="text-lg font-medium">No matching downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
<p className="text-lg font-medium">
No matching downloaded scripts found
</p>
<p className="text-muted-foreground mt-1 text-sm">
Try different filter settings or clear all filters.
</p>
<div className="flex justify-center gap-2 mt-4">
<div className="mt-4 flex justify-center gap-2">
{filters.searchQuery && (
<Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
onClick={() =>
handleFiltersChange({ ...filters, searchQuery: "" })
}
variant="default"
size="default"
>
@@ -502,17 +573,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div>
</div>
</div>
) : (
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) => {
) : viewMode === "card" ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
if (!script || typeof script !== "object") {
return null;
}
// 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 (
<ScriptCard
@@ -522,17 +592,17 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
);
})}
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
if (!script || typeof script !== "object") {
return null;
}
// 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 (
<ScriptCardList
@@ -542,8 +612,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
);
})}
</div>
)
</div>
)}
<ScriptDetailModal

View File

@@ -3,7 +3,17 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
import {
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
GitBranch,
} from "lucide-react";
import { api } from "~/trpc/react";
import { getDefaultFilters } from "./filterUtils";
@@ -98,29 +108,33 @@ export function FilterBar({
};
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
{/* Loading State */}
{isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<span>Loading saved filters...</span>
</div>
</div>
)}
{/* Filter Header */}
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<h3 className="text-foreground text-lg font-medium">
Filter Scripts
</h3>
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<ContextualHelpIcon
section="available-scripts"
tooltip="Help with filtering and searching"
/>
<Button
onClick={() => setIsMinimized(!isMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground h-8 w-8"
title={isMinimized ? "Expand filters" : "Minimize filters"}
>
<svg
@@ -146,10 +160,10 @@ export function FilterBar({
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="relative w-full max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
className="text-muted-foreground h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -167,13 +181,13 @@ export function FilterBar({
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3"
>
<svg
className="h-5 w-5"
@@ -194,318 +208,335 @@ export function FilterBar({
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
const next =
filters.showUpdatable === null
? true
: filters.showUpdatable === true
? false
: null;
updateFilters({ showUpdatable: next });
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
</Button>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border border-primary/20 bg-primary/10 text-primary"
}`}
>
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span>
<svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
/>
<IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-t border-border p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
>
Clear all
</Button>
</div>
</div>
)}
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
const isSelected = filters.selectedRepositories.includes(repo.url);
return (
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
{/* Updateable Filter */}
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(url => url !== repo.url)
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repo.url]
});
}
const next =
filters.showUpdatable === null
? true
: filters.showUpdatable === true
? false
: null;
updateFilters({ showUpdatable: next });
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
isSelected
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border-success/20 bg-success/10 text-success border"
: "border-destructive/20 bg-destructive/10 text-destructive border"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repo.url)}</span>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
</Button>
);
})}
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border-primary/20 bg-primary/10 text-primary border"
}`}
>
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span>
<svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{isSortDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">By Name</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
</button>
</div>
{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="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="border-input text-primary focus:ring-primary rounded"
/>
<IconComponent className="h-4 w-4" />
<span className="text-muted-foreground text-sm">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-border border-t p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
>
Clear all
</Button>
</div>
</div>
)}
</div>
)}
</div>
{/* Sort Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 &&
enabledRepos.map((repo: { id: number; url: string }) => {
const repoUrl = String(repo.url);
const isSelected =
filters.selectedRepositories.includes(repoUrl);
return (
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(
(url) => url !== repoUrl,
),
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repoUrl],
});
}
}}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
isSelected
? "border-primary/20 bg-primary/10 text-primary border"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repoUrl)}</span>
</Button>
);
})}
{/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
) : (
<span>
{filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && (
<span className="font-medium text-info">
(filtered)
</span>
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
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"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
</span>
)}
<span>
{filters.sortBy === "name" ? "By Name" : "By Created Date"}
</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{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="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "name"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">By Name</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "created"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
</button>
</div>
</div>
)}
</div>
{/* Sort Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
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"
>
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="flex items-center space-x-1 text-xs text-success">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Filters are being saved automatically</span>
</div>
)}
</div>
{/* 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 items-center gap-4">
<div className="text-muted-foreground text-sm">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
) : (
<span>
{filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && (
<span className="text-info font-medium">(filtered)</span>
)}
</span>
)}
</div>
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
</Button>
)}
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="text-success flex items-center space-x-1 text-xs">
<svg
className="h-3 w-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<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>
<span>Filters are being saved automatically</span>
</div>
)}
</div>
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="ghost"
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"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
</Button>
)}
</div>
</>
)}

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,9 +1,9 @@
'use client';
"use client";
import { Loader2, CheckCircle, X } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useEffect, useRef } from 'react';
import { Button } from './ui/button';
import { Loader2, CheckCircle, X } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useEffect, useRef } from "react";
import { Button } from "./ui/button";
interface LoadingModalProps {
isOpen: boolean;
@@ -14,21 +14,32 @@ interface LoadingModalProps {
onClose?: () => void;
}
export function LoadingModal({ isOpen, action: _action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
export function LoadingModal({
isOpen,
action: _action,
logs = [],
isComplete = false,
title,
onClose,
}: LoadingModalProps) {
// Allow dismissing with ESC only when complete, prevent during running
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose ?? (() => null) });
useRegisterModal(isOpen, {
id: "loading-modal",
allowEscape: isComplete,
onClose: onClose ?? (() => null),
});
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
<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 relative flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-xl">
{/* Close button - only show when complete */}
{isComplete && onClose && (
<Button
@@ -44,27 +55,26 @@ export function LoadingModal({ isOpen, action: _action, logs = [], isComplete =
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{isComplete ? (
<CheckCircle className="h-12 w-12 text-success" />
<CheckCircle className="text-success h-12 w-12" />
) : (
<>
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
</>
)}
</div>
{/* Static title text */}
{title && (
<p className="text-sm text-muted-foreground">
{title}
</p>
)}
{title && <p className="text-muted-foreground text-sm">{title}</p>}
{/* Log output */}
{logs.length > 0 && (
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
<div className="bg-card border-border text-chart-2 terminal-output max-h-[60vh] w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
{logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
<div
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log}
</div>
))}
@@ -74,9 +84,15 @@ export function LoadingModal({ isOpen, action: _action, logs = [], isComplete =
{!isComplete && (
<div className="flex space-x-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
</div>
)}
</div>
@@ -84,4 +100,3 @@ export function LoadingModal({ isOpen, action: _action, logs = [], isComplete =
</div>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import type { Storage } from '~/server/services/storageService';
import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Lock, CheckCircle, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { api } from "~/trpc/react";
import type { Storage } from "~/server/services/storageService";
interface PBSCredentialsModalProps {
isOpen: boolean;
@@ -20,39 +20,43 @@ export function PBSCredentialsModal({
onClose,
serverId,
serverName: _serverName,
storage
storage,
}: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState('');
const [pbsDatastore, setPbsDatastore] = useState('');
const [pbsPassword, setPbsPassword] = useState('');
const [pbsFingerprint, setPbsFingerprint] = useState('');
const [pbsIp, setPbsIp] = useState("");
const [pbsDatastore, setPbsDatastore] = useState("");
const [pbsPassword, setPbsPassword] = useState("");
const [pbsFingerprint, setPbsFingerprint] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
const pbsDatastoreFromStorage = (storage as { datastore?: string }).datastore ?? null;
const pbsDatastoreFromStorage =
(storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
{ serverId, storageName: storage.name },
{ enabled: isOpen }
);
const { data: credentialData, refetch } =
api.pbsCredentials.getCredentialsForStorage.useQuery(
{ serverId, storageName: storage.name },
{ enabled: isOpen },
);
// Initialize form with storage config values or existing credentials
useEffect(() => {
if (isOpen) {
if (credentialData?.success && credentialData.credential) {
// Load existing credentials
setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(''); // Don't show password
setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? '');
setPbsIp(String(credentialData.credential.pbs_ip));
setPbsDatastore(String(credentialData.credential.pbs_datastore));
setPbsPassword(""); // Don't show password
setPbsFingerprint(
String(credentialData.credential.pbs_fingerprint ?? ""),
);
} else {
// Initialize with storage config values
setPbsIp(pbsIpFromStorage ?? '');
setPbsDatastore(pbsDatastoreFromStorage ?? '');
setPbsPassword('');
setPbsFingerprint('');
setPbsIp(pbsIpFromStorage ?? "");
setPbsDatastore(pbsDatastoreFromStorage ?? "");
setPbsPassword("");
setPbsFingerprint("");
}
}
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
@@ -63,7 +67,7 @@ export function PBSCredentialsModal({
onClose();
},
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}`);
},
});
@@ -74,18 +78,22 @@ export function PBSCredentialsModal({
onClose();
},
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}`);
},
});
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "pbs-credentials-modal",
allowEscape: true,
onClose,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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;
}
@@ -106,7 +114,11 @@ export function PBSCredentialsModal({
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
if (
!confirm(
"Are you sure you want to delete the PBS credentials for this storage?",
)
) {
return;
}
@@ -126,13 +138,13 @@ export function PBSCredentialsModal({
const hasCredentials = credentialData?.success && credentialData.credential;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
<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-2xl flex-col rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<Lock className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">
<Lock className="text-primary h-6 w-6" />
<h2 className="text-card-foreground text-2xl font-bold">
PBS Credentials - {storage.name}
</h2>
</div>
@@ -142,8 +154,18 @@ export function PBSCredentialsModal({
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
@@ -153,7 +175,10 @@ export function PBSCredentialsModal({
<form onSubmit={handleSubmit} className="space-y-4">
{/* Storage Name (read-only) */}
<div>
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="storage-name"
className="text-foreground mb-1 block text-sm font-medium"
>
Storage Name
</label>
<input
@@ -161,13 +186,16 @@ export function PBSCredentialsModal({
id="storage-name"
value={storage.name}
disabled
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm"
/>
</div>
{/* PBS IP */}
<div>
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="pbs-ip"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Server IP <span className="text-error">*</span>
</label>
<input
@@ -177,17 +205,20 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsIp(e.target.value)}
required
disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 10.10.10.226"
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
IP address of the Proxmox Backup Server
</p>
</div>
{/* PBS Datastore */}
<div>
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="pbs-datastore"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Datastore <span className="text-error">*</span>
</label>
<input
@@ -197,37 +228,48 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsDatastore(e.target.value)}
required
disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., NAS03-ISCSI-BACKUP"
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
Name of the datastore on the PBS server
</p>
</div>
{/* PBS Password */}
<div>
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
Password {!hasCredentials && <span className="text-error">*</span>}
<label
htmlFor="pbs-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Password{" "}
{!hasCredentials && <span className="text-error">*</span>}
</label>
<input
<input
type="password"
id="pbs-password"
value={pbsPassword}
onChange={(e) => setPbsPassword(e.target.value)}
required={!hasCredentials}
disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder={
hasCredentials
? "Enter new password (leave empty to keep existing)"
: "Enter PBS password"
}
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
Password for root@pam user on PBS server
</p>
</div>
{/* PBS Fingerprint */}
<div>
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="pbs-fingerprint"
className="text-foreground mb-1 block text-sm font-medium"
>
Fingerprint <span className="text-error">*</span>
</label>
<input
@@ -237,35 +279,37 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsFingerprint(e.target.value)}
required
disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
/>
<p className="mt-1 text-xs text-muted-foreground">
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the &quot;Show Fingerprint&quot; button.
<p className="text-muted-foreground mt-1 text-xs">
Server fingerprint for auto-acceptance. You can find this on
your PBS dashboard by clicking the &quot;Show Fingerprint&quot;
button.
</p>
</div>
{/* Status indicator */}
{hasCredentials && (
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-success" />
<span className="text-sm text-success font-medium">
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
<CheckCircle className="text-success h-4 w-4" />
<span className="text-success text-sm font-medium">
Credentials are configured for this storage
</span>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
<div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row">
{hasCredentials && (
<Button
type="button"
onClick={handleDelete}
variant="outline"
disabled={isLoading}
className="w-full sm:w-auto order-3"
className="order-3 w-full sm:w-auto"
>
<AlertCircle className="h-4 w-4 mr-2" />
<AlertCircle className="mr-2 h-4 w-4" />
Delete Credentials
</Button>
)}
@@ -274,7 +318,7 @@ export function PBSCredentialsModal({
onClick={onClose}
variant="outline"
disabled={isLoading}
className="w-full sm:w-auto order-2"
className="order-2 w-full sm:w-auto"
>
Cancel
</Button>
@@ -282,9 +326,13 @@ export function PBSCredentialsModal({
type="submit"
variant="default"
disabled={isLoading}
className="w-full sm:w-auto order-1"
className="order-1 w-full sm:w-auto"
>
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
{isLoading
? "Saving..."
: hasCredentials
? "Update Credentials"
: "Save Credentials"}
</Button>
</div>
</form>
@@ -293,4 +341,3 @@ export function PBSCredentialsModal({
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardProps {
script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -27,7 +32,7 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
};
const getRepoName = (url?: string): string => {
if (!url) return '';
if (!url) return "";
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
return (
<div
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
onClick={() => onClick(script)}
>
{/* Checkbox in top-left corner */}
{onToggleSelect && (
<div className="absolute top-2 left-2 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</div>
)}
<div className="p-6 flex-1 flex flex-col">
<div className="flex flex-1 flex-col p-6">
{/* Header with logo and name */}
<div className="flex items-start space-x-4 mb-4">
<div className="mb-4 flex items-start space-x-4">
<div className="flex-shrink-0">
{script.logo && !imageError ? (
<Image
@@ -70,28 +79,31 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
alt={`${script.name} logo`}
width={48}
height={48}
className="w-12 h-12 rounded-lg object-contain"
className="h-12 w-12 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
{script.name?.charAt(0)?.toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'}
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate text-lg font-semibold">
{script.name || "Unnamed Script"}
</h3>
<div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} />
<div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />}
{script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
<span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)}
</span>
)}
@@ -99,13 +111,17 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
{/* Download Status */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span>
</div>
</div>
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
</div>
{/* Description */}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || "No description available"}
</p>
{/* Footer with website link */}
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardListProps {
script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
export function ScriptCardList({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardListProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -27,25 +32,26 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'Unknown';
if (!dateString) return "Unknown";
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return 'Unknown';
return "Unknown";
}
};
const getCategoryNames = () => {
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
return script.categoryNames.join(', ');
if (!script.categoryNames || script.categoryNames.length === 0)
return "Uncategorized";
return script.categoryNames.join(", ");
};
const getRepoName = (url?: string): string => {
if (!url) return '';
if (!url) return "";
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
return (
<div
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md"
onClick={() => onClick(script)}
>
{/* Checkbox */}
{onToggleSelect && (
<div className="absolute top-4 left-4 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</div>
)}
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}>
<div className="flex items-start space-x-4">
{/* Logo */}
<div className="flex-shrink-0">
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
alt={`${script.name} logo`}
width={56}
height={56}
className="w-14 h-14 rounded-lg object-contain"
className="h-14 w-14 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
{script.name?.charAt(0)?.toUpperCase() || "?"}
</span>
</div>
)}
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
{script.name || 'Unnamed Script'}
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3 className="text-foreground mb-2 truncate text-xl font-semibold">
{script.name || "Unnamed Script"}
</h3>
<div className="flex items-center space-x-3 flex-wrap gap-2">
<TypeBadge type={script.type ?? 'unknown'} />
<div className="flex flex-wrap items-center gap-2 space-x-3">
<TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />}
{script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
<span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)}
</span>
)}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-sm font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-sm font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span>
</div>
</div>
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
</div>
{/* Description */}
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
{script.description || 'No description available'}
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">
{script.description || "No description available"}
</p>
{/* Metadata Row */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span>Categories: {getCategoryNames()}</span>
</div>
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Created: {formatDate(script.date_created)}</span>
</div>
{(script.os ?? script.version) && (
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<span>
{script.os && script.version
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
: script.os
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
? script.os.charAt(0).toUpperCase() +
script.os.slice(1)
: script.version
? `Version ${script.version}`
: ''
}
: ""}
</span>
</div>
)}
{script.interface_port && (
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Port: {script.interface_port}</span>
</div>
)}
</div>
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>ID: {script.slug || 'unknown'}</span>
<span>ID: {script.slug || "unknown"}</span>
</div>
</div>
</div>

View File

@@ -4,14 +4,20 @@ import { useState } from "react";
import Image from "next/image";
import { api } from "~/trpc/react";
import type { Script } from "~/types/script";
import type { Server } from "~/types/server";
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 {
TypeBadge,
UpdateableBadge,
PrivilegedBadge,
NoteBadge,
} from "./Badge";
import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptDetailModalProps {
script: Script | null;
@@ -21,7 +27,7 @@ interface ScriptDetailModalProps {
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
server?: Server,
) => void;
}
@@ -31,7 +37,11 @@ export function ScriptDetailModal({
onClose,
onInstallScript,
}: ScriptDetailModalProps) {
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "script-detail-modal",
allowEscape: true,
onClose,
});
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -40,7 +50,9 @@ export function ScriptDetailModal({
const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -84,7 +96,7 @@ export function ScriptDetailModal({
setLoadMessage(`[ERROR] ${error}`);
}
// Clear message after 5 seconds
void setTimeout(() => setLoadMessage(null), 5000);
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsLoading(false);
@@ -109,7 +121,7 @@ export function ScriptDetailModal({
setLoadMessage(`[ERROR] ${error}`);
}
// Clear message after 5 seconds
void setTimeout(() => setLoadMessage(null), 5000);
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsDeleting(false);
@@ -143,9 +155,10 @@ export function ScriptDetailModal({
// 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;
const hasMultipleVariants =
installMethods.filter(
(method) => method.type === "default" || method.type === "alpine",
).length > 1;
if (hasMultipleVariants) {
// Show version selection modal first
@@ -153,9 +166,13 @@ export function ScriptDetailModal({
} 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 defaultMethod = installMethods.find(
(method) => method.type === "default",
);
const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type ?? firstMethod?.type ?? 'default');
setSelectedVersionType(
defaultMethod?.type ?? firstMethod?.type ?? "default",
);
setExecutionModeOpen(true);
}
};
@@ -166,16 +183,15 @@ export function ScriptDetailModal({
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
const handleExecuteScript = (mode: "local" | "ssh", server?: Server) => {
if (!script || !onInstallScript) return;
// 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,
);
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}`;
@@ -207,31 +223,31 @@ export function ScriptDetailModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
<div className="bg-card border-border mx-2 max-h-[95vh] min-h-[80vh] w-full max-w-6xl overflow-y-auto rounded-lg border shadow-xl sm:mx-4 lg:mx-0">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
{script.logo && !imageError ? (
<Image
src={script.logo}
alt={`${script.name} logo`}
width={64}
height={64}
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16"
onError={handleImageError}
/>
) : (
<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-lg sm:text-2xl font-semibold text-muted-foreground">
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16">
<span className="text-muted-foreground text-lg font-semibold sm:text-2xl">
{script.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl">
{script.name}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
@@ -243,11 +259,13 @@ export function ScriptDetailModal({
href={script.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors"
className="bg-muted text-muted-foreground border-border hover:bg-accent hover:text-foreground rounded border px-2 py-0.5 text-xs transition-colors"
onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`}
>
{(/github\.com\/([^\/]+)\/([^\/]+)/.exec(script.repository_url))?.[0]?.replace('https://', '') ?? script.repository_url}
{/github\.com\/([^\/]+)\/([^\/]+)/
.exec(script.repository_url)?.[0]
?.replace("https://", "") ?? script.repository_url}
</a>
)}
</div>
@@ -255,12 +273,12 @@ export function ScriptDetailModal({
{/* Interface Port*/}
{script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
<div className="ml-3 flex-shrink-0 sm:ml-4">
<div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm">
Port:
</span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
<span className="text-foreground font-mono text-sm font-semibold sm:text-base">
{script.interface_port}
</span>
</div>
@@ -273,7 +291,7 @@ export function ScriptDetailModal({
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-6"
@@ -292,189 +310,91 @@ export function ScriptDetailModal({
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
{/* Install Button - only show if script files exist */}
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
onInstallScript && (
<Button
onClick={handleInstallScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
<div className="border-border flex flex-col items-stretch space-y-2 border-b p-4 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-2 sm:p-6">
{/* Install Button - only show if script files exist */}
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
onInstallScript && (
<Button
onClick={handleInstallScript}
variant="outline"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Install</span>
</Button>
)}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Install</span>
</Button>
)}
{/* View Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleViewScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
{/* View Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleViewScript}
variant="outline"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>View</span>
</Button>
)}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>View</span>
</Button>
)}
{/* Load/Update Script Button */}
{(() => {
const hasLocalFiles =
scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences;
{/* Load/Update Script Button */}
{(() => {
const hasLocalFiles =
scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences;
if (!hasLocalFiles) {
// No local files - show Load Script button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-success text-success-foreground hover:bg-success/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Loading...</span>
</>
) : (
<>
<svg
className="h-4 w-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>
);
} else if (isUpToDate) {
// Local files exist and are up to date - show disabled Update button
return (
<button
disabled
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
>
<svg
className="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>
<span>Up to Date</span>
</button>
);
} else {
// Local files exist but have differences - show Update button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-warning text-warning-foreground hover:bg-warning/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span>
</>
) : (
<>
<svg
className="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>
<span>Update Script</span>
</>
)}
</button>
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
if (!hasLocalFiles) {
// No local files - show Load Script button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-success text-success-foreground hover:bg-success/90"
}`}
>
{isDeleting ? (
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
<span>Loading...</span>
</>
) : (
<>
@@ -488,23 +408,121 @@ export function ScriptDetailModal({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
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>Delete Script</span>
<span>Load Script</span>
</>
)}
</Button>
)}
</button>
);
} else if (isUpToDate) {
// Local files exist and are up to date - show disabled Update button
return (
<button
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"
>
<svg
className="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>
<span>Up to Date</span>
</button>
);
} else {
// Local files exist but have differences - show Update button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-warning text-warning-foreground hover:bg-warning/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span>
</>
) : (
<>
<svg
className="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>
<span>Update Script</span>
</>
)}
</button>
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
{isDeleting ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>Delete Script</span>
</>
)}
</Button>
)}
</div>
{/* Content */}
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6">
{/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div>
<span>Loading script status...</span>
</div>
</div>
@@ -527,8 +545,8 @@ export function ScriptDetailModal({
}
return (
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
<div className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
@@ -567,31 +585,33 @@ export function ScriptDetailModal({
</>
) : comparisonLoading ? (
<>
<div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div>
<div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div>
<span>Checking for updates...</span>
</>
) : comparisonData?.error ? (
<>
<div className="h-2 w-2 rounded-full bg-destructive"></div>
<span className="text-destructive">Error: {comparisonData.error}</span>
<div className="bg-destructive h-2 w-2 rounded-full"></div>
<span className="text-destructive">
Error: {comparisonData.error}
</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-muted"></div>
<div className="bg-muted h-2 w-2 rounded-full"></div>
<span>Status: Unknown</span>
</>
)}
<button
onClick={() => void refetchComparison()}
disabled={comparisonLoading}
className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
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="h-4 w-4 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -609,7 +629,7 @@ export function ScriptDetailModal({
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground break-words">
<div className="text-muted-foreground mt-2 text-xs break-words">
Files: {scriptFilesData.files.join(", ")}
</div>
)}
@@ -619,17 +639,17 @@ export function ScriptDetailModal({
{/* Load Message */}
{loadMessage && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
{loadMessage}
</div>
)}
{/* Description */}
<div>
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg">
Description
</h3>
<p className="text-sm sm:text-base text-muted-foreground">
<p className="text-muted-foreground text-sm sm:text-base">
{script.description}
</p>
</div>
@@ -637,50 +657,50 @@ export function ScriptDetailModal({
{/* Basic Information */}
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Basic Information
</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Slug
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.slug}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Date Created
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.date_created}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Categories
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.categories.join(", ")}
</dd>
</div>
{script.interface_port && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Interface Port
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.interface_port}
</dd>
</div>
)}
{script.config_path && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Config Path
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.config_path}
</dd>
</div>
@@ -689,13 +709,13 @@ export function ScriptDetailModal({
</div>
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Links
</h3>
<dl className="space-y-2">
{script.website && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Website
</dt>
<dd className="text-sm">
@@ -703,7 +723,7 @@ export function ScriptDetailModal({
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80"
className="text-primary hover:text-primary/80 break-all"
>
{script.website}
</a>
@@ -712,7 +732,7 @@ export function ScriptDetailModal({
)}
{script.documentation && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Documentation
</dt>
<dd className="text-sm">
@@ -720,7 +740,7 @@ export function ScriptDetailModal({
href={script.documentation}
target="_blank"
rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80"
className="text-primary hover:text-primary/80 break-all"
>
{script.documentation}
</a>
@@ -736,26 +756,26 @@ export function ScriptDetailModal({
script.type !== "pve" &&
script.type !== "addon" && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Install Methods
</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div
key={index}
className="rounded-lg border border-border bg-card p-3 sm:p-4"
className="border-border bg-card rounded-lg border p-3 sm:p-4"
>
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0">
<h4 className="text-foreground text-sm font-medium capitalize sm:text-base">
{method.type}
</h4>
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm">
{method.script}
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4">
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
CPU
</dt>
<dd className="text-foreground">
@@ -763,7 +783,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
RAM
</dt>
<dd className="text-foreground">
@@ -771,7 +791,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
HDD
</dt>
<dd className="text-foreground">
@@ -779,7 +799,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
OS
</dt>
<dd className="text-foreground">
@@ -797,26 +817,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ??
script.default_credentials.password) && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Default Credentials
</h3>
<dl className="space-y-2">
{script.default_credentials.username && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Username
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.default_credentials.username}
</dd>
</div>
)}
{script.default_credentials.password && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Password
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.default_credentials.password}
</dd>
</div>
@@ -828,7 +848,7 @@ export function ScriptDetailModal({
{/* Notes */}
{script.notes.length > 0 && (
<div>
<h3 className="mb-3 text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-lg font-semibold">
Notes
</h3>
<ul className="space-y-2">
@@ -843,14 +863,17 @@ export function ScriptDetailModal({
key={index}
className={`rounded-lg p-3 text-sm ${
noteType === "warning"
? "border-l-4 border-warning bg-warning/10 text-warning"
? "border-warning bg-warning/10 text-warning border-l-4"
: noteType === "error"
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
? "border-destructive bg-destructive/10 text-destructive border-l-4"
: "bg-muted text-muted-foreground"
}`}
>
<div className="flex items-start">
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
<NoteBadge
noteType={noteType as "info" | "warning" | "error"}
className="mr-2 flex-shrink-0"
>
{noteType}
</NoteBadge>
<span>{noteText}</span>
@@ -882,7 +905,13 @@ export function ScriptDetailModal({
<TextViewer
scriptName={
script.install_methods
?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/")))
?.find(
(method) =>
method.script &&
(method.script.startsWith("ct/") ||
method.script.startsWith("vm/") ||
method.script.startsWith("tools/")),
)
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import type { Script } from '../../types/script';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useState } from "react";
import type { Script } from "../../types/script";
import { Button } from "./ui/button";
import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptVersionModalProps {
isOpen: boolean;
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
script: Script | null;
}
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
export function ScriptVersionModal({
isOpen,
onClose,
onSelectVersion,
script,
}: ScriptVersionModalProps) {
useRegisterModal(isOpen, {
id: "script-version-modal",
allowEscape: true,
onClose,
});
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null;
// Get available install methods
const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find(method => method.type === 'default');
const alpineMethod = installMethods.find(method => method.type === 'alpine');
const defaultMethod = installMethods.find(
(method) => method.type === "default",
);
const alpineMethod = installMethods.find(
(method) => method.type === "alpine",
);
const handleConfirm = () => {
if (selectedVersion) {
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
<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 w-full max-w-2xl rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
<div className="border-border flex items-center justify-between border-b p-6">
<h2 className="text-foreground text-xl font-bold">Select Version</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
<h3 className="text-foreground mb-2 text-lg font-medium">
Choose a version for &quot;{script.name}&quot;
</h3>
<p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different resource requirements.
<p className="text-muted-foreground text-sm">
Select the version you want to install. Each version has different
resource requirements.
</p>
</div>
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Default Version */}
{defaultMethod && (
<div
onClick={() => handleVersionSelect('default')}
onClick={() => handleVersionSelect("default")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'default'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
selectedVersion === "default"
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="mb-3 flex items-center space-x-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'default'
? 'border-primary bg-primary'
: 'border-border'
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === "default"
? "border-primary bg-primary"
: "border-border"
}`}
>
{selectedVersion === 'default' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
{selectedVersion === "default" && (
<svg
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
<h4 className="text-foreground text-base font-semibold capitalize">
{defaultMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.cpu} cores
</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.ram} MB
</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.hdd} GB
</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{defaultMethod.resources.os} {defaultMethod.resources.version}
{defaultMethod.resources.os}{" "}
{defaultMethod.resources.version}
</span>
</div>
</div>
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Alpine Version */}
{alpineMethod && (
<div
onClick={() => handleVersionSelect('alpine')}
onClick={() => handleVersionSelect("alpine")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'alpine'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
selectedVersion === "alpine"
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="mb-3 flex items-center space-x-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'alpine'
? 'border-primary bg-primary'
: 'border-border'
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === "alpine"
? "border-primary bg-primary"
: "border-border"
}`}
>
{selectedVersion === 'alpine' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
{selectedVersion === "alpine" && (
<svg
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
<h4 className="text-foreground text-base font-semibold capitalize">
{alpineMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.cpu} cores
</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.ram} MB
</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.hdd} GB
</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{alpineMethod.resources.os} {alpineMethod.resources.version}
{alpineMethod.resources.os}{" "}
{alpineMethod.resources.version}
</span>
</div>
</div>
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={onClose}
variant="outline"
size="default"
>
<div className="mt-6 flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default">
Cancel
</Button>
<Button
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
disabled={!selectedVersion}
variant="default"
size="default"
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
className={
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
}
>
Continue
</Button>
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</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 type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
import { useState, useEffect } from "react";
import type { CreateServerData } from "../../types/server";
import { Button } from "./ui/button";
import { SSHKeyInput } from "./SSHKeyInput";
import { PublicKeyModal } from "./PublicKeyModal";
import { Key } from "lucide-react";
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
@@ -14,40 +14,47 @@ interface ServerFormProps {
onCancel?: () => void;
}
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
export function ServerForm({
onSubmit,
initialData,
isEditing = false,
onCancel,
}: ServerFormProps) {
const [formData, setFormData] = useState<CreateServerData>(
initialData ?? {
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
name: "",
ip: "",
user: "",
password: "",
auth_type: "password",
ssh_key: "",
ssh_key_passphrase: "",
ssh_port: 22,
color: '#3b82f6',
}
color: "#3b82f6",
},
);
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
const [errors, setErrors] = useState<
Partial<Record<keyof CreateServerData, string>>
>({});
const [sshKeyError, setSshKeyError] = useState<string>("");
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [generatedPublicKey, setGeneratedPublicKey] = useState("");
const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => {
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
const response = await fetch("/api/settings/color-coding");
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error('Error loading color coding setting:', error);
console.error("Error loading color coding setting:", error);
}
};
void loadColorCodingSetting();
@@ -58,7 +65,8 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (!trimmed) return false;
// IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(trimmed)) {
return true;
}
@@ -66,10 +74,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed;
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1] ?? '';
if (zoneIdMatch?.[1] && zoneIdMatch[2]) {
ipv6Address = zoneIdMatch[1];
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2] ?? '';
const zoneId = zoneIdMatch[2];
if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) {
return false;
}
@@ -79,14 +87,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Pattern =
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists
const regex = /::/g;
let compressionCount = 0;
while (regex.exec(ipv6Address) !== null) {
compressionCount++;
}
const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
if (compressionCount <= 1) {
return true;
}
@@ -95,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split('.');
if (labels.every(label => label.length > 0 && label.length <= 63)) {
const labels = trimmed.split(".");
if (labels.every((label) => label.length > 0 && label.length <= 63)) {
return true;
}
}
// Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
const simpleHostnameRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true;
}
@@ -117,42 +124,45 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) {
newErrors.name = 'Server name is required';
newErrors.name = "Server name is required";
}
if (!formData.ip.trim()) {
newErrors.ip = 'Server address is required';
newErrors.ip = "Server address is required";
} else {
if (!validateServerAddress(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
newErrors.ip =
"Please enter a valid IP address (IPv4/IPv6) or hostname";
}
}
if (!formData.user.trim()) {
newErrors.user = 'Username is required';
newErrors.user = "Username is required";
}
// Validate SSH port
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
if (
formData.ssh_port !== undefined &&
(formData.ssh_port < 1 || formData.ssh_port > 65535)
) {
newErrors.ssh_port = "SSH port must be between 1 and 65535";
}
// Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password';
const authType = formData.auth_type ?? "password";
if (authType === 'password') {
if (authType === "password") {
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()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
newErrors.ssh_key = "SSH key is required for key authentication";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError;
};
@@ -163,347 +173,410 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
onSubmit(formData);
if (!isEditing) {
setFormData({
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
name: "",
ip: "",
user: "",
password: "",
auth_type: "password",
ssh_key: "",
ssh_key_passphrase: "",
ssh_port: 22,
color: '#3b82f6'
color: "#3b82f6",
});
}
}
};
const handleChange = (field: keyof CreateServerData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
// Special handling for numeric ssh_port: keep it strictly numeric
if (field === 'ssh_port') {
const raw = (e.target as HTMLInputElement).value ?? '';
const digitsOnly = raw.replace(/\D+/g, '');
setFormData(prev => ({
...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
}));
if (errors.ssh_port) {
setErrors(prev => ({ ...prev, ssh_port: undefined }));
const handleChange =
(field: keyof CreateServerData) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
// Special handling for numeric ssh_port: keep it strictly numeric
if (field === "ssh_port") {
const raw = (e.target as HTMLInputElement).value ?? "";
const digitsOnly = raw.replace(/\D+/g, "");
setFormData((prev) => ({
...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
}));
if (errors.ssh_port) {
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
}
return;
}
return;
}
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
setFormData((prev) => ({
...prev,
[field]: (e.target as HTMLInputElement).value,
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
// Reset generated key state when switching auth types
if (field === "auth_type") {
setIsGeneratedKey(false);
setGeneratedPublicKey("");
}
};
const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true);
try {
const response = await fetch('/api/servers/generate-keypair', {
method: 'POST',
const response = await fetch("/api/servers/generate-keypair", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error('Failed to generate key pair');
throw new Error("Failed to generate key pair");
}
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
const data = (await response.json()) as {
success: boolean;
privateKey?: string;
publicKey?: string;
serverId?: number;
error?: string;
};
if (data.success) {
const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({
setFormData((prev) => ({
...prev,
ssh_key: data.privateKey ?? '',
ssh_key: data.privateKey ?? "",
ssh_key_path: keyPath,
key_generated: true
key_generated: true,
}));
setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedPublicKey(data.publicKey ?? "");
setGeneratedServerId(serverId);
setIsGeneratedKey(true);
setShowPublicKeyModal(true);
setSshKeyError('');
setSshKeyError("");
} else {
throw new Error(data.error ?? 'Failed to generate key pair');
throw new Error(data.error ?? "Failed to generate key pair");
}
} catch (error) {
console.error('Error generating key pair:', error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
console.error("Error generating key pair:", error);
setSshKeyError(
error instanceof Error ? error.message : "Failed to generate key pair",
);
} finally {
setIsGeneratingKey(false);
}
};
const handleSSHKeyChange = (value: string) => {
setFormData(prev => ({ ...prev, ssh_key: value }));
setFormData((prev) => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) {
setErrors(prev => ({ ...prev, ssh_key: undefined }));
setErrors((prev) => ({ ...prev, ssh_key: undefined }));
}
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., Production Server"
/>
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
Host/IP Address *
</label>
<input
type="text"
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
/>
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
Username *
</label>
<input
type="text"
id="user"
value={formData.user}
onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., root"
/>
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div>
<div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Port
</label>
<input
type="number"
id="ssh_port"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ssh_port ? 'border-destructive' : 'border-border'
}`}
placeholder="22"
min={1}
max={65535}
/>
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
</div>
<div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? 'password'}
onChange={handleChange('auth_type')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
</select>
</div>
{colorCodingEnabled && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
Server Color
<label
htmlFor="name"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Server Name *
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? '#3b82f6'}
onChange={handleChange('color')}
className="w-20 h-10 rounded cursor-pointer border border-border"
/>
<span className="text-sm text-muted-foreground">
Choose a color to identify this server
</span>
</div>
<input
type="text"
id="name"
value={formData.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 ${
errors.name ? "border-destructive" : "border-border"
}`}
placeholder="e.g., Production Server"
/>
{errors.name && (
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
)}
</div>
)}
</div>
{/* Password Authentication */}
{formData.auth_type === 'password' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password *
</label>
<input
type="password"
id="password"
value={formData.password ?? ''}
onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-destructive' : 'border-border'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div>
)}
{/* SSH Key Authentication */}
{formData.auth_type === 'key' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-muted-foreground">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
<label
htmlFor="ip"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Host/IP Address *
</label>
<input
type="text"
id="ip"
value={formData.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 ${
errors.ip ? "border-destructive" : "border-border"
}`}
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
/>
{errors.ip && (
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
)}
</div>
<div>
<label
htmlFor="user"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Username *
</label>
<input
type="text"
id="user"
value={formData.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 ${
errors.user ? "border-destructive" : "border-border"
}`}
placeholder="e.g., root"
/>
{errors.user && (
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
)}
</div>
<div>
<label
htmlFor="ssh_port"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Port
</label>
<input
type="number"
id="ssh_port"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={formData.ssh_port ?? 22}
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 ${
errors.ssh_port ? "border-destructive" : "border-border"
}`}
placeholder="22"
min={1}
max={65535}
/>
{errors.ssh_port && (
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
)}
</div>
<div>
<label
htmlFor="auth_type"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? "password"}
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"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
</select>
</div>
{colorCodingEnabled && (
<div>
<label
htmlFor="color"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
Server Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? "#3b82f6"}
onChange={handleChange("color")}
className="border-border h-10 w-20 cursor-pointer rounded border"
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<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 gap-2">
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-success-foreground">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-xs text-success/80 mt-1">
The private key has been generated and will be saved with the server.
</p>
<span className="text-muted-foreground text-sm">
Choose a color to identify this server
</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Password Authentication */}
{formData.auth_type === "password" && (
<div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
<label
htmlFor="password"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Password *
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ''}
onChange={handleChange('ssh_key_passphrase')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
placeholder="Enter passphrase for encrypted key"
id="password"
value={formData.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 ${
errors.password ? "border-destructive" : "border-border"
}`}
placeholder="Enter password"
/>
<p className="mt-1 text-xs text-muted-foreground">
Only required if your SSH key is encrypted with a passphrase
</p>
{errors.password && (
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
)}
</div>
</div>
)}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && (
<Button
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
>
{isEditing ? 'Update Server' : 'Add Server'}
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
{/* SSH Key Authentication */}
{formData.auth_type === "key" && (
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between">
<label className="text-muted-foreground block text-sm font-medium">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? "Generating..." : "Generate Key Pair"}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ""}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && (
<p className="text-destructive mt-1 text-sm">
{errors.ssh_key}
</p>
)}
{sshKeyError && (
<p className="text-destructive mt-1 text-sm">
{sshKeyError}
</p>
)}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="bg-success/10 border-success/20 rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="text-success h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-success-foreground text-sm font-medium">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-success/80 mt-1 text-xs">
The private key has been generated and will be saved with
the server.
</p>
</div>
)}
</div>
<div>
<label
htmlFor="ssh_key_passphrase"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Key Passphrase (Optional)
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.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"
placeholder="Enter passphrase for encrypted key"
/>
<p className="text-muted-foreground mt-1 text-xs">
Only required if your SSH key is encrypted with a passphrase
</p>
</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">
{isEditing && onCancel && (
<Button
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="order-2 w-full sm:order-1 sm:w-auto"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="default"
size="default"
className="order-1 w-full sm:order-2 sm:w-auto"
>
{isEditing ? "Update Server" : "Add Server"}
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || "New Server"}
serverIp={formData.ip}
/>
</>
);
}

View File

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

View File

@@ -1,16 +1,15 @@
'use client';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Server } from '~/types/server';
interface TerminalProps {
scriptPath: string;
onClose: () => void;
mode?: 'local' | 'ssh';
server?: Server;
server?: any;
isUpdate?: boolean;
isShell?: boolean;
isBackup?: boolean;
@@ -46,13 +45,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
// Create stable server key for dependency tracking to prevent unnecessary reconnections
const serverKey = useMemo((): string | null => {
if (!server) return null;
// Use server ID if available, otherwise stringify (fallback)
return String(server.id ?? server.name ?? JSON.stringify(server));
}, [server]);
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
@@ -217,7 +209,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
void setTimeout(() => {
setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
@@ -227,7 +219,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Add resize listener for mobile responsiveness
const handleResize = () => {
if (fitAddonRef.current) {
void setTimeout(() => {
setTimeout(() => {
fitAddonRef.current.fit();
}, 50);
}
@@ -268,9 +260,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
setTimeout(() => {
setIsTerminalReady(false);
}, 0);
setIsTerminalReady(false);
}
};
}, [isClient, isMobile]);
@@ -306,32 +296,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
useEffect(() => {
// Prevent multiple connections in React Strict Mode
// Check if we already have an active connection
if (wsRef.current) {
const readyState = wsRef.current.readyState;
// If connection is open or connecting, don't create a new one
if (readyState === WebSocket.OPEN || readyState === WebSocket.CONNECTING) {
return;
}
// If connection is closing or closed, clean it up
if (readyState === WebSocket.CLOSING || readyState === WebSocket.CLOSED) {
wsRef.current = null;
hasConnectedRef.current = false;
}
}
// Prevent if already connecting
if (isConnectingRef.current || hasConnectedRef.current) {
if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
return;
}
// Close any existing connection first (safety check)
// Close any existing connection first
if (wsRef.current) {
try {
wsRef.current.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
wsRef.current.close();
wsRef.current = null;
}
@@ -388,12 +359,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
setIsConnected(false);
setIsRunning(false);
isConnectingRef.current = false;
// Reset hasConnectedRef to allow reconnection if needed
// Only reset if this was the current connection
if (wsRef.current === ws) {
hasConnectedRef.current = false;
wsRef.current = null;
}
};
ws.onerror = (error) => {
@@ -401,12 +366,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
console.error('WebSocket readyState:', ws.readyState);
setIsConnected(false);
isConnectingRef.current = false;
// Reset hasConnectedRef to allow reconnection if needed
// Only reset if this was the current connection
if (wsRef.current === ws) {
hasConnectedRef.current = false;
wsRef.current = null;
}
};
};
@@ -416,23 +375,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => {
clearTimeout(timeoutId);
isConnectingRef.current = false;
// Only close and reset if the connection is still active
if (wsRef.current) {
const readyState = wsRef.current.readyState;
if (readyState === WebSocket.OPEN || readyState === WebSocket.CONNECTING) {
try {
wsRef.current.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
}
wsRef.current = null;
}
hasConnectedRef.current = false;
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
wsRef.current.close();
}
};
// Use serverKey instead of server object to prevent unnecessary reconnections
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scriptPath, mode, serverKey, isUpdate, isShell, containerId, isMobile]);
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]);
const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
@@ -481,7 +429,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
};
wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds
void setTimeout(() => setLastInputSent(null), 2000);
setTimeout(() => setLastInputSent(null), 2000);
}
};

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
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';
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;
@@ -20,19 +20,30 @@ interface ScriptContent {
alpineInstallScript?: string;
}
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
export function TextViewer({
scriptName,
isOpen,
onClose,
script,
}: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
const [activeTab, setActiveTab] = useState<"main" | "install">("main");
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
"default",
);
// 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');
const defaultMethod = script?.install_methods?.find(
(method) => method.type === "default",
);
const alpineMethod = script?.install_methods?.find(
(method) => method.type === "alpine",
);
// Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod;
@@ -42,10 +53,11 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript = (defaultScriptPath?.startsWith('ct/') ?? false) || (alpineScriptPath?.startsWith('ct/') ?? false);
const hasInstallScript =
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
// Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, '');
const defaultScriptName = scriptName.replace(/^alpine-/, "");
const loadScriptContent = useCallback(async () => {
setIsLoading(true);
@@ -54,78 +66,109 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
try {
// Build fetch requests based on actual script paths from install_methods
const requests: Promise<Response>[] = [];
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
const requestTypes: Array<
"default-main" | "default-install" | "alpine-main" | "alpine-install"
> = [];
// Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
),
);
requestTypes.push('default-main');
requestTypes.push("default-main");
}
// Default install script (only for ct/ scripts)
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
),
);
requestTypes.push('default-install');
requestTypes.push("default-install");
}
// Alpine main script
if (hasAlpineVariant && alpineScriptPath) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`)
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
),
);
requestTypes.push('alpine-main');
requestTypes.push("alpine-main");
}
// Alpine install script (only for ct/ scripts)
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) {
if (
hasAlpineVariant &&
hasInstallScript &&
alpineScriptPath?.startsWith("ct/")
) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
),
);
requestTypes.push('alpine-install');
requestTypes.push("alpine-install");
}
const responses = await Promise.allSettled(requests);
const content: ScriptContent = {};
// Process responses based on their types
await Promise.all(responses.map(async (response, index) => {
if (response.status === 'fulfilled' && response.value.ok) {
try {
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
const type = requestTypes[index];
if (data.result?.data?.json?.success && data.result.data.json.content) {
switch (type) {
case 'default-main':
content.mainScript = data.result.data.json.content;
break;
case 'default-install':
content.installScript = data.result.data.json.content;
break;
case 'alpine-main':
content.alpineMainScript = data.result.data.json.content;
break;
case 'alpine-install':
content.alpineInstallScript = data.result.data.json.content;
break;
await Promise.all(
responses.map(async (response, index) => {
if (response.status === "fulfilled" && response.value.ok) {
try {
const data = (await response.value.json()) as {
result?: {
data?: { json?: { success?: boolean; content?: string } };
};
};
const type = requestTypes[index];
if (
data.result?.data?.json?.success &&
data.result.data.json.content
) {
switch (type) {
case "default-main":
content.mainScript = data.result.data.json.content;
break;
case "default-install":
content.installScript = data.result.data.json.content;
break;
case "alpine-main":
content.alpineMainScript = data.result.data.json.content;
break;
case "alpine-install":
content.alpineInstallScript = data.result.data.json.content;
break;
}
}
} catch {
// Ignore errors
}
} catch {
// Ignore errors
}
}
}));
}),
);
setScriptContent(content);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load script content');
setError(
err instanceof Error ? err.message : "Failed to load script content",
);
} finally {
setIsLoading(false);
}
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
}, [
defaultScriptPath,
alpineScriptPath,
slug,
hasAlpineVariant,
hasInstallScript,
]);
useEffect(() => {
if (isOpen && scriptName) {
@@ -143,48 +186,58 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
return (
<div
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-4 flex-1">
<h2 className="text-2xl font-bold text-foreground">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex flex-1 items-center space-x-4">
<h2 className="text-foreground text-2xl font-bold">
Script Viewer: {defaultScriptName}
</h2>
{hasAlpineVariant && (
<div className="flex space-x-2">
<Button
variant={selectedVersion === 'default' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('default')}
variant={
selectedVersion === "default" ? "default" : "outline"
}
onClick={() => setSelectedVersion("default")}
className="px-3 py-1 text-sm"
>
Default
</Button>
<Button
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('alpine')}
variant={selectedVersion === "alpine" ? "default" : "outline"}
onClick={() => setSelectedVersion("alpine")}
className="px-3 py-1 text-sm"
>
Alpine
</Button>
</div>
)}
{((selectedVersion === 'default' && (scriptContent.mainScript ?? scriptContent.installScript)) ?? false) ||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript ?? scriptContent.alpineInstallScript)) && (
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{((selectedVersion === "default" &&
Boolean(
scriptContent.mainScript ?? scriptContent.installScript,
)) ||
(selectedVersion === "alpine" &&
Boolean(
scriptContent.alpineMainScript ??
scriptContent.alpineInstallScript,
))) && (
<div className="flex space-x-2">
<Button
variant={activeTab === 'main' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('main')}
variant={activeTab === "main" ? "outline" : "ghost"}
onClick={() => setActiveTab("main")}
className="px-3 py-1 text-sm"
>
Script
</Button>
{hasInstallScript && (
<Button
variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')}
variant={activeTab === "install" ? "outline" : "ghost"}
onClick={() => setActiveTab("install")}
className="px-3 py-1 text-sm"
>
Install Script
@@ -197,51 +250,64 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
onClick={onClose}
className="text-muted-foreground hover:text-foreground 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
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex flex-1 flex-col overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">Loading script content...</div>
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
Loading script content...
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-destructive">Error: {error}</div>
<div className="flex h-full items-center justify-center">
<div className="text-destructive text-lg">Error: {error}</div>
</div>
) : (
<div className="flex-1 overflow-auto">
{activeTab === 'main' && (
selectedVersion === 'default' && scriptContent.mainScript ? (
{activeTab === "main" &&
(selectedVersion === "default" && scriptContent.mainScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.mainScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
) : selectedVersion === "alpine" &&
scriptContent.alpineMainScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
@@ -249,40 +315,43 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
{scriptContent.alpineMainScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
{selectedVersion === "default"
? "Default script not found"
: "Alpine script not found"}
</div>
</div>
)
)}
{activeTab === 'install' && (
selectedVersion === 'default' && scriptContent.installScript ? (
))}
{activeTab === "install" &&
(selectedVersion === "default" &&
scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
) : selectedVersion === "alpine" &&
scriptContent.alpineInstallScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
@@ -290,13 +359,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
{scriptContent.alpineInstallScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
{selectedVersion === "default"
? "Default install script not found"
: "Alpine install script not found"}
</div>
</div>
)
)}
))}
</div>
)}
</div>

View File

@@ -1,11 +1,11 @@
'use client';
"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';
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;
@@ -28,22 +28,28 @@ export function UpdateConfirmationModal({
onConfirm,
releaseInfo,
currentVersion,
latestVersion
latestVersion,
}: UpdateConfirmationModalProps) {
useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "update-confirmation-modal",
allowEscape: true,
onClose,
});
if (!isOpen || !releaseInfo) return null;
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 max-h-[90vh] flex flex-col border border-border">
<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="flex items-center justify-between p-6 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-warning" />
<AlertTriangle className="text-warning h-6 w-6" />
<div>
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
<p className="text-sm text-muted-foreground mt-1">
<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>
@@ -59,13 +65,13 @@ export function UpdateConfirmationModal({
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<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 rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-3">
<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-lg font-semibold text-card-foreground">
<h3 className="text-card-foreground text-lg font-semibold">
{releaseInfo.name || releaseInfo.tagName}
</h3>
<Badge variant="default" className="text-xs">
@@ -88,7 +94,7 @@ export function UpdateConfirmationModal({
</a>
</Button>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<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>
@@ -96,40 +102,92 @@ export function UpdateConfirmationModal({
<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'
})}
{new Date(releaseInfo.publishedAt).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
)}
</span>
</div>
</div>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
<span>Updating from </span>
<span className="font-medium text-card-foreground">v{currentVersion}</span>
<span className="text-card-foreground font-medium">
v{currentVersion}
</span>
<span> to </span>
<span className="font-medium text-card-foreground">v{latestVersion}</span>
<span className="text-card-foreground font-medium">
v{latestVersion}
</span>
</div>
</div>
{/* Changelog */}
{releaseInfo.body ? (
<div className="border rounded-lg p-6 border-border bg-card">
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
<div className="prose prose-sm max-w-none dark:prose-invert">
<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-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 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="font-semibold text-card-foreground">{children}</strong>,
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
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}
@@ -137,20 +195,23 @@ export function UpdateConfirmationModal({
</div>
</div>
) : (
<div className="border rounded-lg p-6 border-border bg-card">
<p className="text-muted-foreground">No changelog available for this release.</p>
<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 border-warning/30 rounded-lg p-4">
<div className="bg-warning/10 border-warning/30 rounded-lg border p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
<div className="text-sm text-card-foreground">
<p className="font-medium mb-1">Important:</p>
<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.
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>
@@ -159,7 +220,7 @@ export function UpdateConfirmationModal({
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<div className="border-border bg-muted/30 flex items-center justify-between border-t p-6">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
@@ -171,5 +232,3 @@ export function UpdateConfirmationModal({
</div>
);
}

View File

@@ -41,10 +41,14 @@ export async function POST(request: NextRequest) {
const sessionDurationDays = authConfig.sessionDurationDays;
const token = generateToken(username, sessionDurationDays);
// Calculate expiration time for client
const expirationTime = Date.now() + (sessionDurationDays * 24 * 60 * 60 * 1000);
const response = NextResponse.json({
success: true,
message: 'Login successful',
username
username,
expirationTime
});
// Determine if request is over HTTPS
@@ -54,7 +58,7 @@ export async function POST(request: NextRequest) {
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: isSecure, // Only secure if actually over HTTPS
sameSite: 'strict',
sameSite: 'lax', // Use lax for cross-origin navigation support
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/',
});

View File

@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
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(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -18,7 +26,7 @@ export async function GET(
}
const db = getDatabase();
const server = await db.getServerById(id);
const server = await db.getServerById(id) as ServerData | null;
if (!server) {
return NextResponse.json(
@@ -28,14 +36,14 @@ export async function GET(
}
// Only allow viewing public key if it was generated by the system
if (!(server as any).key_generated) {
if (!server.key_generated) {
return NextResponse.json(
{ error: 'Public key not available for user-provided keys' },
{ status: 403 }
);
}
if (!(server as any).ssh_key_path) {
if (!server.ssh_key_path) {
return NextResponse.json(
{ error: 'SSH key path not found' },
{ status: 404 }
@@ -43,13 +51,13 @@ export async function GET(
}
const sshService = getSSHService();
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
const publicKey = sshService.getPublicKey(server.ssh_key_path);
return NextResponse.json({
success: true,
publicKey,
serverName: (server as any).name,
serverIp: (server as any).ip
serverName: server.name,
serverIp: server.ip
});
} catch (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
const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId);
const keyPair = await sshService.generateKeyPair(Number(serverId));
return NextResponse.json({
success: true,

View File

@@ -5,13 +5,13 @@ import path from 'path';
import { isValidCron } from 'cron-validator';
interface AutoSyncSettings {
autoSyncEnabled?: boolean;
syncIntervalType?: 'predefined' | 'custom';
autoSyncEnabled: boolean;
syncIntervalType: string;
syncIntervalPredefined?: string;
syncIntervalCron?: string;
autoDownloadNew?: boolean;
autoUpdateExisting?: boolean;
notificationEnabled?: boolean;
autoDownloadNew: boolean;
autoUpdateExisting: boolean;
notificationEnabled: boolean;
appriseUrls?: string[] | string;
lastAutoSync?: string;
lastAutoSyncError?: string;
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
}
// Validate sync interval type
if (settings.syncIntervalType && !['predefined', 'custom'].includes(settings.syncIntervalType)) {
if (!['predefined', 'custom'].includes(settings.syncIntervalType)) {
return NextResponse.json(
{ error: 'syncIntervalType must be "predefined" or "custom"' },
{ status: 400 }
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
// Validate predefined interval
if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (settings.syncIntervalPredefined && !validIntervals.includes(settings.syncIntervalPredefined)) {
if (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json(
{ error: 'Invalid predefined interval' },
{ status: 400 }
@@ -97,11 +97,11 @@ export async function POST(request: NextRequest) {
if (settings.notificationEnabled && settings.appriseUrls) {
try {
// Handle both array and JSON string formats
let urls: string[];
let urls;
if (Array.isArray(settings.appriseUrls)) {
urls = settings.appriseUrls;
} else if (typeof settings.appriseUrls === 'string') {
urls = JSON.parse(settings.appriseUrls) as string[];
urls = JSON.parse(settings.appriseUrls);
} else {
return NextResponse.json(
{ error: 'Apprise URLs must be an array or JSON string' },
@@ -125,8 +125,7 @@ export async function POST(request: NextRequest) {
);
}
}
} catch (parseError) {
console.error('Error parsing Apprise URLs:', parseError);
} catch {
return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 }
@@ -146,13 +145,13 @@ export async function POST(request: NextRequest) {
// Auto-sync settings to add/update
const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType ?? 'predefined',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (typeof settings.appriseUrls === 'string' ? settings.appriseUrls : '[]'),
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls ?? '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync ?? '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? ''
@@ -177,44 +176,36 @@ export async function POST(request: NextRequest) {
// Reschedule auto-sync service with new settings
try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Update the global service instance with new settings
// Make sure required fields are set and not undefined to match type expectations
autoSyncService.saveSettings({
// Normalize appriseUrls to always be an array
const normalizedSettings = {
...settings,
autoSyncEnabled: settings.autoSyncEnabled ?? false,
autoDownloadNew: settings.autoDownloadNew ?? false,
autoUpdateExisting: settings.autoUpdateExisting ?? false,
notificationEnabled: settings.notificationEnabled ?? false,
syncIntervalType: settings.syncIntervalType ?? 'predefined',
syncIntervalPredefined: settings.syncIntervalPredefined ?? '',
syncIntervalCron: settings.syncIntervalCron ?? '',
appriseUrls: Array.isArray(settings.appriseUrls)
? settings.appriseUrls
: (typeof settings.appriseUrls === 'string'
? JSON.parse(settings.appriseUrls || '[]')
: []),
lastAutoSync: settings.lastAutoSync ?? '',
lastAutoSyncError: settings.lastAutoSyncError ?? undefined,
lastAutoSyncErrorTime: settings.lastAutoSyncErrorTime ?? undefined,
});
: settings.appriseUrls
? [settings.appriseUrls]
: undefined
};
autoSyncService.saveSettings(normalizedSettings);
if (settings.autoSyncEnabled === true) {
if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
// Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit');
stopGlobalAutoSync();
}
} catch (error) {
@@ -263,23 +254,23 @@ export async function GET() {
const settings = {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined' as 'predefined' | 'custom',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
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',
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
appriseUrls: (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
return JSON.parse(urlsValue) as string[];
} catch {
return [];
}
})(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') ?? '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') ?? null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') ?? null
};
return NextResponse.json({ settings });
@@ -309,7 +300,7 @@ async function handleTestNotification() {
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
const appriseUrls = (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
return JSON.parse(urlsValue) as string[];
} catch {
return [];
@@ -323,7 +314,7 @@ async function handleTestNotification() {
);
}
if (!appriseUrls || appriseUrls.length === 0) {
if (!appriseUrls?.length) {
return NextResponse.json(
{ error: 'No Apprise URLs configured' },
{ status: 400 }
@@ -331,7 +322,7 @@ async function handleTestNotification() {
}
// Send test notification using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
@@ -379,9 +370,9 @@ async function handleManualSync() {
}
// Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string };
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null;
if (result?.success) {
return NextResponse.json({
@@ -391,7 +382,7 @@ async function handleManualSync() {
});
} else {
return NextResponse.json(
{ error: result.message },
{ error: result?.message ?? 'Unknown error' },
{ status: 500 }
);
}

View File

@@ -1,51 +1,71 @@
"use client";
'use client';
import { useState, useRef, useEffect, startTransition } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { BackupsTab } from './_components/BackupsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { HelpButton } from './_components/HelpButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { ThemeToggle } from './_components/ThemeToggle';
import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
import { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { BackupsTab } from "./_components/BackupsTab";
import { ResyncButton } from "./_components/ResyncButton";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
import { HelpButton } from "./_components/HelpButton";
import { VersionDisplay } from "./_components/VersionDisplay";
import { ThemeToggle } from "./_components/ThemeToggle";
import { Button } from "./_components/ui/button";
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import {
ReleaseNotesModal,
getLastSeenVersion,
} from "./_components/ReleaseNotesModal";
import { Footer } from "./_components/Footer";
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
import { api } from "~/trpc/react";
import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
import type { ScriptCard } from "~/types/script";
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
return savedTab || 'scripts';
const [runningScript, setRunningScript] = useState<{
path: string;
name: string;
mode?: "local" | "ssh";
server?: Server;
} | 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 [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: scriptCardsData } =
api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
if (typeof window !== "undefined") {
localStorage.setItem("activeTab", activeTab);
}
}, [activeTab]);
@@ -56,11 +76,12 @@ export default function Home() {
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
startTransition(() => {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
});
if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
}
}, [versionData]);
@@ -81,9 +102,9 @@ export default function Home() {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach(script => {
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
@@ -98,16 +119,17 @@ export default function Home() {
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, '');
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
const scriptMap = new Map<string, any>();
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach(script => {
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
@@ -116,15 +138,18 @@ export default function Home() {
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? [];
const localScripts = (localScriptsData.scripts ?? []) as Array<{
name?: string;
slug?: string;
}>;
// Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter(script => {
return deduplicatedGithubScripts.filter((script) => {
if (!script?.name) return false;
// Check if there's a corresponding local script
return localScripts.some(local => {
return localScripts.some((local) => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable)
@@ -133,18 +158,28 @@ export default function Home() {
return true;
}
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (normalizeId(local.slug) === normalizeId(script.slug as string | undefined)) {
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);
const matchesInstallBasename = script?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
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) === normalizeId(script.slug as string | undefined)) {
if (
script.slug &&
normalizeId(local.name ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true;
}
@@ -153,7 +188,7 @@ export default function Home() {
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0
backups: backupsData?.success ? backupsData.backups.length : 0,
};
const scrollToTerminal = () => {
@@ -164,12 +199,17 @@ export default function Home() {
window.scrollTo({
top: elementTop - offset,
behavior: 'smooth'
behavior: "smooth",
});
}
};
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: { id: number; name: string; ip: string }) => {
const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: Server,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
@@ -180,16 +220,16 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
<main className="bg-background min-h-screen">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
{/* Header */}
<div className="text-center mb-6 sm:mb-8">
<div className="flex justify-between items-start mb-2">
<div className="mb-6 text-center sm:mb-8">
<div className="mb-2 flex items-start justify-between">
<div className="flex-1"></div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex-1 flex justify-end items-center gap-2">
<div className="flex flex-1 items-center justify-end gap-2">
{isAuthenticated && (
<Button
variant="ghost"
@@ -205,8 +245,9 @@ export default function Home() {
<ThemeToggle />
</div>
</div>
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output streaming
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
Manage and execute Proxmox helper scripts locally with live output
streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -215,7 +256,7 @@ export default function Home() {
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
@@ -225,72 +266,85 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-b border-border">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<div className="border-border border-b">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? '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'
}`}>
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 ${
activeTab === "scripts"
? "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"
}`}
>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.available}
</span>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
<ContextualHelpIcon
section="available-scripts"
tooltip="Help with Available Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? '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'
}`}>
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 ${
activeTab === "downloaded"
? "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"
}`}
>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.downloaded}
</span>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
<ContextualHelpIcon
section="downloaded-scripts"
tooltip="Help with Downloaded Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? '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'
}`}>
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 ${
activeTab === "installed"
? "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"
}`}
>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.installed}
</span>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
<ContextualHelpIcon
section="installed-scripts"
tooltip="Help with Installed Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('backups')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'backups'
? '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'
}`}>
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 ${
activeTab === "backups"
? "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"
}`}
>
<Archive className="h-4 w-4" />
<span className="hidden sm:inline">Backups</span>
<span className="sm:hidden">Backups</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.backups}
</span>
</Button>
@@ -298,8 +352,6 @@ export default function Home() {
</div>
</div>
{/* Running Script Terminal */}
{runningScript && (
<div ref={terminalRef} className="mb-8">
@@ -313,21 +365,17 @@ export default function Home() {
)}
{/* Tab Content */}
{activeTab === 'scripts' && (
{activeTab === "scripts" && (
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === 'downloaded' && (
{activeTab === "downloaded" && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
{activeTab === "installed" && <InstalledScriptsTab />}
{activeTab === 'backups' && (
<BackupsTab />
)}
{activeTab === "backups" && <BackupsTab />}
</div>
{/* Footer */}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-floating-promises */
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database-prisma";
@@ -71,12 +72,9 @@ function parseRawConfig(rawConfig: string): any {
// Parse rootfs into storage and size
if (config.rootfs) {
const regex = /^([^:]+):([^,]+)(?:,size=(.+))?$/;
const match = regex.exec(config.rootfs as string);
const storage = match?.[1];
const path = match?.[2];
if (storage && path) {
config.rootfs_storage = `${storage}:${path}`;
const match = config.rootfs.match(/^([^:]+):([^,]+)(?:,size=(.+))?$/);
if (match) {
config.rootfs_storage = `${match[1]}:${match[2]}`;
config.rootfs_size = match[3] ?? '';
}
delete config.rootfs; // Remove the rootfs field since we only need rootfs_storage and rootfs_size
@@ -478,7 +476,8 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getAllInstalledScripts();
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = await Promise.all(scripts.map(async (script) => {
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
// Determine if it's a VM or LXC
let is_vm = false;
if (script.container_id && script.server_id) {
@@ -524,7 +523,8 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = await Promise.all(scripts.map(async (script) => {
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
// Determine if it's a VM or LXC
let is_vm = false;
if (script.container_id && script.server_id) {
@@ -791,7 +791,7 @@ export const installedScriptsRouter = createTRPCRouter({
let detectedContainers: any[] = [];
// Helper function to parse list output and extract IDs
const parseListOutput = (output: string, _p0: boolean): string[] => {
const parseListOutput = (output: string, _isVM: boolean): string[] => {
const ids: string[] = [];
const lines = output.split('\n').filter(line => line.trim());
@@ -1050,11 +1050,10 @@ export const installedScriptsRouter = createTRPCRouter({
const scriptData = script as any;
if (!scriptData.server_id) continue;
const serverId = Number(scriptData.server_id);
if (!scriptsByServer.has(serverId)) {
scriptsByServer.set(serverId, []);
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
}
scriptsByServer.get(serverId)!.push(scriptData);
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
}
// Process each server
@@ -1233,7 +1232,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData).script_name)}:`, error);
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
}
}
} catch (error) {
@@ -1351,7 +1350,7 @@ export const installedScriptsRouter = createTRPCRouter({
try {
await Promise.race([
new Promise<void>((resolve) => {
new Promise<void>((resolve, _reject) => {
void sshExecutionService.executeCommand(
server as Server,
'pct list',
@@ -1379,7 +1378,7 @@ export const installedScriptsRouter = createTRPCRouter({
try {
await Promise.race([
new Promise<void>((resolve) => {
new Promise<void>((resolve, _reject) => {
void sshExecutionService.executeCommand(
server as Server,
'qm list',
@@ -1483,7 +1482,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// Determine if it's a VM or LXC
const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// Check container status (use qm for VMs, pct for LXC)
const statusCommand = vm
@@ -1586,7 +1585,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// Determine if it's a VM or LXC
const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// Execute control command (use qm for VMs, pct for LXC)
const controlCommand = vm
@@ -1682,7 +1681,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// Determine if it's a VM or LXC
const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// First check if container is running and stop it if necessary
const statusCommand = vm
@@ -2376,7 +2375,7 @@ EOFCONFIG`;
let serverHostname = '';
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
sshExecutionService.executeCommand(
server as Server,
'hostname',
(data: string) => {

View File

@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
return {
success: true,
credentials: credentials.map(c => ({
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({
id: c.id,
server_id: c.server_id,
storage_name: c.storage_name,
@@ -109,7 +109,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
storage_name: input.storageName,
pbs_ip: input.pbs_ip,
pbs_datastore: input.pbs_datastore,
pbs_password: passwordToSave,
pbs_password: passwordToSave ?? '',
pbs_fingerprint: input.pbs_fingerprint,
});

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { scriptManager } from "~/server/lib/scripts";
@@ -100,7 +101,7 @@ export const scriptsRouter = createTRPCRouter({
getAllScripts: publicProcedure
.query(async () => {
try {
const scripts = await githubJsonService.getAllScripts("");
const scripts = await localScriptsService.getAllScripts();
return { success: true, scripts };
} catch (error) {
return {
@@ -177,7 +178,7 @@ export const scriptsRouter = createTRPCRouter({
const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url));
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
@@ -188,7 +189,7 @@ export const scriptsRouter = createTRPCRouter({
}
// Enhance cards with category information and additional script data
const cardsWithCategories = cards.map(card => {
const cardsWithCategories = cards.map((card: ScriptCard) => {
const script = scripts.find(s => s.slug === card.slug);
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
@@ -225,7 +226,7 @@ export const scriptsRouter = createTRPCRouter({
// Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter(card => {
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => {
const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility

View File

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

View File

@@ -3,26 +3,128 @@ import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
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 {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
init(): void {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData: CreateServerData) {
async createServer(serverData: CreateServerData): Promise<Server> {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
let ssh_key_path = null;
let ssh_key_path: string | null = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
@@ -30,7 +132,7 @@ class DatabaseServicePrisma {
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
const result = await prisma.server.create({
data: {
name,
ip,
@@ -45,27 +147,30 @@ class DatabaseServicePrisma {
color,
}
});
return result as Server;
}
async getAllServers() {
return await prisma.server.findMany({
async getAllServers(): Promise<Server[]> {
const result = await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
return result as Server[];
}
async getServerById(id: number) {
return await prisma.server.findUnique({
async getServerById(id: number): Promise<Server | null> {
const result = await prisma.server.findUnique({
where: { id }
});
return result as Server | null;
}
async updateServer(id: number, serverData: CreateServerData) {
async updateServer(id: number, serverData: CreateServerData): Promise<Server> {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
let ssh_key_path = existingServer?.ssh_key_path ?? null;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
@@ -101,7 +206,7 @@ class DatabaseServicePrisma {
ssh_key_path = null;
}
return await prisma.server.update({
const result = await prisma.server.update({
where: { id },
data: {
name,
@@ -117,9 +222,10 @@ class DatabaseServicePrisma {
color,
}
});
return result as Server;
}
async deleteServer(id: number) {
async deleteServer(id: number): Promise<Server> {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
@@ -136,9 +242,10 @@ class DatabaseServicePrisma {
}
}
return await prisma.server.delete({
const result = await prisma.server.delete({
where: { id }
});
return result as Server;
}
// Installed Scripts CRUD operations
@@ -152,10 +259,10 @@ class DatabaseServicePrisma {
output_log?: string;
web_ui_ip?: string;
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;
return await prisma.installedScript.create({
const result = await prisma.installedScript.create({
data: {
script_name,
script_path,
@@ -168,34 +275,38 @@ class DatabaseServicePrisma {
web_ui_port: web_ui_port ?? null,
}
});
return result as InstalledScript;
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
return result as InstalledScriptWithServer[];
}
async getInstalledScriptById(id: number) {
return await prisma.installedScript.findUnique({
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> {
const result = await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
return result as InstalledScriptWithServer | null;
}
async getInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.findMany({
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
return result as InstalledScriptWithServer[];
}
async updateInstalledScript(id: number, updateData: {
@@ -205,17 +316,10 @@ class DatabaseServicePrisma {
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
}): Promise<InstalledScript | { changes: number }> {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
} = {};
const updateFields: Prisma.InstalledScriptUpdateInput = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
@@ -227,33 +331,36 @@ class DatabaseServicePrisma {
return { changes: 0 };
}
return await prisma.installedScript.update({
const result = await prisma.installedScript.update({
where: { id },
data: updateFields
});
return result as InstalledScript;
}
async deleteInstalledScript(id: number) {
return await prisma.installedScript.delete({
async deleteInstalledScript(id: number): Promise<InstalledScript> {
const result = await prisma.installedScript.delete({
where: { id }
});
return result as InstalledScript;
}
async deleteInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.deleteMany({
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> {
const result = await prisma.installedScript.deleteMany({
where: { server_id }
});
return result as { count: number };
}
async getNextServerId() {
async getNextServerId(): Promise<number> {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
return ((result as { id: number } | null)?.id ?? 0) + 1;
}
createSSHKeyFile(serverId: number, sshKey: string) {
createSSHKeyFile(serverId: number, sshKey: string): string {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
@@ -266,17 +373,18 @@ class DatabaseServicePrisma {
}
// LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.create({
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
const result = await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
return result as LXCConfig;
}
async updateLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.upsert({
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
const result = await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
@@ -284,16 +392,18 @@ class DatabaseServicePrisma {
...configData
}
});
return result as LXCConfig;
}
async getLXCConfigByScriptId(scriptId: number) {
return await prisma.lXCConfig.findUnique({
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> {
const result = await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
return result as LXCConfig | null;
}
async deleteLXCConfig(scriptId: number) {
return await prisma.lXCConfig.delete({
async deleteLXCConfig(scriptId: number): Promise<void> {
await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
@@ -309,7 +419,7 @@ class DatabaseServicePrisma {
created_at?: Date;
storage_name: string;
storage_type: 'local' | 'storage' | 'pbs';
}) {
}): Promise<Backup> {
// Find existing backup by container_id, server_id, and backup_path
const existing = await prisma.backup.findFirst({
where: {
@@ -317,11 +427,11 @@ class DatabaseServicePrisma {
server_id: backupData.server_id,
backup_path: backupData.backup_path,
},
});
}) as Backup | null;
if (existing) {
// Update existing backup
return await prisma.backup.update({
const result = await prisma.backup.update({
where: { id: existing.id },
data: {
hostname: backupData.hostname,
@@ -333,9 +443,10 @@ class DatabaseServicePrisma {
discovered_at: new Date(),
},
});
return result as Backup;
} else {
// Create new backup
return await prisma.backup.create({
const result = await prisma.backup.create({
data: {
container_id: backupData.container_id,
server_id: backupData.server_id,
@@ -349,11 +460,12 @@ class DatabaseServicePrisma {
discovered_at: new Date(),
},
});
return result as Backup;
}
}
async getAllBackups() {
return await prisma.backup.findMany({
async getAllBackups(): Promise<BackupWithServer[]> {
const result = await prisma.backup.findMany({
include: {
server: true,
},
@@ -362,58 +474,43 @@ class DatabaseServicePrisma {
{ created_at: 'desc' },
],
});
return result as BackupWithServer[];
}
async getBackupById(id: number) {
return await prisma.backup.findUnique({
async getBackupById(id: number): Promise<BackupWithServer | null> {
const result = await prisma.backup.findUnique({
where: { id },
include: {
server: true,
},
});
return result as BackupWithServer | null;
}
async getBackupsByContainerId(containerId: string) {
return await prisma.backup.findMany({
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> {
const result = await prisma.backup.findMany({
where: { container_id: containerId },
include: {
server: true,
},
orderBy: { created_at: 'desc' },
});
return result as BackupWithServer[];
}
async deleteBackupsForContainer(containerId: string, serverId: number) {
return await prisma.backup.deleteMany({
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> {
const result = await prisma.backup.deleteMany({
where: {
container_id: containerId,
server_id: serverId,
},
});
return result as { count: number };
}
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;
}>>> {
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> {
const backups = await this.getAllBackups();
const grouped = new Map<string, typeof backups>();
const grouped = new Map<string, BackupWithServer[]>();
for (const backup of backups) {
const key = backup.container_id;
@@ -434,8 +531,8 @@ class DatabaseServicePrisma {
pbs_datastore: string;
pbs_password: string;
pbs_fingerprint: string;
}) {
return await prisma.pBSStorageCredential.upsert({
}): Promise<PBSStorageCredential> {
const result = await prisma.pBSStorageCredential.upsert({
where: {
server_id_storage_name: {
server_id: credentialData.server_id,
@@ -458,10 +555,11 @@ class DatabaseServicePrisma {
pbs_fingerprint: credentialData.pbs_fingerprint,
},
});
return result as PBSStorageCredential;
}
async getPBSCredential(serverId: number, storageName: string) {
return await prisma.pBSStorageCredential.findUnique({
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> {
const result = await prisma.pBSStorageCredential.findUnique({
where: {
server_id_storage_name: {
server_id: serverId,
@@ -469,17 +567,19 @@ class DatabaseServicePrisma {
},
},
});
return result as PBSStorageCredential | null;
}
async getPBSCredentialsByServer(serverId: number) {
return await prisma.pBSStorageCredential.findMany({
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> {
const result = await prisma.pBSStorageCredential.findMany({
where: { server_id: serverId },
orderBy: { storage_name: 'asc' },
});
return result as PBSStorageCredential[];
}
async deletePBSCredential(serverId: number, storageName: string) {
return await prisma.pBSStorageCredential.delete({
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> {
const result = await prisma.pBSStorageCredential.delete({
where: {
server_id_storage_name: {
server_id: serverId,
@@ -487,9 +587,10 @@ class DatabaseServicePrisma {
},
},
});
return result as PBSStorageCredential;
}
async close() {
async close(): Promise<void> {
await prisma.$disconnect();
}
}
@@ -497,7 +598,7 @@ class DatabaseServicePrisma {
// Singleton instance
let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase() {
export function getDatabase(): DatabaseServicePrisma {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}

View File

@@ -1,7 +1,11 @@
import { PrismaClient } from '@prisma/client';
import 'dotenv/config'
import { PrismaClient } from '../../prisma/generated/prisma/client.ts'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
const globalForPrisma = globalThis;
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
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;

View File

@@ -1,10 +1,13 @@
import { PrismaClient } from '@prisma/client';
import 'dotenv/config'
import { PrismaClient } from '../../prisma/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const globalForPrisma = globalThis as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! });
export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient({
adapter,
log: ['warn', 'error']
});

View File

@@ -1,17 +1,23 @@
import { AutoSyncService } from '../services/autoSyncService.js';
import { repositoryService } from '../services/repositoryService.ts';
import { repositoryService } from '../services/repositoryService.js';
/** @type {AutoSyncService | null} */
let autoSyncService = null;
let isInitialized = false;
/**
* Initialize default repositories
* @returns {Promise<void>}
*/
export async function initializeRepositories() {
try {
console.log('Initializing default repositories...');
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
if (repositoryService && repositoryService.initializeDefaultRepositories) {
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
} else {
console.warn('Repository service not available, skipping repository initialization');
}
} catch (error) {
console.error('Failed to initialize repositories:', error);
console.error('Error stack:', error.stack);

View File

@@ -13,9 +13,7 @@ export async function initializeRepositories(): Promise<void> {
console.log('Default repositories initialized successfully');
} catch (error) {
console.error('Failed to initialize repositories:', error);
if (error instanceof Error) {
console.error('Error stack:', error.stack);
}
console.error('Error stack:', (error as Error).stack);
}
}

View File

@@ -272,6 +272,12 @@ export class AutoSyncService {
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
/** @type {any} */
const cronOptions = {
scheduled: true,
timezone: 'UTC'
};
this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first
if (globalAutoSyncLock) {
@@ -300,9 +306,7 @@ export class AutoSyncService {
console.log('Starting scheduled auto-sync...');
await this.executeAutoSync();
}, {
timezone: 'UTC'
});
}, cronOptions);
console.log('Auto-sync cron job scheduled successfully');
}
@@ -372,7 +376,7 @@ export class AutoSyncService {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// Get scripts only for the synced files
const localScriptsService = await import('./localScripts.js');
const localScriptsService = await import('./localScripts');
const syncedScripts = [];
for (const filename of syncResult.syncedFiles) {

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unused-vars, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-optional-chain */
import { getSSHExecutionService } from '../ssh-execution-service';
import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma';
@@ -31,14 +32,14 @@ class BackupService {
(data: string) => {
hostname += data;
},
(error: string) => {
reject(new Error(`Failed to get hostname: ${error}`));
(_error: string) => {
reject(new Error(`Failed to get hostname: ${_error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
(_exitCode: number) => {
if (_exitCode === 0) {
resolve();
} else {
reject(new Error(`hostname command failed with exit code ${exitCode}`));
reject(new Error(`hostname command failed with exit code ${_exitCode}`));
}
}
);

View File

@@ -1,6 +1,428 @@
// JavaScript wrapper for githubJsonService.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
// JavaScript wrapper for githubJsonService (for use with node server.js)
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { repositoryService } from './repositoryService.js';
import { githubJsonService } from './githubJsonService.ts';
// Get environment variables
const getEnv = () => ({
REPO_BRANCH: process.env.REPO_BRANCH || 'main',
JSON_FOLDER: process.env.JSON_FOLDER || 'json',
REPO_URL: process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE',
GITHUB_TOKEN: process.env.GITHUB_TOKEN
});
export { githubJsonService };
class GitHubJsonService {
constructor() {
this.branch = null;
this.jsonFolder = null;
this.localJsonDirectory = null;
this.scriptCache = new Map();
}
initializeConfig() {
if (this.branch === null) {
const env = getEnv();
this.branch = env.REPO_BRANCH;
this.jsonFolder = env.JSON_FOLDER;
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
}
}
getBaseUrl(repoUrl) {
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
const [, owner, repo] = urlMatch;
return `https://api.github.com/repos/${owner}/${repo}`;
}
extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
return `${match[1]}/${match[2]}`;
}
async fetchFromGitHub(repoUrl, endpoint) {
const baseUrl = this.getBaseUrl(repoUrl);
const env = getEnv();
const headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${baseUrl}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async downloadJsonFile(repoUrl, filePath) {
this.initializeConfig();
const repoPath = this.extractRepoPath(repoUrl);
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch}/${filePath}`;
const env = getEnv();
const headers = {
'User-Agent': 'PVEScripts-Local/1.0',
};
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits.`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
const content = await response.text();
const script = JSON.parse(content);
script.repository_url = repoUrl;
return script;
}
async getJsonFiles(repoUrl) {
this.initializeConfig();
try {
const files = await this.fetchFromGitHub(
repoUrl,
`/contents/${this.jsonFolder}?ref=${this.branch}`
);
return files.filter(file => file.name.endsWith('.json'));
} catch (error) {
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
}
}
async getAllScripts(repoUrl) {
try {
const jsonFiles = await this.getJsonFiles(repoUrl);
const scripts = [];
for (const file of jsonFiles) {
try {
const script = await this.downloadJsonFile(repoUrl, file.path);
scripts.push(script);
} catch (error) {
console.error(`Failed to download script ${file.name} from ${repoUrl}:`, error);
}
}
return scripts;
} catch (error) {
console.error(`Error fetching all scripts from ${repoUrl}:`, error);
throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
}
}
async getScriptCards(repoUrl) {
try {
const scripts = await this.getAllScripts(repoUrl);
return scripts.map(script => ({
name: script.name,
slug: script.slug,
description: script.description,
logo: script.logo,
type: script.type,
updateable: script.updateable,
website: script.website,
repository_url: script.repository_url,
}));
} catch (error) {
console.error(`Error creating script cards from ${repoUrl}:`, error);
throw new Error(`Failed to create script cards from repository: ${repoUrl}`);
}
}
async getScriptBySlug(slug, repoUrl) {
try {
const localScript = await this.getScriptFromLocal(slug);
if (localScript) {
if (repoUrl && localScript.repository_url !== repoUrl) {
return null;
}
return localScript;
}
if (repoUrl) {
try {
this.initializeConfig();
const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder}/${slug}.json`);
return script;
} catch {
return null;
}
}
const enabledRepos = await repositoryService.getEnabledRepositories();
for (const repo of enabledRepos) {
try {
this.initializeConfig();
const script = await this.downloadJsonFile(repo.url, `${this.jsonFolder}/${slug}.json`);
return script;
} catch {
// Continue to next repo
}
}
return null;
} catch (error) {
console.error('Error fetching script by slug:', error);
throw new Error(`Failed to fetch script: ${slug}`);
}
}
async getScriptFromLocal(slug) {
try {
if (this.scriptCache.has(slug)) {
return this.scriptCache.get(slug);
}
this.initializeConfig();
const filePath = join(this.localJsonDirectory, `${slug}.json`);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url) {
const env = getEnv();
script.repository_url = env.REPO_URL;
}
this.scriptCache.set(slug, script);
return script;
} catch {
return null;
}
}
async syncJsonFilesForRepo(repoUrl) {
try {
console.log(`Starting JSON sync from repository: ${repoUrl}`);
const githubFiles = await this.getJsonFiles(repoUrl);
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} local JSON files`);
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
if (filesToSync.length === 0) {
return {
success: true,
message: `All JSON files are up to date for repository: ${repoUrl}`,
count: 0,
syncedFiles: []
};
}
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
return {
success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
count: syncedFiles.length,
syncedFiles
};
} catch (error) {
console.error(`JSON sync failed for ${repoUrl}:`, error);
return {
success: false,
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0,
syncedFiles: []
};
}
}
async syncJsonFiles() {
try {
console.log('Starting multi-repository JSON sync...');
const enabledRepos = await repositoryService.getEnabledRepositories();
if (enabledRepos.length === 0) {
return {
success: false,
message: 'No enabled repositories found',
count: 0,
syncedFiles: []
};
}
console.log(`Found ${enabledRepos.length} enabled repositories`);
const allSyncedFiles = [];
const processedSlugs = new Set();
let totalSynced = 0;
for (const repo of enabledRepos) {
try {
console.log(`Syncing from repository: ${repo.url} (priority: ${repo.priority})`);
const result = await this.syncJsonFilesForRepo(repo.url);
if (result.success) {
const newFiles = result.syncedFiles.filter(file => {
const slug = file.replace('.json', '');
if (processedSlugs.has(slug)) {
return false;
}
processedSlugs.add(slug);
return true;
});
allSyncedFiles.push(...newFiles);
totalSynced += newFiles.length;
} else {
console.error(`Failed to sync from ${repo.url}: ${result.message}`);
}
} catch (error) {
console.error(`Error syncing from ${repo.url}:`, error);
}
}
await this.updateExistingFilesWithRepositoryUrl();
return {
success: true,
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
count: totalSynced,
syncedFiles: allSyncedFiles
};
} catch (error) {
console.error('Multi-repository JSON sync failed:', error);
return {
success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0,
syncedFiles: []
};
}
}
async updateExistingFilesWithRepositoryUrl() {
try {
this.initializeConfig();
const files = await this.getLocalJsonFiles();
const env = getEnv();
const mainRepoUrl = env.REPO_URL;
for (const file of files) {
try {
const filePath = join(this.localJsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url) {
script.repository_url = mainRepoUrl;
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
console.log(`Updated ${file} with repository_url: ${mainRepoUrl}`);
}
} catch (error) {
console.error(`Error updating ${file}:`, error);
}
}
} catch (error) {
console.error('Error updating existing files with repository_url:', error);
}
}
async getLocalJsonFiles() {
this.initializeConfig();
try {
const files = await readdir(this.localJsonDirectory);
return files.filter(f => f.endsWith('.json'));
} catch {
return [];
}
}
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
const filesToSync = [];
for (const ghFile of githubFiles) {
const localFilePath = join(this.localJsonDirectory, ghFile.name);
let needsSync = false;
if (!localFiles.includes(ghFile.name)) {
needsSync = true;
} else {
try {
const content = await readFile(localFilePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url || script.repository_url !== repoUrl) {
needsSync = true;
}
} catch {
needsSync = true;
}
}
if (needsSync) {
filesToSync.push(ghFile);
}
}
return filesToSync;
}
async syncSpecificFiles(repoUrl, filesToSync) {
this.initializeConfig();
const syncedFiles = [];
await mkdir(this.localJsonDirectory, { recursive: true });
for (const file of filesToSync) {
try {
const script = await this.downloadJsonFile(repoUrl, file.path);
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory, filename);
script.repository_url = repoUrl;
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename);
this.scriptCache.delete(script.slug);
} catch (error) {
console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
}
}
return syncedFiles;
}
}
// Singleton instance
export const githubJsonService = new GitHubJsonService();

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { env } from '../../env.js';

View File

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

View File

@@ -1,3 +1,4 @@
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import type { Script, ScriptCard } from '~/types/script';
@@ -95,7 +96,7 @@ export class LocalScriptsService {
let foundRepo: string | null = null;
for (const repo of enabledRepos) {
try {
const { githubJsonService } = await import('./githubJsonService.js');
const { githubJsonService } = await import('./githubJsonService');
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
if (repoScript) {
foundRepo = repo.url;

View File

@@ -0,0 +1,216 @@
// JavaScript wrapper for repositoryService (for use with node server.js)
import { prisma } from '../db.js';
class RepositoryService {
/**
* Initialize default repositories if they don't exist
*/
async initializeDefaultRepositories() {
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
// Check if repositories already exist
const existingRepos = await prisma.repository.findMany({
where: {
url: {
in: [mainRepoUrl, devRepoUrl]
}
}
});
const existingUrls = new Set(existingRepos.map((r) => r.url));
// Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) {
await prisma.repository.create({
data: {
url: mainRepoUrl,
enabled: true,
is_default: true,
is_removable: false,
priority: 1
}
});
console.log('Initialized main repository:', mainRepoUrl);
}
// Create dev repo if it doesn't exist
if (!existingUrls.has(devRepoUrl)) {
await prisma.repository.create({
data: {
url: devRepoUrl,
enabled: false,
is_default: true,
is_removable: false,
priority: 2
}
});
console.log('Initialized dev repository:', devRepoUrl);
}
}
/**
* Get all repositories, sorted by priority
*/
async getAllRepositories() {
return await prisma.repository.findMany({
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get enabled repositories, sorted by priority
*/
async getEnabledRepositories() {
return await prisma.repository.findMany({
where: {
enabled: true
},
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get repository by URL
*/
async getRepositoryByUrl(url) {
return await prisma.repository.findUnique({
where: { url }
});
}
/**
* Create a new repository
*/
async createRepository(data) {
// Validate GitHub URL
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}
// Check for duplicates
const existing = await this.getRepositoryByUrl(data.url);
if (existing) {
throw new Error('Repository already exists');
}
// Get max priority for user-added repos
const maxPriority = await prisma.repository.aggregate({
_max: {
priority: true
}
});
return await prisma.repository.create({
data: {
url: data.url,
enabled: data.enabled ?? true,
is_default: false,
is_removable: true,
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
}
});
}
/**
* Update repository
*/
async updateRepository(id, data) {
// If updating URL, validate it
if (data.url) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}
// Check for duplicates (excluding current repo)
const existing = await prisma.repository.findFirst({
where: {
url: data.url,
id: { not: id }
}
});
if (existing) {
throw new Error('Repository URL already exists');
}
}
return await prisma.repository.update({
where: { id },
data
});
}
/**
* Delete repository and associated JSON files
*/
async deleteRepository(id) {
const repo = await prisma.repository.findUnique({
where: { id }
});
if (!repo) {
throw new Error('Repository not found');
}
if (!repo.is_removable) {
throw new Error('Cannot delete default repository');
}
// Delete associated JSON files
await this.deleteRepositoryJsonFiles(repo.url);
// Delete repository
await prisma.repository.delete({
where: { id }
});
return { success: true };
}
/**
* Delete all JSON files associated with a repository
*/
async deleteRepositoryJsonFiles(repoUrl) {
const { readdir, unlink, readFile } = await import('fs/promises');
const { join } = await import('path');
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
try {
const files = await readdir(jsonDirectory);
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = join(jsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
// If script has repository_url matching the repo, delete it
if (script.repository_url === repoUrl) {
await unlink(filePath);
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
}
} catch (error) {
// Skip files that can't be read or parsed
console.error(`Error processing file ${file}:`, error);
}
}
} catch (error) {
// Directory might not exist, which is fine
if (error.code !== 'ENOENT') {
console.error('Error deleting repository JSON files:', error);
}
}
}
}
// Singleton instance
export const repositoryService = new RepositoryService();

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-regexp-exec */
import { prisma } from '../db';
export class RepositoryService {
@@ -17,7 +18,7 @@ export class RepositoryService {
}
});
const existingUrls = new Set(existingRepos.map(r => r.url));
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url));
// Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) {
@@ -93,7 +94,7 @@ export class RepositoryService {
priority?: number;
}) {
// Validate GitHub URL
if (!(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/.exec(data.url))) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}
@@ -131,7 +132,7 @@ export class RepositoryService {
}) {
// If updating URL, validate it
if (data.url) {
if (!(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/.exec(data.url))) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}

View File

@@ -1,3 +1,4 @@
/* 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 { getBackupService } from './backupService';
import { getStorageService } from './storageService';
@@ -7,7 +8,6 @@ import type { Storage } from './storageService';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export interface RestoreProgress {
step: string;
message: string;
@@ -33,7 +33,7 @@ class RestoreService {
try {
// Try to read config file (container might not exist, so don't fail on error)
await new Promise<void>((resolve) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
readCommand,
(data: string) => {
@@ -51,8 +51,8 @@ class RestoreService {
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('rootfs:')) {
const match = /^rootfs:\s*([^:]+):/.exec(trimmed);
if (match?.[1]) {
const match = trimmed.match(/^rootfs:\s*([^:]+):/);
if (match && match[1]) {
return match[1].trim();
}
}
@@ -68,16 +68,15 @@ class RestoreService {
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
if (lxcConfig?.rootfs_storage) {
// Extract storage from rootfs_storage format: "STORAGE:vm-148-disk-0"
const match = /^([^:]+):/.exec(lxcConfig.rootfs_storage);
if (match?.[1]) {
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
if (match && match[1]) {
return match[1].trim();
}
}
}
return null;
} catch (error) {
console.error('Error getting rootfs storage:', error);
} catch {
// Try fallback to database
try {
const installedScripts = await db.getAllInstalledScripts();
@@ -85,14 +84,13 @@ class RestoreService {
if (script) {
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
if (lxcConfig?.rootfs_storage) {
const match = /^([^:]+):/.exec(lxcConfig.rootfs_storage);
if (match?.[1]) {
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
if (match && match[1]) {
return match[1].trim();
}
}
}
} catch (dbError) {
console.error('Error getting rootfs storage from database:', dbError);
} catch {
// Ignore database error
}
return null;
@@ -107,12 +105,10 @@ class RestoreService {
const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped
await new Promise<void>((resolve) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
command,
() => {
// Ignore output
},
() => {},
() => resolve(),
() => resolve() // Always resolve, don't fail if already stopped
);
@@ -129,7 +125,7 @@ class RestoreService {
let exitCode = 0;
await new Promise<void>((resolve, reject) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
command,
(data: string) => {
@@ -175,7 +171,7 @@ class RestoreService {
let exitCode = 0;
await new Promise<void>((resolve, reject) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
command,
(data: string) => {
@@ -219,8 +215,8 @@ class RestoreService {
const storageService = getStorageService();
const pbsInfo = storageService.getPBSStorageInfo(storage);
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) {
throw new Error(`Missing PBS IP or datastore for storage ${storage.name}`);
@@ -230,11 +226,11 @@ class RestoreService {
// Extract snapshot name from path (e.g., "2025-10-21T19:14:55Z" from "ct/148/2025-10-21T19:14:55Z")
const snapshotParts = snapshotPath.split('/');
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
// Replace colons with underscores for file paths (tar doesn't like colons in filenames)
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
// Determine file extension - try common extensions
let downloadedPath = '';
let downloadSuccess = false;
@@ -265,7 +261,7 @@ class RestoreService {
// Download from PBS (creates a folder)
await Promise.race([
new Promise<void>((resolve, reject) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
restoreCommand,
(data: string) => {
@@ -296,7 +292,7 @@ class RestoreService {
let checkOutput = '';
await new Promise<void>((resolve) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
checkCommand,
(data: string) => {
@@ -321,7 +317,7 @@ class RestoreService {
await Promise.race([
new Promise<void>((resolve, reject) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
packCommand,
(data: string) => {
@@ -352,7 +348,7 @@ class RestoreService {
let checkTarOutput = '';
await new Promise<void>((resolve) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
checkTarCommand,
(data: string) => {
@@ -385,18 +381,12 @@ class RestoreService {
// Cleanup: delete downloaded folder and tar file
if (onProgress) await onProgress('cleanup', 'Cleaning up temporary files...');
const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
void sshService.executeCommand(
sshService.executeCommand(
server,
cleanupCommand,
() => {
// Ignore output
},
() => {
// Ignore errors
},
() => {
// Ignore exit code
}
() => {},
() => {},
() => {}
);
}
}
@@ -417,8 +407,7 @@ class RestoreService {
const clearLogFile = async () => {
try {
await writeFile(logPath, '', 'utf-8');
} catch (error) {
console.error('Error clearing log file:', error);
} catch {
// Ignore log file errors
}
};
@@ -428,8 +417,7 @@ class RestoreService {
try {
const logLine = `${message}\n`;
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
} catch (error) {
console.error('Error writing progress to log file:', error);
} catch {
// Ignore log file errors
}
};
@@ -463,22 +451,12 @@ class RestoreService {
}
// Get server details
const serverRaw = await db.getServerById(serverId);
if (!serverRaw) {
const serverData = await db.getServerById(serverId);
if (!serverData) {
throw new Error(`Server with ID ${serverId} not found`);
}
// Convert null to undefined for optional properties to match Server interface
const server: Server = {
...serverRaw,
password: serverRaw.password ?? undefined,
auth_type: (serverRaw.auth_type === 'password' || serverRaw.auth_type === 'key') ? serverRaw.auth_type : undefined,
ssh_key: serverRaw.ssh_key ?? undefined,
ssh_key_passphrase: serverRaw.ssh_key_passphrase ?? undefined,
ssh_key_path: serverRaw.ssh_key_path ?? undefined,
key_generated: serverRaw.key_generated ?? undefined,
ssh_port: serverRaw.ssh_port ?? undefined,
color: serverRaw.color ?? undefined,
};
// Cast to Server type (Prisma returns nullable fields as null, Server uses undefined)
const server = serverData as unknown as Server;
// Get rootfs storage
await addProgress('reading_config', 'Reading container configuration...');
@@ -489,7 +467,7 @@ class RestoreService {
const checkCommand = `pct list ${containerId} 2>&1 | grep -q "^${containerId}" && echo "exists" || echo "notfound"`;
let checkOutput = '';
await new Promise<void>((resolve) => {
void sshService.executeCommand(
sshService.executeCommand(
server,
checkCommand,
(data: string) => {
@@ -512,8 +490,7 @@ class RestoreService {
await addProgress('stopping', 'Stopping container...');
try {
await this.stopContainer(server, containerId);
} catch (error) {
console.error('Error stopping container:', error);
} catch {
// Continue even if stop fails
}
@@ -521,8 +498,7 @@ class RestoreService {
await addProgress('destroying', 'Destroying container...');
try {
await this.destroyContainer(server, containerId);
} catch (error) {
console.error('Error destroying container:', error);
} catch {
// Container might not exist, which is fine - continue with restore
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
}
@@ -539,8 +515,8 @@ class RestoreService {
}
// Parse snapshot path from backup_path (format: pbs://root@pam@IP:DATASTORE/ct/148/2025-10-21T19:14:55Z)
const snapshotPathMatch = /pbs:\/\/[^/]+\/(.+)$/.exec(backup.backup_path);
if (!snapshotPathMatch?.[1]) {
const snapshotPathMatch = backup.backup_path.match(/pbs:\/\/[^/]+\/(.+)$/);
if (!snapshotPathMatch || !snapshotPathMatch[1]) {
throw new Error(`Invalid PBS backup path format: ${backup.backup_path}`);
}
@@ -578,7 +554,10 @@ class RestoreService {
let restoreServiceInstance: RestoreService | null = null;
export function getRestoreService(): RestoreService {
restoreServiceInstance ??= new RestoreService();
if (!restoreServiceInstance) {
restoreServiceInstance = new RestoreService();
}
return restoreServiceInstance;
}

View File

@@ -3,32 +3,24 @@ import { join } from 'path';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService {
/**
* @type {string | undefined}
*/
scriptsDirectory;
/**
* @type {string | undefined}
*/
repoUrl;
constructor() {
this.scriptsDirectory = undefined;
this.repoUrl = undefined;
/** @type {string} */
this.scriptsDirectory = join(process.cwd(), 'scripts');
/** @type {string} */
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
initializeConfig() {
if (this.scriptsDirectory === undefined) {
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';
}
// Re-initialize if needed (for environment changes)
this.scriptsDirectory = join(process.cwd(), 'scripts');
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
* (e.g., prevents ct/ct or install/install)
* @param {string} dirPath
* @param {string} dirPath - The directory path to validate
* @returns {boolean}
*/
validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/');
@@ -46,8 +38,9 @@ export class ScriptDownloaderService {
/**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
* @param {string} targetDir
* @param {string} finalTargetDir
* @param {string} targetDir - The base target directory
* @param {string} finalTargetDir - The final target directory to validate
* @returns {string}
*/
validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names
@@ -66,7 +59,9 @@ export class ScriptDownloaderService {
}
/**
* @param {string} dirPath
* Ensure a directory exists, creating it if necessary
* @param {string} dirPath - The directory path to ensure exists
* @returns {Promise<void>}
*/
async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name
@@ -76,13 +71,10 @@ export class ScriptDownloaderService {
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
/** @type {any} */
const errAny = err;
if (errAny.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, err.message);
throw err;
} catch (/** @type {any} */ error) {
if (error.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
throw error;
}
// Directory already exists, which is fine
console.log(`[Directory Creation] Directory already exists: ${dirPath}`);
@@ -90,7 +82,9 @@ export class ScriptDownloaderService {
}
/**
* @param {string} repoUrl
* Extract repository path from GitHub URL
* @param {string} repoUrl - The GitHub repository URL
* @returns {string}
*/
extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
@@ -101,9 +95,11 @@ export class ScriptDownloaderService {
}
/**
* @param {string} repoUrl
* @param {string} filePath
* @param {string} [branch]
* 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') {
this.initializeConfig();
@@ -134,7 +130,9 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* Get repository URL for a script
* @param {import('~/types/script').Script} script - The script object
* @returns {string}
*/
getRepoUrlForScript(script) {
// Use repository_url from script if available, otherwise fallback to env or default
@@ -146,7 +144,9 @@ export class ScriptDownloaderService {
}
/**
* @param {string} content
* Modify script content to use local paths
* @param {string} content - The script content
* @returns {string}
*/
modifyScriptContent(content) {
// Replace the build.func source line
@@ -157,11 +157,14 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* 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) {
this.initializeConfig();
try {
/** @type {string[]} */
const files = [];
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
@@ -169,10 +172,10 @@ export class ScriptDownloaderService {
console.log(`Loading script "${script.name}" (${script.slug}) from repository: ${repoUrl}`);
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'vm'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm'));
if (script.install_methods?.length) {
for (const method of script.install_methods) {
@@ -197,7 +200,7 @@ export class ScriptDownloaderService {
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
@@ -208,8 +211,8 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', finalTargetDir));
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
@@ -220,8 +223,8 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', finalTargetDir));
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
@@ -230,7 +233,7 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
@@ -242,13 +245,13 @@ export class ScriptDownloaderService {
}
// Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(/** @param {any} method */ method => method.script?.startsWith('ct/'));
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName} from ${repoUrl}`);
const installContent = await this.downloadFileFromGitHub(repoUrl, `install/${installScriptName}`, branch);
const localInstallPath = join(this.scriptsDirectory ?? '', 'install', installScriptName);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
console.log(`Successfully downloaded: install/${installScriptName}`);
@@ -260,11 +263,11 @@ export class ScriptDownloaderService {
// Download alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
/** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
console.log(`[${script.slug}] Checking for alpine variant:`, {
hasAlpineCtVariant,
installMethods: script.install_methods?.map(/** @param {any} m */ m => ({ type: m.type, script: m.script }))
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
});
if (hasAlpineCtVariant) {
@@ -272,7 +275,7 @@ export class ScriptDownloaderService {
try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName} from ${repoUrl}`);
const alpineInstallContent = await this.downloadFileFromGitHub(repoUrl, `install/${alpineInstallScriptName}`, branch);
const localAlpineInstallPath = join(this.scriptsDirectory ?? '', '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}`);
@@ -303,7 +306,9 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* Check if a script is downloaded
* @param {import('~/types/script').Script} script - The script to check
* @returns {Promise<boolean>}
*/
async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false;
@@ -323,23 +328,23 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
@@ -358,7 +363,9 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* 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) {
this.initializeConfig();
@@ -382,25 +389,25 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct'; // Default fallback
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
@@ -420,10 +427,10 @@ export class ScriptDownloaderService {
}
// Check for install script for CT scripts
const hasCtScript = script.install_methods?.some(/** @param {any} method */ method => method.script?.startsWith('ct/'));
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const installPath = join(this.scriptsDirectory ?? '', 'install', installScriptName);
const installPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
await access(installPath);
@@ -436,11 +443,11 @@ export class ScriptDownloaderService {
// Check alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
/** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallPath = join(this.scriptsDirectory ?? '', 'install', alpineInstallScriptName);
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
try {
await access(alpineInstallPath);
@@ -459,7 +466,9 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* 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) {
this.initializeConfig();
@@ -480,7 +489,7 @@ export class ScriptDownloaderService {
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory ?? '', filePath);
const fullPath = join(this.scriptsDirectory, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
@@ -513,11 +522,13 @@ export class ScriptDownloaderService {
}
/**
* @param {any} script
* 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) {
this.initializeConfig();
/** @type {any[]} */
/** @type {string[]} */
const differences = [];
let hasDifferences = false;
const repoUrl = this.getRepoUrlForScript(script);
@@ -609,12 +620,12 @@ export class ScriptDownloaderService {
// Compare alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
/** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
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);
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
// Check if alpine install script exists locally
try {
@@ -644,21 +655,22 @@ export class ScriptDownloaderService {
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
} catch (/** @type {any} */ error) {
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
return { hasDifferences: false, differences: [], error: errorMessage };
return { hasDifferences: false, differences: [], error: error.message };
}
}
/**
* @param {any} script
* @param {string} remotePath
* @param {string} filePath
* 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) {
try {
const localPath = join(this.scriptsDirectory ?? '', filePath);
const localPath = join(this.scriptsDirectory, filePath);
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
@@ -691,17 +703,18 @@ export class ScriptDownloaderService {
}
return { hasDifferences, filePath };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[Comparison] Error comparing file ${filePath}:`, errorMessage);
} catch (/** @type {any} */ error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
// Return error information so it can be handled upstream
return { hasDifferences: false, filePath, error: errorMessage };
return { hasDifferences: false, filePath, error: error.message };
}
}
/**
* @param {any} script
* @param {string} filePath
* 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) {
this.initializeConfig();
@@ -715,7 +728,7 @@ export class ScriptDownloaderService {
// Handle CT script
const fileName = filePath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory ?? '', 'ct', fileName);
const localPath = join(this.scriptsDirectory, 'ct', fileName);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
@@ -724,7 +737,7 @@ export class ScriptDownloaderService {
try {
// Find the corresponding script path in install_methods
const method = script.install_methods?.find(/** @param {any} m */ m => m.script === filePath);
const method = script.install_methods?.find(m => m.script === filePath);
if (method?.script) {
const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch);
remoteContent = this.modifyScriptContent(downloadedContent);
@@ -735,7 +748,7 @@ export class ScriptDownloaderService {
}
} else if (filePath.startsWith('install/')) {
// Handle install script
const localPath = join(this.scriptsDirectory ?? '', filePath);
const localPath = join(this.scriptsDirectory, filePath);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
@@ -763,8 +776,10 @@ export class ScriptDownloaderService {
}
/**
* @param {string} localContent
* @param {string} remoteContent
* 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) {
const localLines = localContent.split('\n');

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-for-of */
import { getSSHExecutionService } from '../ssh-execution-service';
import type { Server } from '~/types/server';