feat: add complete update UI with modals and history panel

Implements full update UX based on adapter system:

Components:
- UpdateConfirmationModal: Shows version jump, prerequisites, root warning, acknowledgement checkbox
- UpdateProgressModal: Real-time polling of /api/updates/status with progress bar and stage display
- UpdateHistoryPanel: Settings tab showing full audit log with filtering and status badges
- UpdateBanner: Enhanced with Apply button for automated deployments, manual instructions accordion with copy buttons

Features:
- Automated deployments: Single-click "Apply Update" button launches confirmation modal
- Manual deployments: Displays instruction steps with individual copy buttons
- Progress tracking: Polls status every 2s during updates, shows success/error states
- History: Dedicated Settings → Update History tab with filterable table
- Banner integration: Shows "Manual steps required" badge for non-automated deployments

All components use proper SolidJS signals and effects, fully typed with TypeScript.
This commit is contained in:
rcourtman
2025-10-10 15:55:26 +00:00
parent df2aae0246
commit 5f911afe21
5 changed files with 739 additions and 3 deletions
@@ -10,6 +10,7 @@ import { DockerAgents } from './DockerAgents';
import { OIDCPanel } from './OIDCPanel';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary';
import { UpdateHistoryPanel } from './UpdateHistoryPanel';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
@@ -110,7 +111,8 @@ type SettingsTab =
| 'system'
| 'urls'
| 'security'
| 'diagnostics';
| 'diagnostics'
| 'updates';
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
pve: {
@@ -145,6 +147,10 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
title: 'Diagnostics',
description: 'Inspect discovery scans, connection health, and runtime metrics for troubleshooting.',
},
updates: {
title: 'Update History',
description: 'Review past software updates, rollback events, and upgrade audit logs.',
},
};
// Node with UI-specific fields
@@ -174,6 +180,7 @@ const Settings: Component<SettingsProps> = (props) => {
if (path.includes('/settings/security')) return 'security';
if (path.includes('/settings/diagnostics')) return 'diagnostics';
if (path.includes('/settings/urls')) return 'urls';
if (path.includes('/settings/updates')) return 'updates';
return 'pve'; // default
};
@@ -305,6 +312,7 @@ const Settings: Component<SettingsProps> = (props) => {
{ id: 'system', label: 'System' },
{ id: 'security', label: 'Security' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'updates', label: 'Update History' },
],
},
];
@@ -4290,6 +4298,11 @@ const Settings: Component<SettingsProps> = (props) => {
setHasUnsavedChanges={setHasUnsavedChanges}
/>
</Show>
{/* Update History Tab */}
<Show when={activeTab() === 'updates'}>
<UpdateHistoryPanel />
</Show>
</div>
</div>
</Card>
@@ -0,0 +1,204 @@
import { createSignal, onMount, For, Show, createMemo } from 'solid-js';
import { UpdatesAPI, type UpdateHistoryEntry } from '@/api/updates';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
export function UpdateHistoryPanel() {
const [history, setHistory] = createSignal<UpdateHistoryEntry[]>([]);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [filterStatus, setFilterStatus] = createSignal<string>('all');
const filteredHistory = createMemo(() => {
const filter = filterStatus();
if (filter === 'all') return history();
return history().filter((entry) => entry.status === filter);
});
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const data = await UpdatesAPI.getUpdateHistory(50); // Get last 50 updates
setHistory(data);
} catch (err) {
console.error('Failed to load update history:', err);
setError('Failed to load update history');
} finally {
setLoading(false);
}
};
onMount(() => {
loadHistory();
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'success':
return (
<span class="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded">
Success
</span>
);
case 'failed':
return (
<span class="px-2 py-1 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 rounded">
Failed
</span>
);
case 'in_progress':
return (
<span class="px-2 py-1 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded">
In Progress
</span>
);
case 'rolled_back':
return (
<span class="px-2 py-1 text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 rounded">
Rolled Back
</span>
);
case 'cancelled':
return (
<span class="px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-900/30 text-gray-800 dark:text-gray-200 rounded">
Cancelled
</span>
);
default:
return <span class="px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-900/30 text-gray-800 dark:text-gray-200 rounded">{status}</span>;
}
};
const formatDate = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
const formatDuration = (durationMs: number) => {
if (durationMs === 0) return '-';
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
};
return (
<div class="space-y-6">
<SectionHeader title="Update History" />
<Card>
<div class="p-6">
{/* Filter Controls */}
<div class="mb-4 flex items-center gap-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Filter by status:
</label>
<select
value={filterStatus()}
onChange={(e) => setFilterStatus(e.currentTarget.value)}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="all">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
<option value="rolled_back">Rolled Back</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* Loading State */}
<Show when={loading()}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
Loading update history...
</div>
</Show>
{/* Error State */}
<Show when={error()}>
<div class="text-center py-8">
<div class="text-red-600 dark:text-red-400">{error()}</div>
<button
onClick={loadHistory}
class="mt-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded"
>
Retry
</button>
</div>
</Show>
{/* Empty State */}
<Show when={!loading() && !error() && filteredHistory().length === 0}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
No update history available
</div>
</Show>
{/* History Table */}
<Show when={!loading() && !error() && filteredHistory().length > 0}>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Timestamp
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Action
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Version
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Deployment
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredHistory()}>
{(entry) => (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(entry.timestamp)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
<span class="capitalize">{entry.action}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-700 dark:text-gray-300">
{entry.version_from} {entry.version_to}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
{getStatusBadge(entry.status)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{formatDuration(entry.duration_ms)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
<span class="capitalize">{entry.deployment_type}</span>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Details Section - Could be expanded with more info */}
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Showing {filteredHistory().length} {filteredHistory().length === 1 ? 'entry' : 'entries'}
</div>
</Show>
</div>
</Card>
</div>
);
}
+147 -2
View File
@@ -1,8 +1,61 @@
import { Show, createSignal } from 'solid-js';
import { Show, createSignal, createEffect, For } from 'solid-js';
import { updateStore } from '@/stores/updates';
import { UpdatesAPI, type UpdatePlan } from '@/api/updates';
import { UpdateConfirmationModal } from './UpdateConfirmationModal';
import { UpdateProgressModal } from './UpdateProgressModal';
export function UpdateBanner() {
const [isExpanded, setIsExpanded] = createSignal(false);
const [updatePlan, setUpdatePlan] = createSignal<UpdatePlan | null>(null);
const [showConfirmModal, setShowConfirmModal] = createSignal(false);
const [showProgressModal, setShowProgressModal] = createSignal(false);
const [isApplying, setIsApplying] = createSignal(false);
const [copiedIndex, setCopiedIndex] = createSignal<number | null>(null);
// Fetch update plan when update info is available
createEffect(async () => {
const info = updateStore.updateInfo();
if (info?.available && info.latestVersion) {
try {
const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion);
setUpdatePlan(plan);
} catch (error) {
console.error('Failed to fetch update plan:', error);
}
}
});
const handleApplyUpdate = () => {
setShowConfirmModal(true);
};
const handleConfirmUpdate = async () => {
const info = updateStore.updateInfo();
if (!info?.downloadUrl) return;
setIsApplying(true);
try {
await UpdatesAPI.applyUpdate(info.downloadUrl);
// Close confirmation and show progress
setShowConfirmModal(false);
setShowProgressModal(true);
} catch (error) {
console.error('Failed to start update:', error);
alert('Failed to start update. Please try again.');
} finally {
setIsApplying(false);
}
};
const copyToClipboard = async (text: string, index: number) => {
try {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
// Get deployment type message
const getUpdateInstructions = () => {
@@ -53,8 +106,26 @@ export function UpdateBanner() {
/>
</svg>
<div class="flex items-center gap-2">
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm font-medium">{getShortMessage()}</span>
{/* Apply Update Button (automated deployments) */}
<Show when={updatePlan()?.canAutoUpdate && !isExpanded()}>
<button
onClick={handleApplyUpdate}
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
>
Apply Update
</button>
</Show>
{/* Manual Steps Badge (non-automated deployments) */}
<Show when={updatePlan() && !updatePlan()?.canAutoUpdate && !isExpanded()}>
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 rounded">
Manual steps required
</span>
</Show>
{!isExpanded() && getUpdateInstructions() && (
<>
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline"></span>
@@ -134,6 +205,54 @@ export function UpdateBanner() {
This is a pre-release version
</p>
</Show>
{/* Manual Update Instructions */}
<Show when={updatePlan()?.instructions && updatePlan()!.instructions.length > 0}>
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
<div class="font-medium mb-2">Update Instructions:</div>
<div class="space-y-2">
<For each={updatePlan()!.instructions}>
{(instruction, index) => (
<div class="bg-gray-50 dark:bg-gray-900/50 rounded border border-blue-200 dark:border-blue-700 p-2">
<div class="flex items-start justify-between gap-2">
<code class="text-xs text-gray-800 dark:text-gray-200 font-mono flex-1 break-all">
{instruction}
</code>
<button
onClick={() => copyToClipboard(instruction, index())}
class="flex-shrink-0 p-1 hover:bg-blue-100 dark:hover:bg-blue-800/30 rounded transition-colors"
title="Copy to clipboard"
>
<Show when={copiedIndex() === index()} fallback={
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}>
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Show>
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Apply Update Button (expanded view for automated deployments) */}
<Show when={updatePlan()?.canAutoUpdate}>
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
<button
onClick={handleApplyUpdate}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
>
Apply Update Automatically
</button>
</div>
</Show>
<div class="flex gap-3 mt-2">
<a
href={`https://github.com/rcourtman/Pulse/releases/tag/${updateStore.updateInfo()?.latestVersion}`}
@@ -155,6 +274,32 @@ export function UpdateBanner() {
</Show>
</div>
</div>
{/* Update Confirmation Modal */}
<UpdateConfirmationModal
isOpen={showConfirmModal()}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirmUpdate}
currentVersion={updateStore.versionInfo()?.version || 'Unknown'}
latestVersion={updateStore.updateInfo()?.latestVersion || ''}
plan={updatePlan() || {
canAutoUpdate: false,
requiresRoot: false,
rollbackSupport: false,
}}
isApplying={isApplying()}
/>
{/* Update Progress Modal */}
<UpdateProgressModal
isOpen={showProgressModal()}
onClose={() => setShowProgressModal(false)}
onViewHistory={() => {
setShowProgressModal(false);
// TODO: Navigate to Settings → Update History
// For now, just close the modal
}}
/>
</Show>
);
}
@@ -0,0 +1,164 @@
import { createSignal, Show, For } from 'solid-js';
import type { UpdatePlan } from '@/api/updates';
interface UpdateConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
currentVersion: string;
latestVersion: string;
plan: UpdatePlan;
isApplying: boolean;
}
export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
const [acknowledged, setAcknowledged] = createSignal(false);
const handleConfirm = () => {
if (acknowledged() && !props.isApplying) {
props.onConfirm();
}
};
return (
<Show when={props.isOpen}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
Confirm Update
</h2>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
disabled={props.isApplying}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Body */}
<div class="px-6 py-4 space-y-4">
{/* Version Jump */}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Version Update
</div>
<div class="flex items-center gap-3 text-blue-800 dark:text-blue-200">
<span class="font-mono text-sm">{props.currentVersion}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="font-mono text-sm font-semibold">{props.latestVersion}</span>
</div>
</div>
{/* Estimated Time */}
<Show when={props.plan.estimatedTime}>
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Estimated time: {props.plan.estimatedTime}</span>
</div>
</Show>
{/* Prerequisites */}
<Show when={props.plan.prerequisites && props.plan.prerequisites.length > 0}>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
Prerequisites
</div>
<ul class="space-y-2">
<For each={props.plan.prerequisites}>
{(prerequisite) => (
<li class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{prerequisite}</span>
</li>
)}
</For>
</ul>
</div>
</Show>
{/* Root Required Warning */}
<Show when={props.plan.requiresRoot}>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<div class="font-medium">Root access required</div>
<div class="text-yellow-700 dark:text-yellow-300 mt-1">
This update requires elevated privileges to modify system files.
</div>
</div>
</div>
</div>
</Show>
{/* Rollback Support */}
<Show when={props.plan.rollbackSupport}>
<div class="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Automatic backup will be created</span>
</div>
</Show>
{/* Acknowledgement Checkbox */}
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={acknowledged()}
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
class="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
disabled={props.isApplying}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
I understand that Pulse will be temporarily unavailable during the update process.
{props.plan.rollbackSupport && ' A backup will be created automatically.'}
</span>
</label>
</div>
</div>
{/* Footer */}
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button
onClick={props.onClose}
disabled={props.isApplying}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!acknowledged() || props.isApplying}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Show when={props.isApplying}>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</Show>
<span>{props.isApplying ? 'Starting...' : 'Start Update'}</span>
</button>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,210 @@
import { createSignal, Show, onMount, onCleanup } from 'solid-js';
import { UpdatesAPI, type UpdateStatus } from '@/api/updates';
interface UpdateProgressModalProps {
isOpen: boolean;
onClose: () => void;
onViewHistory: () => void;
}
export function UpdateProgressModal(props: UpdateProgressModalProps) {
const [status, setStatus] = createSignal<UpdateStatus | null>(null);
const [isComplete, setIsComplete] = createSignal(false);
const [hasError, setHasError] = createSignal(false);
let pollInterval: number | undefined;
const pollStatus = async () => {
try {
const currentStatus = await UpdatesAPI.getUpdateStatus();
setStatus(currentStatus);
// Check if complete or error
if (
currentStatus.status === 'completed' ||
currentStatus.status === 'idle' ||
currentStatus.status === 'error'
) {
setIsComplete(true);
if (currentStatus.status === 'error' || currentStatus.error) {
setHasError(true);
}
if (pollInterval) {
clearInterval(pollInterval);
}
}
} catch (error) {
console.error('Failed to poll update status:', error);
}
};
onMount(() => {
if (props.isOpen) {
// Start polling immediately
pollStatus();
// Then poll every 2 seconds
pollInterval = setInterval(pollStatus, 2000) as unknown as number;
}
});
onCleanup(() => {
if (pollInterval) {
clearInterval(pollInterval);
}
});
const getStageIcon = () => {
const currentStatus = status();
if (!currentStatus) return null;
if (hasError()) {
return (
<svg class="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
if (isComplete() && !hasError()) {
return (
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
return (
<svg class="w-12 h-12 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
);
};
const getStatusText = () => {
const currentStatus = status();
if (!currentStatus) return 'Initializing...';
if (hasError()) {
return 'Update Failed';
}
if (isComplete() && !hasError()) {
return 'Update Completed Successfully';
}
return currentStatus.message || 'Updating...';
};
return (
<Show when={props.isOpen}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
Updating Pulse
</h2>
<Show when={isComplete()}>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
{/* Body */}
<div class="px-6 py-8">
{/* Icon and Status */}
<div class="flex flex-col items-center text-center space-y-4">
{getStageIcon()}
<div>
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
{getStatusText()}
</div>
<Show when={status()?.status && !isComplete()}>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1 capitalize">
{status()!.status.replace('-', ' ')}
</div>
</Show>
</div>
</div>
{/* Progress Bar */}
<Show when={!isComplete() && status()?.progress !== undefined}>
<div class="mt-6">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>Progress</span>
<span>{status()!.progress}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${status()!.progress}%` }}
/>
</div>
</div>
</Show>
{/* Error Message */}
<Show when={hasError() && status()?.error}>
<div class="mt-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="text-sm text-red-800 dark:text-red-200">
<div class="font-medium mb-1">Error Details:</div>
<div class="text-red-700 dark:text-red-300">{status()!.error}</div>
</div>
</div>
</Show>
{/* Warning */}
<Show when={!isComplete()}>
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-sm text-yellow-800 dark:text-yellow-200">
Please do not close this window or refresh the page during the update.
</div>
</div>
</div>
</Show>
</div>
{/* Footer */}
<Show when={isComplete()}>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<Show when={!hasError()}>
<button
onClick={props.onViewHistory}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
View History
</button>
</Show>
<Show when={hasError()}>
<button
onClick={() => window.location.reload()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Retry
</button>
</Show>
<button
onClick={props.onClose}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Close
</button>
</div>
</Show>
</div>
</div>
</Show>
);
}