mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user