Merge main into bugfixing_bumps - keep bugfixing_bumps versions (Prisma 7, tsx support)

This commit is contained in:
CanbiZ
2025-11-28 14:43:25 +01:00
11 changed files with 335 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}>;

View File

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

View File

@@ -62,17 +62,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();
}
);
@@ -97,7 +99,7 @@ class BackupService {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
statCommand,
(data: string) => {
@@ -113,11 +115,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,
@@ -145,8 +147,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,
@@ -183,17 +186,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();
}
);
@@ -219,7 +223,7 @@ class BackupService {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
statCommand,
(data: string) => {
@@ -235,11 +239,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,
@@ -271,7 +275,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,
@@ -311,8 +315,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}`);
@@ -340,7 +344,7 @@ class BackupService {
try {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
fullCommand,
(data: string) => {
@@ -406,8 +410,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}`);
@@ -426,8 +430,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) => {
@@ -469,7 +473,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')) {
@@ -490,7 +494,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
@@ -498,11 +502,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
}
}
@@ -510,8 +515,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;
@@ -641,18 +646,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`);
}
@@ -683,9 +688,7 @@ class BackupService {
let backupServiceInstance: BackupService | null = null;
export function getBackupService(): BackupService {
if (!backupServiceInstance) {
backupServiceInstance = new BackupService();
}
backupServiceInstance ??= new BackupService();
return backupServiceInstance;
}

View File

@@ -65,7 +65,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> {
@@ -215,9 +216,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);

View File

@@ -29,8 +29,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
@@ -45,10 +44,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));
}
@@ -66,9 +65,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();
@@ -93,7 +92,7 @@ class StorageService {
}
// Don't forget the last storage
if (currentStorage && currentStorage.name) {
if (currentStorage?.name) {
storages.push(this.finalizeStorage(currentStorage));
}
@@ -107,8 +106,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]) =>
@@ -139,7 +138,7 @@ class StorageService {
let configContent = '';
await new Promise<void>((resolve, reject) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
'cat /etc/pve/storage.cfg',
(data: string) => {
@@ -192,8 +191,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,
};
}
@@ -216,9 +215,7 @@ class StorageService {
let storageServiceInstance: StorageService | null = null;
export function getStorageService(): StorageService {
if (!storageServiceInstance) {
storageServiceInstance = new StorageService();
}
storageServiceInstance ??= new StorageService();
return storageServiceInstance;
}