Fix type errors

This commit is contained in:
Michel Roegl-Brunner
2025-11-28 13:21:37 +01:00
parent 7fe2a8b453
commit 7833d5d408
43 changed files with 829 additions and 524 deletions

View File

@@ -1,15 +1,20 @@
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import { createRequire } from "module";
import { fileURLToPath } from "url";
import path from "path";
const compat = new FlatCompat({ const __filename = fileURLToPath(import.meta.url);
baseDirectory: import.meta.dirname, 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");
export default tseslint.config( export default tseslint.config(
{ {
ignores: [".next"], ignores: [".next", "node_modules"],
}, },
...compat.extends("next/core-web-vitals"), ...nextConfig,
{ {
files: ["**/*.ts", "**/*.tsx"], files: ["**/*.ts", "**/*.tsx"],
extends: [ extends: [

View File

@@ -63,9 +63,9 @@ const config = {
} }
return config; return config;
}, },
// Ignore TypeScript errors during build (they can be fixed separately) // TypeScript errors will fail the build
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: false,
}, },
}; };

View File

@@ -5,14 +5,14 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build --webpack", "build": "next build --webpack",
"check": "next lint && tsc --noEmit", "check": "npm run lint && tsc --noEmit",
"dev": "next dev --webpack", "dev": "next dev --webpack",
"dev:server": "node server.js", "dev:server": "node server.js",
"dev:next": "next dev --webpack", "dev:next": "next dev --webpack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "next lint --fix", "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"preview": "next build && next start", "preview": "next build && next start",
"start": "node server.js", "start": "node server.js",
"test": "vitest", "test": "vitest",

224
server.js
View File

@@ -2,18 +2,14 @@ import { createServer } from 'http';
import { parse } from 'url'; import { parse } from 'url';
import next from 'next'; import next from 'next';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { spawn } from 'child_process';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js'; import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config(); dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import)
function registerGlobalErrorHandlers() { function registerGlobalErrorHandlers() {
if (registerGlobalErrorHandlers._registered) return; if (registerGlobalErrorHandlers._registered) return;
registerGlobalErrorHandlers._registered = true; registerGlobalErrorHandlers._registered = true;
@@ -31,11 +27,9 @@ const hostname = '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10); const port = parseInt(process.env.PORT || '3000', 10);
const app = next({ dev, hostname, port }); const app = next({ dev, hostname, port });
// Register global handlers once at bootstrap
registerGlobalErrorHandlers(); registerGlobalErrorHandlers();
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
// WebSocket handler for script execution
/** /**
* @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket * @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket
*/ */
@@ -71,7 +65,10 @@ const handle = app.getRequestHandler();
* @property {ServerInfo} [server] * @property {ServerInfo} [server]
* @property {boolean} [isUpdate] * @property {boolean} [isUpdate]
* @property {boolean} [isShell] * @property {boolean} [isShell]
* @property {boolean} [isBackup]
* @property {string} [containerId] * @property {string} [containerId]
* @property {string} [storage]
* @property {string} [backupStorage]
*/ */
class ScriptExecutionHandler { class ScriptExecutionHandler {
@@ -79,8 +76,6 @@ class ScriptExecutionHandler {
* @param {import('http').Server} server * @param {import('http').Server} server
*/ */
constructor(server) { constructor(server) {
// Create WebSocketServer without attaching to server
// We'll handle upgrades manually to avoid interfering with Next.js HMR
this.wss = new WebSocketServer({ this.wss = new WebSocketServer({
noServer: true noServer: true
}); });
@@ -90,7 +85,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Handle WebSocket upgrade for our endpoint
* @param {import('http').IncomingMessage} request * @param {import('http').IncomingMessage} request
* @param {import('stream').Duplex} socket * @param {import('stream').Duplex} socket
* @param {Buffer} head * @param {Buffer} head
@@ -102,48 +96,33 @@ class ScriptExecutionHandler {
} }
/** /**
* Parse Container ID from terminal output * @param {string} output
* @param {string} output - Terminal output to parse * @returns {string|null}
* @returns {string|null} - Container ID if found, null otherwise
*/ */
parseContainerId(output) { parseContainerId(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for various patterns that Proxmox scripts might use
const patterns = [ const patterns = [
// Primary pattern - the exact format from the output
/🆔\s+Container\s+ID:\s+(\d+)/i, /🆔\s+Container\s+ID:\s+(\d+)/i,
// Standard patterns with flexible spacing
/🆔\s*Container\s*ID:\s*(\d+)/i, /🆔\s*Container\s*ID:\s*(\d+)/i,
/Container\s*ID:\s*(\d+)/i, /Container\s*ID:\s*(\d+)/i,
/CT\s*ID:\s*(\d+)/i, /CT\s*ID:\s*(\d+)/i,
/Container\s*(\d+)/i, /Container\s*(\d+)/i,
// Alternative patterns
/CT\s*(\d+)/i, /CT\s*(\d+)/i,
/Container\s*created\s*with\s*ID\s*(\d+)/i, /Container\s*created\s*with\s*ID\s*(\d+)/i,
/Created\s*container\s*(\d+)/i, /Created\s*container\s*(\d+)/i,
/Container\s*(\d+)\s*created/i, /Container\s*(\d+)\s*created/i,
/ID:\s*(\d+)/i, /ID:\s*(\d+)/i,
// Patterns with different spacing and punctuation
/Container\s*ID\s*:\s*(\d+)/i, /Container\s*ID\s*:\s*(\d+)/i,
/CT\s*ID\s*:\s*(\d+)/i, /CT\s*ID\s*:\s*(\d+)/i,
/Container\s*#\s*(\d+)/i, /Container\s*#\s*(\d+)/i,
/CT\s*#\s*(\d+)/i, /CT\s*#\s*(\d+)/i,
// Patterns that might appear in success messages
/Successfully\s*created\s*container\s*(\d+)/i, /Successfully\s*created\s*container\s*(\d+)/i,
/Container\s*(\d+)\s*is\s*ready/i, /Container\s*(\d+)\s*is\s*ready/i,
/Container\s*(\d+)\s*started/i, /Container\s*(\d+)\s*started/i,
// Generic number patterns that might be container IDs (3-4 digits)
/(?:^|\s)(\d{3,4})(?:\s|$)/m, /(?:^|\s)(\d{3,4})(?:\s|$)/m,
]; ];
// Try patterns on both original and cleaned output
const outputsToTry = [output, cleanOutput]; const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) { for (const testOutput of outputsToTry) {
@@ -151,7 +130,6 @@ class ScriptExecutionHandler {
const match = testOutput.match(pattern); const match = testOutput.match(pattern);
if (match && match[1]) { if (match && match[1]) {
const containerId = match[1]; const containerId = match[1];
// Additional validation: container IDs are typically 3-4 digits
if (containerId.length >= 3 && containerId.length <= 4) { if (containerId.length >= 3 && containerId.length <= 4) {
return containerId; return containerId;
} }
@@ -159,34 +137,24 @@ class ScriptExecutionHandler {
} }
} }
return null; return null;
} }
/** /**
* Parse Web UI URL from terminal output * @param {string} output
* @param {string} output - Terminal output to parse * @returns {{ip: string, port: number}|null}
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
*/ */
parseWebUIUrl(output) { parseWebUIUrl(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for URL patterns with any valid IP address (private or public)
const patterns = [ const patterns = [
// HTTP/HTTPS URLs with IP and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi, /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, /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, /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, /(?:^|\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, /(?:^|\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]; const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) { for (const testOutput of outputsToTry) {
@@ -197,7 +165,6 @@ class ScriptExecutionHandler {
const ip = match[1]; const ip = match[1];
const port = match[2] || (match[0].startsWith('https') ? '443' : '80'); 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}$/)) { if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return { return {
ip: ip, ip: ip,
@@ -213,12 +180,11 @@ class ScriptExecutionHandler {
} }
/** /**
* Create installation record * @param {string} scriptName
* @param {string} scriptName - Name of the script * @param {string} scriptPath
* @param {string} scriptPath - Path to the script * @param {string} executionMode
* @param {string} executionMode - 'local' or 'ssh' * @param {number|null} serverId
* @param {number|null} serverId - Server ID for SSH executions * @returns {Promise<number|null>}
* @returns {Promise<number|null>} - Installation record ID
*/ */
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) { async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
try { try {
@@ -239,9 +205,8 @@ class ScriptExecutionHandler {
} }
/** /**
* Update installation record * @param {number} installationId
* @param {number} installationId - Installation record ID * @param {Object} updateData
* @param {Object} updateData - Data to update
*/ */
async updateInstallationRecord(installationId, updateData) { async updateInstallationRecord(installationId, updateData) {
try { try {
@@ -253,8 +218,6 @@ class ScriptExecutionHandler {
setupWebSocket() { setupWebSocket() {
this.wss.on('connection', (ws, request) => { this.wss.on('connection', (ws, request) => {
// Set connection metadata
/** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now(); /** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now();
/** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown'; /** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown';
@@ -345,8 +308,6 @@ class ScriptExecutionHandler {
let installationId = null; let installationId = null;
try { try {
// Check if execution is already running
if (this.activeExecutions.has(executionId)) { if (this.activeExecutions.has(executionId)) {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
@@ -356,10 +317,7 @@ class ScriptExecutionHandler {
return; return;
} }
// Extract script name from path
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
// Create installation record
const serverId = server ? (server.id ?? null) : null; const serverId = server ? (server.id ?? null) : null;
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId); installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
@@ -367,17 +325,14 @@ class ScriptExecutionHandler {
console.error('Failed to create installation record'); console.error('Failed to create installation record');
} }
// Handle SSH execution
if (mode === 'ssh' && server) { if (mode === 'ssh' && server) {
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId); await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId);
return; return;
} }
if (mode === 'ssh' && !server) { 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 scriptsDir = join(process.cwd(), 'scripts');
const resolvedPath = resolve(scriptPath); const resolvedPath = resolve(scriptPath);
@@ -388,14 +343,12 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
// Update installation record with failure
if (installationId) { if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
return; return;
} }
// Start script execution with pty for proper TTY support
const childProcess = ptySpawn('bash', [resolvedPath], { const childProcess = ptySpawn('bash', [resolvedPath], {
cwd: scriptsDir, cwd: scriptsDir,
name: 'xterm-256color', name: 'xterm-256color',
@@ -403,16 +356,13 @@ class ScriptExecutionHandler {
rows: 24, rows: 24,
env: { env: {
...process.env, ...process.env,
TERM: 'xterm-256color', // Enable proper terminal support TERM: 'xterm-256color',
FORCE_ANSI: 'true', // Allow ANSI codes for proper display FORCE_ANSI: 'true',
COLUMNS: '80', // Set terminal width COLUMNS: '80',
LINES: '24' // Set terminal height LINES: '24'
} }
}); });
// pty handles encoding automatically
// Store the execution with installation ID
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: childProcess, process: childProcess,
ws, ws,
@@ -420,34 +370,28 @@ class ScriptExecutionHandler {
outputBuffer: '' outputBuffer: ''
}); });
// Send start message
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting execution of ${scriptPath}`, data: `Starting execution of ${scriptPath}`,
timestamp: Date.now() timestamp: Date.now()
}); });
// Handle pty data (both stdout and stderr combined) childProcess.onData(/** @param {string} data */ async (data) => {
childProcess.onData(async (data) => {
const output = data.toString(); const output = data.toString();
// Store output in buffer for logging
const execution = this.activeExecutions.get(executionId); const execution = this.activeExecutions.get(executionId);
if (execution) { if (execution) {
execution.outputBuffer += output; execution.outputBuffer += output;
// Keep only last 1000 characters to avoid memory issues
if (execution.outputBuffer.length > 1000) { if (execution.outputBuffer.length > 1000) {
execution.outputBuffer = execution.outputBuffer.slice(-1000); execution.outputBuffer = execution.outputBuffer.slice(-1000);
} }
} }
// Parse for Container ID
const containerId = this.parseContainerId(output); const containerId = this.parseContainerId(output);
if (containerId && installationId) { if (containerId && installationId) {
await this.updateInstallationRecord(installationId, { container_id: containerId }); await this.updateInstallationRecord(installationId, { container_id: containerId });
} }
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(output); const webUIUrl = this.parseWebUIUrl(output);
if (webUIUrl && installationId) { if (webUIUrl && installationId) {
const { ip, port } = webUIUrl; const { ip, port } = webUIUrl;
@@ -466,12 +410,10 @@ class ScriptExecutionHandler {
}); });
}); });
// Handle process exit
childProcess.onExit((e) => { childProcess.onExit((e) => {
const execution = this.activeExecutions.get(executionId); const execution = this.activeExecutions.get(executionId);
const isSuccess = e.exitCode === 0; const isSuccess = e.exitCode === 0;
// Update installation record with final status and output
if (installationId && execution) { if (installationId && execution) {
this.updateInstallationRecord(installationId, { this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed', status: isSuccess ? 'success' : 'failed',
@@ -485,7 +427,6 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
// Clean up
this.activeExecutions.delete(executionId); this.activeExecutions.delete(executionId);
}); });
@@ -496,7 +437,6 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
// Update installation record with failure
if (installationId) { if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
@@ -504,7 +444,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start SSH script execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} scriptPath * @param {string} scriptPath
* @param {string} executionId * @param {string} executionId
@@ -514,7 +453,6 @@ class ScriptExecutionHandler {
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) { async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
// Send start message
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`, data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`,
@@ -522,27 +460,23 @@ class ScriptExecutionHandler {
}); });
try { try {
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript( const execution = await sshService.executeScript(
server, server,
scriptPath, scriptPath,
/** @param {string} data */ async (data) => { /** @param {string} data */ async (data) => {
// Store output in buffer for logging
const exec = this.activeExecutions.get(executionId); const exec = this.activeExecutions.get(executionId);
if (exec) { if (exec) {
exec.outputBuffer += data; exec.outputBuffer += data;
// Keep only last 1000 characters to avoid memory issues
if (exec.outputBuffer.length > 1000) { if (exec.outputBuffer.length > 1000) {
exec.outputBuffer = exec.outputBuffer.slice(-1000); exec.outputBuffer = exec.outputBuffer.slice(-1000);
} }
} }
// Parse for Container ID
const containerId = this.parseContainerId(data); const containerId = this.parseContainerId(data);
if (containerId && installationId) { if (containerId && installationId) {
await this.updateInstallationRecord(installationId, { container_id: containerId }); await this.updateInstallationRecord(installationId, { container_id: containerId });
} }
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(data); const webUIUrl = this.parseWebUIUrl(data);
if (webUIUrl && installationId) { if (webUIUrl && installationId) {
const { ip, port } = webUIUrl; const { ip, port } = webUIUrl;
@@ -554,7 +488,6 @@ class ScriptExecutionHandler {
} }
} }
// Handle data output
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: data, data: data,
@@ -562,17 +495,14 @@ class ScriptExecutionHandler {
}); });
}, },
/** @param {string} error */ (error) => { /** @param {string} error */ (error) => {
// Store error in buffer for logging
const exec = this.activeExecutions.get(executionId); const exec = this.activeExecutions.get(executionId);
if (exec) { if (exec) {
exec.outputBuffer += error; exec.outputBuffer += error;
// Keep only last 1000 characters to avoid memory issues
if (exec.outputBuffer.length > 1000) { if (exec.outputBuffer.length > 1000) {
exec.outputBuffer = exec.outputBuffer.slice(-1000); exec.outputBuffer = exec.outputBuffer.slice(-1000);
} }
} }
// Handle errors
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
data: error, data: error,
@@ -583,7 +513,6 @@ class ScriptExecutionHandler {
const exec = this.activeExecutions.get(executionId); const exec = this.activeExecutions.get(executionId);
const isSuccess = code === 0; const isSuccess = code === 0;
// Update installation record with final status and output
if (installationId && exec) { if (installationId && exec) {
await this.updateInstallationRecord(installationId, { await this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed', status: isSuccess ? 'success' : 'failed',
@@ -591,21 +520,18 @@ class ScriptExecutionHandler {
}); });
} }
// Handle process exit
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'end',
data: `SSH script execution finished with code: ${code}`, data: `SSH script execution finished with code: ${code}`,
timestamp: Date.now() timestamp: Date.now()
}); });
// Clean up
this.activeExecutions.delete(executionId); this.activeExecutions.delete(executionId);
} }
)); );
// Store the execution with installation ID
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: execution.process, process: /** @type {ExecutionResult} */ (execution).process,
ws, ws,
installationId, installationId,
outputBuffer: '' outputBuffer: ''
@@ -618,7 +544,6 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
// Update installation record with failure
if (installationId) { if (installationId) {
await this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
@@ -658,7 +583,7 @@ class ScriptExecutionHandler {
* @param {any} message * @param {any} message
*/ */
sendMessage(ws, message) { sendMessage(ws, message) {
if (ws.readyState === 1) { // WebSocket.OPEN if (ws.readyState === 1) {
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
} }
} }
@@ -676,7 +601,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start backup execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -686,7 +610,6 @@ class ScriptExecutionHandler {
*/ */
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) { async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
try { try {
// Send start message
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting backup for container ${containerId} to storage ${storage}...`, data: `Starting backup for container ${containerId} to storage ${storage}...`,
@@ -712,13 +635,12 @@ class ScriptExecutionHandler {
} }
/** /**
* Start SSH backup execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
* @param {string} storage * @param {string} storage
* @param {ServerInfo} server * @param {ServerInfo} server
* @param {Function} [onComplete] - Optional callback when backup completes * @param {Function|null} [onComplete]
*/ */
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) { startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
@@ -726,8 +648,6 @@ class ScriptExecutionHandler {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`; const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
// Wrap the onExit callback to resolve our promise
let promiseResolved = false; let promiseResolved = false;
sshService.executeCommand( sshService.executeCommand(
@@ -751,8 +671,6 @@ class ScriptExecutionHandler {
}, },
/** @param {number} code */ /** @param {number} code */
(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; const success = code === 0;
if (!success) { if (!success) {
@@ -763,7 +681,6 @@ class ScriptExecutionHandler {
}); });
} }
// Send a completion message (but not 'end' type to avoid stopping terminal)
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`, data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
@@ -772,14 +689,10 @@ class ScriptExecutionHandler {
if (onComplete) onComplete(success); if (onComplete) onComplete(success);
// Resolve the promise when backup completes
// Use setImmediate to ensure resolution happens in the right execution context
if (!promiseResolved) { if (!promiseResolved) {
promiseResolved = true; promiseResolved = true;
const result = { success, code }; 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(() => { setImmediate(() => {
try { try {
resolve(result); resolve(result);
@@ -793,12 +706,10 @@ class ScriptExecutionHandler {
this.activeExecutions.delete(executionId); this.activeExecutions.delete(executionId);
} }
).then((execution) => { ).then((execution) => {
// Store the execution
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process, process: /** @type {any} */ (execution).process,
ws ws
}); });
// Note: Don't resolve here - wait for onExit callback
}).catch((error) => { }).catch((error) => {
console.error('Error starting backup execution:', error); console.error('Error starting backup execution:', error);
this.sendMessage(ws, { this.sendMessage(ws, {
@@ -827,17 +738,15 @@ class ScriptExecutionHandler {
} }
/** /**
* Start update execution (pct enter + update command)
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
* @param {string} mode * @param {string} mode
* @param {ServerInfo|null} server * @param {ServerInfo|null} server
* @param {string} [backupStorage] - Optional storage to backup to before update * @param {string|null} [backupStorage]
*/ */
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) { async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
try { try {
// If backup storage is provided, run backup first
if (backupStorage && mode === 'ssh' && server) { if (backupStorage && mode === 'ssh' && server) {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
@@ -845,10 +754,8 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
// Create a separate execution ID for backup
const backupExecutionId = `backup_${executionId}`; const backupExecutionId = `backup_${executionId}`;
// Run backup and wait for it to complete
try { try {
const backupResult = await this.startSSHBackupExecution( const backupResult = await this.startSSHBackupExecution(
ws, ws,
@@ -858,16 +765,13 @@ class ScriptExecutionHandler {
server server
); );
// Backup completed (successfully or not)
if (!backupResult || !backupResult.success) { if (!backupResult || !backupResult.success) {
// Backup failed, but we'll still allow update (per requirement 1b)
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: '\n⚠ Backup failed, but proceeding with update as requested...\n', data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
timestamp: Date.now() timestamp: Date.now()
}); });
} else { } else {
// Backup succeeded
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: '\n✅ Backup completed successfully. Starting update...\n', data: '\n✅ Backup completed successfully. Starting update...\n',
@@ -876,7 +780,6 @@ class ScriptExecutionHandler {
} }
} catch (error) { } catch (error) {
console.error('Backup error before update:', error); console.error('Backup error before update:', error);
// Backup failed to start, but allow update to proceed
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`, data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
@@ -884,11 +787,9 @@ class ScriptExecutionHandler {
}); });
} }
// Small delay before starting update
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
} }
// Send start message for update (only if we're actually starting an update)
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting update for container ${containerId}...`, data: `Starting update for container ${containerId}...`,
@@ -911,7 +812,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start local update execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -919,7 +819,6 @@ class ScriptExecutionHandler {
async startLocalUpdateExecution(ws, containerId, executionId) { async startLocalUpdateExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty'); 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}`], { const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color', name: 'xterm-color',
cols: 80, cols: 80,
@@ -928,13 +827,11 @@ class ScriptExecutionHandler {
env: process.env env: process.env
}); });
// Store the execution
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: childProcess, process: childProcess,
ws ws
}); });
// Handle pty data
childProcess.onData((data) => { childProcess.onData((data) => {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
@@ -943,12 +840,10 @@ class ScriptExecutionHandler {
}); });
}); });
// Send the update command after a delay to ensure we're in the container
setTimeout(() => { setTimeout(() => {
childProcess.write('update\n'); childProcess.write('update\n');
}, 4000); }, 4000);
// Handle process exit
childProcess.onExit((e) => { childProcess.onExit((e) => {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'end',
@@ -961,7 +856,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start SSH update execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -1002,13 +896,11 @@ class ScriptExecutionHandler {
} }
); );
// Store the execution
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process, process: /** @type {any} */ (execution).process,
ws ws
}); });
// Send the update command after a delay to ensure we're in the container
setTimeout(() => { setTimeout(() => {
/** @type {any} */ (execution).process.write('update\n'); /** @type {any} */ (execution).process.write('update\n');
}, 4000); }, 4000);
@@ -1023,7 +915,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start shell execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -1032,8 +923,6 @@ class ScriptExecutionHandler {
*/ */
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) { async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
try { try {
// Send start message
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting shell session for container ${containerId}...`, data: `Starting shell session for container ${containerId}...`,
@@ -1056,7 +945,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start local shell execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -1064,7 +952,6 @@ class ScriptExecutionHandler {
async startLocalShellExecution(ws, containerId, executionId) { async startLocalShellExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty'); const { spawn } = await import('node-pty');
// Create a shell process that will run pct enter
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], { const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color', name: 'xterm-color',
cols: 80, cols: 80,
@@ -1073,13 +960,11 @@ class ScriptExecutionHandler {
env: process.env env: process.env
}); });
// Store the execution
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: childProcess, process: childProcess,
ws ws
}); });
// Handle pty data
childProcess.onData((data) => { childProcess.onData((data) => {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
@@ -1088,9 +973,6 @@ class ScriptExecutionHandler {
}); });
}); });
// Note: No automatic command is sent - user can type commands interactively
// Handle process exit
childProcess.onExit((e) => { childProcess.onExit((e) => {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'end',
@@ -1103,7 +985,6 @@ class ScriptExecutionHandler {
} }
/** /**
* Start SSH shell execution
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
@@ -1144,14 +1025,11 @@ class ScriptExecutionHandler {
} }
); );
// Store the execution
this.activeExecutions.set(executionId, { this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process, process: /** @type {any} */ (execution).process,
ws ws
}); });
// Note: No automatic command is sent - user can type commands interactively
} catch (error) { } catch (error) {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
@@ -1162,32 +1040,24 @@ class ScriptExecutionHandler {
} }
} }
// TerminalHandler removed - not used by current application
app.prepare().then(() => { app.prepare().then(() => {
const httpServer = createServer(async (req, res) => { const httpServer = createServer(async (req, res) => {
try { try {
// Be sure to pass `true` as the second argument to `url.parse`.
// This tells it to parse the query portion of the URL.
const parsedUrl = parse(req.url || '', true); const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl; const { pathname, query } = parsedUrl;
// Check if this is a WebSocket upgrade request
const isWebSocketUpgrade = req.headers.upgrade === 'websocket'; 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') { if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
// WebSocket upgrade will be handled by the WebSocket server return;
// Don't call handle() for this path - let WebSocketServer handle it }
if (isWebSocketUpgrade) {
return; 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); await handle(req, res, parsedUrl);
} catch (err) { } catch (err) {
console.error('Error occurred handling', req.url, err); console.error('Error occurred handling', req.url, err);
@@ -1196,36 +1066,19 @@ app.prepare().then(() => {
} }
}); });
// Create WebSocket handlers
const scriptHandler = new ScriptExecutionHandler(httpServer); const scriptHandler = new ScriptExecutionHandler(httpServer);
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
// Save any existing upgrade listeners (Next.js might have set them up)
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
httpServer.removeAllListeners('upgrade');
// Add our upgrade handler that routes based on path
httpServer.on('upgrade', (request, socket, head) => { httpServer.on('upgrade', (request, socket, head) => {
const parsedUrl = parse(request.url || '', true); const parsedUrl = parse(request.url || '', true);
const { pathname } = parsedUrl; const { pathname } = parsedUrl;
if (pathname === '/ws/script-execution') { if (pathname === '/ws/script-execution') {
// Handle our custom WebSocket endpoint
scriptHandler.handleUpgrade(request, socket, head); scriptHandler.handleUpgrade(request, socket, head);
} else { return;
// 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 httpServer
.once('error', (err) => { .once('error', (err) => {
@@ -1236,13 +1089,10 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`); console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
// Initialize default repositories await initializeRepositories();
await initializeRepositories();
initializeAutoSync();
// Initialize auto-sync service
initializeAutoSync();
// Setup graceful shutdown handlers
setupGracefulShutdown(); setupGracefulShutdown();
}); });
}); });

View File

@@ -35,7 +35,7 @@ export function BackupWarningModal({
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you wish. The backup failed, but you can still proceed with the update if you wish.
<br /><br /> <br /><br />
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update. <strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won&apos;t be able to restore the container if something goes wrong during the update.
</p> </p>
{/* Action Buttons */} {/* Action Buttons */}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, startTransition } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
@@ -23,16 +23,12 @@ interface Backup {
storage_name: string; storage_name: string;
storage_type: string; storage_type: string;
discovered_at: Date; discovered_at: Date;
server_id: number; server_id?: number;
server_name: string | null; server_name: string | null;
server_color: string | null; server_color: string | null;
} }
interface ContainerBackups {
container_id: string;
hostname: string;
backups: Backup[];
}
export function BackupsTab() { export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set()); const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
@@ -61,21 +57,23 @@ export function BackupsTab() {
// Update restore progress when log data changes // Update restore progress when log data changes
useEffect(() => { useEffect(() => {
if (restoreLogsData?.success && restoreLogsData.logs) { if (restoreLogsData?.success && restoreLogsData.logs) {
setRestoreProgress(restoreLogsData.logs); startTransition(() => {
setRestoreProgress(restoreLogsData.logs);
// Stop polling when restore is complete
if (restoreLogsData.isComplete) { // Stop polling when restore is complete
setShouldPollRestore(false); if (restoreLogsData.isComplete) {
// Check if restore was successful or failed setShouldPollRestore(false);
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || ''; // Check if restore was successful or failed
if (lastLog.includes('Restore completed successfully')) { const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? '';
setRestoreSuccess(true); if (lastLog.includes('Restore completed successfully')) {
setRestoreError(null); setRestoreSuccess(true);
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) { setRestoreError(null);
setRestoreError(lastLog); } else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
setRestoreSuccess(false); setRestoreError(lastLog);
setRestoreSuccess(false);
}
} }
} });
} }
}, [restoreLogsData]); }, [restoreLogsData]);
@@ -93,7 +91,7 @@ export function BackupsTab() {
if (result.success) { if (result.success) {
// Update progress with all messages from backend (fallback if polling didn't work) // Update progress with all messages from backend (fallback if polling didn't work)
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']); const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) ?? ['Restore completed successfully']);
setRestoreProgress(progressMessages); setRestoreProgress(progressMessages);
setRestoreSuccess(true); setRestoreSuccess(true);
setRestoreError(null); setRestoreError(null);
@@ -101,8 +99,8 @@ export function BackupsTab() {
setSelectedBackup(null); setSelectedBackup(null);
// Keep success message visible - user can dismiss manually // Keep success message visible - user can dismiss manually
} else { } else {
setRestoreError(result.error || 'Restore failed'); setRestoreError(result.error ?? 'Restore failed');
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress); setRestoreProgress(result.progress?.map(p => p.message) ?? restoreProgress);
setRestoreSuccess(false); setRestoreSuccess(false);
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
@@ -112,7 +110,7 @@ export function BackupsTab() {
onError: (error) => { onError: (error) => {
// Stop polling on error // Stop polling on error
setShouldPollRestore(false); setShouldPollRestore(false);
setRestoreError(error.message || 'Restore failed'); setRestoreError(error.message ?? 'Restore failed');
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
setRestoreProgress([]); setRestoreProgress([]);
@@ -124,6 +122,10 @@ export function BackupsTab() {
? restoreProgress[restoreProgress.length - 1] ? restoreProgress[restoreProgress.length - 1]
: 'Restoring backup...'; : 'Restoring backup...';
const handleDiscoverBackups = useCallback(() => {
discoverMutation.mutate();
}, [discoverMutation]);
// Auto-discover backups when tab is first opened // Auto-discover backups when tab is first opened
useEffect(() => { useEffect(() => {
if (!hasAutoDiscovered && !isLoading && backupsData) { if (!hasAutoDiscovered && !isLoading && backupsData) {
@@ -131,13 +133,11 @@ export function BackupsTab() {
if (!backupsData.backups || backupsData.backups.length === 0) { if (!backupsData.backups || backupsData.backups.length === 0) {
handleDiscoverBackups(); handleDiscoverBackups();
} }
setHasAutoDiscovered(true); startTransition(() => {
setHasAutoDiscovered(true);
});
} }
}, [hasAutoDiscovered, isLoading, backupsData]); }, [hasAutoDiscovered, isLoading, backupsData, handleDiscoverBackups]);
const handleDiscoverBackups = () => {
discoverMutation.mutate();
};
const handleRestoreClick = (backup: Backup, containerId: string) => { const handleRestoreClick = (backup: Backup, containerId: string) => {
setSelectedBackup({ backup, containerId }); setSelectedBackup({ backup, containerId });
@@ -150,6 +150,12 @@ export function BackupsTab() {
const handleRestoreConfirm = () => { const handleRestoreConfirm = () => {
if (!selectedBackup) return; if (!selectedBackup) return;
// Ensure server_id is available
if (!selectedBackup.backup.server_id) {
setRestoreError('Server ID is required for restore operation');
return;
}
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setRestoreError(null); setRestoreError(null);
setRestoreSuccess(false); setRestoreSuccess(false);
@@ -247,7 +253,7 @@ export function BackupsTab() {
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> <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> <h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Click "Discover Backups" to scan for backups on your servers. Click &quot;Discover Backups&quot; to scan for backups on your servers.
</p> </p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}> <Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
@@ -259,7 +265,7 @@ export function BackupsTab() {
{/* Backups list */} {/* Backups list */}
{!isLoading && backups.length > 0 && ( {!isLoading && backups.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{backups.map((container: ContainerBackups) => { {backups.map((container) => {
const isExpanded = expandedContainers.has(container.container_id); const isExpanded = expandedContainers.has(container.container_id);
const backupCount = container.backups.length; const backupCount = container.backups.length;
@@ -388,7 +394,7 @@ export function BackupsTab() {
{backupsData && !backupsData.success && ( {backupsData && !backupsData.success && (
<div className="bg-destructive/10 border border-destructive rounded-lg p-4"> <div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<p className="text-destructive"> <p className="text-destructive">
Error loading backups: {backupsData.error || 'Unknown error'} Error loading backups: {backupsData.error ?? 'Unknown error'}
</p> </p>
</div> </div>
)} )}
@@ -415,7 +421,7 @@ export function BackupsTab() {
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && ( {(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal <LoadingModal
isOpen={true} isOpen={true}
action={currentProgressText} action={currentProgressText ?? ""}
logs={restoreProgress} logs={restoreProgress}
isComplete={restoreSuccess} isComplete={restoreSuccess}
title="Restore in progress" title="Restore in progress"

View File

@@ -55,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (saveFilterEnabled) { if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters'); const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) { if (filtersResponse.ok) {
const filtersData = await filtersResponse.json(); const filtersData = await filtersResponse.json() as { filters?: Partial<FilterState> };
if (filtersData.filters) { if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters)); setFilters(mergeFiltersWithDefaults(filtersData.filters));
} }

View File

@@ -53,7 +53,7 @@ export function FilterBar({
// Helper function to extract repository name from URL // Helper function to extract repository name from URL
const getRepoName = (url: string): string => { const getRepoName = (url: string): string => {
try { try {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }

View File

@@ -10,6 +10,7 @@ import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider'; import { useAuth } from './AuthProvider';
import { Trash2, ExternalLink } from 'lucide-react'; import { Trash2, ExternalLink } from 'lucide-react';
import type { FilterState } from './FilterBar';
interface GeneralSettingsModalProps { interface GeneralSettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -24,7 +25,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>(''); const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState(''); const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false); const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null); const [savedFilters, setSavedFilters] = useState<Partial<FilterState> | null>(null);
const [colorCodingEnabled, setColorCodingEnabled] = useState(false); const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -139,8 +140,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try { try {
const response = await fetch('/api/settings/filters'); const response = await fetch('/api/settings/filters');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json() as { filters?: Partial<FilterState> };
setSavedFilters(data.filters); setSavedFilters(data.filters ?? null);
} }
} catch (error) { } catch (error) {
console.error('Error loading saved filters:', error); console.error('Error loading saved filters:', error);
@@ -182,7 +183,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
setMessage({ type: 'success', text: 'GitHub token saved successfully!' }); setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' }); setMessage({ type: 'error', text: (errorData.error as string | undefined) ?? 'Failed to save token' });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Failed to save token' }); setMessage({ type: 'error', text: 'Failed to save token' });
@@ -413,7 +414,21 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try { try {
const response = await fetch('/api/settings/auto-sync'); const response = await fetch('/api/settings/auto-sync');
if (response.ok) { if (response.ok) {
const data = await response.json() as { settings: any }; const data = await response.json() as {
settings?: {
autoSyncEnabled?: boolean;
syncIntervalType?: 'custom' | 'predefined';
syncIntervalPredefined?: string;
syncIntervalCron?: string;
autoDownloadNew?: boolean;
autoUpdateExisting?: boolean;
notificationEnabled?: boolean;
appriseUrls?: string[];
lastAutoSync?: string;
lastAutoSyncError?: string | null;
lastAutoSyncErrorTime?: string | null;
}
};
const settings = data.settings; const settings = data.settings;
if (settings) { if (settings) {
setAutoSyncEnabled(settings.autoSyncEnabled ?? false); setAutoSyncEnabled(settings.autoSyncEnabled ?? false);
@@ -1127,7 +1142,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h5 className="font-medium text-foreground">Auto-download new scripts</h5> <h5 className="font-medium text-foreground">Auto-download new scripts</h5>
<p className="text-sm text-muted-foreground">Automatically download scripts that haven't been downloaded yet</p> <p className="text-sm text-muted-foreground">Automatically download scripts that haven&apos;t been downloaded yet</p>
</div> </div>
<Toggle <Toggle
checked={autoDownloadNew} checked={autoDownloadNew}

View File

@@ -329,7 +329,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<div className="p-4 border border-border rounded-lg"> <div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Auto-Download Options</h4> <h4 className="font-medium text-foreground mb-2">Auto-Download Options</h4>
<ul className="text-sm text-muted-foreground space-y-2"> <ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Auto-download new scripts:</strong> Automatically download scripts that haven't been downloaded yet</li> <li> <strong>Auto-download new scripts:</strong> Automatically download scripts that haven&apos;t been downloaded yet</li>
<li> <strong>Auto-update existing scripts:</strong> Automatically update scripts that have newer versions available</li> <li> <strong>Auto-update existing scripts:</strong> Automatically update scripts that have newer versions available</li>
<li> <strong>Selective Control:</strong> Enable/disable each option independently</li> <li> <strong>Selective Control:</strong> Enable/disable each option independently</li>
</ul> </ul>
@@ -356,7 +356,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li>Choose your sync interval (predefined or custom cron)</li> <li>Choose your sync interval (predefined or custom cron)</li>
<li>Configure auto-download options if desired</li> <li>Configure auto-download options if desired</li>
<li>Set up notifications by adding Apprise URLs</li> <li>Set up notifications by adding Apprise URLs</li>
<li>Test your notification setup using the "Test Notification" button</li> <li>Test your notification setup using the &quot;Test Notification&quot; button</li>
<li>Save your settings to activate auto-sync</li> <li>Save your settings to activate auto-sync</li>
</ol> </ol>
</div> </div>

View File

@@ -255,7 +255,7 @@ export function InstalledScriptsTab() {
void refetchScripts(); void refetchScripts();
setAutoDetectStatus({ setAutoDetectStatus({
type: 'success', type: 'success',
message: data.success ? `Detected IP: ${data.ip}` : (data.error ?? 'Failed to detect Web UI') message: data.success ? `Detected IP: ${data.detectedIp}:${data.detectedPort}` : (data.error ?? 'Failed to detect Web UI')
}); });
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
}, },
@@ -275,12 +275,10 @@ export function InstalledScriptsTab() {
{ enabled: false } // Only fetch when explicitly called { enabled: false } // Only fetch when explicitly called
); );
const fetchStorages = async (serverId: number, forceRefresh = false) => { const fetchStorages = async (serverId: number, _forceRefresh = false) => {
setIsLoadingStorages(true); setIsLoadingStorages(true);
try { try {
const result = await getBackupStoragesQuery.refetch({ const result = await getBackupStoragesQuery.refetch();
queryKey: ['installedScripts.getBackupStorages', { serverId, forceRefresh }]
});
if (result.data?.success) { if (result.data?.success) {
setBackupStorages(result.data.storages); setBackupStorages(result.data.storages);
} else { } else {
@@ -563,7 +561,7 @@ export function InstalledScriptsTab() {
const handleDeleteScript = (id: number, script?: InstalledScript) => { const handleDeleteScript = (id: number, script?: InstalledScript) => {
const scriptToDelete = script ?? scripts.find(s => s.id === id); const scriptToDelete = script ?? scripts.find(s => s.id === id);
if (scriptToDelete && scriptToDelete.container_id && scriptToDelete.execution_mode === 'ssh') { if (scriptToDelete?.container_id && scriptToDelete.execution_mode === 'ssh') {
// For SSH scripts with container_id, use confirmation modal // For SSH scripts with container_id, use confirmation modal
setConfirmationModal({ setConfirmationModal({
isOpen: true, isOpen: true,

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, startTransition } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
@@ -159,9 +159,13 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
useEffect(() => { useEffect(() => {
if (configData?.success) { if (configData?.success) {
populateFormData(configData); populateFormData(configData);
setHasChanges(false); startTransition(() => {
setHasChanges(false);
});
} else if (configData && !configData.success) { } else if (configData && !configData.success) {
setError(String(configData.error ?? 'Failed to load configuration')); startTransition(() => {
setError(String(configData.error ?? 'Failed to load configuration'));
});
} }
}, [configData]); }, [configData]);

View File

@@ -7,16 +7,16 @@ import { Button } from './ui/button';
interface LoadingModalProps { interface LoadingModalProps {
isOpen: boolean; isOpen: boolean;
action: string; action?: string;
logs?: string[]; logs?: string[];
isComplete?: boolean; isComplete?: boolean;
title?: string; title?: string;
onClose?: () => void; onClose?: () => void;
} }
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) { export function LoadingModal({ isOpen, action: _action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
// Allow dismissing with ESC only when complete, prevent during running // Allow dismissing with ESC only when complete, prevent during running
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) }); useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose ?? (() => null) });
const logsEndRef = useRef<HTMLDivElement>(null); const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive

View File

@@ -19,7 +19,7 @@ export function PBSCredentialsModal({
isOpen, isOpen,
onClose, onClose,
serverId, serverId,
serverName, serverName: _serverName,
storage storage
}: PBSCredentialsModalProps) { }: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState(''); const [pbsIp, setPbsIp] = useState('');
@@ -29,8 +29,8 @@ export function PBSCredentialsModal({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object // Extract PBS info from storage object
const pbsIpFromStorage = (storage as any).server || null; const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
const pbsDatastoreFromStorage = (storage as any).datastore || null; const pbsDatastoreFromStorage = (storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials // Fetch existing credentials
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery( const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
@@ -46,11 +46,11 @@ export function PBSCredentialsModal({
setPbsIp(credentialData.credential.pbs_ip); setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(credentialData.credential.pbs_datastore); setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(''); // Don't show password setPbsPassword(''); // Don't show password
setPbsFingerprint(credentialData.credential.pbs_fingerprint || ''); setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? '');
} else { } else {
// Initialize with storage config values // Initialize with storage config values
setPbsIp(pbsIpFromStorage || ''); setPbsIp(pbsIpFromStorage ?? '');
setPbsDatastore(pbsDatastoreFromStorage || ''); setPbsDatastore(pbsDatastoreFromStorage ?? '');
setPbsPassword(''); setPbsPassword('');
setPbsFingerprint(''); setPbsFingerprint('');
} }
@@ -241,7 +241,7 @@ export function PBSCredentialsModal({
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02" placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button. Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the &quot;Show Fingerprint&quot; button.
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, startTransition } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
@@ -47,7 +47,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
// Get current version when modal opens // Get current version when modal opens
useEffect(() => { useEffect(() => {
if (isOpen && versionData?.success && versionData.version) { if (isOpen && versionData?.success && versionData.version) {
setCurrentVersion(versionData.version); startTransition(() => {
setCurrentVersion(versionData.version);
});
} }
}, [isOpen, versionData]); }, [isOpen, versionData]);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon'; import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -11,6 +11,8 @@ export function ResyncButton() {
const [syncMessage, setSyncMessage] = useState<string | null>(null); const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false); const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = useRef<boolean>(false); const isUserInitiatedRef = useRef<boolean>(false);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resyncMutation = api.scripts.resyncScripts.useMutation({ const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -21,7 +23,16 @@ export function ResyncButton() {
// Only reload if this was triggered by user action // Only reload if this was triggered by user action
if (isUserInitiatedRef.current && !hasReloadedRef.current) { if (isUserInitiatedRef.current && !hasReloadedRef.current) {
hasReloadedRef.current = true; hasReloadedRef.current = true;
setTimeout(() => {
// Clear any existing reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Set new reload timeout
reloadTimeoutRef.current = setTimeout(() => {
reloadTimeoutRef.current = null;
window.location.reload(); window.location.reload();
}, 2000); // Wait 2 seconds to show the success message }, 2000); // Wait 2 seconds to show the success message
} else { } else {
@@ -31,14 +42,26 @@ export function ResyncButton() {
} else { } else {
setSyncMessage(data.error ?? 'Failed to sync scripts'); setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors // Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000); if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false; isUserInitiatedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setIsResyncing(false); setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`); setSyncMessage(`Error: ${error.message}`);
setTimeout(() => setSyncMessage(null), 3000); if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false; isUserInitiatedRef.current = false;
}, },
}); });
@@ -47,6 +70,12 @@ export function ResyncButton() {
// Prevent multiple simultaneous sync operations // Prevent multiple simultaneous sync operations
if (isResyncing) return; if (isResyncing) return;
// Clear any pending reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Mark as user-initiated before starting // Mark as user-initiated before starting
isUserInitiatedRef.current = true; isUserInitiatedRef.current = true;
hasReloadedRef.current = false; hasReloadedRef.current = false;
@@ -55,6 +84,23 @@ export function ResyncButton() {
resyncMutation.mutate(); resyncMutation.mutate();
}; };
// Cleanup on unmount - clear any pending timeouts
useEffect(() => {
return () => {
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
messageTimeoutRef.current = null;
}
// Reset refs on unmount
hasReloadedRef.current = false;
isUserInitiatedRef.current = false;
};
}, []);
return ( return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium"> <div className="text-sm text-muted-foreground font-medium">

View File

@@ -28,7 +28,7 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ''; if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }

View File

@@ -46,7 +46,7 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ''; if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }

View File

@@ -84,7 +84,7 @@ export function ScriptDetailModal({
setLoadMessage(`[ERROR] ${error}`); setLoadMessage(`[ERROR] ${error}`);
} }
// Clear message after 5 seconds // Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000); void setTimeout(() => setLoadMessage(null), 5000);
}, },
onError: (error) => { onError: (error) => {
setIsLoading(false); setIsLoading(false);
@@ -109,7 +109,7 @@ export function ScriptDetailModal({
setLoadMessage(`[ERROR] ${error}`); setLoadMessage(`[ERROR] ${error}`);
} }
// Clear message after 5 seconds // Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000); void setTimeout(() => setLoadMessage(null), 5000);
}, },
onError: (error) => { onError: (error) => {
setIsDeleting(false); setIsDeleting(false);
@@ -155,7 +155,7 @@ export function ScriptDetailModal({
// Use the first available method or default to 'default' type // Use the first available method or default to 'default' type
const defaultMethod = installMethods.find(method => method.type === 'default'); const defaultMethod = installMethods.find(method => method.type === 'default');
const firstMethod = installMethods[0]; const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default'); setSelectedVersionType(defaultMethod?.type ?? firstMethod?.type ?? 'default');
setExecutionModeOpen(true); setExecutionModeOpen(true);
} }
}; };
@@ -170,10 +170,10 @@ export function ScriptDetailModal({
if (!script || !onInstallScript) return; if (!script || !onInstallScript) return;
// Find the script path based on selected version type // Find the script path based on selected version type
const versionType = selectedVersionType || 'default'; const versionType = selectedVersionType ?? 'default';
const scriptMethod = script.install_methods?.find( const scriptMethod = script.install_methods?.find(
(method) => method.type === versionType && method.script, (method) => method.type === versionType && method.script,
) || script.install_methods?.find( ) ?? script.install_methods?.find(
(method) => method.script, (method) => method.script,
); );
@@ -247,7 +247,7 @@ export function ScriptDetailModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`} title={`Source: ${script.repository_url}`}
> >
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url} {(/github\.com\/([^\/]+)\/([^\/]+)/.exec(script.repository_url))?.[0]?.replace('https://', '') ?? script.repository_url}
</a> </a>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { Script, ScriptInstallMethod } from '../../types/script'; import type { Script } from '../../types/script';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';

View File

@@ -59,7 +59,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (saveFilterEnabled) { if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters'); const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) { if (filtersResponse.ok) {
const filtersData = await filtersResponse.json(); const filtersData = await filtersResponse.json() as { filters?: Partial<FilterState> };
if (filtersData.filters) { if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters)); setFilters(mergeFiltersWithDefaults(filtersData.filters));
} }

View File

@@ -65,11 +65,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0) // Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed; let ipv6Address = trimmed;
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/); const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
if (zoneIdMatch) { if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1]; ipv6Address = zoneIdMatch[1] ?? '';
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen) // Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2]; const zoneId = zoneIdMatch[2] ?? '';
if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) { if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) {
return false; return false;
} }
@@ -82,7 +82,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(ipv6Address)) { if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists // Additional validation: ensure only one :: compression exists
const compressionCount = (ipv6Address.match(/::/g) || []).length; const regex = /::/g;
let compressionCount = 0;
while (regex.exec(ipv6Address) !== null) {
compressionCount++;
}
if (compressionCount <= 1) { if (compressionCount <= 1) {
return true; return true;
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react'; import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';

View File

@@ -1,15 +1,16 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Server } from '~/types/server';
interface TerminalProps { interface TerminalProps {
scriptPath: string; scriptPath: string;
onClose: () => void; onClose: () => void;
mode?: 'local' | 'ssh'; mode?: 'local' | 'ssh';
server?: any; server?: Server;
isUpdate?: boolean; isUpdate?: boolean;
isShell?: boolean; isShell?: boolean;
isBackup?: boolean; isBackup?: boolean;
@@ -45,6 +46,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; 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) => { const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return; if (!xtermRef.current) return;
@@ -209,7 +217,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
if (isMobile) { if (isMobile) {
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
setTimeout(() => { void setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
}, 200); }, 200);
}, 300); }, 300);
@@ -219,7 +227,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Add resize listener for mobile responsiveness // Add resize listener for mobile responsiveness
const handleResize = () => { const handleResize = () => {
if (fitAddonRef.current) { if (fitAddonRef.current) {
setTimeout(() => { void setTimeout(() => {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
}, 50); }, 50);
} }
@@ -260,7 +268,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
xtermRef.current.dispose(); xtermRef.current.dispose();
xtermRef.current = null; xtermRef.current = null;
fitAddonRef.current = null; fitAddonRef.current = null;
setIsTerminalReady(false); setTimeout(() => {
setIsTerminalReady(false);
}, 0);
} }
}; };
}, [isClient, isMobile]); }, [isClient, isMobile]);
@@ -296,13 +306,32 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
useEffect(() => { useEffect(() => {
// Prevent multiple connections in React Strict Mode // Prevent multiple connections in React Strict Mode
if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) { // 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) {
return; return;
} }
// Close any existing connection first // Close any existing connection first (safety check)
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); try {
wsRef.current.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
wsRef.current = null; wsRef.current = null;
} }
@@ -359,6 +388,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
setIsConnected(false); setIsConnected(false);
setIsRunning(false); setIsRunning(false);
isConnectingRef.current = 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) => { ws.onerror = (error) => {
@@ -366,6 +401,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
console.error('WebSocket readyState:', ws.readyState); console.error('WebSocket readyState:', ws.readyState);
setIsConnected(false); setIsConnected(false);
isConnectingRef.current = 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;
}
}; };
}; };
@@ -375,12 +416,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
isConnectingRef.current = false; isConnectingRef.current = false;
hasConnectedRef.current = false; // Only close and reset if the connection is still active
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { if (wsRef.current) {
wsRef.current.close(); 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;
}; };
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps // 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]);
const startScript = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
@@ -429,7 +481,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
}; };
wsRef.current.send(JSON.stringify(message)); wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds // Clear the feedback after 2 seconds
setTimeout(() => setLastInputSent(null), 2000); void setTimeout(() => setLastInputSent(null), 2000);
} }
}; };

View File

@@ -42,11 +42,10 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
const alpineScriptPath = alpineMethod?.script; const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically) // Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/'); const hasInstallScript = (defaultScriptPath?.startsWith('ct/') ?? false) || (alpineScriptPath?.startsWith('ct/') ?? false);
// Get script names for display // Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, ''); const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => { const loadScriptContent = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -172,8 +171,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</Button> </Button>
</div> </div>
)} )}
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) || {((selectedVersion === 'default' && (scriptContent.mainScript ?? scriptContent.installScript)) ?? false) ||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && ( (selectedVersion === 'alpine' && (scriptContent.alpineMainScript ?? scriptContent.alpineInstallScript)) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === 'main' ? 'outline' : 'ghost'} variant={activeTab === 'main' ? 'outline' : 'ghost'}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState, startTransition } from 'react';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
@@ -31,9 +31,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme; const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) { if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
setThemeState(savedTheme); startTransition(() => {
setThemeState(savedTheme);
});
} }
setMounted(true); startTransition(() => {
setMounted(true);
});
}, []); }, []);
// Apply theme to document element // Apply theme to document element

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react'; import { X, ExternalLink, Calendar, Tag, AlertTriangle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';

View File

@@ -87,37 +87,59 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const [shouldSubscribe, setShouldSubscribe] = useState(false); const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null); const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false); const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
const lastLogTimeRef = useRef<number>(Date.now()); const lastLogTimeRef = useRef<number>(0);
// Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render
useEffect(() => {
if (lastLogTimeRef.current === 0) {
lastLogTimeRef.current = Date.now();
}
}, []);
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null); const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasReloadedRef = useRef<boolean>(false); const hasReloadedRef = useRef<boolean>(false);
const isUpdatingRef = useRef<boolean>(false); const isUpdatingRef = useRef<boolean>(false);
const isNetworkErrorRef = useRef<boolean>(false); const isNetworkErrorRef = useRef<boolean>(false);
const updateSessionIdRef = useRef<string | null>(null);
const updateStartTimeRef = useRef<number | null>(null);
const logFileModifiedTimeRef = useRef<number | null>(null);
const isCompleteProcessedRef = useRef<boolean>(false);
const executeUpdate = api.version.executeUpdate.useMutation({ const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
setUpdateResult({ success: result.success, message: result.message }); setUpdateResult({ success: result.success, message: result.message });
if (result.success) { if (result.success) {
// Start subscribing to update logs // Start subscribing to update logs only if we're actually updating
setShouldSubscribe(true); if (isUpdatingRef.current) {
setUpdateLogs(['Update started...']); setShouldSubscribe(true);
setUpdateLogs(['Update started...']);
}
} else { } else {
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on failure setShouldSubscribe(false); // Reset subscription on failure
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setUpdateResult({ success: false, message: error.message }); setUpdateResult({ success: false, message: error.message });
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on error setShouldSubscribe(false); // Reset subscription on error
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
} }
}); });
// Poll for update logs // Poll for update logs - only enabled when shouldSubscribe is true AND we're updating
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, { const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe, enabled: shouldSubscribe && isUpdating,
refetchInterval: 1000, // Poll every second refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating
refetchIntervalInBackground: true, refetchIntervalInBackground: false, // Don't poll in background to prevent stale data
}); });
// Attempt to reconnect and reload page when server is back // Attempt to reconnect and reload page when server is back
@@ -126,8 +148,16 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const startReconnectAttempts = useCallback(() => { const startReconnectAttempts = useCallback(() => {
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts // CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
// Only start if we're actually updating and haven't already started // Only start if we're actually updating and haven't already started
// Double-check isUpdating state to prevent false triggers from stale data // Double-check isUpdating state and session validity to prevent false triggers from stale data
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) { if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
return;
}
// Validate session age before starting reconnection attempts
const sessionAge = Date.now() - updateStartTimeRef.current;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (sessionAge > MAX_SESSION_AGE) {
// Session is stale, don't start reconnection
return; return;
} }
@@ -137,7 +167,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
void (async () => { void (async () => {
// Guard: Only proceed if we're still updating and in network error state // Guard: Only proceed if we're still updating and in network error state
// Check refs directly to avoid stale closures // Check refs directly to avoid stale closures
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) { if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
// Clear interval if we're no longer updating // Clear interval if we're no longer updating
if (!isUpdatingRef.current && reconnectIntervalRef.current) { if (!isUpdatingRef.current && reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
@@ -146,12 +176,29 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return; return;
} }
// Validate session is still valid
const currentSessionAge = Date.now() - updateStartTimeRef.current;
if (currentSessionAge > MAX_SESSION_AGE) {
// Session expired, stop reconnection attempts
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
return;
}
try { try {
// Try to fetch the root path to check if server is back // Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' }); const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) { if (response.ok || response.status === 200) {
// Double-check we're still updating before reloading // Double-check we're still updating and session is valid before reloading
if (!isUpdatingRef.current || hasReloadedRef.current) { if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
return;
}
// Final session validation
const finalSessionAge = Date.now() - updateStartTimeRef.current;
if (finalSessionAge > MAX_SESSION_AGE) {
return; return;
} }
@@ -159,13 +206,21 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
hasReloadedRef.current = true; hasReloadedRef.current = true;
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']); setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload // Clear interval
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
setTimeout(() => { // Clear any existing reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Set reload timeout
reloadTimeoutRef.current = setTimeout(() => {
reloadTimeoutRef.current = null;
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} }
@@ -180,21 +235,68 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
useEffect(() => { useEffect(() => {
// CRITICAL: Only process update logs if we're actually updating // CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating // This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating) { if (!isUpdating || !updateStartTimeRef.current) {
return;
}
// CRITICAL: Validate session - only process logs from current update session
// Check that update started within last 30 minutes (reasonable window for update)
const sessionAge = Date.now() - updateStartTimeRef.current;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (sessionAge > MAX_SESSION_AGE) {
// Session is stale, reset everything
setTimeout(() => {
setIsUpdating(false);
setShouldSubscribe(false);
}, 0);
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
return; return;
} }
if (updateLogsData?.success && updateLogsData.logs) { if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
// CRITICAL: Only process isComplete if we're actually updating if (updateLogsData.logFileModifiedTime !== null && logFileModifiedTimeRef.current !== null) {
// Double-check isUpdating state to prevent false triggers
if (updateLogsData.isComplete && isUpdating) { if (updateLogsData.logFileModifiedTime < logFileModifiedTimeRef.current) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true); return;
}
} else if (updateLogsData.logFileModifiedTime !== null && updateStartTimeRef.current) {
const timeDiff = updateLogsData.logFileModifiedTime - updateStartTimeRef.current;
if (timeDiff < -5000) {
}
logFileModifiedTimeRef.current = updateLogsData.logFileModifiedTime;
}
lastLogTimeRef.current = Date.now();
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0);
if (
updateLogsData.isComplete &&
isUpdating &&
updateStartTimeRef.current &&
sessionAge < MAX_SESSION_AGE &&
!isCompleteProcessedRef.current
) {
// Mark as processed immediately to prevent multiple triggers
isCompleteProcessedRef.current = true;
// Stop polling immediately to prevent further stale data processing
setTimeout(() => setShouldSubscribe(false), 0);
setTimeout(() => {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
}, 0);
// Start reconnection attempts when we know update is complete // Start reconnection attempts when we know update is complete
startReconnectAttempts(); setTimeout(() => startReconnectAttempts(), 0);
} }
} }
}, [updateLogsData, startReconnectAttempts, isUpdating]); }, [updateLogsData, startReconnectAttempts, isUpdating]);
@@ -218,8 +320,10 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
// Additional guard: check refs again before triggering // Additional guard: check refs again before triggering and validate session
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) { const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
setIsNetworkError(true); setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']); setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
@@ -234,12 +338,26 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
// Keep refs in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
isUpdatingRef.current = isUpdating; isUpdatingRef.current = isUpdating;
// CRITICAL: Reset shouldSubscribe immediately when isUpdating becomes false
// This prevents stale polling from continuing
if (!isUpdating) {
setTimeout(() => {
setShouldSubscribe(false);
}, 0);
// Reset completion processing flag when update stops
isCompleteProcessedRef.current = false;
}
}, [isUpdating]); }, [isUpdating]);
useEffect(() => { useEffect(() => {
isNetworkErrorRef.current = isNetworkError; isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]); }, [isNetworkError]);
// Keep updateStartTime ref in sync
useEffect(() => {
updateStartTimeRef.current = updateStartTime;
}, [updateStartTime]);
// Clear reconnect interval when update completes or component unmounts // Clear reconnect interval when update completes or component unmounts
useEffect(() => { useEffect(() => {
// If we're no longer updating, clear the reconnect interval and reset subscription // If we're no longer updating, clear the reconnect interval and reset subscription
@@ -248,8 +366,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
// Clear reload timeout if update stops
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Reset subscription to prevent stale polling // Reset subscription to prevent stale polling
setShouldSubscribe(false); setTimeout(() => {
setShouldSubscribe(false);
}, 0);
// Reset completion processing flag
isCompleteProcessedRef.current = false;
// Don't clear session refs here - they're cleared explicitly on unmount or new update
} }
return () => { return () => {
@@ -257,9 +385,32 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
}; };
}, [isUpdating]); }, [isUpdating]);
// Cleanup on component unmount - reset all update-related state
useEffect(() => {
return () => {
// Clear all intervals
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
// Reset all refs and state
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
hasReloadedRef.current = false;
isUpdatingRef.current = false;
isNetworkErrorRef.current = false;
};
}, []);
const handleUpdate = () => { const handleUpdate = () => {
// Show confirmation modal instead of starting update directly // Show confirmation modal instead of starting update directly
setShowUpdateConfirmation(true); setShowUpdateConfirmation(true);
@@ -269,19 +420,34 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
// Close the confirmation modal // Close the confirmation modal
setShowUpdateConfirmation(false); setShowUpdateConfirmation(false);
// Start the actual update process // Start the actual update process
const sessionId = `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
setIsUpdating(true); setIsUpdating(true);
setUpdateResult(null); setUpdateResult(null);
setIsNetworkError(false); setIsNetworkError(false);
setUpdateLogs([]); setUpdateLogs([]);
setShouldSubscribe(false); setShouldSubscribe(false); // Will be set to true in mutation onSuccess
setUpdateStartTime(Date.now()); setUpdateStartTime(startTime);
lastLogTimeRef.current = Date.now();
// Set refs for session tracking
updateSessionIdRef.current = sessionId;
updateStartTimeRef.current = startTime;
lastLogTimeRef.current = startTime;
logFileModifiedTimeRef.current = null; // Will be set when we first see log file
isCompleteProcessedRef.current = false; // Reset completion flag
hasReloadedRef.current = false; // Reset reload flag when starting new update hasReloadedRef.current = false; // Reset reload flag when starting new update
// Clear any existing reconnect interval
// Clear any existing reconnect interval and reload timeout
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
executeUpdate.mutate(); executeUpdate.mutate();
}; };

View File

@@ -4,9 +4,25 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { isValidCron } from 'cron-validator'; import { isValidCron } from 'cron-validator';
interface AutoSyncSettings {
autoSyncEnabled?: boolean;
syncIntervalType?: 'predefined' | 'custom';
syncIntervalPredefined?: string;
syncIntervalCron?: string;
autoDownloadNew?: boolean;
autoUpdateExisting?: boolean;
notificationEnabled?: boolean;
appriseUrls?: string[] | string;
lastAutoSync?: string;
lastAutoSyncError?: string;
lastAutoSyncErrorTime?: string;
testNotification?: boolean;
triggerManualSync?: boolean;
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const settings = await request.json(); const settings = await request.json() as AutoSyncSettings;
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== 'object') {
return NextResponse.json( return NextResponse.json(
@@ -44,7 +60,7 @@ export async function POST(request: NextRequest) {
} }
// Validate sync interval type // Validate sync interval type
if (!['predefined', 'custom'].includes(settings.syncIntervalType)) { if (settings.syncIntervalType && !['predefined', 'custom'].includes(settings.syncIntervalType)) {
return NextResponse.json( return NextResponse.json(
{ error: 'syncIntervalType must be "predefined" or "custom"' }, { error: 'syncIntervalType must be "predefined" or "custom"' },
{ status: 400 } { status: 400 }
@@ -54,7 +70,7 @@ export async function POST(request: NextRequest) {
// Validate predefined interval // Validate predefined interval
if (settings.syncIntervalType === 'predefined') { if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours']; const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (!validIntervals.includes(settings.syncIntervalPredefined)) { if (settings.syncIntervalPredefined && !validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid predefined interval' }, { error: 'Invalid predefined interval' },
{ status: 400 } { status: 400 }
@@ -67,7 +83,7 @@ export async function POST(request: NextRequest) {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') { if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression // Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined'; settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour'; settings.syncIntervalPredefined = settings.syncIntervalPredefined ?? '1hour';
settings.syncIntervalCron = ''; settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) { } else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json( return NextResponse.json(
@@ -81,11 +97,11 @@ export async function POST(request: NextRequest) {
if (settings.notificationEnabled && settings.appriseUrls) { if (settings.notificationEnabled && settings.appriseUrls) {
try { try {
// Handle both array and JSON string formats // Handle both array and JSON string formats
let urls; let urls: string[];
if (Array.isArray(settings.appriseUrls)) { if (Array.isArray(settings.appriseUrls)) {
urls = settings.appriseUrls; urls = settings.appriseUrls;
} else if (typeof settings.appriseUrls === 'string') { } else if (typeof settings.appriseUrls === 'string') {
urls = JSON.parse(settings.appriseUrls); urls = JSON.parse(settings.appriseUrls) as string[];
} else { } else {
return NextResponse.json( return NextResponse.json(
{ error: 'Apprise URLs must be an array or JSON string' }, { error: 'Apprise URLs must be an array or JSON string' },
@@ -110,6 +126,7 @@ export async function POST(request: NextRequest) {
} }
} }
} catch (parseError) { } catch (parseError) {
console.error('Error parsing Apprise URLs:', parseError);
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' }, { error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 } { status: 400 }
@@ -129,16 +146,16 @@ export async function POST(request: NextRequest) {
// Auto-sync settings to add/update // Auto-sync settings to add/update
const autoSyncSettings = { const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false', 'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType, 'SYNC_INTERVAL_TYPE': settings.syncIntervalType ?? 'predefined',
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '', 'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '', 'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false', 'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false', 'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false', 'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'), 'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (typeof settings.appriseUrls === 'string' ? settings.appriseUrls : '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '', 'LAST_AUTO_SYNC': settings.lastAutoSync ?? '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '', 'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || '' 'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? ''
}; };
// Update or add each setting // Update or add each setting
@@ -169,11 +186,28 @@ export async function POST(request: NextRequest) {
autoSyncService = new AutoSyncService(); autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService); setAutoSyncService(autoSyncService);
} }
// Update the global service instance with new settings // Update the global service instance with new settings
autoSyncService.saveSettings(settings); // Make sure required fields are set and not undefined to match type expectations
autoSyncService.saveSettings({
if (settings.autoSyncEnabled) { ...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,
});
if (settings.autoSyncEnabled === true) {
autoSyncService.scheduleAutoSync(); autoSyncService.scheduleAutoSync();
} else { } else {
autoSyncService.stopAutoSync(); autoSyncService.stopAutoSync();
@@ -229,7 +263,7 @@ export async function GET() {
const settings = { const settings = {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true', autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined', syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined' as 'predefined' | 'custom',
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour', syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '', syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true', autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
@@ -238,7 +272,7 @@ export async function GET() {
appriseUrls: (() => { appriseUrls: (() => {
try { try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]'; const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue); return JSON.parse(urlsValue) as string[];
} catch { } catch {
return []; return [];
} }
@@ -276,7 +310,7 @@ async function handleTestNotification() {
const appriseUrls = (() => { const appriseUrls = (() => {
try { try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]'; const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue); return JSON.parse(urlsValue) as string[];
} catch { } catch {
return []; return [];
} }
@@ -347,9 +381,9 @@ async function handleManualSync() {
// Trigger manual sync using the auto-sync service // Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService(); const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as any; const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string };
if (result && result.success) { if (result?.success) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Manual sync completed successfully', message: 'Manual sync completed successfully',
@@ -376,7 +410,7 @@ function getEnvValue(envContent: string, key: string): string {
const regex = new RegExp(`^${key}="(.+)"$`, 'm'); const regex = new RegExp(`^${key}="(.+)"$`, 'm');
let match = regex.exec(envContent); let match = regex.exec(envContent);
if (match && match[1]) { if (match?.[1]) {
let value = match[1]; let value = match[1];
// Remove extra quotes that might be around JSON values // Remove extra quotes that might be around JSON values
if (value.startsWith('"') && value.endsWith('"')) { if (value.startsWith('"') && value.endsWith('"')) {
@@ -388,7 +422,7 @@ function getEnvValue(envContent: string, key: string): string {
// Try to match without quotes (fallback) // Try to match without quotes (fallback)
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm'); const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
match = regexNoQuotes.exec(envContent); match = regexNoQuotes.exec(envContent);
if (match && match[1]) { if (match?.[1]) {
return match[1]; return match[1];
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, startTransition } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid'; import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab'; import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab'; import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
@@ -57,8 +57,10 @@ export default function Home() {
// If we have a current version and either no last seen version or versions don't match // If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) { if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
setHighlightVersion(currentVersion); startTransition(() => {
setReleaseNotesOpen(true); setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
});
} }
} }
}, [versionData]); }, [versionData]);
@@ -131,18 +133,18 @@ export default function Home() {
return true; return true;
} }
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs) // Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (normalizeId(local.slug) === normalizeId(script.slug)) { if (normalizeId(local.slug) === normalizeId(script.slug as string | undefined)) {
return true; return true;
} }
} }
// Secondary: Check install basenames (for edge cases where install script names differ from slugs) // Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name); const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false; const matchesInstallBasename = script?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
if (matchesInstallBasename) return true; if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching // Tertiary: Normalized filename to normalized slug matching
if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) { if (script.slug && normalizeId(local.name) === normalizeId(script.slug as string | undefined)) {
return true; return true;
} }
@@ -167,7 +169,7 @@ export default function Home() {
} }
}; };
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: { id: number; name: string; ip: string }) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered // Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100); setTimeout(scrollToTerminal, 100);

View File

@@ -147,7 +147,7 @@ export function getAuthConfig(): {
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m; const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent); const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch const sessionDurationDays = sessionDurationMatch
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS ? parseInt(sessionDurationMatch[1]?.trim() ?? String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
: DEFAULT_JWT_EXPIRY_DAYS; : DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash); const hasCredentials = !!(username && passwordHash);

View File

@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
storage_name: string; storage_name: string;
storage_type: string; storage_type: string;
discovered_at: Date; discovered_at: Date;
server_id?: number;
server_name: string | null; server_name: string | null;
server_color: string | null; server_color: string | null;
}>; }>;
@@ -38,7 +39,7 @@ export const backupsRouter = createTRPCRouter({
if (backups.length === 0) continue; if (backups.length === 0) continue;
// Get hostname from first backup (all backups for same container should have same hostname) // Get hostname from first backup (all backups for same container should have same hostname)
const hostname = backups[0]?.hostname || ''; const hostname = backups[0]?.hostname ?? '';
result.push({ result.push({
container_id: containerId, container_id: containerId,

View File

@@ -71,9 +71,12 @@ function parseRawConfig(rawConfig: string): any {
// Parse rootfs into storage and size // Parse rootfs into storage and size
if (config.rootfs) { if (config.rootfs) {
const match = config.rootfs.match(/^([^:]+):([^,]+)(?:,size=(.+))?$/); const regex = /^([^:]+):([^,]+)(?:,size=(.+))?$/;
if (match) { const match = regex.exec(config.rootfs as string);
config.rootfs_storage = `${match[1]}:${match[2]}`; const storage = match?.[1];
const path = match?.[2];
if (storage && path) {
config.rootfs_storage = `${storage}:${path}`;
config.rootfs_size = match[3] ?? ''; config.rootfs_size = match[3] ?? '';
} }
delete config.rootfs; // Remove the rootfs field since we only need rootfs_storage and rootfs_size delete config.rootfs; // Remove the rootfs field since we only need rootfs_storage and rootfs_size
@@ -788,7 +791,7 @@ export const installedScriptsRouter = createTRPCRouter({
let detectedContainers: any[] = []; let detectedContainers: any[] = [];
// Helper function to parse list output and extract IDs // Helper function to parse list output and extract IDs
const parseListOutput = (output: string, isVM: boolean): string[] => { const parseListOutput = (output: string, _p0: boolean): string[] => {
const ids: string[] = []; const ids: string[] = [];
const lines = output.split('\n').filter(line => line.trim()); const lines = output.split('\n').filter(line => line.trim());
@@ -1047,10 +1050,11 @@ export const installedScriptsRouter = createTRPCRouter({
const scriptData = script as any; const scriptData = script as any;
if (!scriptData.server_id) continue; if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) { const serverId = Number(scriptData.server_id);
scriptsByServer.set(scriptData.server_id, []); if (!scriptsByServer.has(serverId)) {
scriptsByServer.set(serverId, []);
} }
scriptsByServer.get(scriptData.server_id)!.push(scriptData); scriptsByServer.get(serverId)!.push(scriptData);
} }
// Process each server // Process each server
@@ -1229,7 +1233,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
} }
} catch (error) { } catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error); console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData).script_name)}:`, error);
} }
} }
} catch (error) { } catch (error) {
@@ -1347,7 +1351,7 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve, reject) => { new Promise<void>((resolve) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
'pct list', 'pct list',
@@ -1375,7 +1379,7 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve, reject) => { new Promise<void>((resolve) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
'qm list', 'qm list',
@@ -1479,7 +1483,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Determine if it's a VM or LXC // Determine if it's a VM or LXC
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
// Check container status (use qm for VMs, pct for LXC) // Check container status (use qm for VMs, pct for LXC)
const statusCommand = vm const statusCommand = vm
@@ -1582,7 +1586,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Determine if it's a VM or LXC // Determine if it's a VM or LXC
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
// Execute control command (use qm for VMs, pct for LXC) // Execute control command (use qm for VMs, pct for LXC)
const controlCommand = vm const controlCommand = vm
@@ -1678,7 +1682,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Determine if it's a VM or LXC // Determine if it's a VM or LXC
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); const vm = await isVM(input.id, String(scriptData.container_id), Number(scriptData.server_id));
// First check if container is running and stop it if necessary // First check if container is running and stop it if necessary
const statusCommand = vm const statusCommand = vm
@@ -2372,7 +2376,7 @@ EOFCONFIG`;
let serverHostname = ''; let serverHostname = '';
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
'hostname', 'hostname',
(data: string) => { (data: string) => {

View File

@@ -100,7 +100,7 @@ export const scriptsRouter = createTRPCRouter({
getAllScripts: publicProcedure getAllScripts: publicProcedure
.query(async () => { .query(async () => {
try { try {
const scripts = await githubJsonService.getAllScripts(); const scripts = await githubJsonService.getAllScripts("");
return { success: true, scripts }; return { success: true, scripts };
} catch (error) { } catch (error) {
return { return {

View File

@@ -1,5 +1,5 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile } from "fs/promises"; import { readFile, writeFile, stat } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { env } from "~/env"; import { env } from "~/env";
@@ -176,10 +176,21 @@ export const versionRouter = createTRPCRouter({
return { return {
success: true, success: true,
logs: [], logs: [],
isComplete: false isComplete: false,
logFileModifiedTime: null
}; };
} }
// Get log file modification time for session validation
let logFileModifiedTime: number | null = null;
try {
const stats = await stat(logPath);
logFileModifiedTime = stats.mtimeMs;
} catch (statError) {
// If we can't get stats, continue without timestamp
console.warn('Could not get log file stats:', statError);
}
const logs = await readFile(logPath, 'utf-8'); const logs = await readFile(logPath, 'utf-8');
const logLines = logs.split('\n') const logLines = logs.split('\n')
.filter(line => line.trim()) .filter(line => line.trim())
@@ -202,7 +213,8 @@ export const versionRouter = createTRPCRouter({
return { return {
success: true, success: true,
logs: logLines, logs: logLines,
isComplete isComplete,
logFileModifiedTime
}; };
} catch (error) { } catch (error) {
console.error('Error reading update logs:', error); console.error('Error reading update logs:', error);
@@ -210,7 +222,8 @@ export const versionRouter = createTRPCRouter({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs', error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [], logs: [],
isComplete: false isComplete: false,
logFileModifiedTime: null
}; };
} }
}), }),

View File

@@ -1,7 +1,24 @@
import { AutoSyncService } from '~/server/services/autoSyncService'; import { AutoSyncService } from '~/server/services/autoSyncService';
import { repositoryService } from '~/server/services/repositoryService';
let autoSyncService: AutoSyncService | null = null; let autoSyncService: AutoSyncService | null = null;
/**
* Initialize default repositories
*/
export async function initializeRepositories(): Promise<void> {
try {
console.log('Initializing default repositories...');
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
} catch (error) {
console.error('Failed to initialize repositories:', error);
if (error instanceof Error) {
console.error('Error stack:', error.stack);
}
}
}
/** /**
* Initialize auto-sync service and schedule cron job if enabled * Initialize auto-sync service and schedule cron job if enabled
*/ */

View File

@@ -301,7 +301,6 @@ export class AutoSyncService {
console.log('Starting scheduled auto-sync...'); console.log('Starting scheduled auto-sync...');
await this.executeAutoSync(); await this.executeAutoSync();
}, { }, {
scheduled: true,
timezone: 'UTC' timezone: 'UTC'
}); });

View File

@@ -25,7 +25,7 @@ class BackupService {
let hostname = ''; let hostname = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
'hostname', 'hostname',
(data: string) => { (data: string) => {
@@ -61,17 +61,19 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
findCommand, findCommand,
(data: string) => { (data: string) => {
findOutput += data; findOutput += data;
}, },
(error: string) => { (error: string) => {
console.error('Error getting hostname:', error);
// Ignore errors - directory might not exist // Ignore errors - directory might not exist
resolve(); resolve();
}, },
(exitCode: number) => { (exitCode: number) => {
console.error('Error getting find command:', exitCode);
resolve(); resolve();
} }
); );
@@ -96,7 +98,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -112,11 +114,11 @@ class BackupService {
]); ]);
const statParts = statOutput.trim().split('|'); const statParts = statOutput.trim().split('|');
const fileName = backupPath.split('/').pop() || backupPath; const fileName = backupPath.split('/').pop() ?? backupPath;
if (statParts.length >= 2 && statParts[0] && statParts[1]) { if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] || '0'); const size = BigInt(statParts[0] ?? '0');
const mtime = parseInt(statParts[1] || '0', 10); const mtime = parseInt(statParts[1] ?? '0', 10);
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
@@ -144,8 +146,9 @@ class BackupService {
}); });
} }
} catch (error) { } catch (error) {
console.error('Error processing backup:', error);
// Still try to add the backup even if stat fails // Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() || backupPath; const fileName = backupPath.split('/').pop() ?? backupPath;
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
server_id: server.id, server_id: server.id,
@@ -182,17 +185,18 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
findCommand, findCommand,
(data: string) => { (data: string) => {
findOutput += data; findOutput += data;
}, },
(error: string) => { (error: string) => {
// Ignore errors - storage might not be mounted console.error('Error getting stat command:', error);
resolve(); resolve();
}, },
(exitCode: number) => { (exitCode: number) => {
console.error('Error getting stat command:', exitCode);
resolve(); resolve();
} }
); );
@@ -218,7 +222,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -234,11 +238,11 @@ class BackupService {
]); ]);
const statParts = statOutput.trim().split('|'); const statParts = statOutput.trim().split('|');
const fileName = backupPath.split('/').pop() || backupPath; const fileName = backupPath.split('/').pop() ?? backupPath;
if (statParts.length >= 2 && statParts[0] && statParts[1]) { if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] || '0'); const size = BigInt(statParts[0] ?? '0');
const mtime = parseInt(statParts[1] || '0', 10); const mtime = parseInt(statParts[1] ?? '0', 10);
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
@@ -270,7 +274,7 @@ class BackupService {
} catch (error) { } catch (error) {
console.error(`Error processing backup ${backupPath}:`, error); console.error(`Error processing backup ${backupPath}:`, error);
// Still try to add the backup even if stat fails // Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() || backupPath; const fileName = backupPath.split('/').pop() ?? backupPath;
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
server_id: server.id, server_id: server.id,
@@ -310,8 +314,8 @@ class BackupService {
const pbsInfo = storageService.getPBSStorageInfo(storage); const pbsInfo = storageService.getPBSStorageInfo(storage);
// Use IP and datastore from credentials (they override config if different) // Use IP and datastore from credentials (they override config if different)
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip; const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore; const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) { if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`); console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -339,7 +343,7 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
fullCommand, fullCommand,
(data: string) => { (data: string) => {
@@ -405,8 +409,8 @@ class BackupService {
const storageService = getStorageService(); const storageService = getStorageService();
const pbsInfo = storageService.getPBSStorageInfo(storage); const pbsInfo = storageService.getPBSStorageInfo(storage);
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip; const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore; const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) { if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`); console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -425,8 +429,8 @@ class BackupService {
try { try {
// Add timeout to prevent hanging // Add timeout to prevent hanging
await Promise.race([ await Promise.race([
new Promise<void>((resolve, reject) => { new Promise<void>((resolve) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
command, command,
(data: string) => { (data: string) => {
@@ -468,7 +472,7 @@ class BackupService {
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) { if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
continue; // Skip header row continue; // Skip header row
} }
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) { if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) {
continue; // Skip table separator lines continue; // Skip table separator lines
} }
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) { if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
@@ -489,7 +493,7 @@ class BackupService {
// Extract snapshot name (last part after /) // Extract snapshot name (last part after /)
const snapshotParts = snapshotPath.split('/'); const snapshotParts = snapshotPath.split('/');
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath; const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
if (!snapshotName) { if (!snapshotName) {
continue; // Skip if no snapshot name continue; // Skip if no snapshot name
@@ -497,11 +501,12 @@ class BackupService {
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z) // Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
let createdAt: Date | undefined; let createdAt: Date | undefined;
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/); const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName);
if (dateMatch && dateMatch[1]) { if (dateMatch?.[1]) {
try { try {
createdAt = new Date(dateMatch[1]); createdAt = new Date(dateMatch[1]);
} catch (e) { } catch (e) {
console.error('Error parsing date:', e);
// Invalid date, leave undefined // Invalid date, leave undefined
} }
} }
@@ -509,8 +514,8 @@ class BackupService {
// Parse size (convert MiB/GiB to bytes) // Parse size (convert MiB/GiB to bytes)
let size: bigint | undefined; let size: bigint | undefined;
if (sizeStr) { if (sizeStr) {
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i); const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr);
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) { if (sizeMatch?.[1] && sizeMatch[2]) {
const sizeValue = parseFloat(sizeMatch[1]); const sizeValue = parseFloat(sizeMatch[1]);
const unit = sizeMatch[2].toUpperCase(); const unit = sizeMatch[2].toUpperCase();
let bytes = sizeValue; let bytes = sizeValue;
@@ -640,18 +645,18 @@ class BackupService {
if (!script.container_id || !script.server_id || !script.server) continue; if (!script.container_id || !script.server_id || !script.server) continue;
const containerId = script.container_id; const containerId = script.container_id;
const serverId = script.server_id;
const server = script.server as Server; const server = script.server as Server;
try { try {
// Get hostname from LXC config if available, otherwise use script name // Get hostname from LXC config if available, otherwise use script name
let hostname = script.script_name || `CT-${script.container_id}`; let hostname = script.script_name ?? `CT-${script.container_id}`;
try { try {
const lxcConfig = await db.getLXCConfigByScriptId(script.id); const lxcConfig = await db.getLXCConfigByScriptId(script.id);
if (lxcConfig?.hostname) { if (lxcConfig?.hostname) {
hostname = lxcConfig.hostname; hostname = lxcConfig.hostname;
} }
} catch (error) { } catch (error) {
console.error('Error getting LXC config:', error);
// LXC config might not exist, use script name // LXC config might not exist, use script name
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`); console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
} }
@@ -682,9 +687,7 @@ class BackupService {
let backupServiceInstance: BackupService | null = null; let backupServiceInstance: BackupService | null = null;
export function getBackupService(): BackupService { export function getBackupService(): BackupService {
if (!backupServiceInstance) { backupServiceInstance ??= new BackupService();
backupServiceInstance = new BackupService();
}
return backupServiceInstance; return backupServiceInstance;
} }

View File

@@ -2,7 +2,7 @@ import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { env } from '../../env.js'; import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script'; import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { repositoryService } from './repositoryService.ts'; import { repositoryService } from './repositoryService';
export class GitHubJsonService { export class GitHubJsonService {
private branch: string | null = null; private branch: string | null = null;
@@ -64,7 +64,8 @@ export class GitHubJsonService {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} }
return response.json() as Promise<T>; const data = await response.json();
return data as T;
} }
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> { private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
@@ -214,9 +215,7 @@ export class GitHubJsonService {
const script = JSON.parse(content) as Script; const script = JSON.parse(content) as Script;
// If script doesn't have repository_url, set it to main repo (for backward compatibility) // If script doesn't have repository_url, set it to main repo (for backward compatibility)
if (!script.repository_url) { script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
}
// Cache the script // Cache the script
this.scriptCache.set(slug, script); this.scriptCache.set(slug, script);
@@ -397,7 +396,6 @@ export class GitHubJsonService {
const filesToSync: GitHubFile[] = []; const filesToSync: GitHubFile[] = [];
for (const ghFile of githubFiles) { for (const ghFile of githubFiles) {
const slug = ghFile.name.replace('.json', '');
const localFilePath = join(this.localJsonDirectory!, ghFile.name); const localFilePath = join(this.localJsonDirectory!, ghFile.name);
let needsSync = false; let needsSync = false;

View File

@@ -1,4 +1,4 @@
import { prisma } from '../db.ts'; import { prisma } from '../db';
export class RepositoryService { export class RepositoryService {
/** /**
@@ -93,7 +93,7 @@ export class RepositoryService {
priority?: number; priority?: number;
}) { }) {
// Validate GitHub URL // Validate GitHub URL
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) { if (!(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/.exec(data.url))) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo'); throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
} }
@@ -131,7 +131,7 @@ export class RepositoryService {
}) { }) {
// If updating URL, validate it // If updating URL, validate it
if (data.url) { if (data.url) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) { if (!(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/.exec(data.url))) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo'); throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
} }

View File

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

View File

@@ -3,13 +3,22 @@ import { join } from 'path';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises'; import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService { export class ScriptDownloaderService {
/**
* @type {string | undefined}
*/
scriptsDirectory;
/**
* @type {string | undefined}
*/
repoUrl;
constructor() { constructor() {
this.scriptsDirectory = null; this.scriptsDirectory = undefined;
this.repoUrl = null; this.repoUrl = undefined;
} }
initializeConfig() { initializeConfig() {
if (this.scriptsDirectory === null) { if (this.scriptsDirectory === undefined) {
this.scriptsDirectory = join(process.cwd(), 'scripts'); this.scriptsDirectory = join(process.cwd(), 'scripts');
// Get REPO_URL from environment or use default // Get REPO_URL from environment or use default
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE'; this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
@@ -19,6 +28,7 @@ export class ScriptDownloaderService {
/** /**
* Validates that a directory path doesn't contain nested directories with the same name * Validates that a directory path doesn't contain nested directories with the same name
* (e.g., prevents ct/ct or install/install) * (e.g., prevents ct/ct or install/install)
* @param {string} dirPath
*/ */
validateDirectoryPath(dirPath) { validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/'); const normalizedPath = dirPath.replace(/\\/g, '/');
@@ -36,6 +46,8 @@ export class ScriptDownloaderService {
/** /**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install * Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
* @param {string} targetDir
* @param {string} finalTargetDir
*/ */
validateTargetDir(targetDir, finalTargetDir) { validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names // Check if finalTargetDir contains nested directory names
@@ -53,6 +65,9 @@ export class ScriptDownloaderService {
return finalTargetDir; return finalTargetDir;
} }
/**
* @param {string} dirPath
*/
async ensureDirectoryExists(dirPath) { async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name // Validate the directory path to prevent nested directories with the same name
this.validateDirectoryPath(dirPath); this.validateDirectoryPath(dirPath);
@@ -62,15 +77,21 @@ export class ScriptDownloaderService {
await mkdir(dirPath, { recursive: true }); await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`); console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (error) { } catch (error) {
if (error.code !== 'EEXIST') { const err = error instanceof Error ? error : new Error(String(error));
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message); /** @type {any} */
throw error; const errAny = err;
if (errAny.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, err.message);
throw err;
} }
// Directory already exists, which is fine // Directory already exists, which is fine
console.log(`[Directory Creation] Directory already exists: ${dirPath}`); console.log(`[Directory Creation] Directory already exists: ${dirPath}`);
} }
} }
/**
* @param {string} repoUrl
*/
extractRepoPath(repoUrl) { extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) { if (!match) {
@@ -79,6 +100,11 @@ export class ScriptDownloaderService {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
/**
* @param {string} repoUrl
* @param {string} filePath
* @param {string} [branch]
*/
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') { async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
this.initializeConfig(); this.initializeConfig();
if (!repoUrl) { if (!repoUrl) {
@@ -88,6 +114,7 @@ export class ScriptDownloaderService {
const repoPath = this.extractRepoPath(repoUrl); const repoPath = this.extractRepoPath(repoUrl);
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`; const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
/** @type {Record<string, string>} */
const headers = { const headers = {
'User-Agent': 'PVEScripts-Local/1.0', 'User-Agent': 'PVEScripts-Local/1.0',
}; };
@@ -106,6 +133,9 @@ export class ScriptDownloaderService {
return response.text(); return response.text();
} }
/**
* @param {any} script
*/
getRepoUrlForScript(script) { getRepoUrlForScript(script) {
// Use repository_url from script if available, otherwise fallback to env or default // Use repository_url from script if available, otherwise fallback to env or default
if (script.repository_url) { if (script.repository_url) {
@@ -115,6 +145,9 @@ export class ScriptDownloaderService {
return this.repoUrl; return this.repoUrl;
} }
/**
* @param {string} content
*/
modifyScriptContent(content) { modifyScriptContent(content) {
// Replace the build.func source line // Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g; const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
@@ -123,6 +156,9 @@ export class ScriptDownloaderService {
return content.replace(oldPattern, newPattern); return content.replace(oldPattern, newPattern);
} }
/**
* @param {any} script
*/
async loadScript(script) { async loadScript(script) {
this.initializeConfig(); this.initializeConfig();
try { try {
@@ -133,10 +169,10 @@ export class ScriptDownloaderService {
console.log(`Loading script "${script.name}" (${script.slug}) from repository: ${repoUrl}`); console.log(`Loading script "${script.name}" (${script.slug}) from repository: ${repoUrl}`);
// Ensure directories exist // Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct')); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install')); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools')); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm')); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', 'vm'));
if (script.install_methods?.length) { if (script.install_methods?.length) {
for (const method of script.install_methods) { for (const method of script.install_methods) {
@@ -161,7 +197,7 @@ export class ScriptDownloaderService {
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir); finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Modify the content for CT scripts // Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content); const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8'); await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) { } else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools'; targetDir = 'tools';
@@ -172,8 +208,8 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir // Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir); finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists // Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir)); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8'); await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) { } else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm'; targetDir = 'vm';
@@ -184,8 +220,8 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir // Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir); finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists // Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir)); await this.ensureDirectoryExists(join(this.scriptsDirectory ?? '', finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8'); await writeFile(filePath, content, 'utf-8');
} else { } else {
// Handle other script types (fallback to ct directory) // Handle other script types (fallback to ct directory)
@@ -194,7 +230,7 @@ export class ScriptDownloaderService {
// Validate and sanitize finalTargetDir // Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir); finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
const modifiedContent = this.modifyScriptContent(content); const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8'); await writeFile(filePath, modifiedContent, 'utf-8');
} }
@@ -206,13 +242,13 @@ export class ScriptDownloaderService {
} }
// Only download install script for CT scripts // Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/')); const hasCtScript = script.install_methods?.some(/** @param {any} method */ method => method.script?.startsWith('ct/'));
if (hasCtScript) { if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`; const installScriptName = `${script.slug}-install.sh`;
try { try {
console.log(`Downloading install script: install/${installScriptName} from ${repoUrl}`); console.log(`Downloading install script: install/${installScriptName} from ${repoUrl}`);
const installContent = await this.downloadFileFromGitHub(repoUrl, `install/${installScriptName}`, branch); 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'); await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`); files.push(`install/${installScriptName}`);
console.log(`Successfully downloaded: install/${installScriptName}`); console.log(`Successfully downloaded: install/${installScriptName}`);
@@ -224,11 +260,11 @@ export class ScriptDownloaderService {
// Download alpine install script if alpine variant exists (only for CT scripts) // Download alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some( const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/') /** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
); );
console.log(`[${script.slug}] Checking for alpine variant:`, { console.log(`[${script.slug}] Checking for alpine variant:`, {
hasAlpineCtVariant, hasAlpineCtVariant,
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script })) installMethods: script.install_methods?.map(/** @param {any} m */ m => ({ type: m.type, script: m.script }))
}); });
if (hasAlpineCtVariant) { if (hasAlpineCtVariant) {
@@ -236,7 +272,7 @@ export class ScriptDownloaderService {
try { try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName} from ${repoUrl}`); console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName} from ${repoUrl}`);
const alpineInstallContent = await this.downloadFileFromGitHub(repoUrl, `install/${alpineInstallScriptName}`, branch); 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'); await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
files.push(`install/${alpineInstallScriptName}`); files.push(`install/${alpineInstallScriptName}`);
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`); console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
@@ -266,6 +302,9 @@ export class ScriptDownloaderService {
} }
} }
/**
* @param {any} script
*/
async isScriptDownloaded(script) { async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false; if (!script.install_methods?.length) return false;
@@ -284,23 +323,23 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) { if (scriptPath.startsWith('ct/')) {
targetDir = 'ct'; targetDir = 'ct';
finalTargetDir = targetDir; finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) { } else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools'; targetDir = 'tools';
const subPath = scriptPath.replace('tools/', ''); const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : ''; const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir; finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) { } else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm'; targetDir = 'vm';
const subPath = scriptPath.replace('vm/', ''); const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : ''; const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir; finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
} else { } else {
targetDir = 'ct'; targetDir = 'ct';
finalTargetDir = targetDir; finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
} }
try { try {
@@ -318,6 +357,9 @@ export class ScriptDownloaderService {
return true; return true;
} }
/**
* @param {any} script
*/
async checkScriptExists(script) { async checkScriptExists(script) {
this.initializeConfig(); this.initializeConfig();
const files = []; const files = [];
@@ -340,25 +382,25 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) { if (scriptPath.startsWith('ct/')) {
targetDir = 'ct'; targetDir = 'ct';
finalTargetDir = targetDir; finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) { } else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools'; targetDir = 'tools';
// Preserve subdirectory structure for tools scripts // Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', ''); const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : ''; const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir; finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) { } else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm'; targetDir = 'vm';
// Preserve subdirectory structure for VM scripts // Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', ''); const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : ''; const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir; finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory ?? '', finalTargetDir, fileName);
} else { } else {
targetDir = 'ct'; // Default fallback targetDir = 'ct'; // Default fallback
finalTargetDir = targetDir; finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName); filePath = join(this.scriptsDirectory ?? '', targetDir, fileName);
} }
try { try {
@@ -378,10 +420,10 @@ export class ScriptDownloaderService {
} }
// Check for install script for CT scripts // Check for install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/')); const hasCtScript = script.install_methods?.some(/** @param {any} method */ method => method.script?.startsWith('ct/'));
if (hasCtScript) { if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`; const installScriptName = `${script.slug}-install.sh`;
const installPath = join(this.scriptsDirectory, 'install', installScriptName); const installPath = join(this.scriptsDirectory ?? '', 'install', installScriptName);
try { try {
await access(installPath); await access(installPath);
@@ -394,11 +436,11 @@ export class ScriptDownloaderService {
// Check alpine install script if alpine variant exists (only for CT scripts) // Check alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some( const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/') /** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
); );
if (hasAlpineCtVariant) { if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`; const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName); const alpineInstallPath = join(this.scriptsDirectory ?? '', 'install', alpineInstallScriptName);
try { try {
await access(alpineInstallPath); await access(alpineInstallPath);
@@ -416,6 +458,9 @@ export class ScriptDownloaderService {
} }
} }
/**
* @param {any} script
*/
async deleteScript(script) { async deleteScript(script) {
this.initializeConfig(); this.initializeConfig();
const deletedFiles = []; const deletedFiles = [];
@@ -435,7 +480,7 @@ export class ScriptDownloaderService {
// Delete all files // Delete all files
for (const filePath of fileCheck.files) { for (const filePath of fileCheck.files) {
try { try {
const fullPath = join(this.scriptsDirectory, filePath); const fullPath = join(this.scriptsDirectory ?? '', filePath);
await unlink(fullPath); await unlink(fullPath);
deletedFiles.push(filePath); deletedFiles.push(filePath);
} catch (error) { } catch (error) {
@@ -467,8 +512,12 @@ export class ScriptDownloaderService {
} }
} }
/**
* @param {any} script
*/
async compareScriptContent(script) { async compareScriptContent(script) {
this.initializeConfig(); this.initializeConfig();
/** @type {any[]} */
const differences = []; const differences = [];
let hasDifferences = false; let hasDifferences = false;
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
@@ -560,12 +609,12 @@ export class ScriptDownloaderService {
// Compare alpine install script if alpine variant exists (only for CT scripts) // Compare alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some( const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/') /** @param {any} method */ method => method.type === 'alpine' && method.script?.startsWith('ct/')
); );
if (hasAlpineCtVariant) { if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`; const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`; const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath); const localAlpineInstallPath = join(this.scriptsDirectory ?? '', alpineInstallScriptPath);
// Check if alpine install script exists locally // Check if alpine install script exists locally
try { try {
@@ -596,14 +645,20 @@ export class ScriptDownloaderService {
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`); console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences }; return { hasDifferences, differences };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error); console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
return { hasDifferences: false, differences: [], error: error.message }; return { hasDifferences: false, differences: [], error: errorMessage };
} }
} }
/**
* @param {any} script
* @param {string} remotePath
* @param {string} filePath
*/
async compareSingleFile(script, remotePath, filePath) { async compareSingleFile(script, remotePath, filePath) {
try { try {
const localPath = join(this.scriptsDirectory, filePath); const localPath = join(this.scriptsDirectory ?? '', filePath);
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main'; const branch = process.env.REPO_BRANCH || 'main';
@@ -637,12 +692,17 @@ export class ScriptDownloaderService {
return { hasDifferences, filePath }; return { hasDifferences, filePath };
} catch (error) { } catch (error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message); const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[Comparison] Error comparing file ${filePath}:`, errorMessage);
// Return error information so it can be handled upstream // Return error information so it can be handled upstream
return { hasDifferences: false, filePath, error: error.message }; return { hasDifferences: false, filePath, error: errorMessage };
} }
} }
/**
* @param {any} script
* @param {string} filePath
*/
async getScriptDiff(script, filePath) { async getScriptDiff(script, filePath) {
this.initializeConfig(); this.initializeConfig();
try { try {
@@ -655,7 +715,7 @@ export class ScriptDownloaderService {
// Handle CT script // Handle CT script
const fileName = filePath.split('/').pop(); const fileName = filePath.split('/').pop();
if (fileName) { if (fileName) {
const localPath = join(this.scriptsDirectory, 'ct', fileName); const localPath = join(this.scriptsDirectory ?? '', 'ct', fileName);
try { try {
localContent = await readFile(localPath, 'utf-8'); localContent = await readFile(localPath, 'utf-8');
} catch { } catch {
@@ -664,7 +724,7 @@ export class ScriptDownloaderService {
try { try {
// Find the corresponding script path in install_methods // Find the corresponding script path in install_methods
const method = script.install_methods?.find(m => m.script === filePath); const method = script.install_methods?.find(/** @param {any} m */ m => m.script === filePath);
if (method?.script) { if (method?.script) {
const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch); const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch);
remoteContent = this.modifyScriptContent(downloadedContent); remoteContent = this.modifyScriptContent(downloadedContent);
@@ -675,7 +735,7 @@ export class ScriptDownloaderService {
} }
} else if (filePath.startsWith('install/')) { } else if (filePath.startsWith('install/')) {
// Handle install script // Handle install script
const localPath = join(this.scriptsDirectory, filePath); const localPath = join(this.scriptsDirectory ?? '', filePath);
try { try {
localContent = await readFile(localPath, 'utf-8'); localContent = await readFile(localPath, 'utf-8');
} catch { } catch {
@@ -702,6 +762,10 @@ export class ScriptDownloaderService {
} }
} }
/**
* @param {string} localContent
* @param {string} remoteContent
*/
generateDiff(localContent, remoteContent) { generateDiff(localContent, remoteContent) {
const localLines = localContent.split('\n'); const localLines = localContent.split('\n');
const remoteLines = remoteContent.split('\n'); const remoteLines = remoteContent.split('\n');

View File

@@ -28,8 +28,7 @@ class StorageService {
let currentStorage: Partial<Storage> | null = null; let currentStorage: Partial<Storage> | null = null;
for (let i = 0; i < lines.length; i++) { for (const rawLine of lines) {
const rawLine = lines[i];
if (!rawLine) continue; if (!rawLine) continue;
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming // Check if line is indented (has leading whitespace/tabs) BEFORE trimming
@@ -44,10 +43,10 @@ class StorageService {
// Check if this is a storage definition line (format: "type: name") // Check if this is a storage definition line (format: "type: name")
// Storage definitions are NOT indented // Storage definitions are NOT indented
if (!isIndented) { if (!isIndented) {
const storageMatch = line.match(/^(\w+):\s*(.+)$/); const storageMatch = /^(\w+):\s*(.+)$/.exec(line);
if (storageMatch && storageMatch[1] && storageMatch[2]) { if (storageMatch?.[1] && storageMatch[2]) {
// Save previous storage if exists // Save previous storage if exists
if (currentStorage && currentStorage.name) { if (currentStorage?.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -65,9 +64,9 @@ class StorageService {
// Parse storage properties (indented lines - can be tabs or spaces) // Parse storage properties (indented lines - can be tabs or spaces)
if (currentStorage && isIndented) { if (currentStorage && isIndented) {
// Split on first whitespace (space or tab) to separate key and value // Split on first whitespace (space or tab) to separate key and value
const match = line.match(/^(\S+)\s+(.+)$/); const match = /^(\S+)\s+(.+)$/.exec(line);
if (match && match[1] && match[2]) { if (match?.[1] && match[2]) {
const key = match[1]; const key = match[1];
const value = match[2].trim(); const value = match[2].trim();
@@ -92,7 +91,7 @@ class StorageService {
} }
// Don't forget the last storage // Don't forget the last storage
if (currentStorage && currentStorage.name) { if (currentStorage?.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -106,8 +105,8 @@ class StorageService {
return { return {
name: storage.name!, name: storage.name!,
type: storage.type!, type: storage.type!,
content: storage.content || [], content: storage.content ?? [],
supportsBackup: storage.supportsBackup || false, supportsBackup: storage.supportsBackup ?? false,
nodes: storage.nodes, nodes: storage.nodes,
...Object.fromEntries( ...Object.fromEntries(
Object.entries(storage).filter(([key]) => Object.entries(storage).filter(([key]) =>
@@ -138,7 +137,7 @@ class StorageService {
let configContent = ''; let configContent = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sshService.executeCommand( void sshService.executeCommand(
server, server,
'cat /etc/pve/storage.cfg', 'cat /etc/pve/storage.cfg',
(data: string) => { (data: string) => {
@@ -191,8 +190,8 @@ class StorageService {
} }
return { return {
pbs_ip: (storage as any).server || null, pbs_ip: (storage as any).server ?? null,
pbs_datastore: (storage as any).datastore || null, pbs_datastore: (storage as any).datastore ?? null,
}; };
} }
@@ -215,9 +214,7 @@ class StorageService {
let storageServiceInstance: StorageService | null = null; let storageServiceInstance: StorageService | null = null;
export function getStorageService(): StorageService { export function getStorageService(): StorageService {
if (!storageServiceInstance) { storageServiceInstance ??= new StorageService();
storageServiceInstance = new StorageService();
}
return storageServiceInstance; return storageServiceInstance;
} }