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
This commit is contained in:
Pulse Monitor
2025-07-29 07:31:15 +00:00
parent 5665106a7d
commit 305e1e91bc
11 changed files with 487 additions and 19 deletions
+1
View File
@@ -28,6 +28,7 @@ vendor/
node_modules/
.npm/
.yarn/
frontend-modern/.vite/
# Environment
.env
@@ -1,3 +0,0 @@
{
"type": "module"
}
@@ -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<string>(); // 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 = () => {
}
`}</style>
<Show
when={groupedData().length > 0}
when={!isLoading()}
fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p class="text-lg">No backups found</p>
<p class="text-sm mt-2">No backups, snapshots, or remote backups match your filters</p>
<div class="flex flex-col items-center gap-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
<p class="text-lg">Loading backup data...</p>
<p class="text-sm">This may take up to 20 seconds on first load</p>
</div>
</div>
}
>
<Show
when={groupedData().length > 0}
fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p class="text-lg">No backups found</p>
<p class="text-sm mt-2">No backups, snapshots, or remote backups match your filters</p>
</div>
}
>
{/* Mobile Card View - Compact */}
<div class="block lg:hidden space-y-3">
<For each={groupedData()}>
@@ -1428,6 +1504,7 @@ const UnifiedBackups: Component = () => {
</For>
</tbody>
</table>
</Show>
</Show>
</div>
+1
View File
@@ -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);
+16
View File
@@ -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;
+1 -1
View File
@@ -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': {
+7 -4
View File
@@ -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")
+52
View File
@@ -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()
}
+122 -2
View File
@@ -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)
}
+200 -2
View File
@@ -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
}
}
// 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
}
+4 -1
View File
@@ -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