diff --git a/next.config.js b/next.config.js index 1de1988..df68f48 100644 --- a/next.config.js +++ b/next.config.js @@ -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, }, }; diff --git a/src/app/_components/LXCSettingsModal.tsx b/src/app/_components/LXCSettingsModal.tsx index 6861682..74b281f 100644 --- a/src/app/_components/LXCSettingsModal.tsx +++ b/src/app/_components/LXCSettingsModal.tsx @@ -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]); diff --git a/src/app/_components/ReleaseNotesModal.tsx b/src/app/_components/ReleaseNotesModal.tsx index 96b4ed5..24668c6 100644 --- a/src/app/_components/ReleaseNotesModal.tsx +++ b/src/app/_components/ReleaseNotesModal.tsx @@ -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]); diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx index 8137404..6ba6171 100644 --- a/src/app/_components/ResyncButton.tsx +++ b/src/app/_components/ResyncButton.tsx @@ -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(null); const hasReloadedRef = useRef(false); const isUserInitiatedRef = useRef(false); + const reloadTimeoutRef = useRef(null); + const messageTimeoutRef = useRef(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 (
diff --git a/src/app/_components/ThemeProvider.tsx b/src/app/_components/ThemeProvider.tsx index 43ffadc..38e493e 100644 --- a/src/app/_components/ThemeProvider.tsx +++ b/src/app/_components/ThemeProvider.tsx @@ -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 diff --git a/src/app/_components/VersionDisplay.tsx b/src/app/_components/VersionDisplay.tsx index 61f2d9a..03fc279 100644 --- a/src/app/_components/VersionDisplay.tsx +++ b/src/app/_components/VersionDisplay.tsx @@ -87,37 +87,59 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) const [shouldSubscribe, setShouldSubscribe] = useState(false); const [updateStartTime, setUpdateStartTime] = useState(null); const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false); - const lastLogTimeRef = useRef(Date.now()); + const lastLogTimeRef = useRef(0); + + // Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render + useEffect(() => { + if (lastLogTimeRef.current === 0) { + lastLogTimeRef.current = Date.now(); + } + }, []); const reconnectIntervalRef = useRef(null); + const reloadTimeoutRef = useRef(null); const hasReloadedRef = useRef(false); const isUpdatingRef = useRef(false); const isNetworkErrorRef = useRef(false); + const updateSessionIdRef = useRef(null); + const updateStartTimeRef = useRef(null); + const logFileModifiedTimeRef = useRef(null); + const isCompleteProcessedRef = useRef(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(); }; diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts index 92660b6..be6d837 100644 --- a/src/server/api/routers/backups.ts +++ b/src/server/api/routers/backups.ts @@ -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; }>; diff --git a/src/server/api/routers/version.ts b/src/server/api/routers/version.ts index 06f61d3..31acad4 100644 --- a/src/server/api/routers/version.ts +++ b/src/server/api/routers/version.ts @@ -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 }; } }), diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 94c28ea..cfde3a5 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -62,17 +62,19 @@ class BackupService { try { await Promise.race([ new Promise((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((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((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((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((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((resolve, reject) => { - sshService.executeCommand( + new Promise((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; } diff --git a/src/server/services/githubJsonService.ts b/src/server/services/githubJsonService.ts index 54f2878..f3c6f57 100644 --- a/src/server/services/githubJsonService.ts +++ b/src/server/services/githubJsonService.ts @@ -65,7 +65,8 @@ export class GitHubJsonService { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } - return response.json() as Promise; + const data = await response.json(); + return data as T; } private async downloadJsonFile(repoUrl: string, filePath: string): Promise