Handle inactive VM and Docker services
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c backupper-2026.06.14.5-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
72cbb82fd82f26c3099130bb618ac29077e315693b12a9e77585d05ed355c851 backupper-2026.06.14.6-x86_64-1.txz
|
||||||
Vendored
+5
-2
@@ -2,13 +2,16 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Backupper Project">
|
<!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 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 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">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.6
|
||||||
|
- Show clear inactive-state messages when Unraid VM Manager or Docker is disabled.
|
||||||
|
|
||||||
### 2026.06.14.5
|
### 2026.06.14.5
|
||||||
- Make backup job forms type-aware with Docker container and VM discovery lists.
|
- Make backup job forms type-aware with Docker container and VM discovery lists.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ type Workload struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkloadDiscovery struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Items []Workload `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
type Prepared struct {
|
type Prepared struct {
|
||||||
Sources []string
|
Sources []string
|
||||||
Stopped []model.Source
|
Stopped []model.Source
|
||||||
@@ -29,12 +35,15 @@ type Prepared struct {
|
|||||||
Kind model.JobType
|
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 {
|
switch kind {
|
||||||
case model.JobDocker:
|
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()
|
output, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{json .}}").Output()
|
||||||
if err != nil {
|
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
|
var result []Workload
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
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"`
|
Status string `json:"Status"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
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})
|
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:
|
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()
|
output, err := exec.CommandContext(ctx, "virsh", "list", "--all", "--name").Output()
|
||||||
if err != nil {
|
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
|
var result []Workload
|
||||||
for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
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()
|
state, _ := exec.CommandContext(ctx, "virsh", "domstate", name).Output()
|
||||||
result = append(result, Workload{ID: name, Name: name, Status: strings.TrimSpace(string(state))})
|
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:
|
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) {
|
func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared, error) {
|
||||||
prepared := Prepared{Kind: job.Type}
|
prepared := Prepared{Kind: job.Type}
|
||||||
for _, source := range job.Sources {
|
for _, source := range job.Sources {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ func (s *Service) BrowseDirectories(path string) ([]platform.DirectoryEntry, err
|
|||||||
return platform.BrowseDirectories(path)
|
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)
|
return s.workloads.Discover(ctx, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Backupper Project">
|
<!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 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 packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!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">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.6
|
||||||
|
- Show clear inactive-state messages when Unraid VM Manager or Docker is disabled.
|
||||||
|
|
||||||
### 2026.06.14.5
|
### 2026.06.14.5
|
||||||
- Make backup job forms type-aware with Docker container and VM discovery lists.
|
- Make backup job forms type-aware with Docker container and VM discovery lists.
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
|
|||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/backupper';
|
$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">
|
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<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>
|
<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>
|
</header>
|
||||||
<nav class="bu-tabs" aria-label="Backupper sections">
|
<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 id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<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>
|
||||||
|
|||||||
@@ -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-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-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-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-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-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; }
|
.bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
const toast = document.getElementById('bu-toast');
|
const toast = document.getElementById('bu-toast');
|
||||||
const tooltip = document.getElementById('bu-tooltip');
|
const tooltip = document.getElementById('bu-tooltip');
|
||||||
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
|
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 = {
|
const fieldHelp = {
|
||||||
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
function jobForm(job = {}) {
|
function jobForm(job = {}) {
|
||||||
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
|
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.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 = '';
|
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('');
|
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">
|
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;
|
if (!host) return;
|
||||||
const selected = new Set(state.workloadSelections);
|
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 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>` : '';
|
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>${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>`;
|
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) {
|
async function loadWorkloads(type) {
|
||||||
try {
|
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) {
|
} 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);
|
renderWorkloadBrowser(type);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user