Fix type errors
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
216
server.js
216
server.js
@@ -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;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (isWebSocketUpgrade) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
setupGracefulShutdown();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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't be able to restore the container if something goes wrong during the update.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
// 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 "Discover Backups" 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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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't been downloaded yet</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={autoDownloadNew}
|
||||
|
||||
@@ -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'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 "Test Notification" button</li>
|
||||
<li>Save your settings to activate auto-sync</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Show Fingerprint" button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
// 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) {
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -301,7 +301,6 @@ export class AutoSyncService {
|
||||
console.log('Starting scheduled auto-sync...');
|
||||
await this.executeAutoSync();
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user