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