diff --git a/server.js b/server.js index 0e6371a..fbc32fd 100644 --- a/server.js +++ b/server.js @@ -79,14 +79,27 @@ class ScriptExecutionHandler { * @param {import('http').Server} server */ constructor(server) { + // Create WebSocketServer without attaching to server + // We'll handle upgrades manually to avoid interfering with Next.js HMR this.wss = new WebSocketServer({ - server, - path: '/ws/script-execution' + noServer: true }); this.activeExecutions = new Map(); this.db = getDatabase(); this.setupWebSocket(); } + + /** + * Handle WebSocket upgrade for our endpoint + * @param {import('http').IncomingMessage} request + * @param {import('stream').Duplex} socket + * @param {Buffer} head + */ + handleUpgrade(request, socket, head) { + this.wss.handleUpgrade(request, socket, head, (ws) => { + this.wss.emit('connection', ws, request); + }); + } /** * Parse Container ID from terminal output @@ -1159,12 +1172,22 @@ app.prepare().then(() => { const parsedUrl = parse(req.url || '', true); const { pathname, query } = parsedUrl; - if (pathname === '/ws/script-execution') { + // Check if this is a WebSocket upgrade request + const isWebSocketUpgrade = req.headers.upgrade === 'websocket'; + + // Only intercept WebSocket upgrades for /ws/script-execution + // Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests + if (isWebSocketUpgrade && pathname === '/ws/script-execution') { // WebSocket upgrade will be handled by the WebSocket server + // Don't call handle() for this path - let WebSocketServer handle it return; } - // Let Next.js handle all other requests including HMR + // Let Next.js handle all other requests including: + // - HTTP requests to /ws/script-execution (non-WebSocket) + // - WebSocket upgrades to other paths (like /_next/webpack-hmr) + // - All static assets (_next routes) + // - All other routes await handle(req, res, parsedUrl); } catch (err) { console.error('Error occurred handling', req.url, err); @@ -1175,6 +1198,33 @@ app.prepare().then(() => { // Create WebSocket handlers const scriptHandler = new ScriptExecutionHandler(httpServer); + + // Handle WebSocket upgrades manually to avoid interfering with Next.js HMR + // We need to preserve Next.js's upgrade handlers and call them for non-matching paths + // Save any existing upgrade listeners (Next.js might have set them up) + const existingUpgradeListeners = httpServer.listeners('upgrade').slice(); + httpServer.removeAllListeners('upgrade'); + + // Add our upgrade handler that routes based on path + httpServer.on('upgrade', (request, socket, head) => { + const parsedUrl = parse(request.url || '', true); + const { pathname } = parsedUrl; + + if (pathname === '/ws/script-execution') { + // Handle our custom WebSocket endpoint + scriptHandler.handleUpgrade(request, socket, head); + } else { + // For all other paths (including Next.js HMR), call existing listeners + // This allows Next.js to handle its own WebSocket upgrades + for (const listener of existingUpgradeListeners) { + try { + listener.call(httpServer, request, socket, head); + } catch (err) { + console.error('Error in upgrade listener:', err); + } + } + } + }); // Note: TerminalHandler removed as it's not being used by the current application httpServer diff --git a/src/app/_components/CategorySidebar.tsx b/src/app/_components/CategorySidebar.tsx index 6786f98..61cb723 100644 --- a/src/app/_components/CategorySidebar.tsx +++ b/src/app/_components/CategorySidebar.tsx @@ -187,9 +187,10 @@ export function CategorySidebar({ 'Miscellaneous': 'box' }; - // Sort categories by count (descending) and then alphabetically + // Filter categories to only show those with scripts, then sort by count (descending) and alphabetically const sortedCategories = categories .map(category => [category, categoryCounts[category] ?? 0] as const) + .filter(([, count]) => count > 0) // Only show categories with at least one script .sort(([a, countA], [b, countB]) => { if (countB !== countA) return countB - countA; return a.localeCompare(b); diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx index b4ee596..8137404 100644 --- a/src/app/_components/ResyncButton.tsx +++ b/src/app/_components/ResyncButton.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { api } from '~/trpc/react'; import { Button } from './ui/button'; import { ContextualHelpIcon } from './ContextualHelpIcon'; @@ -9,6 +9,8 @@ export function ResyncButton() { const [isResyncing, setIsResyncing] = useState(false); const [lastSync, setLastSync] = useState(null); const [syncMessage, setSyncMessage] = useState(null); + const hasReloadedRef = useRef(false); + const isUserInitiatedRef = useRef(false); const resyncMutation = api.scripts.resyncScripts.useMutation({ onSuccess: (data) => { @@ -16,24 +18,38 @@ export function ResyncButton() { setLastSync(new Date()); if (data.success) { setSyncMessage(data.message ?? 'Scripts synced successfully'); - // Reload the page after successful sync - setTimeout(() => { - window.location.reload(); - }, 2000); // Wait 2 seconds to show the success message + // Only reload if this was triggered by user action + if (isUserInitiatedRef.current && !hasReloadedRef.current) { + hasReloadedRef.current = true; + setTimeout(() => { + window.location.reload(); + }, 2000); // Wait 2 seconds to show the success message + } else { + // Reset flag if reload didn't happen + isUserInitiatedRef.current = false; + } } else { setSyncMessage(data.error ?? 'Failed to sync scripts'); // Clear message after 3 seconds for errors setTimeout(() => setSyncMessage(null), 3000); + isUserInitiatedRef.current = false; } }, onError: (error) => { setIsResyncing(false); setSyncMessage(`Error: ${error.message}`); setTimeout(() => setSyncMessage(null), 3000); + isUserInitiatedRef.current = false; }, }); const handleResync = async () => { + // Prevent multiple simultaneous sync operations + if (isResyncing) return; + + // Mark as user-initiated before starting + isUserInitiatedRef.current = true; + hasReloadedRef.current = false; setIsResyncing(true); setSyncMessage(null); resyncMutation.mutate(); diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 8a6f3c2..d6d5e0b 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -882,7 +882,7 @@ export function ScriptDetailModal({ method.script?.startsWith("ct/")) + ?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/"))) ?.script?.split("/") .pop() ?? `${script.slug}.sh` } diff --git a/src/app/_components/TextViewer.tsx b/src/app/_components/TextViewer.tsx index 20785a6..3fc8425 100644 --- a/src/app/_components/TextViewer.tsx +++ b/src/app/_components/TextViewer.tsx @@ -14,9 +14,9 @@ interface TextViewerProps { } interface ScriptContent { - ctScript?: string; + mainScript?: string; installScript?: string; - alpineCtScript?: string; + alpineMainScript?: string; alpineInstallScript?: string; } @@ -24,18 +24,27 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr const [scriptContent, setScriptContent] = useState({}); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct'); + const [activeTab, setActiveTab] = useState<'main' | 'install'>('main'); const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default'); // Extract slug from script name (remove .sh extension) const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, ''); - // Check if alpine variant exists - const hasAlpineVariant = script?.install_methods?.some( - method => method.type === 'alpine' && method.script?.startsWith('ct/') - ); + // Get default and alpine install methods + const defaultMethod = script?.install_methods?.find(method => method.type === 'default'); + const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine'); - // Get script names for default and alpine versions + // Check if alpine variant exists + const hasAlpineVariant = !!alpineMethod; + + // Get script paths from install_methods + const defaultScriptPath = defaultMethod?.script; + const alpineScriptPath = alpineMethod?.script; + + // Determine if install scripts exist (only for ct/ scripts typically) + const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/'); + + // Get script names for display const defaultScriptName = scriptName.replace(/^alpine-/, ''); const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`; @@ -44,116 +53,72 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr setError(null); try { - // Build fetch requests for default version + // Build fetch requests based on actual script paths from install_methods const requests: Promise[] = []; - - // Default CT script - requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`) - ); - - // Tools, VM, VW scripts - requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`) - ); - requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`) - ); - requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`) - ); - - // Default install script - requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) - ); - - // Alpine versions if variant exists - if (hasAlpineVariant) { + const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = []; + + // Default main script (ct/, vm/, tools/, etc.) + if (defaultScriptPath) { requests.push( - fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`) + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`) ); + requestTypes.push('default-main'); + } + + // Default install script (only for ct/ scripts) + if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) { + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) + ); + requestTypes.push('default-install'); + } + + // Alpine main script + if (hasAlpineVariant && alpineScriptPath) { + requests.push( + fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`) + ); + requestTypes.push('alpine-main'); + } + + // Alpine install script (only for ct/ scripts) + if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) { requests.push( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`) ); + requestTypes.push('alpine-install'); } const responses = await Promise.allSettled(requests); - const content: ScriptContent = {}; - let responseIndex = 0; - // Default CT script - const ctResponse = responses[responseIndex]; - if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) { - const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (ctData.result?.data?.json?.success) { - content.ctScript = ctData.result.data.json.content; - } - } - - responseIndex++; - // Tools script - const toolsResponse = responses[responseIndex]; - if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) { - const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (toolsData.result?.data?.json?.success) { - content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too - } - } - - responseIndex++; - // VM script - const vmResponse = responses[responseIndex]; - if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) { - const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (vmData.result?.data?.json?.success) { - content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too - } - } - - responseIndex++; - // VW script - const vwResponse = responses[responseIndex]; - if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) { - const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (vwData.result?.data?.json?.success) { - content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too - } - } - - responseIndex++; - // Default install script - const installResponse = responses[responseIndex]; - if (installResponse?.status === 'fulfilled' && installResponse.value.ok) { - const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (installData.result?.data?.json?.success) { - content.installScript = installData.result.data.json.content; - } - } - responseIndex++; - // Alpine CT script - if (hasAlpineVariant) { - const alpineCtResponse = responses[responseIndex]; - if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) { - const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (alpineCtData.result?.data?.json?.success) { - content.alpineCtScript = alpineCtData.result.data.json.content; + // Process responses based on their types + await Promise.all(responses.map(async (response, index) => { + if (response.status === 'fulfilled' && response.value.ok) { + try { + const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; + const type = requestTypes[index]; + if (data.result?.data?.json?.success && data.result.data.json.content) { + switch (type) { + case 'default-main': + content.mainScript = data.result.data.json.content; + break; + case 'default-install': + content.installScript = data.result.data.json.content; + break; + case 'alpine-main': + content.alpineMainScript = data.result.data.json.content; + break; + case 'alpine-install': + content.alpineInstallScript = data.result.data.json.content; + break; + } + } + } catch { + // Ignore errors } } - responseIndex++; - } - - // Alpine install script - if (hasAlpineVariant) { - const alpineInstallResponse = responses[responseIndex]; - if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) { - const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; - if (alpineInstallData.result?.data?.json?.success) { - content.alpineInstallScript = alpineInstallData.result.data.json.content; - } - } - } + })); setScriptContent(content); } catch (err) { @@ -161,7 +126,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr } finally { setIsLoading(false); } - }, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]); + }, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]); useEffect(() => { if (isOpen && scriptName) { @@ -207,23 +172,25 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr )} - {((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) || - (selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && ( + {((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) || + (selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
- + {hasInstallScript && ( + + )}
)} @@ -249,8 +216,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr ) : (
- {activeTab === 'ct' && ( - selectedVersion === 'default' && scriptContent.ctScript ? ( + {activeTab === 'main' && ( + selectedVersion === 'default' && scriptContent.mainScript ? ( - {scriptContent.ctScript} + {scriptContent.mainScript} - ) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? ( + ) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? ( - {scriptContent.alpineCtScript} + {scriptContent.alpineMainScript} ) : (
- {selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'} + {selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
) diff --git a/src/app/_components/UpdateConfirmationModal.tsx b/src/app/_components/UpdateConfirmationModal.tsx new file mode 100644 index 0000000..3fb231e --- /dev/null +++ b/src/app/_components/UpdateConfirmationModal.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { api } from '~/trpc/react'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface UpdateConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + releaseInfo: { + tagName: string; + name: string; + publishedAt: string; + htmlUrl: string; + body?: string; + } | null; + currentVersion: string; + latestVersion: string; +} + +export function UpdateConfirmationModal({ + isOpen, + onClose, + onConfirm, + releaseInfo, + currentVersion, + latestVersion +}: UpdateConfirmationModalProps) { + useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose }); + + if (!isOpen || !releaseInfo) return null; + + return ( +
+
+ {/* Header */} +
+
+ +
+

Confirm Update

+

+ Review the changelog before proceeding with the update +

+
+
+ +
+ + {/* Content */} +
+
+ {/* Version Info */} +
+
+
+

+ {releaseInfo.name || releaseInfo.tagName} +

+ + Latest + +
+ +
+
+
+ + {releaseInfo.tagName} +
+
+ + + {new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + +
+
+
+ Updating from + v{currentVersion} + to + v{latestVersion} +
+
+ + {/* Changelog */} + {releaseInfo.body ? ( +
+

Changelog

+
+

{children}

, + h2: ({children}) =>

{children}

, + h3: ({children}) =>

{children}

, + p: ({children}) =>

{children}

, + ul: ({children}) =>
    {children}
, + ol: ({children}) =>
    {children}
, + li: ({children}) =>
  • {children}
  • , + a: ({href, children}) => {children}, + strong: ({children}) => {children}, + em: ({children}) => {children}, + }} + > + {releaseInfo.body} +
    +
    +
    + ) : ( +
    +

    No changelog available for this release.

    +
    + )} + + {/* Warning */} +
    +
    + +
    +

    Important:

    +

    + Please review the changelog above for any breaking changes or important updates before proceeding. + The server will restart automatically after the update completes. +

    +
    +
    +
    +
    +
    + + {/* Footer */} +
    + + +
    +
    +
    + ); +} + diff --git a/src/app/_components/VersionDisplay.tsx b/src/app/_components/VersionDisplay.tsx index 5fc6660..61f2d9a 100644 --- a/src/app/_components/VersionDisplay.tsx +++ b/src/app/_components/VersionDisplay.tsx @@ -4,9 +4,10 @@ import { api } from "~/trpc/react"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ContextualHelpIcon } from "./ContextualHelpIcon"; +import { UpdateConfirmationModal } from "./UpdateConfirmationModal"; import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; interface VersionDisplayProps { onOpenReleaseNotes?: () => void; @@ -85,8 +86,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) const [updateLogs, setUpdateLogs] = useState([]); const [shouldSubscribe, setShouldSubscribe] = useState(false); const [updateStartTime, setUpdateStartTime] = useState(null); + const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false); const lastLogTimeRef = useRef(Date.now()); const reconnectIntervalRef = useRef(null); + const hasReloadedRef = useRef(false); + const isUpdatingRef = useRef(false); + const isNetworkErrorRef = useRef(false); const executeUpdate = api.version.executeUpdate.useMutation({ onSuccess: (result) => { @@ -98,11 +103,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) setUpdateLogs(['Update started...']); } else { setIsUpdating(false); + setShouldSubscribe(false); // Reset subscription on failure } }, onError: (error) => { setUpdateResult({ success: false, message: error.message }); setIsUpdating(false); + setShouldSubscribe(false); // Reset subscription on error } }); @@ -113,63 +120,49 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) refetchIntervalInBackground: true, }); - // Update logs when data changes - useEffect(() => { - if (updateLogsData?.success && updateLogsData.logs) { - lastLogTimeRef.current = Date.now(); - setUpdateLogs(updateLogsData.logs); - - if (updateLogsData.isComplete) { - setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']); - setIsNetworkError(true); - // Start reconnection attempts when we know update is complete - startReconnectAttempts(); - } - } - }, [updateLogsData]); - - // Monitor for server connection loss and auto-reload (fallback only) - useEffect(() => { - if (!shouldSubscribe) return; - - // Only use this as a fallback - the main trigger should be completion detection - const checkInterval = setInterval(() => { - const timeSinceLastLog = Date.now() - lastLogTimeRef.current; - - // Only start reconnection if we've been updating for at least 3 minutes - // and no logs for 60 seconds (very conservative fallback) - const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes - const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds - - if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) { - setIsNetworkError(true); - setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']); - - // Start trying to reconnect - startReconnectAttempts(); - } - }, 10000); // Check every 10 seconds - - return () => clearInterval(checkInterval); - }, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]); - // Attempt to reconnect and reload page when server is back - const startReconnectAttempts = () => { - if (reconnectIntervalRef.current) return; + // Memoized with useCallback to prevent recreation on every render + // Only depends on refs to avoid stale closures + 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) { + return; + } setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']); reconnectIntervalRef.current = setInterval(() => { 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) { + // Clear interval if we're no longer updating + if (!isUpdatingRef.current && 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) { + return; + } + + // Mark that we're about to reload to prevent multiple reloads + hasReloadedRef.current = true; setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']); // Clear interval and reload if (reconnectIntervalRef.current) { clearInterval(reconnectIntervalRef.current); + reconnectIntervalRef.current = null; } setTimeout(() => { @@ -181,18 +174,101 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) } })(); }, 2000); - }; + }, []); // Empty deps - only uses refs which are stable - // Cleanup reconnect interval on unmount + // Update logs when data changes useEffect(() => { + // CRITICAL: Only process update logs if we're actually updating + // This prevents stale isComplete data from triggering reloads when not updating + if (!isUpdating) { + 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); + // Start reconnection attempts when we know update is complete + startReconnectAttempts(); + } + } + }, [updateLogsData, startReconnectAttempts, isUpdating]); + + // Monitor for server connection loss and auto-reload (fallback only) + useEffect(() => { + // Early return: only run if we're actually updating + if (!shouldSubscribe || !isUpdating) return; + + // Only use this as a fallback - the main trigger should be completion detection + const checkInterval = setInterval(() => { + // Check refs first to ensure we're still updating + if (!isUpdatingRef.current || hasReloadedRef.current) { + return; + } + + const timeSinceLastLog = Date.now() - lastLogTimeRef.current; + + // Only start reconnection if we've been updating for at least 3 minutes + // and no logs for 60 seconds (very conservative fallback) + 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) { + setIsNetworkError(true); + setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']); + + // Start trying to reconnect + startReconnectAttempts(); + } + }, 10000); // Check every 10 seconds + + return () => clearInterval(checkInterval); + }, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]); + + // Keep refs in sync with state + useEffect(() => { + isUpdatingRef.current = isUpdating; + }, [isUpdating]); + + useEffect(() => { + isNetworkErrorRef.current = isNetworkError; + }, [isNetworkError]); + + // Clear reconnect interval when update completes or component unmounts + useEffect(() => { + // If we're no longer updating, clear the reconnect interval and reset subscription + if (!isUpdating) { + if (reconnectIntervalRef.current) { + clearInterval(reconnectIntervalRef.current); + reconnectIntervalRef.current = null; + } + // Reset subscription to prevent stale polling + setShouldSubscribe(false); + } + return () => { if (reconnectIntervalRef.current) { clearInterval(reconnectIntervalRef.current); + reconnectIntervalRef.current = null; } }; - }, []); + }, [isUpdating]); const handleUpdate = () => { + // Show confirmation modal instead of starting update directly + setShowUpdateConfirmation(true); + }; + + const handleConfirmUpdate = () => { + // Close the confirmation modal + setShowUpdateConfirmation(false); + // Start the actual update process setIsUpdating(true); setUpdateResult(null); setIsNetworkError(false); @@ -200,6 +276,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) setShouldSubscribe(false); setUpdateStartTime(Date.now()); lastLogTimeRef.current = Date.now(); + hasReloadedRef.current = false; // Reset reload flag when starting new update + // Clear any existing reconnect interval + if (reconnectIntervalRef.current) { + clearInterval(reconnectIntervalRef.current); + reconnectIntervalRef.current = null; + } executeUpdate.mutate(); }; @@ -233,6 +315,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {/* Loading overlay */} {isUpdating && } + {/* Update Confirmation Modal */} + {versionStatus?.releaseInfo && ( + setShowUpdateConfirmation(false)} + onConfirm={handleConfirmUpdate} + releaseInfo={versionStatus.releaseInfo} + currentVersion={versionStatus.currentVersion} + latestVersion={versionStatus.latestVersion} + /> + )} +
    { if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; + // Helper to normalize identifiers for robust matching + const normalizeId = (s?: string): string => (s ?? '') + .toLowerCase() + .replace(/\.(sh|bash|py|js|ts)$/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + // First deduplicate GitHub scripts using Map by slug const scriptMap = new Map(); @@ -110,13 +117,36 @@ export default function Home() { const localScripts = localScriptsData.scripts ?? []; // Count scripts that are both in deduplicated GitHub data and have local versions + // Use the same matching logic as DownloadedScriptsTab and ScriptsGrid return deduplicatedGithubScripts.filter(script => { if (!script?.name) return false; + + // Check if there's a corresponding local script return localScripts.some(local => { if (!local?.name) return false; - const localName = local.name.replace(/\.sh$/, ''); - return localName.toLowerCase() === script.name.toLowerCase() || - localName.toLowerCase() === (script.slug ?? '').toLowerCase(); + + // Primary: Exact slug-to-slug matching (most reliable) + if (local.slug && script.slug) { + if (local.slug.toLowerCase() === script.slug.toLowerCase()) { + return true; + } + // Also try normalized slug matching (handles filename-based slugs vs JSON slugs) + if (normalizeId(local.slug) === normalizeId(script.slug)) { + return true; + } + } + + // Secondary: Check install basenames (for edge cases where install script names differ from slugs) + const normalizedLocal = normalizeId(local.name); + const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false; + if (matchesInstallBasename) return true; + + // Tertiary: Normalized filename to normalized slug matching + if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) { + return true; + } + + return false; }); }).length; })(), diff --git a/src/server/api/routers/version.ts b/src/server/api/routers/version.ts index de60b05..06f61d3 100644 --- a/src/server/api/routers/version.ts +++ b/src/server/api/routers/version.ts @@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({ tagName: release.tag_name, name: release.name, publishedAt: release.published_at, - htmlUrl: release.html_url + htmlUrl: release.html_url, + body: release.body } }; } catch (error) {