From 5f911afe2116cfd740f289fde037a1231a4dcb2e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Oct 2025 15:55:26 +0000 Subject: [PATCH] feat: add complete update UI with modals and history panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/Settings/Settings.tsx | 15 +- .../Settings/UpdateHistoryPanel.tsx | 204 +++++++++++++++++ .../src/components/UpdateBanner.tsx | 149 ++++++++++++- .../components/UpdateConfirmationModal.tsx | 164 ++++++++++++++ .../src/components/UpdateProgressModal.tsx | 210 ++++++++++++++++++ 5 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx create mode 100644 frontend-modern/src/components/UpdateConfirmationModal.tsx create mode 100644 frontend-modern/src/components/UpdateProgressModal.tsx diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 3a34405b1..abc4acc71 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -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 = { pve: { @@ -145,6 +147,10 @@ const SETTINGS_HEADER_META: Record = (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 = (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 = (props) => { setHasUnsavedChanges={setHasUnsavedChanges} /> + + {/* Update History Tab */} + + + diff --git a/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx b/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx new file mode 100644 index 000000000..101b18898 --- /dev/null +++ b/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx @@ -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([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [filterStatus, setFilterStatus] = createSignal('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 ( + + Success + + ); + case 'failed': + return ( + + Failed + + ); + case 'in_progress': + return ( + + In Progress + + ); + case 'rolled_back': + return ( + + Rolled Back + + ); + case 'cancelled': + return ( + + Cancelled + + ); + default: + return {status}; + } + }; + + 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 ( +
+ + + +
+ {/* Filter Controls */} +
+ + +
+ + {/* Loading State */} + +
+ Loading update history... +
+
+ + {/* Error State */} + +
+
{error()}
+ +
+
+ + {/* Empty State */} + +
+ No update history available +
+
+ + {/* History Table */} + 0}> +
+ + + + + + + + + + + + + + {(entry) => ( + + + + + + + + + )} + + +
+ Timestamp + + Action + + Version + + Status + + Duration + + Deployment +
+ {formatDate(entry.timestamp)} + + {entry.action} + + {entry.version_from} → {entry.version_to} + + {getStatusBadge(entry.status)} + + {formatDuration(entry.duration_ms)} + + {entry.deployment_type} +
+
+ + {/* Details Section - Could be expanded with more info */} +
+ Showing {filteredHistory().length} {filteredHistory().length === 1 ? 'entry' : 'entries'} +
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index 54b5d69f8..e3498ac4c 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -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(null); + const [showConfirmModal, setShowConfirmModal] = createSignal(false); + const [showProgressModal, setShowProgressModal] = createSignal(false); + const [isApplying, setIsApplying] = createSignal(false); + const [copiedIndex, setCopiedIndex] = createSignal(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() { /> -
+
{getShortMessage()} + + {/* Apply Update Button (automated deployments) */} + + + + + {/* Manual Steps Badge (non-automated deployments) */} + + + Manual steps required + + + {!isExpanded() && getUpdateInstructions() && ( <> @@ -134,6 +205,54 @@ export function UpdateBanner() { This is a pre-release version

+ + {/* Manual Update Instructions */} + 0}> +
+
Update Instructions:
+
+ + {(instruction, index) => ( +
+
+ + {instruction} + + +
+
+ )} +
+
+
+
+ + {/* Apply Update Button (expanded view for automated deployments) */} + +
+ +
+
+
+ + {/* Update Confirmation Modal */} + 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 */} + setShowProgressModal(false)} + onViewHistory={() => { + setShowProgressModal(false); + // TODO: Navigate to Settings → Update History + // For now, just close the modal + }} + /> ); } diff --git a/frontend-modern/src/components/UpdateConfirmationModal.tsx b/frontend-modern/src/components/UpdateConfirmationModal.tsx new file mode 100644 index 000000000..048f4ea08 --- /dev/null +++ b/frontend-modern/src/components/UpdateConfirmationModal.tsx @@ -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 ( + +
+
+ {/* Header */} +
+
+

+ Confirm Update +

+ +
+
+ + {/* Body */} +
+ {/* Version Jump */} +
+
+ Version Update +
+
+ {props.currentVersion} + + + + {props.latestVersion} +
+
+ + {/* Estimated Time */} + +
+ + + + Estimated time: {props.plan.estimatedTime} +
+
+ + {/* Prerequisites */} + 0}> +
+
+ Prerequisites +
+
    + + {(prerequisite) => ( +
  • + + + + {prerequisite} +
  • + )} +
    +
+
+
+ + {/* Root Required Warning */} + +
+
+ + + +
+
Root access required
+
+ This update requires elevated privileges to modify system files. +
+
+
+
+
+ + {/* Rollback Support */} + +
+ + + + Automatic backup will be created +
+
+ + {/* Acknowledgement Checkbox */} +
+ +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/UpdateProgressModal.tsx b/frontend-modern/src/components/UpdateProgressModal.tsx new file mode 100644 index 000000000..06a60b139 --- /dev/null +++ b/frontend-modern/src/components/UpdateProgressModal.tsx @@ -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(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 ( + + + + ); + } + + if (isComplete() && !hasError()) { + return ( + + + + ); + } + + return ( + + + + + ); + }; + + 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 ( + +
+
+ {/* Header */} +
+
+

+ Updating Pulse +

+ + + +
+
+ + {/* Body */} +
+ {/* Icon and Status */} +
+ {getStageIcon()} +
+
+ {getStatusText()} +
+ +
+ {status()!.status.replace('-', ' ')} +
+
+
+
+ + {/* Progress Bar */} + +
+
+ Progress + {status()!.progress}% +
+
+
+
+
+ + + {/* Error Message */} + +
+
+
Error Details:
+
{status()!.error}
+
+
+
+ + {/* Warning */} + +
+
+ + + +
+ Please do not close this window or refresh the page during the update. +
+
+
+
+
+ + {/* Footer */} + +
+ + + + + + + +
+
+
+
+ + ); +}