package proxmox import ( "context" "encoding/json" "fmt" "math" "strconv" "strings" "time" ) // ReplicationJob represents the parsed status of a Proxmox storage replication job. type ReplicationJob struct { ID string `json:"id"` Guest string `json:"guest,omitempty"` GuestID int `json:"guestId,omitempty"` JobNumber int `json:"jobNumber,omitempty"` Source string `json:"source,omitempty"` SourceStorage string `json:"sourceStorage,omitempty"` Target string `json:"target,omitempty"` TargetStorage string `json:"targetStorage,omitempty"` Schedule string `json:"schedule,omitempty"` Type string `json:"type,omitempty"` Enabled bool `json:"enabled"` State string `json:"state,omitempty"` Status string `json:"status,omitempty"` LastSyncStatus string `json:"lastSyncStatus,omitempty"` LastSyncTime *time.Time `json:"lastSyncTime,omitempty"` LastSyncUnix int64 `json:"lastSyncUnix,omitempty"` LastSyncDurationSeconds int `json:"lastSyncDurationSeconds,omitempty"` LastSyncDurationHuman string `json:"lastSyncDurationHuman,omitempty"` NextSyncTime *time.Time `json:"nextSyncTime,omitempty"` NextSyncUnix int64 `json:"nextSyncUnix,omitempty"` DurationSeconds int `json:"durationSeconds,omitempty"` DurationHuman string `json:"durationHuman,omitempty"` FailCount int `json:"failCount,omitempty"` Error string `json:"error,omitempty"` Comment string `json:"comment,omitempty"` RemoveJob string `json:"removeJob,omitempty"` RateLimitMbps *float64 `json:"rateLimitMbps,omitempty"` } // GetReplicationStatus returns the replication jobs configured on a PVE instance. func (c *Client) GetReplicationStatus(ctx context.Context) ([]ReplicationJob, error) { resp, err := c.get(ctx, "/cluster/replication") if err != nil { return nil, err } defer resp.Body.Close() var raw struct { Data []map[string]json.RawMessage `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, err } jobs := make([]ReplicationJob, 0, len(raw.Data)) for _, entry := range raw.Data { jobs = append(jobs, parseReplicationJob(entry)) } return jobs, nil } func parseReplicationJob(entry map[string]json.RawMessage) ReplicationJob { job := ReplicationJob{ Enabled: true, } job.ID = stringFromAny(decodeRaw(entry["id"])) if job.ID == "" { job.ID = stringFromAny(decodeRaw(entry["jobid"])) } job.Guest = stringFromAny(decodeRaw(entry["guest"])) if guestID, ok := intFromAny(decodeRaw(entry["guest"])); ok { job.GuestID = guestID } if jobNum, ok := intFromAny(decodeRaw(entry["jobnum"])); ok { job.JobNumber = jobNum } else if parts := strings.Split(job.ID, "-"); len(parts) == 2 { if num, err := strconv.Atoi(parts[1]); err == nil { job.JobNumber = num } } job.Source = stringFromAny(decodeRaw(entry["source"])) job.SourceStorage = stringFromAny(firstNonNilRaw(entry, "source-storage", "source_storage")) job.Target = stringFromAny(decodeRaw(entry["target"])) job.TargetStorage = stringFromAny(firstNonNilRaw(entry, "target-storage", "target_storage")) job.Schedule = stringFromAny(decodeRaw(entry["schedule"])) job.Type = stringFromAny(decodeRaw(entry["type"])) job.Comment = stringFromAny(decodeRaw(entry["comment"])) job.RemoveJob = stringFromAny(firstNonNilRaw(entry, "remove_job", "remove-job")) if enabled, ok := boolFromAny(decodeRaw(entry["enabled"])); ok { job.Enabled = enabled } if disabled, ok := boolFromAny(decodeRaw(entry["disable"])); ok && disabled { job.Enabled = false } if active, ok := boolFromAny(decodeRaw(entry["active"])); ok && !active { job.Enabled = false } job.State = stringFromAny(decodeRaw(entry["state"])) job.Status = stringFromAny(decodeRaw(entry["status"])) if job.Status == "" && job.State != "" { job.Status = job.State } job.LastSyncStatus = stringFromAny(firstNonNilRaw(entry, "last_sync_status", "last-sync-status", "last_sync_state", "last-sync-state")) job.Error = stringFromAny(decodeRaw(entry["error"])) if job.Error == "" { job.Error = stringFromAny(decodeRaw(entry["last_sync_error"])) } job.FailCount, _ = intFromAny(firstNonNilRaw(entry, "fail_count", "fail-count")) if seconds, human := parseDurationSeconds(decodeRaw(entry["last_sync_duration"])); seconds > 0 { job.LastSyncDurationSeconds = seconds job.LastSyncDurationHuman = human } else if seconds, human := parseDurationSeconds(firstNonNilRaw(entry, "last-sync-duration", "last_sync_duration_sec")); seconds > 0 { job.LastSyncDurationSeconds = seconds job.LastSyncDurationHuman = human } if seconds, human := parseDurationSeconds(decodeRaw(entry["duration"])); seconds > 0 { job.DurationSeconds = seconds job.DurationHuman = human } if t, unix := parseReplicationTime(decodeRaw(entry["last_sync"])); t != nil { job.LastSyncTime = t job.LastSyncUnix = unix } else if t, unix := parseReplicationTime(firstNonNilRaw(entry, "last-sync", "last_sync_time")); t != nil { job.LastSyncTime = t job.LastSyncUnix = unix } if t, unix := parseReplicationTime(decodeRaw(entry["next_sync"])); t != nil { job.NextSyncTime = t job.NextSyncUnix = unix } else if t, unix := parseReplicationTime(firstNonNilRaw(entry, "next-sync", "next_sync_time")); t != nil { job.NextSyncTime = t job.NextSyncUnix = unix } if rate, ok := floatFromAny(decodeRaw(entry["rate"])); ok { job.RateLimitMbps = copyFloat(rate) } return job } func decodeRaw(raw json.RawMessage) interface{} { if raw == nil { return nil } var value interface{} if err := json.Unmarshal(raw, &value); err != nil { return nil } return value } func firstNonNilRaw(entry map[string]json.RawMessage, keys ...string) interface{} { for _, key := range keys { if value, ok := entry[key]; ok && value != nil { return decodeRaw(value) } } return nil } func stringFromAny(value interface{}) string { switch v := value.(type) { case nil: return "" case string: return strings.TrimSpace(v) case json.Number: return strings.TrimSpace(v.String()) case float64: if math.IsNaN(v) || math.IsInf(v, 0) { return "" } return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64)) case float32: return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32)) case int: return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) case int32: return strconv.FormatInt(int64(v), 10) case uint: return strconv.FormatUint(uint64(v), 10) case uint64: return strconv.FormatUint(v, 10) case uint32: return strconv.FormatUint(uint64(v), 10) case bool: if v { return "true" } return "false" default: return strings.TrimSpace(fmt.Sprint(v)) } } func intFromAny(value interface{}) (int, bool) { switch v := value.(type) { case nil: return 0, false case int: return v, true case int8: return int(v), true case int16: return int(v), true case int32: return int(v), true case int64: return int(v), true case uint: return int(v), true case uint8: return int(v), true case uint16: return int(v), true case uint32: return int(v), true case uint64: return int(v), true case float32: if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) { return 0, false } return int(math.Round(float64(v))), true case float64: if math.IsNaN(v) || math.IsInf(v, 0) { return 0, false } return int(math.Round(v)), true case json.Number: if i, err := v.Int64(); err == nil { return int(i), true } if f, err := v.Float64(); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { return int(math.Round(f)), true } case string: s := strings.TrimSpace(v) if s == "" { return 0, false } if i, err := strconv.ParseInt(s, 10, 64); err == nil { return int(i), true } if f, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { return int(math.Round(f)), true } } return 0, false } func boolFromAny(value interface{}) (bool, bool) { switch v := value.(type) { case nil: return false, false case bool: return v, true case int, int8, int16, int32, int64: i, ok := intFromAny(v) return i != 0, ok case uint, uint8, uint16, uint32, uint64: i, ok := intFromAny(v) return i != 0, ok case float32, float64: i, ok := intFromAny(v) return i != 0, ok case json.Number: i, ok := intFromAny(v) return i != 0, ok case string: s := strings.TrimSpace(strings.ToLower(v)) switch s { case "true", "yes", "1", "on", "enabled": return true, true case "false", "no", "0", "off", "disabled": return false, true } } return false, false } func floatFromAny(value interface{}) (float64, bool) { switch v := value.(type) { case nil: return 0, false case float64: if math.IsNaN(v) || math.IsInf(v, 0) { return 0, false } return v, true case float32: if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) { return 0, false } return float64(v), true case int, int8, int16, int32, int64: i, ok := intFromAny(v) return float64(i), ok case uint, uint8, uint16, uint32, uint64: i, ok := intFromAny(v) return float64(i), ok case json.Number: if f, err := v.Float64(); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { return f, true } case string: s := strings.TrimSpace(v) if s == "" { return 0, false } if f, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { return f, true } } return 0, false } func parseReplicationTime(value interface{}) (*time.Time, int64) { switch v := value.(type) { case nil: return nil, 0 case time.Time: t := v.UTC() return &t, t.Unix() case *time.Time: if v == nil { return nil, 0 } t := v.UTC() return &t, t.Unix() case int, int32, int64, uint, uint32, uint64: i, ok := intFromAny(v) if !ok || i <= 0 { return nil, 0 } t := time.Unix(int64(i), 0).UTC() return &t, t.Unix() case float32, float64, json.Number: f, ok := floatFromAny(v) if !ok || f <= 0 { return nil, 0 } t := time.Unix(int64(math.Round(f)), 0).UTC() return &t, t.Unix() case string: s := strings.TrimSpace(v) if s == "" || strings.EqualFold(s, "n/a") || strings.EqualFold(s, "pending") || strings.EqualFold(s, "-") { return nil, 0 } if i, err := strconv.ParseInt(s, 10, 64); err == nil { if i <= 0 { return nil, 0 } t := time.Unix(i, 0).UTC() return &t, t.Unix() } layouts := []string{ time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04:05 -0700", "2006-01-02 15:04:05 MST", "2006-01-02T15:04:05", } for _, layout := range layouts { if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { t = t.UTC() return &t, t.Unix() } } } return nil, 0 } func parseDurationSeconds(value interface{}) (int, string) { if value == nil { return 0, "" } switch v := value.(type) { case int, int32, int64, uint, uint32, uint64: if secs, ok := intFromAny(v); ok && secs >= 0 { return secs, strconv.Itoa(secs) } case float32, float64, json.Number: if secs, ok := floatFromAny(v); ok && secs >= 0 { return int(math.Round(secs)), strconv.FormatFloat(secs, 'f', -1, 64) } case string: s := strings.TrimSpace(v) if s == "" { return 0, "" } if strings.Contains(s, ":") { if secs, ok := parseHHMMSSToSeconds(s); ok { return secs, s } } if secs, err := strconv.ParseFloat(s, 64); err == nil && secs >= 0 { return int(math.Round(secs)), s } } return 0, stringFromAny(value) } func parseHHMMSSToSeconds(value string) (int, bool) { parts := strings.Split(value, ":") if len(parts) < 2 || len(parts) > 3 { return 0, false } total := 0 multipliers := []int{3600, 60, 1} for i := 0; i < len(parts); i++ { idx := len(multipliers) - len(parts) + i part := strings.TrimSpace(parts[i]) if part == "" { return 0, false } val, err := strconv.Atoi(part) if err != nil { return 0, false } total += val * multipliers[idx] } return total, true } func copyFloat(value float64) *float64 { return &value }