diff --git a/dist/backupper-2026.06.14.5-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.5-x86_64-1.txz.sha256 deleted file mode 100644 index 6f94295..0000000 --- a/dist/backupper-2026.06.14.5-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c backupper-2026.06.14.5-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.5-x86_64-1.txz b/dist/backupper-2026.06.14.6-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.5-x86_64-1.txz rename to dist/backupper-2026.06.14.6-x86_64-1.txz index 3efac32..63dd831 100644 Binary files a/dist/backupper-2026.06.14.5-x86_64-1.txz and b/dist/backupper-2026.06.14.6-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 new file mode 100644 index 0000000..98f4c21 --- /dev/null +++ b/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +72cbb82fd82f26c3099130bb618ac29077e315693b12a9e77585d05ed355c851 backupper-2026.06.14.6-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 129cfe8..858d247 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.6 +- Show clear inactive-state messages when Unraid VM Manager or Docker is disabled. + ### 2026.06.14.5 - Make backup job forms type-aware with Docker container and VM discovery lists. diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index d205aa3..991494d 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -22,6 +22,12 @@ type Workload struct { Status string `json:"status"` } +type WorkloadDiscovery struct { + Available bool `json:"available"` + Message string `json:"message,omitempty"` + Items []Workload `json:"items"` +} + type Prepared struct { Sources []string Stopped []model.Source @@ -29,12 +35,15 @@ type Prepared struct { Kind model.JobType } -func (w *WorkloadManager) Discover(ctx context.Context, kind model.JobType) ([]Workload, error) { +func (w *WorkloadManager) Discover(ctx context.Context, kind model.JobType) (WorkloadDiscovery, error) { switch kind { case model.JobDocker: + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + return WorkloadDiscovery{Available: false, Message: "Docker is not enabled or is not running on this Unraid server.", Items: []Workload{}}, nil + } output, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{json .}}").Output() if err != nil { - return nil, fmt.Errorf("environment: list Docker containers: %w", err) + return WorkloadDiscovery{}, fmt.Errorf("environment: list Docker containers: %w", err) } var result []Workload for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { @@ -47,15 +56,22 @@ func (w *WorkloadManager) Discover(ctx context.Context, kind model.JobType) ([]W Status string `json:"Status"` } if err := json.Unmarshal([]byte(line), &item); err != nil { - return nil, fmt.Errorf("environment: decode Docker container list: %w", err) + return WorkloadDiscovery{}, fmt.Errorf("environment: decode Docker container list: %w", err) } result = append(result, Workload{ID: item.Names, Name: item.Names, Status: item.Status}) } - return result, nil + message := "" + if len(result) == 0 { + message = "Docker is active, but no containers are configured." + } + return WorkloadDiscovery{Available: true, Message: message, Items: result}, nil case model.JobVM: + if !unraidServiceEnabled("/boot/config/domain.cfg") { + return WorkloadDiscovery{Available: false, Message: "The Unraid VM Manager is not enabled. Enable VMs under Settings > VM Manager to select virtual machines.", Items: []Workload{}}, nil + } output, err := exec.CommandContext(ctx, "virsh", "list", "--all", "--name").Output() if err != nil { - return nil, fmt.Errorf("environment: list virtual machines: %w", err) + return WorkloadDiscovery{}, fmt.Errorf("environment: VM Manager is enabled but virtual machines could not be listed: %w", err) } var result []Workload for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") { @@ -66,12 +82,30 @@ func (w *WorkloadManager) Discover(ctx context.Context, kind model.JobType) ([]W state, _ := exec.CommandContext(ctx, "virsh", "domstate", name).Output() result = append(result, Workload{ID: name, Name: name, Status: strings.TrimSpace(string(state))}) } - return result, nil + message := "" + if len(result) == 0 { + message = "The VM Manager is active, but no virtual machines are configured." + } + return WorkloadDiscovery{Available: true, Message: message, Items: result}, nil default: - return nil, fmt.Errorf("validation: unsupported workload type %q", kind) + return WorkloadDiscovery{}, fmt.Errorf("validation: unsupported workload type %q", kind) } } +func unraidServiceEnabled(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "=", 2) + if len(parts) == 2 && parts[0] == "SERVICE" { + return strings.Trim(strings.TrimSpace(parts[1]), "\"'") == "enable" + } + } + return false +} + func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared, error) { prepared := Prepared{Kind: job.Type} for _, source := range job.Sources { diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go new file mode 100644 index 0000000..c01d5ae --- /dev/null +++ b/internal/platform/workloads_test.go @@ -0,0 +1,26 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUnraidServiceEnabled(t *testing.T) { + path := filepath.Join(t.TempDir(), "domain.cfg") + for _, test := range []struct { + content string + want bool + }{ + {content: "SERVICE=\"enable\"\n", want: true}, + {content: "SERVICE=disable\n", want: false}, + {content: "OTHER=value\n", want: false}, + } { + if err := os.WriteFile(path, []byte(test.content), 0600); err != nil { + t.Fatal(err) + } + if got := unraidServiceEnabled(path); got != test.want { + t.Fatalf("content %q: got %v, want %v", test.content, got, test.want) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 0f8fd8b..a0594c4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -70,7 +70,7 @@ func (s *Service) BrowseDirectories(path string) ([]platform.DirectoryEntry, err return platform.BrowseDirectories(path) } -func (s *Service) DiscoverWorkloads(ctx context.Context, kind model.JobType) ([]platform.Workload, error) { +func (s *Service) DiscoverWorkloads(ctx context.Context, kind model.JobType) (platform.WorkloadDiscovery, error) { return s.workloads.Discover(ctx, kind) } diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 2de8e3f..ef79ad8 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.6 +- Show clear inactive-state messages when Unraid VM Manager or Docker is disabled. + ### 2026.06.14.5 - Make backup job forms type-aware with Docker container and VM discovery lists. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 1de5296..02e42df 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,10 +9,10 @@ Tag="backup restic snapshots restore" - +
-

Backupper 2026.06.14.5

Encrypted Restic backups for Unraid

+

Backupper 2026.06.14.6

Encrypted Restic backups for Unraid

Connecting...
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index e8ae580..6ce4d4a 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -78,6 +78,7 @@ .bu-browser-card input[type="checkbox"], .bu-table input[type="checkbox"] { accent-color: var(--bu-accent); height: 20px; width: 20px; } .bu-count { align-items: center; border: 1px solid var(--bu-border); border-radius: 999px; display: inline-flex; font-size: 13px; font-weight: 700; padding: 7px 11px; } .bu-inline-error { background: rgba(180,35,47,.08); border: 1px solid var(--bu-bad); border-radius: 5px; color: var(--bu-bad); font-weight: 600; margin: 10px 0; padding: 10px 12px; } +.bu-inline-info { background: rgba(19,138,98,.08); border: 1px solid var(--bu-good); border-radius: 5px; color: var(--bu-text); font-weight: 600; margin: 10px 0; padding: 10px 12px; } .bu-workload-options { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); margin-top: 15px; } .bu-manual-source { display: grid; gap: 7px; margin-top: 14px; } .bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 89b7c7b..d4e1f77 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -7,7 +7,7 @@ const toast = document.getElementById('bu-toast'); const tooltip = document.getElementById('bu-tooltip'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); - const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' }; + const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -244,7 +244,7 @@ function jobForm(job = {}) { state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path); state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId); - state.workloads = []; state.workloadError = ''; + state.workloads = []; state.workloadAvailable = true; state.workloadError = ''; state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = ''; const options = state.config.repositories.map(r => ``).join(''); content.innerHTML = `

${job.id ? 'Edit' : 'New'} backup job

@@ -283,15 +283,15 @@ if (!host) return; const selected = new Set(state.workloadSelections); const rows = state.workloads.map(item=>`${esc(item.name)}
${esc(item.id)}${esc(item.status||'unknown')}`).join(''); - const error = state.workloadError ? `
${esc(state.workloadError)}
` : ''; - host.innerHTML = `

${type==='docker'?'Available containers':'Available virtual machines'}

Selected: ${state.workloadSelections.length}
${error}
${rows||''}
SelectNameStatus
No workloads found.
`; + const notice = state.workloadError ? `
${esc(state.workloadError)}
` : ''; + host.innerHTML = `

${type==='docker'?'Available containers':'Available virtual machines'}

Selected: ${state.workloadSelections.length}
${notice}
${rows||(!state.workloadError?'':'')}
SelectNameStatus
No workloads found.
`; } async function loadWorkloads(type) { try { - state.workloads = await api(`/v1/workloads/${type}`); state.workloadError = ''; + const result = await api(`/v1/workloads/${type}`); state.workloads = result.items || []; state.workloadAvailable = result.available; state.workloadError = result.message || ''; } catch (error) { - state.workloads = []; state.workloadError = `Workloads could not be loaded: ${error.message}`; + state.workloads = []; state.workloadAvailable = true; state.workloadError = `Workloads could not be loaded: ${error.message}`; } renderWorkloadBrowser(type); }