From 7a0e8475fefdafab42c6c03dc6a38e615e60fc00 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Wed, 28 May 2025 09:01:24 +0100 Subject: [PATCH 1/2] feat: add PVE backup and snapshot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add support for local PVE backups (vzdump tasks) - Add support for backup files on PVE storage (including NFS) - Add VM/CT snapshot display with modal view - Update backup tab to show both PBS and PVE backups - Change columns to Source/Location for clarity - Update diagnostics to handle PVE-only setups Fixes #81 - PBS token permission warnings for PVE-only users Fixes #80 - Support for backups on NFS and other PVE storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- server/dataFetcher.js | 236 +++++++++++++++++++++++++++++++++++- server/diagnostics.js | 21 ++++ server/state.js | 17 ++- src/public/index.html | 5 +- src/public/js/state.js | 10 +- src/public/js/ui/backups.js | 188 ++++++++++++++++++++++++++-- 6 files changed, 463 insertions(+), 14 deletions(-) diff --git a/server/dataFetcher.js b/server/dataFetcher.js index a8dcfce4d..7f2c7b871 100644 --- a/server/dataFetcher.js +++ b/server/dataFetcher.js @@ -583,6 +583,148 @@ async function fetchAllPbsTasksForProcessing({ client, config }, nodeName) { } } +/** + * Fetches PVE backup tasks (vzdump) for a specific node. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @returns {Promise} - Array of backup task objects. + */ +async function fetchPveBackupTasks(apiClient, endpointId, nodeName) { + try { + const response = await apiClient.get(`/nodes/${nodeName}/tasks`, { + params: { + typefilter: 'vzdump', + limit: 1000 + } + }); + const tasks = response.data?.data || []; + + // Calculate 30-day cutoff timestamp + const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000); + + // Filter to last 30 days and transform to match PBS backup task format + return tasks + .filter(task => task.starttime >= thirtyDaysAgo) + .map(task => { + // Extract guest info from task description or ID + let guestId = null; + let guestType = null; + + // Try to extract from task description (e.g., "vzdump VM 100") + const vmMatch = task.type?.match(/VM\s+(\d+)/i) || task.id?.match(/VM\s+(\d+)/i); + const ctMatch = task.type?.match(/CT\s+(\d+)/i) || task.id?.match(/CT\s+(\d+)/i); + + if (vmMatch) { + guestId = vmMatch[1]; + guestType = 'vm'; + } else if (ctMatch) { + guestId = ctMatch[1]; + guestType = 'ct'; + } else if (task.id) { + // Try to extract from task ID format + const idMatch = task.id.match(/vzdump-(\w+)-(\d+)/); + if (idMatch) { + guestType = idMatch[1] === 'qemu' ? 'vm' : 'ct'; + guestId = idMatch[2]; + } + } + + return { + type: 'backup', + status: task.status || 'unknown', + starttime: task.starttime, + endtime: task.endtime || (task.starttime + 60), + node: nodeName, + guest: guestId ? `${guestType}/${guestId}` : task.id, + guestType: guestType, + guestId: guestId, + upid: task.upid, + user: task.user || 'unknown', + // PVE-specific fields + pveBackupTask: true, + endpointId: endpointId, + taskType: 'vzdump' + }; + }); + } catch (error) { + console.error(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching PVE backup tasks: ${error.message}`); + return []; + } +} + +/** + * Fetches storage content (backup files) for a specific storage. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @param {string} storage - The storage name. + * @returns {Promise} - Array of backup file objects. + */ +async function fetchStorageBackups(apiClient, endpointId, nodeName, storage) { + try { + const response = await apiClient.get(`/nodes/${nodeName}/storage/${storage}/content`, { + params: { content: 'backup' } + }); + const backups = response.data?.data || []; + + // Transform to a consistent format + return backups.map(backup => ({ + volid: backup.volid, + size: backup.size, + vmid: backup.vmid, + ctime: backup.ctime, + format: backup.format, + notes: backup.notes, + protected: backup.protected || false, + storage: storage, + node: nodeName, + endpointId: endpointId + })); + } catch (error) { + // Storage might not support backups or might be inaccessible + if (error.response?.status !== 501) { // 501 = not implemented + console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching backups from storage ${storage}: ${error.message}`); + } + return []; + } +} + +/** + * Fetches VM/CT snapshots for a specific guest. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @param {string} vmid - The VM/CT ID. + * @param {string} type - 'qemu' or 'lxc'. + * @returns {Promise} - Array of snapshot objects. + */ +async function fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type) { + try { + const endpoint = type === 'qemu' ? 'qemu' : 'lxc'; + const response = await apiClient.get(`/nodes/${nodeName}/${endpoint}/${vmid}/snapshot`); + const snapshots = response.data?.data || []; + + // Filter out the 'current' snapshot which is not a real snapshot + return snapshots + .filter(snap => snap.name !== 'current') + .map(snap => ({ + name: snap.name, + description: snap.description, + snaptime: snap.snaptime, + vmstate: snap.vmstate || false, + parent: snap.parent, + vmid: vmid, + type: type, + node: nodeName, + endpointId: endpointId + })); + } catch (error) { + // Guest might not exist or snapshots not supported + return []; + } +} + /** * Fetches and processes all data for configured PBS instances. * @param {Object} currentPbsApiClients - Initialized PBS API clients. @@ -669,12 +811,91 @@ async function fetchPbsData(currentPbsApiClients) { return pbsDataResults; } +/** + * Fetches PVE backup data (backup tasks, storage backups, and snapshots). + * @param {Object} currentApiClients - Initialized PVE API clients. + * @param {Array} nodes - Array of node objects. + * @param {Array} vms - Array of VM objects. + * @param {Array} containers - Array of container objects. + * @returns {Promise} - { backupTasks, storageBackups, guestSnapshots } + */ +async function fetchPveBackupData(currentApiClients, nodes, vms, containers) { + const allBackupTasks = []; + const allStorageBackups = []; + const allGuestSnapshots = []; + + if (!nodes || nodes.length === 0) { + return { backupTasks: [], storageBackups: [], guestSnapshots: [] }; + } + + // Fetch backup tasks and storage backups for each node + const nodeBackupPromises = nodes.map(async node => { + const endpointId = node.endpointId; + const nodeName = node.node; + + if (!currentApiClients[endpointId]) { + console.warn(`[DataFetcher] No API client found for endpoint: ${endpointId}`); + return; + } + + const { client: apiClient } = currentApiClients[endpointId]; + + // Fetch backup tasks for this node + const backupTasks = await fetchPveBackupTasks(apiClient, endpointId, nodeName); + allBackupTasks.push(...backupTasks); + + // Fetch backups from each storage on this node + if (node.storage && Array.isArray(node.storage)) { + const storagePromises = node.storage + .filter(storage => storage.content && storage.content.includes('backup')) + .map(storage => fetchStorageBackups(apiClient, endpointId, nodeName, storage.storage)); + + const storageResults = await Promise.allSettled(storagePromises); + storageResults.forEach(result => { + if (result.status === 'fulfilled' && result.value) { + allStorageBackups.push(...result.value); + } + }); + } + }); + + // Fetch snapshots for all VMs and containers + const guestSnapshotPromises = []; + + [...vms, ...containers].forEach(guest => { + const endpointId = guest.endpointId; + const nodeName = guest.node; + const vmid = guest.vmid; + const type = guest.type || (vms.includes(guest) ? 'qemu' : 'lxc'); + + if (currentApiClients[endpointId]) { + const { client: apiClient } = currentApiClients[endpointId]; + guestSnapshotPromises.push( + fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type) + .then(snapshots => allGuestSnapshots.push(...snapshots)) + .catch(err => { + // Silently handle errors for individual guests + }) + ); + } + }); + + // Wait for all promises to complete + await Promise.allSettled([...nodeBackupPromises, ...guestSnapshotPromises]); + + return { + backupTasks: allBackupTasks, + storageBackups: allStorageBackups, + guestSnapshots: allGuestSnapshots + }; +} + /** * Fetches structural data: PVE nodes/VMs/CTs and all PBS data. * @param {Object} currentApiClients - Initialized PVE clients. * @param {Object} currentPbsApiClients - Initialized PBS clients. * @param {Function} [_fetchPbsDataInternal=fetchPbsData] - Internal override for testing. - * @returns {Promise} - { nodes, vms, containers, pbs: pbsDataArray } + * @returns {Promise} - { nodes, vms, containers, pbs: pbsDataArray, pveBackups } */ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetchPbsDataInternal = fetchPbsData) { // console.log("[DataFetcher] Starting full discovery cycle..."); @@ -694,14 +915,23 @@ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetc return [{ nodes: [], vms: [], containers: [] }, []]; }); + // Now fetch PVE backup data using the discovered nodes, VMs, and containers + const pveBackups = await fetchPveBackupData( + currentApiClients, + pveResult.nodes || [], + pveResult.vms || [], + pveResult.containers || [] + ); + const aggregatedResult = { nodes: pveResult.nodes || [], vms: pveResult.vms || [], containers: pveResult.containers || [], - pbs: pbsResult || [] // pbsResult is already the array we need + pbs: pbsResult || [], // pbsResult is already the array we need + pveBackups: pveBackups // Add PVE backup data }; - console.log(`[DataFetcher] Discovery cycle completed. Found: ${aggregatedResult.nodes.length} PVE nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} CTs, ${aggregatedResult.pbs.length} PBS instances.`); + console.log(`[DataFetcher] Discovery cycle completed. Found: ${aggregatedResult.nodes.length} PVE nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} CTs, ${aggregatedResult.pbs.length} PBS instances, ${pveBackups.backupTasks.length} PVE backup tasks.`); return aggregatedResult; } diff --git a/server/diagnostics.js b/server/diagnostics.js index 100e4f932..a9ce56a74 100644 --- a/server/diagnostics.js +++ b/server/diagnostics.js @@ -511,6 +511,11 @@ class DiagnosticTool { datastores: 0, sampleBackupIds: [] }, + pveBackups: { + backupTasks: state.pveBackups?.backupTasks?.length || 0, + storageBackups: state.pveBackups?.storageBackups?.length || 0, + guestSnapshots: state.pveBackups?.guestSnapshots?.length || 0 + }, performance: { lastDiscoveryTime: stats.lastDiscoveryCycleTime || 'N/A', lastMetricsTime: stats.lastMetricsCycleTime || 'N/A' @@ -701,6 +706,22 @@ class DiagnosticTool { }); } } + + // Check PVE backups + if (report.state && report.state.pveBackups) { + const totalPveBackups = (report.state.pveBackups.backupTasks || 0) + + (report.state.pveBackups.storageBackups || 0); + const totalPveSnapshots = report.state.pveBackups.guestSnapshots || 0; + + // If no PBS configured but PVE backups exist, that's fine + if ((!report.state.pbs || report.state.pbs.instances === 0) && totalPveBackups > 0) { + report.recommendations.push({ + severity: 'info', + category: 'Backup Status', + message: `Found ${totalPveBackups} PVE backups and ${totalPveSnapshots} VM/CT snapshots. Note: PBS is not configured, showing only local PVE backups.` + }); + } + } // Check guest count if (report.state && report.state.guests && report.state.nodes) { diff --git a/server/state.js b/server/state.js index 6819e7e47..65d73e092 100644 --- a/server/state.js +++ b/server/state.js @@ -6,6 +6,11 @@ const state = { containers: [], metrics: [], pbs: [], // Array to hold data for each PBS instance + pveBackups: { // Add PVE backup data + backupTasks: [], + storageBackups: [], + guestSnapshots: [] + }, isConfigPlaceholder: false, // Add this flag // Enhanced monitoring data @@ -81,6 +86,7 @@ function getState() { containers: state.containers, metrics: state.metrics, // Assuming metrics are updated elsewhere pbs: state.pbs, // This is what's sent to the client and should now be correct + pveBackups: state.pveBackups, // Add PVE backup data isConfigPlaceholder: state.isConfigPlaceholder, // Enhanced monitoring data @@ -99,7 +105,7 @@ function getState() { }; } -function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) { +function updateDiscoveryData({ nodes, vms, containers, pbs, pveBackups, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) { const startTime = Date.now(); try { @@ -109,6 +115,15 @@ function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggrega state.containers = containers || []; state.pbs = pbs || []; + // Update PVE backup data + if (pveBackups) { + state.pveBackups = { + backupTasks: pveBackups.backupTasks || [], + storageBackups: pveBackups.storageBackups || [], + guestSnapshots: pveBackups.guestSnapshots || [] + }; + } + // If the discovery data structure nests these under the main 'pbs' array (e.g., from fetchPbsData), // they might not be separate top-level items in the discoveryData object passed here. // If they are indeed separate, this update is fine. diff --git a/src/public/index.html b/src/public/index.html index 35c64fa52..3289cfc65 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -793,9 +793,10 @@ Type Node Latest Backup - PBS Instance - Datastore + Source + Location # Backups + Snapshots 7-Day History diff --git a/src/public/js/state.js b/src/public/js/state.js index eb5769176..8dd6aeebb 100644 --- a/src/public/js/state.js +++ b/src/public/js/state.js @@ -10,6 +10,11 @@ PulseApp.state = (() => { metricsData: [], dashboardData: [], pbsDataArray: [], + pveBackups: { // Add PVE backup data + backupTasks: [], + storageBackups: [], + guestSnapshots: [] + }, dashboardHistory: {}, initialDataReceived: false, @@ -133,7 +138,7 @@ PulseApp.state = (() => { }); // Check what actually changed using hashing - const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs']; + const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs', 'pveBackups']; dataTypes.forEach(type => { if (newData[type]) { @@ -163,6 +168,9 @@ PulseApp.state = (() => { case 'pbs': internalState.pbsDataArray = newData.pbs; break; + case 'pveBackups': + internalState.pveBackups = newData.pveBackups; + break; } } } diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js index 6fbbd82d0..1b0173d4c 100644 --- a/src/public/js/ui/backups.js +++ b/src/public/js/ui/backups.js @@ -63,36 +63,66 @@ PulseApp.ui.backups = (() => { if (window.innerWidth < 768) { _initMobileScrollIndicators(); } + + // Initialize snapshot modal handlers + _initSnapshotModal(); } function _getInitialBackupData() { const vmsData = PulseApp.state.get('vmsData') || []; const containersData = PulseApp.state.get('containersData') || []; const pbsDataArray = PulseApp.state.get('pbsDataArray') || []; + const pveBackups = PulseApp.state.get('pveBackups') || {}; const initialDataReceived = PulseApp.state.get('initialDataReceived'); const allGuests = [...vmsData, ...containersData]; - const allRecentBackupTasks = pbsDataArray.flatMap(pbs => + // Combine PBS and PVE backup tasks + const pbsBackupTasks = pbsDataArray.flatMap(pbs => (pbs.backupTasks?.recentTasks || []).map(task => ({ ...task, guestId: task.id?.split('/')[1] || null, guestTypePbs: task.id?.split('/')[0] || null, - pbsInstanceName: pbs.pbsInstanceName + pbsInstanceName: pbs.pbsInstanceName, + source: 'pbs' })) ); - const allSnapshots = pbsDataArray.flatMap(pbsInstance => + const pveBackupTasks = (pveBackups.backupTasks || []).map(task => ({ + ...task, + guestId: task.guestId, + guestTypePbs: task.guestType, + startTime: task.starttime, + source: 'pve' + })); + + const allRecentBackupTasks = [...pbsBackupTasks, ...pveBackupTasks]; + + // Combine PBS snapshots and PVE storage backups + const pbsSnapshots = pbsDataArray.flatMap(pbsInstance => (pbsInstance.datastores || []).flatMap(ds => (ds.snapshots || []).map(snap => ({ ...snap, pbsInstanceName: pbsInstance.pbsInstanceName, datastoreName: ds.name, backupType: snap['backup-type'], - backupVMID: snap['backup-id'] + backupVMID: snap['backup-id'], + source: 'pbs' })) ) ); + const pveStorageBackups = (pveBackups.storageBackups || []).map(backup => ({ + 'backup-time': backup.ctime, + backupType: backup.vmid ? 'vm' : 'ct', // Guess based on context + backupVMID: backup.vmid, + size: backup.size, + protected: backup.protected, + storage: backup.storage, + source: 'pve' + })); + + const allSnapshots = [...pbsSnapshots, ...pveStorageBackups]; + // Pre-index data by guest ID and type for performance const tasksByGuest = new Map(); const snapshotsByGuest = new Map(); @@ -141,6 +171,12 @@ PulseApp.ui.backups = (() => { function _determineGuestBackupStatus(guest, guestSnapshots, guestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo) { const guestId = String(guest.vmid); + // Get guest snapshots from pveBackups + const pveBackups = PulseApp.state.get('pveBackups') || {}; + const guestSnapshotCount = (pveBackups.guestSnapshots || []) + .filter(snap => snap.vmid === guest.vmid) + .length; + // Use pre-filtered data instead of filtering large arrays const totalBackups = guestSnapshots ? guestSnapshots.length : 0; const latestSnapshot = guestSnapshots && guestSnapshots.length > 0 @@ -208,6 +244,21 @@ PulseApp.ui.backups = (() => { return dailyStatus; }); + // Determine backup source and location + let backupSource = 'N/A'; + let backupLocation = 'N/A'; + + if (latestSnapshot || latestTask) { + const source = latestSnapshot?.source || latestTask?.source; + if (source === 'pbs') { + backupSource = latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'PBS'; + backupLocation = latestSnapshot?.datastoreName || 'N/A'; + } else if (source === 'pve') { + backupSource = 'PVE'; + backupLocation = latestSnapshot?.storage || latestTask?.node || 'Local'; + } + } + return { guestName: guest.name || `Guest ${guest.vmid}`, guestId: guest.vmid, @@ -215,11 +266,13 @@ PulseApp.ui.backups = (() => { node: guest.node, guestPveStatus: guest.status, latestBackupTime: displayTimestamp, - pbsInstanceName: latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'N/A', - datastoreName: latestSnapshot?.datastoreName || 'N/A', + pbsInstanceName: backupSource, + datastoreName: backupLocation, totalBackups: totalBackups, backupHealthStatus: healthStatus, - last7DaysBackupStatus: last7DaysBackupStatus + last7DaysBackupStatus: last7DaysBackupStatus, + snapshotCount: guestSnapshotCount, + endpointId: guest.endpointId }; } @@ -288,6 +341,18 @@ PulseApp.ui.backups = (() => { } sevenDayDots += ''; + // Create snapshot button or count display + let snapshotCell = ''; + if (guestStatus.snapshotCount > 0) { + snapshotCell = ``; + } else { + snapshotCell = '0'; + } + row.innerHTML = ` ${guestStatus.guestName} ${guestStatus.guestId} @@ -297,6 +362,7 @@ PulseApp.ui.backups = (() => { ${guestStatus.pbsInstanceName} ${guestStatus.datastoreName} ${guestStatus.totalBackups} + ${snapshotCell} ${sevenDayDots} `; return row; @@ -511,6 +577,114 @@ PulseApp.ui.backups = (() => { PulseApp.state.saveFilterState(); // Save reset state } + function _initSnapshotModal() { + const modal = document.getElementById('snapshot-modal'); + const modalClose = document.getElementById('snapshot-modal-close'); + const modalBody = document.getElementById('snapshot-modal-body'); + const modalTitle = document.getElementById('snapshot-modal-title'); + + if (!modal || !modalClose || !modalBody) { + console.warn('[Backups] Snapshot modal elements not found'); + return; + } + + // Close modal on click outside or close button + modalClose.addEventListener('click', () => { + modal.classList.add('hidden'); + modal.classList.remove('flex'); + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.add('hidden'); + modal.classList.remove('flex'); + } + }); + + // Handle snapshot button clicks + document.addEventListener('click', (e) => { + if (e.target.classList.contains('view-snapshots-btn')) { + const vmid = e.target.dataset.vmid; + const node = e.target.dataset.node; + const endpoint = e.target.dataset.endpoint; + const type = e.target.dataset.type; + + _showSnapshotModal(vmid, node, endpoint, type); + } + }); + } + + function _showSnapshotModal(vmid, node, endpoint, type) { + const modal = document.getElementById('snapshot-modal'); + const modalBody = document.getElementById('snapshot-modal-body'); + const modalTitle = document.getElementById('snapshot-modal-title'); + + if (!modal || !modalBody || !modalTitle) return; + + // Get guest info + const vmsData = PulseApp.state.get('vmsData') || []; + const containersData = PulseApp.state.get('containersData') || []; + const guest = [...vmsData, ...containersData].find(g => g.vmid === vmid); + const guestName = guest?.name || `Guest ${vmid}`; + + modalTitle.textContent = `Snapshots for ${guestName} (${type.toUpperCase()} ${vmid})`; + modalBody.innerHTML = '

Loading snapshots...

'; + + modal.classList.remove('hidden'); + modal.classList.add('flex'); + + // Get snapshots from state + const pveBackups = PulseApp.state.get('pveBackups') || {}; + const snapshots = (pveBackups.guestSnapshots || []) + .filter(snap => snap.vmid === vmid) + .sort((a, b) => (b.snaptime || 0) - (a.snaptime || 0)); + + if (snapshots.length === 0) { + modalBody.innerHTML = '

No snapshots found for this guest.

'; + return; + } + + // Build snapshot table + let html = ` +
+ + + + + + + + + + + `; + + snapshots.forEach(snap => { + const created = snap.snaptime + ? new Date(snap.snaptime * 1000).toLocaleString() + : 'Unknown'; + const hasRam = snap.vmstate ? 'Yes' : 'No'; + const description = snap.description || '-'; + + html += ` + + + + + + + `; + }); + + html += ` + +
NameCreatedDescriptionRAM
${snap.name}${created}${description}${hasRam}
+
+ `; + + modalBody.innerHTML = html; + } + return { init, updateBackupsTab, From 3a8b8e6b4aac62d6fdec757c274fded1cbf48fda Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Wed, 28 May 2025 09:32:56 +0100 Subject: [PATCH 2/2] feat: add --branch option to install script for testing - Add --branch parameter to allow installing from any branch - Users can now test features with: curl ... | sudo bash -s -- --branch feature-name - Validates branch exists before checkout - Shows clear warnings that branches are for testing only - Updates version display to show branch/commit info - Prevents using both --version and --branch together This makes it easy for users to test features before release --- scripts/install-pulse.sh | 240 +++++++++++++++++++++++++++++---------- 1 file changed, 179 insertions(+), 61 deletions(-) diff --git a/scripts/install-pulse.sh b/scripts/install-pulse.sh index c9235c321..54975ac09 100755 --- a/scripts/install-pulse.sh +++ b/scripts/install-pulse.sh @@ -17,6 +17,8 @@ MODE_UPDATE="" INSTALL_MODE="" SPECIFIED_VERSION_TAG="" TARGET_TAG="" +SPECIFIED_BRANCH="" +TARGET_BRANCH="" INSTALLER_WAS_REEXECUTED=false if command -v readlink &> /dev/null && readlink -f "$0" &> /dev/null; then @@ -48,10 +50,25 @@ while [[ "$#" -gt 0 ]]; do exit 1 fi ;; + --branch) + if [[ -n "$2" ]] && [[ "$2" != --* ]]; then + SPECIFIED_BRANCH="$2" + shift 2 + else + echo "Error: --branch requires a branch name [e.g., feature/pve-backups]" >&2 + exit 1 + fi + ;; *) echo "Unknown parameter passed: $1"; exit 1 ;; esac done +# Validate that both --version and --branch aren't specified +if [[ -n "$SPECIFIED_VERSION_TAG" ]] && [[ -n "$SPECIFIED_BRANCH" ]]; then + echo "Error: Cannot specify both --version and --branch. Please choose one." >&2 + exit 1 +fi + print_info() { echo -e "\033[1;34m[INFO]\033[0m $1" } @@ -298,6 +315,23 @@ check_installation_status_and_determine_action() { print_error "Cannot validate specified version tag: Pulse directory $PULSE_DIR not found." INSTALL_MODE="error"; return fi + elif [ -n "$SPECIFIED_BRANCH" ]; then + if [ -d "$PULSE_DIR/.git" ]; then + cd "$PULSE_DIR" || { print_error "Failed to cd into $PULSE_DIR"; INSTALL_MODE="error"; return; } + print_info "Checking if remote branch '$SPECIFIED_BRANCH' exists..." + if sudo -u "$PULSE_USER" git ls-remote --heads origin "$SPECIFIED_BRANCH" | grep -q "$SPECIFIED_BRANCH"; then + TARGET_BRANCH="$SPECIFIED_BRANCH" + print_info "Will update to branch: $TARGET_BRANCH" + else + print_error "Remote branch '$SPECIFIED_BRANCH' not found." + INSTALL_MODE="error" + cd ..; return + fi + cd .. + else + print_error "Cannot validate specified branch: Pulse directory $PULSE_DIR not found." + INSTALL_MODE="error"; return + fi else if [ -d "$PULSE_DIR/.git" ]; then cd "$PULSE_DIR" || { print_error "Failed to cd into $PULSE_DIR"; INSTALL_MODE="error"; return; } @@ -349,6 +383,16 @@ check_installation_status_and_determine_action() { else INSTALL_MODE="error"; cd ..; return fi + elif [ -n "$SPECIFIED_BRANCH" ]; then + print_info "Checking if remote branch '$SPECIFIED_BRANCH' exists..." + if sudo -u "$PULSE_USER" git ls-remote --heads origin "$SPECIFIED_BRANCH" | grep -q "$SPECIFIED_BRANCH"; then + TARGET_BRANCH="$SPECIFIED_BRANCH" + print_info "Will switch to branch: $TARGET_BRANCH" + INSTALL_MODE="update" + else + print_error "Remote branch '$SPECIFIED_BRANCH' not found." + INSTALL_MODE="error"; cd ..; return + fi else TARGET_TAG="$latest_tag" fi @@ -428,6 +472,10 @@ check_installation_status_and_determine_action() { TARGET_TAG="$SPECIFIED_VERSION_TAG" print_info "Will attempt to install specified version: $TARGET_TAG" INSTALL_MODE="install" + elif [ -n "$SPECIFIED_BRANCH" ]; then + TARGET_BRANCH="$SPECIFIED_BRANCH" + print_info "Will attempt to install from branch: $TARGET_BRANCH" + INSTALL_MODE="install" else echo "Choose an action:" echo " 1) Install Pulse [latest version]" @@ -566,11 +614,18 @@ create_pulse_user() { perform_update() { - if [ -z "$TARGET_TAG" ]; then - print_error "Target version tag not determined. Cannot update." + if [ -z "$TARGET_TAG" ] && [ -z "$TARGET_BRANCH" ]; then + print_error "Target version tag or branch not determined. Cannot update." return 1 fi - print_info "Attempting to update Pulse to version $TARGET_TAG..." + + if [ -n "$TARGET_TAG" ]; then + print_info "Attempting to update Pulse to version $TARGET_TAG..." + else + print_info "Attempting to update Pulse to branch $TARGET_BRANCH..." + print_warning "⚠️ Branch installations are for testing only and may be unstable!" + fi + cd "$PULSE_DIR" || { print_error "Failed to change directory to $PULSE_DIR"; return 1; } local script_backup_path="/tmp/${SCRIPT_NAME}.bak" @@ -602,37 +657,60 @@ perform_update() { print_warning "Failed to clean untracked files, continuing anyway." fi - print_info "Fetching latest changes and tags from git [running as user $PULSE_USER]..." - if ! sudo -u "$PULSE_USER" git fetch origin --tags --force; then - print_error "Failed to fetch latest changes/tags from git." - [ -n "$script_backup_path" ] && rm -f "$script_backup_path" - cd .. - return 1 - fi + print_info "Fetching latest changes from git [running as user $PULSE_USER]..." + if [ -n "$TARGET_TAG" ]; then + if ! sudo -u "$PULSE_USER" git fetch origin --tags --force; then + print_error "Failed to fetch latest changes/tags from git." + [ -n "$script_backup_path" ] && rm -f "$script_backup_path" + cd .. + return 1 + fi + + if ! sudo -u "$PULSE_USER" git rev-parse "$TARGET_TAG" >/dev/null 2>&1; then + print_error "Target tag '$TARGET_TAG' could not be found locally after fetch." + print_error "It might have been deleted remotely or there was a fetch issue." + [ -n "$script_backup_path" ] && rm -f "$script_backup_path" + cd .. + return 1 + fi - if ! sudo -u "$PULSE_USER" git rev-parse "$TARGET_TAG" >/dev/null 2>&1; then - print_error "Target tag '$TARGET_TAG' could not be found locally after fetch." - print_error "It might have been deleted remotely or there was a fetch issue." - [ -n "$script_backup_path" ] && rm -f "$script_backup_path" - cd .. - return 1 - fi - - print_info "Checking out target version tag '$TARGET_TAG'..." - if ! sudo -u "$PULSE_USER" git checkout -f "$TARGET_TAG"; then - print_error "Failed to checkout tag '$TARGET_TAG'." - [ -n "$script_backup_path" ] && rm -f "$script_backup_path" - cd .. - return 1 - fi - - print_info "Verifying package.json was updated..." - local package_version=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") - local expected_version=$(echo "$TARGET_TAG" | sed 's/^v//') - if [ "$package_version" != "$expected_version" ] && [ "$package_version" != "unknown" ]; then - print_warning "package.json version ($package_version) does not match expected version ($expected_version)" - print_info "Forcing checkout to ensure all files are updated..." - sudo -u "$PULSE_USER" git checkout -f "$TARGET_TAG" -- . + print_info "Checking out target version tag '$TARGET_TAG'..." + if ! sudo -u "$PULSE_USER" git checkout -f "$TARGET_TAG"; then + print_error "Failed to checkout tag '$TARGET_TAG'." + [ -n "$script_backup_path" ] && rm -f "$script_backup_path" + cd .. + return 1 + fi + + print_info "Verifying package.json was updated..." + local package_version=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") + local expected_version=$(echo "$TARGET_TAG" | sed 's/^v//') + if [ "$package_version" != "$expected_version" ] && [ "$package_version" != "unknown" ]; then + print_warning "package.json version ($package_version) does not match expected version ($expected_version)" + print_info "Forcing checkout to ensure all files are updated..." + sudo -u "$PULSE_USER" git checkout -f "$TARGET_TAG" -- . + fi + else + # Branch checkout + if ! sudo -u "$PULSE_USER" git fetch origin "$TARGET_BRANCH"; then + print_error "Failed to fetch branch '$TARGET_BRANCH' from git." + [ -n "$script_backup_path" ] && rm -f "$script_backup_path" + cd .. + return 1 + fi + + print_info "Checking out branch '$TARGET_BRANCH'..." + if ! sudo -u "$PULSE_USER" git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"; then + print_error "Failed to checkout branch '$TARGET_BRANCH'." + [ -n "$script_backup_path" ] && rm -f "$script_backup_path" + cd .. + return 1 + fi + + print_info "Pulling latest changes from branch..." + if ! sudo -u "$PULSE_USER" git pull origin "$TARGET_BRANCH"; then + print_warning "Failed to pull latest changes, continuing with current state" + fi fi local current_tag @@ -697,27 +775,39 @@ perform_update() { return 1 fi - print_info "Performing post-update version verification..." - local installed_version=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") - local expected_version=$(echo "$TARGET_TAG" | sed 's/^v//') - - if [ "$installed_version" = "$expected_version" ]; then - print_success "Version verification passed: $installed_version" - elif [ "$installed_version" = "unknown" ]; then - print_warning "Could not verify installed version from package.json" + if [ -n "$TARGET_TAG" ]; then + print_info "Performing post-update version verification..." + local installed_version=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") + local expected_version=$(echo "$TARGET_TAG" | sed 's/^v//') + + if [ "$installed_version" = "$expected_version" ]; then + print_success "Version verification passed: $installed_version" + elif [ "$installed_version" = "unknown" ]; then + print_warning "Could not verify installed version from package.json" + else + print_warning "Version mismatch detected!" + print_warning "Expected: $expected_version, Found: $installed_version" + print_warning "The application may not report the correct version." + fi + + if [ -n "$current_tag" ]; then + print_success "Pulse updated successfully to version $current_tag!" + else + print_success "Pulse updated successfully! [Could not confirm exact tag]" + fi + + print_info "The application should now report version: $expected_version" else - print_warning "Version mismatch detected!" - print_warning "Expected: $expected_version, Found: $installed_version" - print_warning "The application may not report the correct version." + # Branch update + local current_branch=$(sudo -u "$PULSE_USER" git rev-parse --abbrev-ref HEAD 2>/dev/null) + local current_commit=$(sudo -u "$PULSE_USER" git rev-parse --short HEAD 2>/dev/null) + + print_success "Pulse updated successfully to branch: $current_branch!" + if [ -n "$current_commit" ]; then + print_info "Current commit: $current_commit" + fi + print_warning "⚠️ Running from branch - version reporting may show development version" fi - - if [ -n "$current_tag" ]; then - print_success "Pulse updated successfully to version $current_tag!" - else - print_success "Pulse updated successfully! [Could not confirm exact tag]" - fi - - print_info "The application should now report version: $expected_version" return 0 } @@ -1197,9 +1287,16 @@ final_instructions() { fi local final_tag="" + local final_branch="" + local final_commit="" + if [ -d "$PULSE_DIR/.git" ]; then - cd "$PULSE_DIR" || print_warning "Could not cd to $PULSE_DIR to get final tag." + cd "$PULSE_DIR" || print_warning "Could not cd to $PULSE_DIR to get version info." final_tag=$(get_current_local_tag) + if [ -z "$final_tag" ]; then + final_branch=$(sudo -u "$PULSE_USER" git rev-parse --abbrev-ref HEAD 2>/dev/null) + final_commit=$(sudo -u "$PULSE_USER" git rev-parse --short HEAD 2>/dev/null) + fi cd .. fi @@ -1207,6 +1304,12 @@ final_instructions() { print_success "Pulse for Proxmox VE installation/update complete!" if [ -n "$final_tag" ]; then print_success "Current version installed: $final_tag" + elif [ -n "$final_branch" ]; then + print_success "Running from branch: $final_branch" + if [ -n "$final_commit" ]; then + print_info "Current commit: $final_commit" + fi + print_warning "⚠️ This is a test/development branch - not for production use!" fi echo "-------------------------------------------------------------" print_info "You should be able to access the Pulse dashboard at:" @@ -1285,21 +1388,36 @@ case "$INSTALL_MODE" in exit 1 fi - if [ -z "$TARGET_TAG" ]; then + if [ -n "$TARGET_BRANCH" ]; then + print_info "Checking out branch '$TARGET_BRANCH'..." + if ! sudo -u "$PULSE_USER" git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"; then + print_error "Failed to checkout branch '$TARGET_BRANCH' after cloning." + cd ..; exit 1 + fi + print_success "Checked out branch $TARGET_BRANCH." + print_warning "⚠️ Branch installation is for testing only and may be unstable!" + elif [ -z "$TARGET_TAG" ]; then TARGET_TAG=$(get_latest_remote_tag) if [ $? -ne 0 ] || [ -z "$TARGET_TAG" ]; then print_error "Could not determine latest release tag to install." cd ..; exit 1 fi print_info "Determined latest version tag: $TARGET_TAG" + + print_info "Checking out target version tag '$TARGET_TAG'..." + if ! sudo -u "$PULSE_USER" git checkout "$TARGET_TAG"; then + print_error "Failed to checkout tag '$TARGET_TAG' after cloning." + cd ..; exit 1 + fi + print_success "Checked out version $TARGET_TAG." + else + print_info "Checking out target version tag '$TARGET_TAG'..." + if ! sudo -u "$PULSE_USER" git checkout "$TARGET_TAG"; then + print_error "Failed to checkout tag '$TARGET_TAG' after cloning." + cd ..; exit 1 + fi + print_success "Checked out version $TARGET_TAG." fi - - print_info "Checking out target version tag '$TARGET_TAG'..." - if ! sudo -u "$PULSE_USER" git checkout "$TARGET_TAG"; then - print_error "Failed to checkout tag '$TARGET_TAG' after cloning." - cd ..; exit 1 - fi - print_success "Checked out version $TARGET_TAG." cd .. print_info "Installing NPM dependencies in $PULSE_DIR..."