Skip to content

Commit 6a84da5

Browse files
feat: implement real-time update progress with proper theming (#72)
* fix(update): properly detach update script to survive service shutdown - Use setsid and nohup to completely detach update process from parent Node.js - Add 3-second grace period to allow parent process to respond to client - Fix issue where update script would stop when killing Node.js process - Improve systemd service detection using systemctl status with exit code check * fix(update): prevent infinite loop in script relocation - Check for --relocated flag at the start of main() before any other logic - Set PVE_UPDATE_RELOCATED environment variable immediately when --relocated is detected - Prevents relocated script from triggering relocation logic again * fix(update): use systemd-run and double-fork for complete process isolation - Primary: Use systemd-run --user --scope with KillMode=none for complete isolation - Fallback: Implement double-fork daemonization technique - Ensures update script survives systemd service shutdown - Script is fully orphaned and reparented to init/systemd * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh * Update update.sh
1 parent 0d40ced commit 6a84da5

File tree

6 files changed

+425
-378
lines changed

6 files changed

+425
-378
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ ALLOWED_SCRIPT_PATHS="scripts/"
1616

1717
# WebSocket Configuration
1818
WEBSOCKET_PORT="3001"
19+
GITHUB_TOKEN=your_github_token_here

src/app/_components/VersionDisplay.tsx

Lines changed: 147 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,68 @@
33
import { api } from "~/trpc/react";
44
import { Badge } from "./ui/badge";
55
import { Button } from "./ui/button";
6-
import { ExternalLink, Download, RefreshCw, Loader2, Check } from "lucide-react";
7-
import { useState } from "react";
86

9-
// Loading overlay component
10-
function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }) {
7+
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
8+
import { useState, useEffect, useRef } from "react";
9+
10+
// Loading overlay component with log streaming
11+
function LoadingOverlay({
12+
isNetworkError = false,
13+
logs = []
14+
}: {
15+
isNetworkError?: boolean;
16+
logs?: string[];
17+
}) {
18+
const logsEndRef = useRef<HTMLDivElement>(null);
19+
20+
// Auto-scroll to bottom when new logs arrive
21+
useEffect(() => {
22+
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
23+
}, [logs]);
24+
25+
1126
return (
1227
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
13-
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md mx-4">
28+
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
1429
<div className="flex flex-col items-center space-y-4">
1530
<div className="relative">
16-
<Loader2 className="h-12 w-12 animate-spin text-blue-600 dark:text-blue-400" />
17-
<div className="absolute inset-0 rounded-full border-2 border-blue-200 dark:border-blue-800 animate-pulse"></div>
31+
<Loader2 className="h-12 w-12 animate-spin text-primary" />
32+
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
1833
</div>
1934
<div className="text-center">
20-
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
35+
<h3 className="text-lg font-semibold text-card-foreground mb-2">
2136
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
2237
</h3>
23-
<p className="text-sm text-gray-600 dark:text-gray-400">
38+
<p className="text-sm text-muted-foreground">
2439
{isNetworkError
2540
? 'The server is restarting after the update...'
2641
: 'Please stand by while we update your application...'
2742
}
2843
</p>
29-
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
44+
<p className="text-xs text-muted-foreground mt-2">
3045
{isNetworkError
31-
? 'This may take a few moments. The page will reload automatically. You may see a blank page for up to a minute!.'
46+
? 'This may take a few moments. The page will reload automatically.'
3247
: 'The server will restart automatically when complete.'
3348
}
3449
</p>
3550
</div>
51+
52+
{/* Log output */}
53+
{logs.length > 0 && (
54+
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
55+
{logs.map((log, index) => (
56+
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
57+
{log}
58+
</div>
59+
))}
60+
<div ref={logsEndRef} />
61+
</div>
62+
)}
63+
3664
<div className="flex space-x-1">
37-
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
38-
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
39-
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
65+
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
66+
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
67+
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
4068
</div>
4169
</div>
4270
</div>
@@ -48,79 +76,126 @@ export function VersionDisplay() {
4876
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
4977
const [isUpdating, setIsUpdating] = useState(false);
5078
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
51-
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
5279
const [isNetworkError, setIsNetworkError] = useState(false);
80+
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
81+
const [shouldSubscribe, setShouldSubscribe] = useState(false);
82+
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
83+
const lastLogTimeRef = useRef<number>(Date.now());
84+
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
5385

5486
const executeUpdate = api.version.executeUpdate.useMutation({
55-
onSuccess: (result: any) => {
56-
const now = Date.now();
57-
const elapsed = updateStartTime ? now - updateStartTime : 0;
58-
59-
87+
onSuccess: (result) => {
6088
setUpdateResult({ success: result.success, message: result.message });
6189

6290
if (result.success) {
63-
// The script now runs independently, so we show a longer overlay
64-
// and wait for the server to restart
65-
setIsNetworkError(true);
66-
setUpdateResult({ success: true, message: 'Update in progress... Server will restart automatically.' });
67-
68-
// Wait longer for the update to complete and server to restart
69-
setTimeout(() => {
70-
setIsUpdating(false);
71-
setIsNetworkError(false);
72-
// Try to reload after the update completes
73-
setTimeout(() => {
74-
window.location.reload();
75-
}, 10000); // 10 seconds to allow for update completion
76-
}, 5000); // Show overlay for 5 seconds
91+
// Start subscribing to update logs
92+
setShouldSubscribe(true);
93+
setUpdateLogs(['Update started...']);
7794
} else {
78-
// For errors, show for at least 1 second
79-
const remainingTime = Math.max(0, 1000 - elapsed);
80-
setTimeout(() => {
81-
setIsUpdating(false);
82-
}, remainingTime);
95+
setIsUpdating(false);
8396
}
8497
},
8598
onError: (error) => {
86-
const now = Date.now();
87-
const elapsed = updateStartTime ? now - updateStartTime : 0;
99+
setUpdateResult({ success: false, message: error.message });
100+
setIsUpdating(false);
101+
}
102+
});
103+
104+
// Poll for update logs
105+
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
106+
enabled: shouldSubscribe,
107+
refetchInterval: 1000, // Poll every second
108+
refetchIntervalInBackground: true,
109+
});
110+
111+
// Update logs when data changes
112+
useEffect(() => {
113+
if (updateLogsData?.success && updateLogsData.logs) {
114+
lastLogTimeRef.current = Date.now();
115+
setUpdateLogs(updateLogsData.logs);
88116

89-
// Check if this is a network error (expected during server restart)
90-
const isNetworkError = error.message.includes('Failed to fetch') ||
91-
error.message.includes('NetworkError') ||
92-
error.message.includes('fetch') ||
93-
error.message.includes('network');
117+
if (updateLogsData.isComplete) {
118+
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
119+
setIsNetworkError(true);
120+
// Start reconnection attempts when we know update is complete
121+
startReconnectAttempts();
122+
}
123+
}
124+
}, [updateLogsData]);
125+
126+
// Monitor for server connection loss and auto-reload (fallback only)
127+
useEffect(() => {
128+
if (!shouldSubscribe) return;
129+
130+
// Only use this as a fallback - the main trigger should be completion detection
131+
const checkInterval = setInterval(() => {
132+
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
133+
134+
// Only start reconnection if we've been updating for at least 3 minutes
135+
// and no logs for 60 seconds (very conservative fallback)
136+
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
137+
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
94138

95-
if (isNetworkError && elapsed < 60000) { // If it's a network error within 30 seconds, treat as success
139+
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
140+
console.log('Fallback: Assuming server restart due to long silence');
96141
setIsNetworkError(true);
97-
setUpdateResult({ success: true, message: 'Update in progress... Server is restarting.' });
142+
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
98143

99-
// Wait longer for server to come back up
100-
setTimeout(() => {
101-
setIsUpdating(false);
102-
setIsNetworkError(false);
103-
// Try to reload after a longer delay
104-
setTimeout(() => {
105-
window.location.reload();
106-
}, 5000);
107-
}, 3000);
108-
} else {
109-
// For real errors, show for at least 1 second
110-
setUpdateResult({ success: false, message: error.message });
111-
const remainingTime = Math.max(0, 1000 - elapsed);
112-
setTimeout(() => {
113-
setIsUpdating(false);
114-
}, remainingTime);
144+
// Start trying to reconnect
145+
startReconnectAttempts();
115146
}
116-
}
117-
});
147+
}, 10000); // Check every 10 seconds
148+
149+
return () => clearInterval(checkInterval);
150+
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
151+
152+
// Attempt to reconnect and reload page when server is back
153+
const startReconnectAttempts = () => {
154+
if (reconnectIntervalRef.current) return;
155+
156+
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
157+
158+
reconnectIntervalRef.current = setInterval(() => {
159+
void (async () => {
160+
try {
161+
// Try to fetch the root path to check if server is back
162+
const response = await fetch('/', { method: 'HEAD' });
163+
if (response.ok || response.status === 200) {
164+
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
165+
166+
// Clear interval and reload
167+
if (reconnectIntervalRef.current) {
168+
clearInterval(reconnectIntervalRef.current);
169+
}
170+
171+
setTimeout(() => {
172+
window.location.reload();
173+
}, 1000);
174+
}
175+
} catch {
176+
// Server still down, keep trying
177+
}
178+
})();
179+
}, 2000);
180+
};
181+
182+
// Cleanup reconnect interval on unmount
183+
useEffect(() => {
184+
return () => {
185+
if (reconnectIntervalRef.current) {
186+
clearInterval(reconnectIntervalRef.current);
187+
}
188+
};
189+
}, []);
118190

119191
const handleUpdate = () => {
120192
setIsUpdating(true);
121193
setUpdateResult(null);
122194
setIsNetworkError(false);
195+
setUpdateLogs([]);
196+
setShouldSubscribe(false);
123197
setUpdateStartTime(Date.now());
198+
lastLogTimeRef.current = Date.now();
124199
executeUpdate.mutate();
125200
};
126201

@@ -152,7 +227,7 @@ export function VersionDisplay() {
152227
return (
153228
<>
154229
{/* Loading overlay */}
155-
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} />}
230+
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
156231

157232
<div className="flex items-center gap-2">
158233
<Badge variant={isUpToDate ? "default" : "secondary"}>
@@ -168,7 +243,7 @@ export function VersionDisplay() {
168243
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
169244
<div className="text-center">
170245
<div className="font-semibold mb-1">How to update:</div>
171-
<div>Click the button to update</div>
246+
<div>Click the button to update, when installed via the helper script</div>
172247
<div>or update manually:</div>
173248
<div>cd $PVESCRIPTLOCAL_DIR</div>
174249
<div>git pull</div>
@@ -213,8 +288,8 @@ export function VersionDisplay() {
213288
{updateResult && (
214289
<div className={`text-xs px-2 py-1 rounded ${
215290
updateResult.success
216-
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
217-
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
291+
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
292+
: 'bg-destructive/20 text-destructive border border-destructive/30'
218293
}`}>
219294
{updateResult.message}
220295
</div>
@@ -223,9 +298,8 @@ export function VersionDisplay() {
223298
)}
224299

225300
{isUpToDate && (
226-
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
227-
<Check className="h-3 w-3" />
228-
Up to date
301+
<span className="text-xs text-chart-2">
302+
✓ Up to date
229303
</span>
230304
)}
231305
</div>

src/env.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const env = createEnv({
2323
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
2424
// WebSocket Configuration
2525
WEBSOCKET_PORT: z.string().default("3001"),
26+
// GitHub Configuration
27+
GITHUB_TOKEN: z.string().optional(),
2628
},
2729

2830
/**
@@ -52,6 +54,8 @@ export const env = createEnv({
5254
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
5355
// WebSocket Configuration
5456
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
57+
// GitHub Configuration
58+
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
5559
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
5660
},
5761
/**

0 commit comments

Comments
 (0)