Handle inactive VM and Docker services

This commit is contained in:
Mikei386
2026-06-14 12:53:22 +02:00
parent 800cb60f83
commit 7e2c604c5f
11 changed files with 88 additions and 21 deletions
-1
View File
@@ -1 +0,0 @@
9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c backupper-2026.06.14.5-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
72cbb82fd82f26c3099130bb618ac29077e315693b12a9e77585d05ed355c851 backupper-2026.06.14.6-x86_64-1.txz
+5 -2
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "2026.06.14.5">
<!ENTITY version "2026.06.14.6">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c">
<!ENTITY packageSHA256 "72cbb82fd82f26c3099130bb618ac29077e315693b12a9e77585d05ed355c851">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 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.
+42 -8
View File
@@ -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,10 +82,28 @@ 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
default:
return nil, fmt.Errorf("validation: unsupported workload type %q", kind)
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 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) {
+26
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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)
}
+4 -1
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "2026.06.14.5">
<!ENTITY version "2026.06.14.6">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 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.
+3 -3
View File
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
<?php
$pluginRoot = '/plugins/backupper';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606145">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606146">
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div><h1>Backupper <span class="bu-version">2026.06.14.5</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div><h1>Backupper <span class="bu-version">2026.06.14.6</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
</header>
<nav class="bu-tabs" aria-label="Backupper sections">
@@ -29,4 +29,4 @@ $pluginRoot = '/plugins/backupper';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.5.js"></script>
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.6.js"></script>
+1
View File
@@ -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; }
+6 -6
View File
@@ -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 => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
content.innerHTML = `<section class="bu-card"><h2>${job.id ? 'Edit' : 'New'} backup job</h2><form id="bu-job-form" class="bu-form">
@@ -283,15 +283,15 @@
if (!host) return;
const selected = new Set(state.workloadSelections);
const rows = state.workloads.map(item=>`<tr><td><input type="checkbox" class="bu-workload-select" value="${esc(item.id)}" ${selected.has(item.id)?'checked':''}></td><td><strong>${esc(item.name)}</strong><br><span class="bu-muted">${esc(item.id)}</span></td><td>${esc(item.status||'unknown')}</td></tr>`).join('');
const error = state.workloadError ? `<div class="bu-inline-error">${esc(state.workloadError)}</div>` : '';
host.innerHTML = `<div class="bu-toolbar"><h3>${type==='docker'?'Available containers':'Available virtual machines'}</h3><span class="bu-count">Selected: ${state.workloadSelections.length}</span></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">No workloads found.</td></tr>'}</tbody></table></div>`;
const notice = state.workloadError ? `<div class="${state.workloadAvailable===false?'bu-inline-info':'bu-inline-error'}">${esc(state.workloadError)}</div>` : '';
host.innerHTML = `<div class="bu-toolbar"><h3>${type==='docker'?'Available containers':'Available virtual machines'}</h3><span class="bu-count">Selected: ${state.workloadSelections.length}</span></div>${notice}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||(!state.workloadError?'<tr><td colspan="3" class="bu-empty">No workloads found.</td></tr>':'')}</tbody></table></div>`;
}
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);
}