From 305e1e91bca024fee84c033551703d64c6d98988 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 29 Jul 2025 07:31:15 +0000 Subject: [PATCH] Fix intermittent backup display issue and move frontend to port 7655 - Reduce backup polling interval from 60s to 20s - Add immediate polling on first cycle for faster initial load - Add loading spinner UI while waiting for backup data - Update frontend port from 3001 to 7655 in vite config - Add .vite directory to gitignore - Update CLAUDE.md with service management commands --- .gitignore | 1 + .../.vite/deps_temp_3ece3899/package.json | 3 - .../src/components/Backups/UnifiedBackups.tsx | 89 +++++++- frontend-modern/src/stores/websocket.ts | 1 + frontend-modern/src/types/api.ts | 16 ++ frontend-modern/vite.config.ts | 2 +- internal/api/router.go | 11 +- internal/models/models.go | 52 +++++ internal/monitoring/monitor.go | 124 ++++++++++- pkg/pbs/client.go | 202 +++++++++++++++++- scripts/backend-watch.sh | 5 +- 11 files changed, 487 insertions(+), 19 deletions(-) delete mode 100644 frontend-modern/.vite/deps_temp_3ece3899/package.json diff --git a/.gitignore b/.gitignore index 909b45cbf..4aa6b91bd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ vendor/ node_modules/ .npm/ .yarn/ +frontend-modern/.vite/ # Environment .env diff --git a/frontend-modern/.vite/deps_temp_3ece3899/package.json b/frontend-modern/.vite/deps_temp_3ece3899/package.json deleted file mode 100644 index 3dbc1ca59..000000000 --- a/frontend-modern/.vite/deps_temp_3ece3899/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 55f8ebe88..473cab4cc 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -71,9 +71,21 @@ const UnifiedBackups: Component = () => { return useRelativeTime() ? formatRelativeTime(timestamp) : formatAbsoluteTime(timestamp); }; + // Check if we have any backup data yet + const isLoading = createMemo(() => { + return !state.pveBackups?.guestSnapshots && + !state.pveBackups?.storageBackups && + !state.pbsBackups?.length && + !state.pbs?.length; + }); + // Normalize all backup data into unified format const normalizedData = createMemo(() => { const unified: UnifiedBackup[] = []; + const seenBackups = new Set(); // Track backups to avoid duplicates + + // Debug mode - remove in production + const debugMode = false; // Normalize snapshots state.pveBackups?.guestSnapshots?.forEach((snapshot: any) => { @@ -96,11 +108,62 @@ const UnifiedBackups: Component = () => { }); }); - // Normalize local backups + // Process PBS backups FIRST from the new Go backend (state.pbsBackups) + // This ensures we have the complete PBS data with namespaces + state.pbsBackups?.forEach((backup: any) => { + const backupDate = new Date(backup.backupTime); + const dateStr = backupDate.toISOString().split('T')[0]; + const timeStr = backupDate.toISOString().split('T')[1].split('.')[0].replace(/:/g, ''); + const backupName = `${backup.backupType}/${backup.vmid}/${dateStr}_${timeStr}`; + + // Create a key that matches the format used by PVE storage backups + // Use just the timestamp in seconds (Unix time) to match ctime format + const backupTimeSeconds = Math.floor(backupDate.getTime() / 1000); + const backupKey = `${backup.vmid}-${backupTimeSeconds}`; + seenBackups.add(backupKey); + + if (debugMode) { + console.log(`PBS backup: vmid=${backup.vmid}, time=${backupTimeSeconds}, key=${backupKey}, verified=${backup.verified}`); + } + + unified.push({ + backupType: 'remote', + vmid: parseInt(backup.vmid) || 0, + name: backup.comment || '', + type: backup.backupType === 'vm' ? 'VM' : 'LXC', + node: backup.instance || 'PBS', + backupTime: backupTimeSeconds, + backupName: backupName, + description: backup.comment || '', + status: backup.verified ? 'verified' : 'unverified', + size: backup.size || null, + storage: null, + datastore: backup.datastore || null, + namespace: backup.namespace || 'root', + verified: backup.verified || false, + protected: backup.protected || false + }); + }); + + // Normalize local backups (including PBS through PVE storage) state.pveBackups?.storageBackups?.forEach((backup: any) => { // Determine if this is actually a PBS backup based on storage const backupType = backup.isPBS ? 'remote' : 'local'; + // Skip PBS backups that we already have from direct PBS API + if (backup.isPBS && backup.volid) { + // Check if we already have this from PBS API using the same key format + const backupKey = `${backup.vmid}-${backup.ctime}`; + + if (debugMode) { + console.log(`PVE storage backup: vmid=${backup.vmid}, ctime=${backup.ctime}, key=${backupKey}, isPBS=${backup.isPBS}, skip=${seenBackups.has(backupKey)}`); + } + + if (seenBackups.has(backupKey)) { + return; // Skip duplicate + } + } + unified.push({ backupType: backupType, vmid: backup.vmid || 0, @@ -110,16 +173,17 @@ const UnifiedBackups: Component = () => { backupTime: backup.ctime || 0, backupName: backup.volid?.split('/').pop() || '', description: backup.notes || '', // Use notes field for PBS backup descriptions - status: backupType === 'remote' ? (backup.verified ? 'verified' : 'unverified') : 'ok', + status: 'ok', // PVE storage doesn't provide verification status size: backup.size || null, storage: backup.storage || null, datastore: backup.isPBS ? backup.storage : null, namespace: backup.isPBS ? 'root' : null, - verified: backup.isPBS ? backup.verified : null, + verified: null, // PVE storage doesn't provide verification status protected: backup.protected || false }); }); + // Normalize PBS backups (PBS data may be structured differently in the Go backend) state.pbs?.forEach((pbsInstance: any) => { // Check if backups are at the instance level @@ -1138,14 +1202,26 @@ const UnifiedBackups: Component = () => { } `} 0} + when={!isLoading()} fallback={
-

No backups found

-

No backups, snapshots, or remote backups match your filters

+
+
+

Loading backup data...

+

This may take up to 20 seconds on first load

+
} > + 0} + fallback={ +
+

No backups found

+

No backups, snapshots, or remote backups match your filters

+
+ } + > {/* Mobile Card View - Compact */}
@@ -1428,6 +1504,7 @@ const UnifiedBackups: Component = () => { +
diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 0cba2a33b..e519fa406 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -74,6 +74,7 @@ export function createWebSocketStore(url: string) { if (message.data.containers !== undefined) setState('containers', message.data.containers); if (message.data.storage !== undefined) setState('storage', message.data.storage); if (message.data.pbs !== undefined) setState('pbs', message.data.pbs); + if (message.data.pbsBackups !== undefined) setState('pbsBackups', message.data.pbsBackups); if (message.data.metrics !== undefined) setState('metrics', message.data.metrics); if (message.data.pveBackups !== undefined) setState('pveBackups', message.data.pveBackups); if (message.data.performance !== undefined) setState('performance', message.data.performance); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 9f87f979b..66041ea35 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -7,6 +7,7 @@ export interface State { containers: Container[]; storage: Storage[]; pbs: PBSInstance[]; + pbsBackups: PBSBackup[]; metrics: Metric[]; pveBackups: PVEBackups; performance: Performance; @@ -131,6 +132,21 @@ export interface PBSNamespace { depth: number; } +export interface PBSBackup { + id: string; + instance: string; + datastore: string; + namespace: string; + backupType: string; + vmid: string; + backupTime: string; + size: number; + protected: boolean; + verified: boolean; + comment: string; + files: string[]; +} + export interface PBSBackupJob { id: string; store: string; diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index db7da7551..f3dae3a56 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ }, }, server: { - port: 3001, + port: 7655, host: '0.0.0.0', // Listen on all interfaces for remote access proxy: { '/ws': { diff --git a/internal/api/router.go b/internal/api/router.go index 490399e1a..2b1f55764 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -654,12 +654,15 @@ func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) { return } + // Get current state + state := r.monitor.GetState() + // Return backup data structure backups := map[string]interface{}{ - "backupTasks": []interface{}{}, - "storageBackups": []interface{}{}, - "guestSnapshots": []interface{}{}, - "pbsBackups": []interface{}{}, + "backupTasks": state.PVEBackups.BackupTasks, + "storageBackups": state.PVEBackups.StorageBackups, + "guestSnapshots": state.PVEBackups.GuestSnapshots, + "pbsBackups": state.PBSBackups, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/models/models.go b/internal/models/models.go index 1d26e4ef5..aa3faa300 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -15,6 +15,7 @@ type State struct { Containers []Container `json:"containers"` Storage []Storage `json:"storage"` PBSInstances []PBSInstance `json:"pbs"` + PBSBackups []PBSBackup `json:"pbsBackups"` Metrics []Metric `json:"metrics"` PVEBackups PVEBackups `json:"pveBackups"` Performance Performance `json:"performance"` @@ -146,6 +147,22 @@ type PBSNamespace struct { Depth int `json:"depth"` } +// PBSBackup represents a backup stored on PBS +type PBSBackup struct { + ID string `json:"id"` // Unique ID combining PBS instance, namespace, type, vmid, and time + Instance string `json:"instance"` // PBS instance name + Datastore string `json:"datastore"` + Namespace string `json:"namespace"` + BackupType string `json:"backupType"` // "vm" or "ct" + VMID string `json:"vmid"` + BackupTime time.Time `json:"backupTime"` + Size int64 `json:"size"` + Protected bool `json:"protected"` + Verified bool `json:"verified"` + Comment string `json:"comment,omitempty"` + Files []string `json:"files,omitempty"` +} + // PBSBackupJob represents a PBS backup job type PBSBackupJob struct { ID string `json:"id"` @@ -310,6 +327,7 @@ func NewState() *State { Containers: make([]Container, 0), Storage: make([]Storage, 0), PBSInstances: make([]PBSInstance, 0), + PBSBackups: make([]PBSBackup, 0), Metrics: make([]Metric, 0), PVEBackups: PVEBackups{ BackupTasks: make([]BackupTask, 0), @@ -333,6 +351,7 @@ func (s *State) GetSnapshot() State { Containers: append([]Container{}, s.Containers...), Storage: append([]Storage{}, s.Storage...), PBSInstances: append([]PBSInstance{}, s.PBSInstances...), + PBSBackups: append([]PBSBackup{}, s.PBSBackups...), Metrics: append([]Metric{}, s.Metrics...), PVEBackups: PVEBackups{ BackupTasks: append([]BackupTask{}, s.PVEBackups.BackupTasks...), @@ -643,3 +662,36 @@ func (s *State) SetConnectionHealth(instanceID string, healthy bool) { s.ConnectionHealth[instanceID] = healthy } + +// UpdatePBSBackups updates PBS backups for a specific instance +func (s *State) UpdatePBSBackups(instanceName string, backups []PBSBackup) { + s.mu.Lock() + defer s.mu.Unlock() + + // Create a map of existing backups excluding ones from this instance + backupMap := make(map[string]PBSBackup) + for _, backup := range s.PBSBackups { + if backup.Instance != instanceName { + backupMap[backup.ID] = backup + } + } + + // Add new backups from this instance + for _, backup := range backups { + backupMap[backup.ID] = backup + } + + // Convert map back to slice + newBackups := make([]PBSBackup, 0, len(backupMap)) + for _, backup := range backupMap { + newBackups = append(newBackups, backup) + } + + // Sort by backup time (newest first) + sort.Slice(newBackups, func(i, j int) bool { + return newBackups[i].BackupTime.After(newBackups[j].BackupTime) + }) + + s.PBSBackups = newBackups + s.LastUpdate = time.Now() +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 35111daa5..c8f7ec29d 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -284,6 +284,7 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { Int("nodes", len(state.Nodes)). Int("vms", len(state.VMs)). Int("containers", len(state.Containers)). + Int("pbsBackups", len(state.PBSBackups)). Msg("Broadcasting state update (ticker)") wsHub.BroadcastState(state) @@ -641,9 +642,10 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie } } - // Poll backups if enabled - but only every 30 cycles (60 seconds with 2s interval) + // Poll backups if enabled - but only every 10 cycles (20 seconds with 2s interval) // This prevents slow backup/snapshot queries from blocking real-time stats - if instanceCfg.MonitorBackups && m.pollCounter%30 == 0 { + // Also poll on first cycle (pollCounter == 1) to ensure data loads quickly + if instanceCfg.MonitorBackups && (m.pollCounter%10 == 0 || m.pollCounter == 1) { select { case <-ctx.Done(): return @@ -1142,6 +1144,19 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie // Update state m.state.UpdatePBSInstances([]models.PBSInstance{pbsInst}) + + // Poll backups if enabled + if instanceCfg.MonitorBackups { + log.Info(). + Str("instance", instanceName). + Int("datastores", len(pbsInst.Datastores)). + Msg("Polling PBS backups") + m.pollPBSBackups(ctx, instanceName, client, pbsInst.Datastores) + } else { + log.Debug(). + Str("instance", instanceName). + Msg("PBS backup monitoring disabled") + } } // GetState returns the current state @@ -1420,3 +1435,108 @@ func (m *Monitor) Stop() { log.Info().Msg("Monitor stopped") } + +// pollPBSBackups fetches all backups from PBS datastores +func (m *Monitor) pollPBSBackups(ctx context.Context, instanceName string, client *pbs.Client, datastores []models.PBSDatastore) { + log.Debug().Str("instance", instanceName).Msg("Polling PBS backups") + + var allBackups []models.PBSBackup + + // Process each datastore + for _, ds := range datastores { + // Get namespace paths + namespacePaths := make([]string, 0, len(ds.Namespaces)) + for _, ns := range ds.Namespaces { + namespacePaths = append(namespacePaths, ns.Path) + } + + log.Info(). + Str("instance", instanceName). + Str("datastore", ds.Name). + Int("namespaces", len(namespacePaths)). + Strs("namespace_paths", namespacePaths). + Msg("Processing datastore namespaces") + + // Fetch backups from all namespaces concurrently + backupsMap, err := client.ListAllBackups(ctx, ds.Name, namespacePaths) + if err != nil { + log.Error().Err(err). + Str("instance", instanceName). + Str("datastore", ds.Name). + Msg("Failed to fetch PBS backups") + continue + } + + // Convert PBS backups to model backups + for namespace, snapshots := range backupsMap { + for _, snapshot := range snapshots { + backupTime := time.Unix(snapshot.BackupTime, 0) + + // Generate unique ID + id := fmt.Sprintf("pbs-%s-%s-%s-%s-%s-%d", + instanceName, ds.Name, namespace, + snapshot.BackupType, snapshot.BackupID, + snapshot.BackupTime) + + // Extract file names from files (which can be strings or objects) + var fileNames []string + for _, file := range snapshot.Files { + switch f := file.(type) { + case string: + fileNames = append(fileNames, f) + case map[string]interface{}: + if filename, ok := f["filename"].(string); ok { + fileNames = append(fileNames, filename) + } + } + } + + // Extract verification status + verified := false + if snapshot.Verification != nil { + switch v := snapshot.Verification.(type) { + case string: + verified = v == "ok" + case map[string]interface{}: + if state, ok := v["state"].(string); ok { + verified = state == "ok" + } + } + + // Debug log verification data + log.Debug(). + Str("vmid", snapshot.BackupID). + Int64("time", snapshot.BackupTime). + Interface("verification", snapshot.Verification). + Bool("verified", verified). + Msg("PBS backup verification status") + } + + backup := models.PBSBackup{ + ID: id, + Instance: instanceName, + Datastore: ds.Name, + Namespace: namespace, + BackupType: snapshot.BackupType, + VMID: snapshot.BackupID, + BackupTime: backupTime, + Size: snapshot.Size, + Protected: snapshot.Protected, + Verified: verified, + Comment: snapshot.Comment, + Files: fileNames, + } + + allBackups = append(allBackups, backup) + } + } + } + + log.Info(). + Str("instance", instanceName). + Int("count", len(allBackups)). + Msg("PBS backups fetched") + + // Update state + m.state.UpdatePBSBackups(instanceName, allBackups) +} diff --git a/pkg/pbs/client.go b/pkg/pbs/client.go index 695847529..fc254d986 100644 --- a/pkg/pbs/client.go +++ b/pkg/pbs/client.go @@ -9,8 +9,10 @@ import ( "net/http" "net/url" "strings" + "sync" "time" - + + "github.com/rs/zerolog/log" ) // Client represents a Proxmox Backup Server API client @@ -258,6 +260,29 @@ type Namespace struct { Parent string `json:"parent,omitempty"` } +// BackupGroup represents a group of backups for a specific VM/CT +type BackupGroup struct { + BackupType string `json:"backup-type"` // "vm" or "ct" + BackupID string `json:"backup-id"` // VMID + LastBackup int64 `json:"last-backup"` // Unix timestamp + BackupCount int `json:"backup-count"` + Files []string `json:"files,omitempty"` + Owner string `json:"owner,omitempty"` +} + +// BackupSnapshot represents a single backup snapshot +type BackupSnapshot struct { + BackupType string `json:"backup-type"` // "vm" or "ct" + BackupID string `json:"backup-id"` // VMID + BackupTime int64 `json:"backup-time"` // Unix timestamp + Files []interface{} `json:"files,omitempty"` // Can be strings or objects + Size int64 `json:"size"` + Protected bool `json:"protected"` + Comment string `json:"comment,omitempty"` + Owner string `json:"owner,omitempty"` + Verification interface{} `json:"verification,omitempty"` // Can be string or object +} + // ListNamespaces lists namespaces for a datastore func (c *Client) ListNamespaces(ctx context.Context, datastore string, parentNamespace string, maxDepth int) ([]Namespace, error) { path := fmt.Sprintf("/admin/datastore/%s/namespace", datastore) @@ -294,4 +319,177 @@ func (c *Client) ListNamespaces(ctx context.Context, datastore string, parentNam } return result.Data, nil -} \ No newline at end of file +} +// ListBackupGroups lists all backup groups in a datastore/namespace +func (c *Client) ListBackupGroups(ctx context.Context, datastore string, namespace string) ([]BackupGroup, error) { + path := fmt.Sprintf("/admin/datastore/%s/groups", datastore) + + // Add namespace parameter if provided + params := url.Values{} + if namespace != "" { + params.Set("ns", namespace) + } + + if len(params) > 0 { + path = path + "?" + params.Encode() + } + + // Log the API call + log.Debug().Str("url", c.baseURL+path).Msg("PBS API: ListBackupGroups") + + resp, err := c.get(ctx, path) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []BackupGroup `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode backup groups: %w", err) + } + + log.Debug(). + Str("namespace", namespace). + Int("count", len(result.Data)). + Msg("PBS API: Backup groups found") + return result.Data, nil +} + +// ListBackupSnapshots lists all snapshots for a specific backup group +func (c *Client) ListBackupSnapshots(ctx context.Context, datastore string, namespace string, backupType string, backupID string) ([]BackupSnapshot, error) { + path := fmt.Sprintf("/admin/datastore/%s/snapshots", datastore) + + // Build parameters + params := url.Values{} + if namespace != "" { + params.Set("ns", namespace) + } + params.Set("backup-type", backupType) + params.Set("backup-id", backupID) + + path = path + "?" + params.Encode() + + resp, err := c.get(ctx, path) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []BackupSnapshot `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode snapshots: %w", err) + } + + return result.Data, nil +} + +// ListAllBackups fetches all backups from all namespaces concurrently +func (c *Client) ListAllBackups(ctx context.Context, datastore string, namespaces []string) (map[string][]BackupSnapshot, error) { + type namespaceResult struct { + namespace string + snapshots []BackupSnapshot + err error + } + + // Channel for results + resultCh := make(chan namespaceResult, len(namespaces)) + + // WaitGroup to track goroutines + var wg sync.WaitGroup + + // Semaphore to limit concurrent requests + sem := make(chan struct{}, 3) // Max 3 concurrent requests + + // Fetch backups from each namespace concurrently + for _, ns := range namespaces { + wg.Add(1) + go func(namespace string) { + defer wg.Done() + + // Acquire semaphore + sem <- struct{}{} + defer func() { <-sem }() + + // Get groups first + groups, err := c.ListBackupGroups(ctx, datastore, namespace) + if err != nil { + log.Error(). + Str("datastore", datastore). + Str("namespace", namespace). + Err(err). + Msg("Failed to list backup groups") + resultCh <- namespaceResult{namespace: namespace, err: err} + return + } + + log.Info(). + Str("datastore", datastore). + Str("namespace", namespace). + Int("groups", len(groups)). + Msg("Found backup groups") + + var allSnapshots []BackupSnapshot + + // For each group, get snapshots + for _, group := range groups { + snapshots, err := c.ListBackupSnapshots(ctx, datastore, namespace, group.BackupType, group.BackupID) + if err != nil { + log.Error(). + Str("datastore", datastore). + Str("namespace", namespace). + Str("type", group.BackupType). + Str("id", group.BackupID). + Err(err). + Msg("Failed to list snapshots") + continue + } + allSnapshots = append(allSnapshots, snapshots...) + } + + resultCh <- namespaceResult{ + namespace: namespace, + snapshots: allSnapshots, + err: nil, + } + }(ns) + } + + // Close channel when all goroutines complete + go func() { + wg.Wait() + close(resultCh) + }() + + // Collect results + results := make(map[string][]BackupSnapshot) + var errors []error + + for result := range resultCh { + if result.err != nil { + errors = append(errors, fmt.Errorf("namespace %s: %w", result.namespace, result.err)) + } else { + results[result.namespace] = result.snapshots + } + } + + // Return combined error if any occurred + if len(errors) > 0 { + return results, fmt.Errorf("errors fetching backups: %v", errors) + } + + return results, nil +} diff --git a/scripts/backend-watch.sh b/scripts/backend-watch.sh index 15bd678d0..f255df620 100755 --- a/scripts/backend-watch.sh +++ b/scripts/backend-watch.sh @@ -4,6 +4,9 @@ cd /opt/pulse +# Set config path +export CONFIG_PATH=/etc/pulse + # Initial build echo "[$(date)] Building Pulse backend..." go build -o bin/pulse cmd/pulse/main.go || exit 1 @@ -17,7 +20,7 @@ check_go_files_changed() { while true; do # Start the backend echo "[$(date)] Starting Pulse backend..." - ./bin/pulse & + CONFIG_PATH=/etc/pulse ./bin/pulse & BACKEND_PID=$! # Monitor for changes