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 { createRequire } from "module";
import { fileURLToPath } from "url";
import path from "path";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
// Import Next.js config directly (it's already in flat config format)
const nextConfig = require("eslint-config-next/core-web-vitals");
export default tseslint.config(
{
ignores: [".next"],
ignores: [".next", "node_modules"],
},
...compat.extends("next/core-web-vitals"),
...nextConfig,
{
files: ["**/*.ts", "**/*.tsx"],
extends: [

View File

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

View File

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

224
server.js
View File

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

View File

@@ -35,7 +35,7 @@ export function BackupWarningModal({
<p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you wish.
<br /><br />
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won'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>
{/* Action Buttons */}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider';
import { Trash2, ExternalLink } from 'lucide-react';
import type { FilterState } from './FilterBar';
interface GeneralSettingsModalProps {
isOpen: boolean;
@@ -24,7 +25,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState('');
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 [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -139,8 +140,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try {
const response = await fetch('/api/settings/filters');
if (response.ok) {
const data = await response.json();
setSavedFilters(data.filters);
const data = await response.json() as { filters?: Partial<FilterState> };
setSavedFilters(data.filters ?? null);
}
} catch (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!' });
} else {
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 {
setMessage({ type: 'error', text: 'Failed to save token' });
@@ -413,7 +414,21 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try {
const response = await fetch('/api/settings/auto-sync');
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;
if (settings) {
setAutoSyncEnabled(settings.autoSyncEnabled ?? false);
@@ -1127,7 +1142,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
<div className="flex items-center justify-between">
<div>
<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>
<Toggle
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">
<h4 className="font-medium text-foreground mb-2">Auto-Download Options</h4>
<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>Selective Control:</strong> Enable/disable each option independently</li>
</ul>
@@ -356,7 +356,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li>Choose your sync interval (predefined or custom cron)</li>
<li>Configure auto-download options if desired</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>
</ol>
</div>

View File

@@ -255,7 +255,7 @@ export function InstalledScriptsTab() {
void refetchScripts();
setAutoDetectStatus({
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);
},
@@ -275,12 +275,10 @@ export function InstalledScriptsTab() {
{ enabled: false } // Only fetch when explicitly called
);
const fetchStorages = async (serverId: number, forceRefresh = false) => {
const fetchStorages = async (serverId: number, _forceRefresh = false) => {
setIsLoadingStorages(true);
try {
const result = await getBackupStoragesQuery.refetch({
queryKey: ['installedScripts.getBackupStorages', { serverId, forceRefresh }]
});
const result = await getBackupStoragesQuery.refetch();
if (result.data?.success) {
setBackupStorages(result.data.storages);
} else {
@@ -563,7 +561,7 @@ export function InstalledScriptsTab() {
const handleDeleteScript = (id: number, script?: InstalledScript) => {
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
setConfirmationModal({
isOpen: true,

View File

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

View File

@@ -7,16 +7,16 @@ import { Button } from './ui/button';
interface LoadingModalProps {
isOpen: boolean;
action: string;
action?: string;
logs?: string[];
isComplete?: boolean;
title?: string;
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
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose ?? (() => null) });
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive

View File

@@ -19,7 +19,7 @@ export function PBSCredentialsModal({
isOpen,
onClose,
serverId,
serverName,
serverName: _serverName,
storage
}: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState('');
@@ -29,8 +29,8 @@ export function PBSCredentialsModal({
const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object
const pbsIpFromStorage = (storage as any).server || null;
const pbsDatastoreFromStorage = (storage as any).datastore || null;
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
const pbsDatastoreFromStorage = (storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
@@ -46,11 +46,11 @@ export function PBSCredentialsModal({
setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(''); // Don't show password
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? '');
} else {
// Initialize with storage config values
setPbsIp(pbsIpFromStorage || '');
setPbsDatastore(pbsDatastoreFromStorage || '');
setPbsIp(pbsIpFromStorage ?? '');
setPbsDatastore(pbsDatastoreFromStorage ?? '');
setPbsPassword('');
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"
/>
<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>
</div>

View File

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

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -11,6 +11,8 @@ export function ResyncButton() {
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = useRef<boolean>(false);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => {
@@ -21,7 +23,16 @@ export function ResyncButton() {
// Only reload if this was triggered by user action
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
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();
}, 2000); // Wait 2 seconds to show the success message
} else {
@@ -31,14 +42,26 @@ export function ResyncButton() {
} else {
setSyncMessage(data.error ?? 'Failed to sync scripts');
// 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;
}
},
onError: (error) => {
setIsResyncing(false);
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;
},
});
@@ -47,6 +70,12 @@ export function ResyncButton() {
// Prevent multiple simultaneous sync operations
if (isResyncing) return;
// Clear any pending reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Mark as user-initiated before starting
isUserInitiatedRef.current = true;
hasReloadedRef.current = false;
@@ -55,6 +84,23 @@ export function ResyncButton() {
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 (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<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 => {
if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
}

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
const filtersData = await filtersResponse.json() as { filters?: Partial<FilterState> };
if (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)
let ipv6Address = trimmed;
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1];
ipv6Address = zoneIdMatch[1] ?? '';
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2];
const zoneId = zoneIdMatch[2] ?? '';
if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) {
return false;
}
@@ -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]?)$/;
if (ipv6Pattern.test(ipv6Address)) {
// 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) {
return true;
}

View File

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

View File

@@ -1,15 +1,16 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Server } from '~/types/server';
interface TerminalProps {
scriptPath: string;
onClose: () => void;
mode?: 'local' | 'ssh';
server?: any;
server?: Server;
isUpdate?: boolean;
isShell?: 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';
// Create stable server key for dependency tracking to prevent unnecessary reconnections
const serverKey = useMemo((): string | null => {
if (!server) return null;
// Use server ID if available, otherwise stringify (fallback)
return String(server.id ?? server.name ?? JSON.stringify(server));
}, [server]);
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
@@ -209,7 +217,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
void setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
@@ -219,7 +227,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Add resize listener for mobile responsiveness
const handleResize = () => {
if (fitAddonRef.current) {
setTimeout(() => {
void setTimeout(() => {
fitAddonRef.current.fit();
}, 50);
}
@@ -260,7 +268,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
setIsTerminalReady(false);
setTimeout(() => {
setIsTerminalReady(false);
}, 0);
}
};
}, [isClient, isMobile]);
@@ -296,13 +306,32 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
useEffect(() => {
// 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;
}
// Close any existing connection first
// Close any existing connection first (safety check)
if (wsRef.current) {
wsRef.current.close();
try {
wsRef.current.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
wsRef.current = null;
}
@@ -359,6 +388,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
setIsConnected(false);
setIsRunning(false);
isConnectingRef.current = false;
// Reset hasConnectedRef to allow reconnection if needed
// Only reset if this was the current connection
if (wsRef.current === ws) {
hasConnectedRef.current = false;
wsRef.current = null;
}
};
ws.onerror = (error) => {
@@ -366,6 +401,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
console.error('WebSocket readyState:', ws.readyState);
setIsConnected(false);
isConnectingRef.current = false;
// Reset hasConnectedRef to allow reconnection if needed
// Only reset if this was the current connection
if (wsRef.current === ws) {
hasConnectedRef.current = false;
wsRef.current = null;
}
};
};
@@ -375,12 +416,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => {
clearTimeout(timeoutId);
isConnectingRef.current = false;
hasConnectedRef.current = false;
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
wsRef.current.close();
// Only close and reset if the connection is still active
if (wsRef.current) {
const readyState = wsRef.current.readyState;
if (readyState === WebSocket.OPEN || readyState === WebSocket.CONNECTING) {
try {
wsRef.current.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
}
wsRef.current = null;
}
hasConnectedRef.current = false;
};
}, [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 = () => {
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));
// 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;
// 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
const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => {
setIsLoading(true);
@@ -172,8 +171,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</Button>
</div>
)}
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
{((selectedVersion === 'default' && (scriptContent.mainScript ?? scriptContent.installScript)) ?? false) ||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript ?? scriptContent.alpineInstallScript)) && (
<div className="flex space-x-2">
<Button
variant={activeTab === 'main' ? 'outline' : 'ghost'}

View File

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

View File

@@ -1,9 +1,8 @@
'use client';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, startTransition } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
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 (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
startTransition(() => {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
});
}
}
}, [versionData]);
@@ -131,18 +133,18 @@ export default function Home() {
return true;
}
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (normalizeId(local.slug) === normalizeId(script.slug)) {
if (normalizeId(local.slug) === normalizeId(script.slug as string | undefined)) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script 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;
// 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;
}
@@ -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 });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);

View File

@@ -147,7 +147,7 @@ export function getAuthConfig(): {
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
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;
const hasCredentials = !!(username && passwordHash);

View File

@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
storage_name: string;
storage_type: string;
discovered_at: Date;
server_id?: number;
server_name: string | null;
server_color: string | null;
}>;
@@ -38,7 +39,7 @@ export const backupsRouter = createTRPCRouter({
if (backups.length === 0) continue;
// 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({
container_id: containerId,

View File

@@ -71,9 +71,12 @@ function parseRawConfig(rawConfig: string): any {
// Parse rootfs into storage and size
if (config.rootfs) {
const match = config.rootfs.match(/^([^:]+):([^,]+)(?:,size=(.+))?$/);
if (match) {
config.rootfs_storage = `${match[1]}:${match[2]}`;
const regex = /^([^:]+):([^,]+)(?:,size=(.+))?$/;
const match = regex.exec(config.rootfs as string);
const storage = match?.[1];
const path = match?.[2];
if (storage && path) {
config.rootfs_storage = `${storage}:${path}`;
config.rootfs_size = match[3] ?? '';
}
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[] = [];
// 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 lines = output.split('\n').filter(line => line.trim());
@@ -1047,10 +1050,11 @@ export const installedScriptsRouter = createTRPCRouter({
const scriptData = script as any;
if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
const serverId = Number(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
@@ -1229,7 +1233,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
}
} 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) {
@@ -1347,7 +1351,7 @@ export const installedScriptsRouter = createTRPCRouter({
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
'pct list',
@@ -1375,7 +1379,7 @@ export const installedScriptsRouter = createTRPCRouter({
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
'qm list',
@@ -1479,7 +1483,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// 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)
const statusCommand = vm
@@ -1582,7 +1586,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// 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)
const controlCommand = vm
@@ -1678,7 +1682,7 @@ export const installedScriptsRouter = createTRPCRouter({
}
// 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
const statusCommand = vm
@@ -2372,7 +2376,7 @@ EOFCONFIG`;
let serverHostname = '';
try {
await new Promise<void>((resolve, reject) => {
sshExecutionService.executeCommand(
void sshExecutionService.executeCommand(
server as Server,
'hostname',
(data: string) => {

View File

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

View File

@@ -1,5 +1,5 @@
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 { spawn } from "child_process";
import { env } from "~/env";
@@ -176,10 +176,21 @@ export const versionRouter = createTRPCRouter({
return {
success: true,
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 logLines = logs.split('\n')
.filter(line => line.trim())
@@ -202,7 +213,8 @@ export const versionRouter = createTRPCRouter({
return {
success: true,
logs: logLines,
isComplete
isComplete,
logFileModifiedTime
};
} catch (error) {
console.error('Error reading update logs:', error);
@@ -210,7 +222,8 @@ export const versionRouter = createTRPCRouter({
success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [],
isComplete: false
isComplete: false,
logFileModifiedTime: null
};
}
}),

View File

@@ -1,7 +1,24 @@
import { AutoSyncService } from '~/server/services/autoSyncService';
import { repositoryService } from '~/server/services/repositoryService';
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
*/

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { repositoryService } from './repositoryService.ts';
import { repositoryService } from './repositoryService';
export class GitHubJsonService {
private branch: string | null = null;
@@ -64,7 +64,8 @@ export class GitHubJsonService {
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> {
@@ -214,9 +215,7 @@ export class GitHubJsonService {
const script = JSON.parse(content) as Script;
// 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
this.scriptCache.set(slug, script);
@@ -397,7 +396,6 @@ export class GitHubJsonService {
const filesToSync: GitHubFile[] = [];
for (const ghFile of githubFiles) {
const slug = ghFile.name.replace('.json', '');
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
let needsSync = false;

View File

@@ -1,4 +1,4 @@
import { prisma } from '../db.ts';
import { prisma } from '../db';
export class RepositoryService {
/**
@@ -93,7 +93,7 @@ export class RepositoryService {
priority?: number;
}) {
// 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');
}
@@ -131,7 +131,7 @@ export class RepositoryService {
}) {
// If updating URL, validate it
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');
}

View File

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

View File

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

View File

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