mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user