Merge main into bugfixing_bumps - keep bugfixing_bumps versions (Prisma 7, tsx support)
This commit is contained in:
@@ -53,9 +53,9 @@ const config = {
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
// TypeScript errors will fail the build
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, startTransition } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
@@ -159,9 +159,13 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configData?.success) {
|
if (configData?.success) {
|
||||||
populateFormData(configData);
|
populateFormData(configData);
|
||||||
setHasChanges(false);
|
startTransition(() => {
|
||||||
|
setHasChanges(false);
|
||||||
|
});
|
||||||
} else if (configData && !configData.success) {
|
} else if (configData && !configData.success) {
|
||||||
setError(String(configData.error ?? 'Failed to load configuration'));
|
startTransition(() => {
|
||||||
|
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [configData]);
|
}, [configData]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, startTransition } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -47,7 +47,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
|
|||||||
// Get current version when modal opens
|
// Get current version when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && versionData?.success && versionData.version) {
|
if (isOpen && versionData?.success && versionData.version) {
|
||||||
setCurrentVersion(versionData.version);
|
startTransition(() => {
|
||||||
|
setCurrentVersion(versionData.version);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen, versionData]);
|
}, [isOpen, versionData]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
@@ -11,6 +11,8 @@ export function ResyncButton() {
|
|||||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||||
const hasReloadedRef = useRef<boolean>(false);
|
const hasReloadedRef = useRef<boolean>(false);
|
||||||
const isUserInitiatedRef = useRef<boolean>(false);
|
const isUserInitiatedRef = useRef<boolean>(false);
|
||||||
|
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -21,7 +23,16 @@ export function ResyncButton() {
|
|||||||
// Only reload if this was triggered by user action
|
// Only reload if this was triggered by user action
|
||||||
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||||
hasReloadedRef.current = true;
|
hasReloadedRef.current = true;
|
||||||
setTimeout(() => {
|
|
||||||
|
// Clear any existing reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new reload timeout
|
||||||
|
reloadTimeoutRef.current = setTimeout(() => {
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 2000); // Wait 2 seconds to show the success message
|
}, 2000); // Wait 2 seconds to show the success message
|
||||||
} else {
|
} else {
|
||||||
@@ -31,14 +42,26 @@ export function ResyncButton() {
|
|||||||
} else {
|
} else {
|
||||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||||
// Clear message after 3 seconds for errors
|
// Clear message after 3 seconds for errors
|
||||||
setTimeout(() => setSyncMessage(null), 3000);
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
messageTimeoutRef.current = setTimeout(() => {
|
||||||
|
setSyncMessage(null);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}, 3000);
|
||||||
isUserInitiatedRef.current = false;
|
isUserInitiatedRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setIsResyncing(false);
|
setIsResyncing(false);
|
||||||
setSyncMessage(`Error: ${error.message}`);
|
setSyncMessage(`Error: ${error.message}`);
|
||||||
setTimeout(() => setSyncMessage(null), 3000);
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
messageTimeoutRef.current = setTimeout(() => {
|
||||||
|
setSyncMessage(null);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}, 3000);
|
||||||
isUserInitiatedRef.current = false;
|
isUserInitiatedRef.current = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -47,6 +70,12 @@ export function ResyncButton() {
|
|||||||
// Prevent multiple simultaneous sync operations
|
// Prevent multiple simultaneous sync operations
|
||||||
if (isResyncing) return;
|
if (isResyncing) return;
|
||||||
|
|
||||||
|
// Clear any pending reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as user-initiated before starting
|
// Mark as user-initiated before starting
|
||||||
isUserInitiatedRef.current = true;
|
isUserInitiatedRef.current = true;
|
||||||
hasReloadedRef.current = false;
|
hasReloadedRef.current = false;
|
||||||
@@ -55,6 +84,23 @@ export function ResyncButton() {
|
|||||||
resyncMutation.mutate();
|
resyncMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount - clear any pending timeouts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
messageTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset refs on unmount
|
||||||
|
hasReloadedRef.current = false;
|
||||||
|
isUserInitiatedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState, startTransition } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -31,9 +31,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||||
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
||||||
setThemeState(savedTheme);
|
startTransition(() => {
|
||||||
|
setThemeState(savedTheme);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setMounted(true);
|
startTransition(() => {
|
||||||
|
setMounted(true);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme to document element
|
// Apply theme to document element
|
||||||
|
|||||||
@@ -87,37 +87,59 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||||
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
||||||
const lastLogTimeRef = useRef<number>(Date.now());
|
const lastLogTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastLogTimeRef.current === 0) {
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const hasReloadedRef = useRef<boolean>(false);
|
const hasReloadedRef = useRef<boolean>(false);
|
||||||
const isUpdatingRef = useRef<boolean>(false);
|
const isUpdatingRef = useRef<boolean>(false);
|
||||||
const isNetworkErrorRef = useRef<boolean>(false);
|
const isNetworkErrorRef = useRef<boolean>(false);
|
||||||
|
const updateSessionIdRef = useRef<string | null>(null);
|
||||||
|
const updateStartTimeRef = useRef<number | null>(null);
|
||||||
|
const logFileModifiedTimeRef = useRef<number | null>(null);
|
||||||
|
const isCompleteProcessedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const executeUpdate = api.version.executeUpdate.useMutation({
|
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setUpdateResult({ success: result.success, message: result.message });
|
setUpdateResult({ success: result.success, message: result.message });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Start subscribing to update logs
|
// Start subscribing to update logs only if we're actually updating
|
||||||
setShouldSubscribe(true);
|
if (isUpdatingRef.current) {
|
||||||
setUpdateLogs(['Update started...']);
|
setShouldSubscribe(true);
|
||||||
|
setUpdateLogs(['Update started...']);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
setShouldSubscribe(false); // Reset subscription on failure
|
setShouldSubscribe(false); // Reset subscription on failure
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setUpdateResult({ success: false, message: error.message });
|
setUpdateResult({ success: false, message: error.message });
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
setShouldSubscribe(false); // Reset subscription on error
|
setShouldSubscribe(false); // Reset subscription on error
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll for update logs
|
// Poll for update logs - only enabled when shouldSubscribe is true AND we're updating
|
||||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||||
enabled: shouldSubscribe,
|
enabled: shouldSubscribe && isUpdating,
|
||||||
refetchInterval: 1000, // Poll every second
|
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attempt to reconnect and reload page when server is back
|
// Attempt to reconnect and reload page when server is back
|
||||||
@@ -126,8 +148,16 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
const startReconnectAttempts = useCallback(() => {
|
const startReconnectAttempts = useCallback(() => {
|
||||||
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
||||||
// Only start if we're actually updating and haven't already started
|
// Only start if we're actually updating and haven't already started
|
||||||
// Double-check isUpdating state to prevent false triggers from stale data
|
// Double-check isUpdating state and session validity to prevent false triggers from stale data
|
||||||
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) {
|
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session age before starting reconnection attempts
|
||||||
|
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (sessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session is stale, don't start reconnection
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +167,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
// Guard: Only proceed if we're still updating and in network error state
|
// Guard: Only proceed if we're still updating and in network error state
|
||||||
// Check refs directly to avoid stale closures
|
// Check refs directly to avoid stale closures
|
||||||
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) {
|
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
// Clear interval if we're no longer updating
|
// Clear interval if we're no longer updating
|
||||||
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
@@ -146,12 +176,29 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate session is still valid
|
||||||
|
const currentSessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
if (currentSessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session expired, stop reconnection attempts
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to fetch the root path to check if server is back
|
// Try to fetch the root path to check if server is back
|
||||||
const response = await fetch('/', { method: 'HEAD' });
|
const response = await fetch('/', { method: 'HEAD' });
|
||||||
if (response.ok || response.status === 200) {
|
if (response.ok || response.status === 200) {
|
||||||
// Double-check we're still updating before reloading
|
// Double-check we're still updating and session is valid before reloading
|
||||||
if (!isUpdatingRef.current || hasReloadedRef.current) {
|
if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final session validation
|
||||||
|
const finalSessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
if (finalSessionAge > MAX_SESSION_AGE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +206,21 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
hasReloadedRef.current = true;
|
hasReloadedRef.current = true;
|
||||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||||
|
|
||||||
// Clear interval and reload
|
// Clear interval
|
||||||
if (reconnectIntervalRef.current) {
|
if (reconnectIntervalRef.current) {
|
||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
reconnectIntervalRef.current = null;
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
// Clear any existing reload timeout
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set reload timeout
|
||||||
|
reloadTimeoutRef.current = setTimeout(() => {
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@@ -180,21 +235,68 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// CRITICAL: Only process update logs if we're actually updating
|
// CRITICAL: Only process update logs if we're actually updating
|
||||||
// This prevents stale isComplete data from triggering reloads when not updating
|
// This prevents stale isComplete data from triggering reloads when not updating
|
||||||
if (!isUpdating) {
|
if (!isUpdating || !updateStartTimeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Validate session - only process logs from current update session
|
||||||
|
// Check that update started within last 30 minutes (reasonable window for update)
|
||||||
|
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (sessionAge > MAX_SESSION_AGE) {
|
||||||
|
// Session is stale, reset everything
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
}, 0);
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateLogsData?.success && updateLogsData.logs) {
|
if (updateLogsData?.success && updateLogsData.logs) {
|
||||||
lastLogTimeRef.current = Date.now();
|
|
||||||
setUpdateLogs(updateLogsData.logs);
|
|
||||||
|
|
||||||
// CRITICAL: Only process isComplete if we're actually updating
|
if (updateLogsData.logFileModifiedTime !== null && logFileModifiedTimeRef.current !== null) {
|
||||||
// Double-check isUpdating state to prevent false triggers
|
|
||||||
if (updateLogsData.isComplete && isUpdating) {
|
if (updateLogsData.logFileModifiedTime < logFileModifiedTimeRef.current) {
|
||||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
|
||||||
setIsNetworkError(true);
|
return;
|
||||||
|
}
|
||||||
|
} else if (updateLogsData.logFileModifiedTime !== null && updateStartTimeRef.current) {
|
||||||
|
|
||||||
|
const timeDiff = updateLogsData.logFileModifiedTime - updateStartTimeRef.current;
|
||||||
|
if (timeDiff < -5000) {
|
||||||
|
|
||||||
|
}
|
||||||
|
logFileModifiedTimeRef.current = updateLogsData.logFileModifiedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0);
|
||||||
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
updateLogsData.isComplete &&
|
||||||
|
isUpdating &&
|
||||||
|
updateStartTimeRef.current &&
|
||||||
|
sessionAge < MAX_SESSION_AGE &&
|
||||||
|
!isCompleteProcessedRef.current
|
||||||
|
) {
|
||||||
|
// Mark as processed immediately to prevent multiple triggers
|
||||||
|
isCompleteProcessedRef.current = true;
|
||||||
|
|
||||||
|
// Stop polling immediately to prevent further stale data processing
|
||||||
|
setTimeout(() => setShouldSubscribe(false), 0);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||||
|
setIsNetworkError(true);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// Start reconnection attempts when we know update is complete
|
// Start reconnection attempts when we know update is complete
|
||||||
startReconnectAttempts();
|
setTimeout(() => startReconnectAttempts(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [updateLogsData, startReconnectAttempts, isUpdating]);
|
}, [updateLogsData, startReconnectAttempts, isUpdating]);
|
||||||
@@ -218,8 +320,10 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||||
|
|
||||||
// Additional guard: check refs again before triggering
|
// Additional guard: check refs again before triggering and validate session
|
||||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) {
|
const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity;
|
||||||
|
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
|
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
|
||||||
setIsNetworkError(true);
|
setIsNetworkError(true);
|
||||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||||
|
|
||||||
@@ -234,12 +338,26 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
// Keep refs in sync with state
|
// Keep refs in sync with state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isUpdatingRef.current = isUpdating;
|
isUpdatingRef.current = isUpdating;
|
||||||
|
// CRITICAL: Reset shouldSubscribe immediately when isUpdating becomes false
|
||||||
|
// This prevents stale polling from continuing
|
||||||
|
if (!isUpdating) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
}, 0);
|
||||||
|
// Reset completion processing flag when update stops
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
}
|
||||||
}, [isUpdating]);
|
}, [isUpdating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isNetworkErrorRef.current = isNetworkError;
|
isNetworkErrorRef.current = isNetworkError;
|
||||||
}, [isNetworkError]);
|
}, [isNetworkError]);
|
||||||
|
|
||||||
|
// Keep updateStartTime ref in sync
|
||||||
|
useEffect(() => {
|
||||||
|
updateStartTimeRef.current = updateStartTime;
|
||||||
|
}, [updateStartTime]);
|
||||||
|
|
||||||
// Clear reconnect interval when update completes or component unmounts
|
// Clear reconnect interval when update completes or component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we're no longer updating, clear the reconnect interval and reset subscription
|
// If we're no longer updating, clear the reconnect interval and reset subscription
|
||||||
@@ -248,8 +366,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
reconnectIntervalRef.current = null;
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
// Clear reload timeout if update stops
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
// Reset subscription to prevent stale polling
|
// Reset subscription to prevent stale polling
|
||||||
setShouldSubscribe(false);
|
setTimeout(() => {
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
}, 0);
|
||||||
|
// Reset completion processing flag
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
// Don't clear session refs here - they're cleared explicitly on unmount or new update
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -257,9 +385,32 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
reconnectIntervalRef.current = null;
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [isUpdating]);
|
}, [isUpdating]);
|
||||||
|
|
||||||
|
// Cleanup on component unmount - reset all update-related state
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clear all intervals
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
reconnectIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset all refs and state
|
||||||
|
updateSessionIdRef.current = null;
|
||||||
|
updateStartTimeRef.current = null;
|
||||||
|
logFileModifiedTimeRef.current = null;
|
||||||
|
isCompleteProcessedRef.current = false;
|
||||||
|
hasReloadedRef.current = false;
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
isNetworkErrorRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
// Show confirmation modal instead of starting update directly
|
// Show confirmation modal instead of starting update directly
|
||||||
setShowUpdateConfirmation(true);
|
setShowUpdateConfirmation(true);
|
||||||
@@ -269,19 +420,34 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
|||||||
// Close the confirmation modal
|
// Close the confirmation modal
|
||||||
setShowUpdateConfirmation(false);
|
setShowUpdateConfirmation(false);
|
||||||
// Start the actual update process
|
// Start the actual update process
|
||||||
|
const sessionId = `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setUpdateResult(null);
|
setUpdateResult(null);
|
||||||
setIsNetworkError(false);
|
setIsNetworkError(false);
|
||||||
setUpdateLogs([]);
|
setUpdateLogs([]);
|
||||||
setShouldSubscribe(false);
|
setShouldSubscribe(false); // Will be set to true in mutation onSuccess
|
||||||
setUpdateStartTime(Date.now());
|
setUpdateStartTime(startTime);
|
||||||
lastLogTimeRef.current = Date.now();
|
|
||||||
|
// Set refs for session tracking
|
||||||
|
updateSessionIdRef.current = sessionId;
|
||||||
|
updateStartTimeRef.current = startTime;
|
||||||
|
lastLogTimeRef.current = startTime;
|
||||||
|
logFileModifiedTimeRef.current = null; // Will be set when we first see log file
|
||||||
|
isCompleteProcessedRef.current = false; // Reset completion flag
|
||||||
hasReloadedRef.current = false; // Reset reload flag when starting new update
|
hasReloadedRef.current = false; // Reset reload flag when starting new update
|
||||||
// Clear any existing reconnect interval
|
|
||||||
|
// Clear any existing reconnect interval and reload timeout
|
||||||
if (reconnectIntervalRef.current) {
|
if (reconnectIntervalRef.current) {
|
||||||
clearInterval(reconnectIntervalRef.current);
|
clearInterval(reconnectIntervalRef.current);
|
||||||
reconnectIntervalRef.current = null;
|
reconnectIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reloadTimeoutRef.current) {
|
||||||
|
clearTimeout(reloadTimeoutRef.current);
|
||||||
|
reloadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
executeUpdate.mutate();
|
executeUpdate.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
|
|||||||
storage_name: string;
|
storage_name: string;
|
||||||
storage_type: string;
|
storage_type: string;
|
||||||
discovered_at: Date;
|
discovered_at: Date;
|
||||||
|
server_id?: number;
|
||||||
server_name: string | null;
|
server_name: string | null;
|
||||||
server_color: string | null;
|
server_color: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { readFile, writeFile } from "fs/promises";
|
import { readFile, writeFile, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -176,10 +176,21 @@ export const versionRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
logs: [],
|
logs: [],
|
||||||
isComplete: false
|
isComplete: false,
|
||||||
|
logFileModifiedTime: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get log file modification time for session validation
|
||||||
|
let logFileModifiedTime: number | null = null;
|
||||||
|
try {
|
||||||
|
const stats = await stat(logPath);
|
||||||
|
logFileModifiedTime = stats.mtimeMs;
|
||||||
|
} catch (statError) {
|
||||||
|
// If we can't get stats, continue without timestamp
|
||||||
|
console.warn('Could not get log file stats:', statError);
|
||||||
|
}
|
||||||
|
|
||||||
const logs = await readFile(logPath, 'utf-8');
|
const logs = await readFile(logPath, 'utf-8');
|
||||||
const logLines = logs.split('\n')
|
const logLines = logs.split('\n')
|
||||||
.filter(line => line.trim())
|
.filter(line => line.trim())
|
||||||
@@ -202,7 +213,8 @@ export const versionRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
logs: logLines,
|
logs: logLines,
|
||||||
isComplete
|
isComplete,
|
||||||
|
logFileModifiedTime
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading update logs:', error);
|
console.error('Error reading update logs:', error);
|
||||||
@@ -210,7 +222,8 @@ export const versionRouter = createTRPCRouter({
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||||
logs: [],
|
logs: [],
|
||||||
isComplete: false
|
isComplete: false,
|
||||||
|
logFileModifiedTime: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -62,17 +62,19 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
findCommand,
|
findCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
findOutput += data;
|
findOutput += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
|
console.error('Error getting hostname:', error);
|
||||||
// Ignore errors - directory might not exist
|
// Ignore errors - directory might not exist
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(exitCode: number) => {
|
||||||
|
console.error('Error getting find command:', exitCode);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -97,7 +99,7 @@ class BackupService {
|
|||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
statCommand,
|
statCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -113,11 +115,11 @@ class BackupService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statParts = statOutput.trim().split('|');
|
const statParts = statOutput.trim().split('|');
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
|
|
||||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||||
const size = BigInt(statParts[0] || '0');
|
const size = BigInt(statParts[0] ?? '0');
|
||||||
const mtime = parseInt(statParts[1] || '0', 10);
|
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||||
|
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
@@ -145,8 +147,9 @@ class BackupService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error processing backup:', error);
|
||||||
// Still try to add the backup even if stat fails
|
// Still try to add the backup even if stat fails
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
server_id: server.id,
|
server_id: server.id,
|
||||||
@@ -183,17 +186,18 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
findCommand,
|
findCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
findOutput += data;
|
findOutput += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
// Ignore errors - storage might not be mounted
|
console.error('Error getting stat command:', error);
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(exitCode: number) => {
|
||||||
|
console.error('Error getting stat command:', exitCode);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -219,7 +223,7 @@ class BackupService {
|
|||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
statCommand,
|
statCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -235,11 +239,11 @@ class BackupService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statParts = statOutput.trim().split('|');
|
const statParts = statOutput.trim().split('|');
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
|
|
||||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||||
const size = BigInt(statParts[0] || '0');
|
const size = BigInt(statParts[0] ?? '0');
|
||||||
const mtime = parseInt(statParts[1] || '0', 10);
|
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||||
|
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
@@ -271,7 +275,7 @@ class BackupService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing backup ${backupPath}:`, error);
|
console.error(`Error processing backup ${backupPath}:`, error);
|
||||||
// Still try to add the backup even if stat fails
|
// Still try to add the backup even if stat fails
|
||||||
const fileName = backupPath.split('/').pop() || backupPath;
|
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||||
backups.push({
|
backups.push({
|
||||||
container_id: ctId,
|
container_id: ctId,
|
||||||
server_id: server.id,
|
server_id: server.id,
|
||||||
@@ -311,8 +315,8 @@ class BackupService {
|
|||||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||||
|
|
||||||
// Use IP and datastore from credentials (they override config if different)
|
// Use IP and datastore from credentials (they override config if different)
|
||||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||||
|
|
||||||
if (!pbsIp || !pbsDatastore) {
|
if (!pbsIp || !pbsDatastore) {
|
||||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||||
@@ -340,7 +344,7 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
fullCommand,
|
fullCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -406,8 +410,8 @@ class BackupService {
|
|||||||
|
|
||||||
const storageService = getStorageService();
|
const storageService = getStorageService();
|
||||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||||
|
|
||||||
if (!pbsIp || !pbsDatastore) {
|
if (!pbsIp || !pbsDatastore) {
|
||||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||||
@@ -426,8 +430,8 @@ class BackupService {
|
|||||||
try {
|
try {
|
||||||
// Add timeout to prevent hanging
|
// Add timeout to prevent hanging
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
command,
|
command,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -469,7 +473,7 @@ class BackupService {
|
|||||||
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
||||||
continue; // Skip header row
|
continue; // Skip header row
|
||||||
}
|
}
|
||||||
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
|
if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) {
|
||||||
continue; // Skip table separator lines
|
continue; // Skip table separator lines
|
||||||
}
|
}
|
||||||
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
||||||
@@ -490,7 +494,7 @@ class BackupService {
|
|||||||
|
|
||||||
// Extract snapshot name (last part after /)
|
// Extract snapshot name (last part after /)
|
||||||
const snapshotParts = snapshotPath.split('/');
|
const snapshotParts = snapshotPath.split('/');
|
||||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
|
||||||
|
|
||||||
if (!snapshotName) {
|
if (!snapshotName) {
|
||||||
continue; // Skip if no snapshot name
|
continue; // Skip if no snapshot name
|
||||||
@@ -498,11 +502,12 @@ class BackupService {
|
|||||||
|
|
||||||
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
||||||
let createdAt: Date | undefined;
|
let createdAt: Date | undefined;
|
||||||
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
|
const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName);
|
||||||
if (dateMatch && dateMatch[1]) {
|
if (dateMatch?.[1]) {
|
||||||
try {
|
try {
|
||||||
createdAt = new Date(dateMatch[1]);
|
createdAt = new Date(dateMatch[1]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('Error parsing date:', e);
|
||||||
// Invalid date, leave undefined
|
// Invalid date, leave undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,8 +515,8 @@ class BackupService {
|
|||||||
// Parse size (convert MiB/GiB to bytes)
|
// Parse size (convert MiB/GiB to bytes)
|
||||||
let size: bigint | undefined;
|
let size: bigint | undefined;
|
||||||
if (sizeStr) {
|
if (sizeStr) {
|
||||||
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
|
const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr);
|
||||||
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
|
if (sizeMatch?.[1] && sizeMatch[2]) {
|
||||||
const sizeValue = parseFloat(sizeMatch[1]);
|
const sizeValue = parseFloat(sizeMatch[1]);
|
||||||
const unit = sizeMatch[2].toUpperCase();
|
const unit = sizeMatch[2].toUpperCase();
|
||||||
let bytes = sizeValue;
|
let bytes = sizeValue;
|
||||||
@@ -641,18 +646,18 @@ class BackupService {
|
|||||||
if (!script.container_id || !script.server_id || !script.server) continue;
|
if (!script.container_id || !script.server_id || !script.server) continue;
|
||||||
|
|
||||||
const containerId = script.container_id;
|
const containerId = script.container_id;
|
||||||
const serverId = script.server_id;
|
|
||||||
const server = script.server as Server;
|
const server = script.server as Server;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get hostname from LXC config if available, otherwise use script name
|
// Get hostname from LXC config if available, otherwise use script name
|
||||||
let hostname = script.script_name || `CT-${script.container_id}`;
|
let hostname = script.script_name ?? `CT-${script.container_id}`;
|
||||||
try {
|
try {
|
||||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||||
if (lxcConfig?.hostname) {
|
if (lxcConfig?.hostname) {
|
||||||
hostname = lxcConfig.hostname;
|
hostname = lxcConfig.hostname;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error getting LXC config:', error);
|
||||||
// LXC config might not exist, use script name
|
// LXC config might not exist, use script name
|
||||||
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
||||||
}
|
}
|
||||||
@@ -683,9 +688,7 @@ class BackupService {
|
|||||||
let backupServiceInstance: BackupService | null = null;
|
let backupServiceInstance: BackupService | null = null;
|
||||||
|
|
||||||
export function getBackupService(): BackupService {
|
export function getBackupService(): BackupService {
|
||||||
if (!backupServiceInstance) {
|
backupServiceInstance ??= new BackupService();
|
||||||
backupServiceInstance = new BackupService();
|
|
||||||
}
|
|
||||||
return backupServiceInstance;
|
return backupServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export class GitHubJsonService {
|
|||||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
const data = await response.json();
|
||||||
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
||||||
@@ -215,9 +216,7 @@ export class GitHubJsonService {
|
|||||||
const script = JSON.parse(content) as Script;
|
const script = JSON.parse(content) as Script;
|
||||||
|
|
||||||
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
||||||
if (!script.repository_url) {
|
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||||
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the script
|
// Cache the script
|
||||||
this.scriptCache.set(slug, script);
|
this.scriptCache.set(slug, script);
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class StorageService {
|
|||||||
|
|
||||||
let currentStorage: Partial<Storage> | null = null;
|
let currentStorage: Partial<Storage> | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (const rawLine of lines) {
|
||||||
const rawLine = lines[i];
|
|
||||||
if (!rawLine) continue;
|
if (!rawLine) continue;
|
||||||
|
|
||||||
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
||||||
@@ -45,10 +44,10 @@ class StorageService {
|
|||||||
// Check if this is a storage definition line (format: "type: name")
|
// Check if this is a storage definition line (format: "type: name")
|
||||||
// Storage definitions are NOT indented
|
// Storage definitions are NOT indented
|
||||||
if (!isIndented) {
|
if (!isIndented) {
|
||||||
const storageMatch = line.match(/^(\w+):\s*(.+)$/);
|
const storageMatch = /^(\w+):\s*(.+)$/.exec(line);
|
||||||
if (storageMatch && storageMatch[1] && storageMatch[2]) {
|
if (storageMatch?.[1] && storageMatch[2]) {
|
||||||
// Save previous storage if exists
|
// Save previous storage if exists
|
||||||
if (currentStorage && currentStorage.name) {
|
if (currentStorage?.name) {
|
||||||
storages.push(this.finalizeStorage(currentStorage));
|
storages.push(this.finalizeStorage(currentStorage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +65,9 @@ class StorageService {
|
|||||||
// Parse storage properties (indented lines - can be tabs or spaces)
|
// Parse storage properties (indented lines - can be tabs or spaces)
|
||||||
if (currentStorage && isIndented) {
|
if (currentStorage && isIndented) {
|
||||||
// Split on first whitespace (space or tab) to separate key and value
|
// Split on first whitespace (space or tab) to separate key and value
|
||||||
const match = line.match(/^(\S+)\s+(.+)$/);
|
const match = /^(\S+)\s+(.+)$/.exec(line);
|
||||||
|
|
||||||
if (match && match[1] && match[2]) {
|
if (match?.[1] && match[2]) {
|
||||||
const key = match[1];
|
const key = match[1];
|
||||||
const value = match[2].trim();
|
const value = match[2].trim();
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last storage
|
// Don't forget the last storage
|
||||||
if (currentStorage && currentStorage.name) {
|
if (currentStorage?.name) {
|
||||||
storages.push(this.finalizeStorage(currentStorage));
|
storages.push(this.finalizeStorage(currentStorage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +106,8 @@ class StorageService {
|
|||||||
return {
|
return {
|
||||||
name: storage.name!,
|
name: storage.name!,
|
||||||
type: storage.type!,
|
type: storage.type!,
|
||||||
content: storage.content || [],
|
content: storage.content ?? [],
|
||||||
supportsBackup: storage.supportsBackup || false,
|
supportsBackup: storage.supportsBackup ?? false,
|
||||||
nodes: storage.nodes,
|
nodes: storage.nodes,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(storage).filter(([key]) =>
|
Object.entries(storage).filter(([key]) =>
|
||||||
@@ -139,7 +138,7 @@ class StorageService {
|
|||||||
let configContent = '';
|
let configContent = '';
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sshService.executeCommand(
|
void sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
'cat /etc/pve/storage.cfg',
|
'cat /etc/pve/storage.cfg',
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
@@ -192,8 +191,8 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pbs_ip: (storage as any).server || null,
|
pbs_ip: (storage as any).server ?? null,
|
||||||
pbs_datastore: (storage as any).datastore || null,
|
pbs_datastore: (storage as any).datastore ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +215,7 @@ class StorageService {
|
|||||||
let storageServiceInstance: StorageService | null = null;
|
let storageServiceInstance: StorageService | null = null;
|
||||||
|
|
||||||
export function getStorageService(): StorageService {
|
export function getStorageService(): StorageService {
|
||||||
if (!storageServiceInstance) {
|
storageServiceInstance ??= new StorageService();
|
||||||
storageServiceInstance = new StorageService();
|
|
||||||
}
|
|
||||||
return storageServiceInstance;
|
return storageServiceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user