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;
|
||||
},
|
||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
||||
// TypeScript errors will fail the build
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
startTransition(() => {
|
||||
setHasChanges(false);
|
||||
});
|
||||
} else if (configData && !configData.success) {
|
||||
startTransition(() => {
|
||||
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||
});
|
||||
}
|
||||
}, [configData]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
startTransition(() => {
|
||||
setCurrentVersion(versionData.version);
|
||||
});
|
||||
}
|
||||
}, [isOpen, versionData]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
@@ -11,6 +11,8 @@ export function ResyncButton() {
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -21,7 +23,16 @@ export function ResyncButton() {
|
||||
// Only reload if this was triggered by user action
|
||||
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
setTimeout(() => {
|
||||
|
||||
// Clear any existing reload timeout
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Set new reload timeout
|
||||
reloadTimeoutRef.current = setTimeout(() => {
|
||||
reloadTimeoutRef.current = null;
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
@@ -31,14 +42,26 @@ export function ResyncButton() {
|
||||
} else {
|
||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||
// Clear message after 3 seconds for errors
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
setSyncMessage(null);
|
||||
messageTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsResyncing(false);
|
||||
setSyncMessage(`Error: ${error.message}`);
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
setSyncMessage(null);
|
||||
messageTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
},
|
||||
});
|
||||
@@ -47,6 +70,12 @@ export function ResyncButton() {
|
||||
// Prevent multiple simultaneous sync operations
|
||||
if (isResyncing) return;
|
||||
|
||||
// Clear any pending reload timeout
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Mark as user-initiated before starting
|
||||
isUserInitiatedRef.current = true;
|
||||
hasReloadedRef.current = false;
|
||||
@@ -55,6 +84,23 @@ export function ResyncButton() {
|
||||
resyncMutation.mutate();
|
||||
};
|
||||
|
||||
// Cleanup on unmount - clear any pending timeouts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
messageTimeoutRef.current = null;
|
||||
}
|
||||
// Reset refs on unmount
|
||||
hasReloadedRef.current = false;
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
|
||||
@@ -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')) {
|
||||
startTransition(() => {
|
||||
setThemeState(savedTheme);
|
||||
});
|
||||
}
|
||||
startTransition(() => {
|
||||
setMounted(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Apply theme to document element
|
||||
|
||||
@@ -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
|
||||
// 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) {
|
||||
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
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user