Add replication monitoring plumbing and UI

Refs #395
This commit is contained in:
rcourtman 2025-10-22 16:10:15 +00:00
parent 7ae393c8ec
commit 3a3e0e080c
9 changed files with 898 additions and 23 deletions

View file

@ -17,6 +17,7 @@ import { getGlobalWebSocketStore } from './stores/websocket-global';
import { Dashboard } from './components/Dashboard/Dashboard';
import StorageComponent from './components/Storage/Storage';
import Backups from './components/Backups/Backups';
import Replication from './components/Replication/Replication';
import Settings from './components/Settings/Settings';
import { Alerts } from './pages/Alerts';
import { DockerHosts } from './components/Docker/DockerHosts';
@ -96,12 +97,14 @@ function App() {
vms: [],
containers: [],
dockerHosts: [],
hosts: [],
storage: [],
cephClusters: [],
physicalDisks: [],
pbs: [],
pmg: [],
metrics: [],
pmg: [],
replicationJobs: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
@ -607,6 +610,7 @@ function App() {
component={() => <Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />}
/>
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
<Route path="/proxmox/backups" component={Backups} />
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />

View file

@ -3,7 +3,7 @@ import { createMemo, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App';
type ProxmoxSection = 'overview' | 'storage' | 'backups' | 'mail';
type ProxmoxSection = 'overview' | 'storage' | 'replication' | 'backups' | 'mail';
interface ProxmoxSectionNavProps {
current: ProxmoxSection;
@ -25,6 +25,11 @@ const allSections: Array<{
label: 'Storage',
path: '/proxmox/storage',
},
{
id: 'replication',
label: 'Replication',
path: '/proxmox/replication',
},
{
id: 'mail',
label: 'Mail Gateway',

View file

@ -0,0 +1,212 @@
import type { Component } from 'solid-js';
import { Show, For, createMemo } from 'solid-js';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import type { ReplicationJob } from '@/types/api';
function formatDuration(durationSeconds?: number, durationHuman?: string): string {
if (durationHuman && durationHuman.trim()) return durationHuman;
if (!durationSeconds || durationSeconds <= 0) return '';
const hours = Math.floor(durationSeconds / 3600)
.toString()
.padStart(2, '0');
const minutes = Math.floor((durationSeconds % 3600) / 60)
.toString()
.padStart(2, '0');
const seconds = Math.floor(durationSeconds % 60)
.toString()
.padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
function getStatusBadge(job: ReplicationJob) {
const status = (job.status || job.state || '').toLowerCase();
const lastStatus = (job.lastSyncStatus || '').toLowerCase();
if (status.includes('error') || lastStatus.includes('error')) {
return {
tone: 'danger' as const,
label: status || lastStatus || 'Error',
};
}
if (status.includes('sync')) {
return {
tone: 'warning' as const,
label: job.status || job.state || 'Syncing',
};
}
return {
tone: 'success' as const,
label: job.status || job.state || 'Idle',
};
}
function formatRate(limit?: number): string {
if (!limit || limit <= 0) return '—';
return `${limit.toFixed(0)} MB/s`;
}
const Replication: Component = () => {
const { state, connected } = useWebSocket();
const replicationJobs = createMemo(() => {
const jobs = state.replicationJobs ?? [];
return [...jobs].sort((a, b) => {
if (a.instance !== b.instance) return a.instance.localeCompare(b.instance);
if ((a.guestName || '') !== (b.guestName || '')) {
return (a.guestName || '').localeCompare(b.guestName || '');
}
return (a.jobId || '').localeCompare(b.jobId || '');
});
});
return (
<div class="space-y-3">
<ProxmoxSectionNav current="replication" />
<Show when={connected()} fallback={
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg class="h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
title="Connection lost"
description="Unable to connect to the backend server. Attempting to reconnect..."
tone="danger"
/>
</Card>
}>
<Show
when={replicationJobs().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 4h16M4 10h16M4 16h16" />
</svg>
}
title="No replication jobs detected"
description="Replication jobs will appear here once configured in Proxmox."
/>
</Card>
}
>
<Card padding="none">
<div class="overflow-x-auto">
<table class="min-w-[1000px] w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
<th class="px-4 py-3">Guest</th>
<th class="px-4 py-3">Job</th>
<th class="px-4 py-3">Source Target</th>
<th class="px-4 py-3">Last Sync</th>
<th class="px-4 py-3">Next Sync</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Failures</th>
<th class="px-4 py-3">Rate</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 text-sm text-gray-700 dark:text-gray-200">
<For each={replicationJobs()}>
{(job) => {
const badge = getStatusBadge(job);
return (
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{job.guestName || `VM ${job.guestId ?? ''}`}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.instance} · ID {job.guestId ?? '—'} · {job.guestNode || job.sourceNode || 'Unknown node'}
</div>
</td>
<td class="px-4 py-3">
<div class="font-medium">{job.jobId || '—'}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.type ? `${job.type.toUpperCase()} · ` : ''}{job.schedule || '*/15'}
</div>
</td>
<td class="px-4 py-3">
<div class="text-sm">
{job.sourceNode || '—'}
<span class="mx-1 text-gray-400"></span>
{job.targetNode || '—'}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.sourceStorage || 'local'} {job.targetStorage || 'remote'}
</div>
</td>
<td class="px-4 py-3">
<Show when={job.lastSyncTime}>
<div class="font-medium">{formatAbsoluteTime(job.lastSyncTime!)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatRelativeTime(job.lastSyncTime!)}
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
<span class="mx-1">·</span>
{formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)}
</Show>
</div>
</Show>
<Show when={!job.lastSyncTime}>
<span class="text-gray-400">Never</span>
</Show>
</td>
<td class="px-4 py-3">
<Show when={job.nextSyncTime}>
<div class="font-medium">{formatAbsoluteTime(job.nextSyncTime!)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatRelativeTime(job.nextSyncTime!)}
</div>
</Show>
<Show when={!job.nextSyncTime}>
<span class="text-gray-400"></span>
</Show>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold capitalize"
classList={{
'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-300': badge.tone === 'success',
'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200': badge.tone === 'warning',
'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300': badge.tone === 'danger',
}}
>
{badge.label}
</span>
<Show when={job.error}>
<div class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-2">
{job.error}
</div>
</Show>
</td>
<td class="px-4 py-3">
<span class="text-sm font-medium">{job.failCount ?? 0}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm">{formatRate(job.rateLimitMbps)}</span>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Card>
</Show>
</Show>
</div>
);
};
export default Replication;

View file

@ -26,6 +26,8 @@ export function createWebSocketStore(url: string) {
vms: [],
containers: [],
dockerHosts: [],
hosts: [],
replicationJobs: [],
storage: [],
cephClusters: [],
physicalDisks: [],
@ -350,10 +352,13 @@ export function createWebSocketStore(url: string) {
console.log('[WebSocket] Received null dockerHosts, ignoring');
}
if (message.data.storage !== undefined) setState('storage', message.data.storage);
if (message.data.hosts !== undefined) setState('hosts', message.data.hosts);
if (message.data.cephClusters !== undefined)
setState('cephClusters', message.data.cephClusters);
if (message.data.pbs !== undefined) setState('pbs', message.data.pbs);
if (message.data.pmg !== undefined) setState('pmg', message.data.pmg);
if (message.data.replicationJobs !== undefined)
setState('replicationJobs', message.data.replicationJobs);
if (message.data.backups !== undefined) {
setState('backups', message.data.backups);
if (message.data.backups.pve !== undefined)

View file

@ -173,6 +173,7 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
VMs: []models.VM{},
Containers: []models.Container{},
PhysicalDisks: []models.PhysicalDisk{},
ReplicationJobs: []models.ReplicationJob{},
LastUpdate: time.Now(),
ConnectionHealth: make(map[string]bool),
Stats: models.Stats{},
@ -336,6 +337,8 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
PMG: append([]models.PMGBackup(nil), data.PMGBackups...),
}
data.ReplicationJobs = generateReplicationJobs(data.Nodes, data.VMs)
// Calculate stats
data.Stats.StartTime = time.Now()
data.Stats.Uptime = 0
@ -344,6 +347,106 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
return data
}
func generateReplicationJobs(nodes []models.Node, vms []models.VM) []models.ReplicationJob {
if len(nodes) == 0 || len(vms) == 0 {
return []models.ReplicationJob{}
}
maxJobs := len(vms)
if maxJobs > 8 {
maxJobs = 8
}
jobs := make([]models.ReplicationJob, 0, maxJobs)
now := time.Now()
nodeCount := len(nodes)
for i := 0; i < maxJobs; i++ {
vm := vms[i%len(vms)]
instance := vm.Instance
if instance == "" {
instance = vm.Node
}
jobNumber := i % 3
jobID := fmt.Sprintf("%d-%d", vm.VMID, jobNumber)
lastSync := now.Add(-time.Duration(300+rand.Intn(3600)) * time.Second)
nextSync := lastSync.Add(15 * time.Minute)
durationSeconds := 90 + rand.Intn(240)
durationHuman := formatSecondsAsClock(durationSeconds)
status := "idle"
lastStatus := "ok"
errorMessage := ""
failCount := 0
roll := rand.Float64()
if roll < 0.1 {
status = "error"
lastStatus = "error"
errorMessage = "last sync timed out"
failCount = 1 + rand.Intn(2)
} else if roll < 0.35 {
status = "syncing"
}
targetNode := nodes[(i+1)%nodeCount].Name
rate := 80.0 + rand.Float64()*140.0
job := models.ReplicationJob{
ID: fmt.Sprintf("%s-%s", instance, jobID),
Instance: instance,
JobID: jobID,
JobNumber: jobNumber,
Guest: fmt.Sprintf("%d", vm.VMID),
GuestID: vm.VMID,
GuestName: vm.Name,
GuestType: vm.Type,
GuestNode: vm.Node,
SourceNode: vm.Node,
SourceStorage: "local-zfs",
TargetNode: targetNode,
TargetStorage: "replica-zfs",
Schedule: "*/15",
Type: "local",
Enabled: true,
State: status,
Status: status,
LastSyncStatus: lastStatus,
LastSyncTime: ptrTime(lastSync),
LastSyncUnix: lastSync.Unix(),
LastSyncDurationSeconds: durationSeconds,
LastSyncDurationHuman: durationHuman,
NextSyncTime: ptrTime(nextSync),
NextSyncUnix: nextSync.Unix(),
DurationSeconds: durationSeconds,
DurationHuman: durationHuman,
FailCount: failCount,
Error: errorMessage,
RateLimitMbps: ptrFloat64(rate),
LastPolled: now,
}
jobs = append(jobs, job)
}
return jobs
}
func formatSecondsAsClock(totalSeconds int) string {
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func ptrTime(t time.Time) *time.Time {
return &t
}
func ptrFloat64(v float64) *float64 {
return &v
}
func generateNodes(config MockConfig) []models.Node {
nodes := make([]models.Node, 0, config.NodeCount)

View file

@ -311,6 +311,7 @@ func cloneState(state models.StateSnapshot) models.StateSnapshot {
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...),
Metrics: append([]models.Metric(nil), state.Metrics...),
Performance: state.Performance,
Stats: state.Stats,

View file

@ -4,26 +4,27 @@ import "time"
// StateSnapshot represents a snapshot of the state without mutex
type StateSnapshot struct {
Nodes []Node `json:"nodes"`
VMs []VM `json:"vms"`
Containers []Container `json:"containers"`
DockerHosts []DockerHost `json:"dockerHosts"`
Storage []Storage `json:"storage"`
CephClusters []CephCluster `json:"cephClusters"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBSInstances []PBSInstance `json:"pbs"`
PMGInstances []PMGInstance `json:"pmg"`
PBSBackups []PBSBackup `json:"pbsBackups"`
PMGBackups []PMGBackup `json:"pmgBackups"`
Backups Backups `json:"backups"`
Metrics []Metric `json:"metrics"`
PVEBackups PVEBackups `json:"pveBackups"`
Performance Performance `json:"performance"`
ConnectionHealth map[string]bool `json:"connectionHealth"`
Stats Stats `json:"stats"`
ActiveAlerts []Alert `json:"activeAlerts"`
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
Nodes []Node `json:"nodes"`
VMs []VM `json:"vms"`
Containers []Container `json:"containers"`
DockerHosts []DockerHost `json:"dockerHosts"`
Storage []Storage `json:"storage"`
CephClusters []CephCluster `json:"cephClusters"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBSInstances []PBSInstance `json:"pbs"`
PMGInstances []PMGInstance `json:"pmg"`
PBSBackups []PBSBackup `json:"pbsBackups"`
PMGBackups []PMGBackup `json:"pmgBackups"`
Backups Backups `json:"backups"`
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
Metrics []Metric `json:"metrics"`
PVEBackups PVEBackups `json:"pveBackups"`
Performance Performance `json:"performance"`
ConnectionHealth map[string]bool `json:"connectionHealth"`
Stats Stats `json:"stats"`
ActiveAlerts []Alert `json:"activeAlerts"`
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
}
// GetSnapshot returns a snapshot of the current state without mutex
@ -57,6 +58,7 @@ func (s *State) GetSnapshot() StateSnapshot {
PBS: pbsBackups,
PMG: pmgBackups,
},
ReplicationJobs: append([]ReplicationJob{}, s.ReplicationJobs...),
Metrics: append([]Metric{}, s.Metrics...),
PVEBackups: pveBackups,
Performance: s.Performance,
@ -112,6 +114,11 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
cephClusters[i] = cluster.ToFrontend()
}
replicationJobs := make([]ReplicationJobFrontend, len(s.ReplicationJobs))
for i, job := range s.ReplicationJobs {
replicationJobs[i] = job.ToFrontend()
}
return StateFrontend{
Nodes: nodes,
VMs: vms,
@ -125,6 +132,7 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
PBSBackups: s.PBSBackups,
PMGBackups: s.PMGBackups,
Backups: s.Backups,
ReplicationJobs: replicationJobs,
ActiveAlerts: s.ActiveAlerts,
Metrics: make(map[string]any),
PVEBackups: s.PVEBackups,

View file

@ -168,6 +168,86 @@ func TestMemoryStatusEffectiveAvailable(t *testing.T) {
}
}
func TestMemoryStatusUnmarshalFlexibleValues(t *testing.T) {
tests := []struct {
name string
payload string
want MemoryStatus
}{
{
name: "numeric strings",
payload: `{"total":"16549875712","used":"6050492416","free":"2467389440","available":"10499383296","buffers":"0","cached":"0","shared":"0"}`,
want: MemoryStatus{
Total: 16549875712,
Used: 6050492416,
Free: 2467389440,
Available: 10499383296,
Buffers: 0,
Cached: 0,
Shared: 0,
},
},
{
name: "scientific notation",
payload: `{"total":1.6549875712e+10,"used":6.050492416e+09,"free":2.46738944e+09,"available":1.0499383296e+10,"buffers":0,"cached":0,"shared":0}`,
want: MemoryStatus{
Total: 16549875712,
Used: 6050492416,
Free: 2467389440,
Available: 10499383296,
},
},
{
name: "float-like strings with spaces",
payload: `{"total":" 8589934592.0 ","used":"3221225472.0","free":"536870912.0","avail":"4831838208.0","buffers":"67108864","cached":"4026531840","shared":"0"}`,
want: MemoryStatus{
Total: 8589934592,
Used: 3221225472,
Free: 536870912,
Avail: 4831838208,
Buffers: 67108864,
Cached: 4026531840,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var status MemoryStatus
if err := json.Unmarshal([]byte(tc.payload), &status); err != nil {
t.Fatalf("unexpected error unmarshalling %s payload: %v", tc.name, err)
}
if status.Total != tc.want.Total {
t.Fatalf("total: got %d, want %d", status.Total, tc.want.Total)
}
if status.Used != tc.want.Used {
t.Fatalf("used: got %d, want %d", status.Used, tc.want.Used)
}
if status.Free != tc.want.Free {
t.Fatalf("free: got %d, want %d", status.Free, tc.want.Free)
}
if status.Available != tc.want.Available {
t.Fatalf("available: got %d, want %d", status.Available, tc.want.Available)
}
if status.Avail != tc.want.Avail {
t.Fatalf("avail: got %d, want %d", status.Avail, tc.want.Avail)
}
if status.Buffers != tc.want.Buffers {
t.Fatalf("buffers: got %d, want %d", status.Buffers, tc.want.Buffers)
}
if status.Cached != tc.want.Cached {
t.Fatalf("cached: got %d, want %d", status.Cached, tc.want.Cached)
}
if status.Shared != tc.want.Shared {
t.Fatalf("shared: got %d, want %d", status.Shared, tc.want.Shared)
}
})
}
}
// TestMemoryStatusEffectiveAvailable_RegressionIssue435 tests the specific scenarios
// reported in GitHub issue #435 where memory calculations incorrectly included cache/buffers
func TestMemoryStatusEffectiveAvailable_RegressionIssue435(t *testing.T) {

457
pkg/proxmox/replication.go Normal file
View file

@ -0,0 +1,457 @@
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
}