feat: add visual update notifications

Implemented a comprehensive update notification system that alerts users
when new versions are available:

- Automatic update checking on app startup
- Visual indicators: dismissible banner at top and badge on Settings tab
- Smart dismissal with localStorage persistence
- Deployment-specific update instructions (Docker, ProxmoxVE, systemd)
- 24-hour cache to avoid excessive API calls
- Manual check option in Settings

The system respects user preferences and won't re-show dismissed versions
unless explicitly requested. Works seamlessly with both stable and RC
update channels.
This commit is contained in:
Pulse Monitor
2025-08-27 18:19:05 +00:00
parent 7bf627f019
commit 968cc3fc79
5 changed files with 342 additions and 7 deletions
+22 -4
View File
@@ -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() {
<Show when={enhancedStore()} fallback={<div>Initializing...</div>}>
<WebSocketContext.Provider value={enhancedStore()!}>
<SecurityWarning />
<UpdateBanner />
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-2 font-sans">
<div class="container w-[95%] max-w-screen-xl mx-auto">
{/* Header */}
@@ -525,7 +540,7 @@ function App() {
<span>Alerts</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors relative ${
activeTab() === 'settings'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
@@ -538,6 +553,9 @@ function App() {
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Settings</span>
<Show when={updateStore.isUpdateVisible()}>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</Show>
</div>
</div>
@@ -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) {
@@ -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 (
<Show when={updateStore.isUpdateVisible()}>
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white relative animate-slideDown">
<div class="px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{/* Update icon */}
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6m0 0l3-3m-3 3l-3-3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17l.621 2.485A2 2 0 0 0 4.561 21h14.878a2 2 0 0 0 1.94-1.515L22 17" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{getShortMessage()}</span>
{!isExpanded() && (
<>
<span class="text-white/80 text-sm hidden sm:inline"></span>
<span class="text-white/80 text-sm hidden sm:inline">{getUpdateInstructions()}</span>
</>
)}
</div>
</div>
<div class="flex items-center gap-2">
{/* Expand/Collapse button */}
<button
onClick={() => setIsExpanded(!isExpanded())}
class="p-1 hover:bg-white/10 rounded transition-colors"
title={isExpanded() ? 'Show less' : 'Show more'}
>
<svg
class={`w-4 h-4 transform transition-transform ${isExpanded() ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
{/* Dismiss button */}
<button
onClick={() => updateStore.dismissUpdate()}
class="p-1 hover:bg-white/10 rounded transition-colors"
title="Dismiss this update"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
{/* Expanded content */}
<Show when={isExpanded()}>
<div class="mt-3 pb-1">
<div class="text-sm text-white/90 space-y-1">
<p>
<span class="font-medium">Current:</span> {updateStore.versionInfo()?.version || 'Unknown'}
<span class="font-medium ml-1">Latest:</span> {updateStore.updateInfo()?.latestVersion}
</p>
<p>
<span class="font-medium">How to update:</span> {getUpdateInstructions()}
</p>
<Show when={updateStore.updateInfo()?.isPrerelease}>
<p class="text-yellow-200 text-xs">This is a pre-release version</p>
</Show>
<div class="flex gap-3 mt-2">
<a
href={`https://github.com/rcourtman/Pulse/releases/tag/${updateStore.updateInfo()?.latestVersion}`}
target="_blank"
rel="noopener noreferrer"
class="text-white/90 underline hover:text-white text-xs"
>
View release notes
</a>
<button
onClick={() => updateStore.dismissUpdate()}
class="text-white/70 hover:text-white text-xs underline"
>
Don't show again for this version
</button>
</div>
</div>
</div>
</Show>
</div>
</div>
</Show>
);
}
+16
View File
@@ -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 {
+169
View File
@@ -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<UpdateInfo | null>(null);
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
const [isChecking, setIsChecking] = createSignal(false);
const [isDismissed, setIsDismissed] = createSignal(false);
const [lastError, setLastError] = createSignal<string | null>(null);
// Check for updates
const checkForUpdates = async (force = false): Promise<void> => {
// 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;
}