diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index e979dd496..27d00bf51 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -17,6 +17,8 @@ import type { VersionInfo } from './api/updates'; import { apiFetch } from './utils/apiClient'; import { SettingsAPI } from './api/settings'; import { eventBus } from './stores/events'; +import { updateStore } from './stores/updates'; +import { UpdateBanner } from './components/UpdateBanner'; type TabType = 'main' | 'storage' | 'backups' | 'alerts' | 'settings'; @@ -176,7 +178,11 @@ function App() { // Load version info even when auth is disabled UpdatesAPI.getVersion() - .then(version => setVersionInfo(version)) + .then(version => { + setVersionInfo(version); + // Check for updates after loading version info (non-blocking) + updateStore.checkForUpdates(); + }) .catch(error => console.error('Failed to load version:', error)); setIsLoading(false); @@ -211,7 +217,11 @@ function App() { // Load version info UpdatesAPI.getVersion() - .then(version => setVersionInfo(version)) + .then(version => { + setVersionInfo(version); + // Check for updates after loading version info (non-blocking) + updateStore.checkForUpdates(); + }) .catch(error => console.error('Failed to load version:', error)); setIsLoading(false); @@ -303,7 +313,11 @@ function App() { // Load version info UpdatesAPI.getVersion() - .then(version => setVersionInfo(version)) + .then(version => { + setVersionInfo(version); + // Check for updates after loading version info (non-blocking) + updateStore.checkForUpdates(); + }) .catch(error => console.error('Failed to load version:', error)); }); @@ -379,6 +393,7 @@ function App() { Initializing...}> +
{/* Header */} @@ -525,7 +540,7 @@ function App() { Alerts
Settings + + +
diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 50c926e1d..9ca28f787 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -12,6 +12,7 @@ import type { NodeConfig } from '@/types/nodes'; import type { UpdateInfo, VersionInfo } from '@/api/updates'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; +import { updateStore } from '@/stores/updates'; // Type definitions interface DiscoveredServer { @@ -390,6 +391,8 @@ const Settings: Component = () => { try { const version = await UpdatesAPI.getVersion(); setVersionInfo(version); + // Also set it in the store so it's available globally + updateStore.checkForUpdates(); // This will load version info too if (version.channel) { setUpdateChannel(version.channel as 'stable' | 'rc'); } @@ -465,10 +468,17 @@ const Settings: Component = () => { const checkForUpdates = async () => { setCheckingForUpdates(true); try { - // Pass the current channel selection (from UI, not saved config) - const info = await UpdatesAPI.checkForUpdates(updateChannel()); + // Force check with current channel selection + await updateStore.checkForUpdates(true); + const info = updateStore.updateInfo(); setUpdateInfo(info); - if (!info.available) { + + // If update was dismissed, clear it so user can see it again + if (info?.available && updateStore.isDismissed()) { + updateStore.clearDismissed(); + } + + if (!info?.available) { showSuccess('You are running the latest version'); } } catch (error) { diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx new file mode 100644 index 000000000..a29d32e2c --- /dev/null +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -0,0 +1,122 @@ +import { Show, createSignal } from 'solid-js'; +import { updateStore } from '@/stores/updates'; + +export function UpdateBanner() { + const [isExpanded, setIsExpanded] = createSignal(false); + + // Get deployment type message + const getUpdateInstructions = () => { + const versionInfo = updateStore.versionInfo(); + const deploymentType = versionInfo?.deploymentType || 'systemd'; + + switch (deploymentType) { + case 'proxmoxve': + return "Type 'update' in the ProxmoxVE console"; + case 'docker': + return 'Pull the latest Docker image and recreate container'; + case 'source': + return 'Pull latest changes and rebuild'; + default: + return 'Run the install script to update'; + } + }; + + const getShortMessage = () => { + const info = updateStore.updateInfo(); + if (!info) return ''; + return `Update available: ${info.latestVersion}`; + }; + + return ( + +
+
+
+
+ {/* Update icon */} + + + + + +
+ {getShortMessage()} + {!isExpanded() && ( + <> + + + + )} +
+
+ +
+ {/* Expand/Collapse button */} + + + {/* Dismiss button */} + +
+
+ + {/* Expanded content */} + +
+
+

+ Current: {updateStore.versionInfo()?.version || 'Unknown'} → + Latest: {updateStore.updateInfo()?.latestVersion} +

+

+ How to update: {getUpdateInstructions()} +

+ +

This is a pre-release version

+
+
+ + View release notes + + +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index 256c462ed..908771234 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -2,6 +2,22 @@ @tailwind components; @tailwind utilities; +/* Update banner animation */ +@keyframes slideDown { + from { + transform: translateY(-100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.animate-slideDown { + animation: slideDown 0.3s ease-out; +} + /* Notification animations */ @keyframes slide-in-glass { from { diff --git a/frontend-modern/src/stores/updates.ts b/frontend-modern/src/stores/updates.ts new file mode 100644 index 000000000..963c54b7c --- /dev/null +++ b/frontend-modern/src/stores/updates.ts @@ -0,0 +1,169 @@ +import { createSignal } from 'solid-js'; +import { UpdatesAPI } from '@/api/updates'; +import type { UpdateInfo, VersionInfo } from '@/api/updates'; + +const STORAGE_KEY = 'pulse-updates'; +const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours + +interface UpdateState { + lastCheck: number; + dismissedVersion?: string; + updateInfo?: UpdateInfo; +} + +// Load state from localStorage +const loadState = (): UpdateState => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load update state:', e); + } + return { lastCheck: 0 }; +}; + +// Save state to localStorage +const saveState = (state: UpdateState) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.error('Failed to save update state:', e); + } +}; + +// Create signals +const [updateAvailable, setUpdateAvailable] = createSignal(false); +const [updateInfo, setUpdateInfo] = createSignal(null); +const [versionInfo, setVersionInfo] = createSignal(null); +const [isChecking, setIsChecking] = createSignal(false); +const [isDismissed, setIsDismissed] = createSignal(false); +const [lastError, setLastError] = createSignal(null); + +// Check for updates +const checkForUpdates = async (force = false): Promise => { + // Don't check if already checking + if (isChecking()) return; + + const state = loadState(); + const now = Date.now(); + + // Skip if checked recently (unless forced) + if (!force && state.lastCheck && (now - state.lastCheck) < CHECK_INTERVAL) { + // Use cached data if available + if (state.updateInfo) { + setUpdateInfo(state.updateInfo); + setUpdateAvailable(state.updateInfo.available); + + // Check if this version was dismissed + if (state.dismissedVersion === state.updateInfo.latestVersion) { + setIsDismissed(true); + } + } + return; + } + + setIsChecking(true); + setLastError(null); + + try { + // First get version info to check deployment type + const version = await UpdatesAPI.getVersion(); + setVersionInfo(version); + + // Don't check for updates in Docker + if (version.isDocker) { + setUpdateAvailable(false); + return; + } + + // Get the saved update channel from system settings + const info = await UpdatesAPI.checkForUpdates(); + + setUpdateInfo(info); + setUpdateAvailable(info.available); + + // Check if this version was dismissed + if (state.dismissedVersion === info.latestVersion) { + setIsDismissed(true); + } else { + setIsDismissed(false); + } + + // Save to cache + saveState({ + ...state, + lastCheck: now, + updateInfo: info + }); + } catch (error) { + console.error('Failed to check for updates:', error); + setLastError(error instanceof Error ? error.message : 'Failed to check for updates'); + setUpdateAvailable(false); + } finally { + setIsChecking(false); + } +}; + +// Dismiss current update +const dismissUpdate = () => { + const info = updateInfo(); + if (!info) return; + + const state = loadState(); + saveState({ + ...state, + dismissedVersion: info.latestVersion + }); + + setIsDismissed(true); +}; + +// Clear dismissed version (useful when user wants to see the update again) +const clearDismissed = () => { + const state = loadState(); + delete state.dismissedVersion; + saveState(state); + setIsDismissed(false); +}; + +// Check if update is visible (available and not dismissed) +const isUpdateVisible = () => updateAvailable() && !isDismissed(); + +// Export store +export const updateStore = { + // State + updateAvailable, + updateInfo, + versionInfo, + isChecking, + isDismissed, + lastError, + isUpdateVisible, + + // Actions + checkForUpdates, + dismissUpdate, + clearDismissed, + + // Manual testing helpers + simulateUpdate: (version: string = 'v5.0.0') => { + setUpdateInfo({ + available: true, + currentVersion: versionInfo()?.version || 'v4.9.0', + latestVersion: version, + releaseNotes: 'Test update notification', + releaseDate: new Date().toISOString(), + downloadUrl: '#', + isPrerelease: false + }); + setUpdateAvailable(true); + setIsDismissed(false); + } +}; + +// Expose for testing in development +if (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168')) { + (window as any).updateStore = updateStore; +} \ No newline at end of file