diff --git a/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 deleted file mode 100644 index 8f53df7..0000000 --- a/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76 backupper-2026.06.14.4-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.4-x86_64-1.txz b/dist/backupper-2026.06.14.5-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.4-x86_64-1.txz rename to dist/backupper-2026.06.14.5-x86_64-1.txz index 317a045..3efac32 100644 Binary files a/dist/backupper-2026.06.14.4-x86_64-1.txz and b/dist/backupper-2026.06.14.5-x86_64-1.txz differ 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 new file mode 100644 index 0000000..6f94295 --- /dev/null +++ b/dist/backupper-2026.06.14.5-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c backupper-2026.06.14.5-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index d18db92..129cfe8 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.5 +- Make backup job forms type-aware with Docker container and VM discovery lists. + ### 2026.06.14.4 - Add dashboard history charts for processed backup size and backed-up file count. diff --git a/internal/api/server.go b/internal/api/server.go index abe7a76..fcad597 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /v1/runs", s.runs) mux.HandleFunc("GET /v1/filesystem/directories", s.directories) + mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob) mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun) mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository) @@ -125,6 +126,16 @@ func (s *Server) directories(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, items) } +func (s *Server) workloads(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + items, err := s.service.DiscoverWorkloads(ctx, model.JobType(r.PathValue("kind"))) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, items) +} func (s *Server) runJob(w http.ResponseWriter, r *http.Request) { run, err := s.service.EnqueueJob(r.PathValue("id"), 10) diff --git a/internal/model/validate.go b/internal/model/validate.go index 1e67538..df2e543 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -74,6 +74,13 @@ func ValidateJob(j Job) error { return errors.New("source requires path or workloadId") } } + if j.Type == JobDocker || j.Type == JobVM { + for _, source := range j.Sources { + if source.WorkloadID == "" { + return errors.New("docker and vm jobs require workload selections") + } + } + } if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { return errors.New("compression must be auto, off, or max") } diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 9967151..71b3e4d 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -34,3 +34,14 @@ func TestRepositoryCannotBeSharedByJobs(t *testing.T) { t.Fatal("shared repository accepted") } } + +func TestDockerJobRequiresWorkloadSources(t *testing.T) { + job := Job{SchemaVersion: 1, ID: "docker-job", Name: "Docker", Type: JobDocker, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/appdata"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto"} + if err := ValidateJob(job); err == nil { + t.Fatal("docker path source accepted without workload selection") + } + job.Sources = []Source{{WorkloadID: "plex"}} + if err := ValidateJob(job); err != nil { + t.Fatalf("valid docker workload rejected: %v", err) + } +} diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 8db81df..d205aa3 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -16,6 +16,12 @@ import ( type WorkloadManager struct{ RuntimeDir string } +type Workload struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + type Prepared struct { Sources []string Stopped []model.Source @@ -23,6 +29,49 @@ type Prepared struct { Kind model.JobType } +func (w *WorkloadManager) Discover(ctx context.Context, kind model.JobType) ([]Workload, error) { + switch kind { + case model.JobDocker: + output, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{json .}}").Output() + if err != nil { + return nil, fmt.Errorf("environment: list Docker containers: %w", err) + } + var result []Workload + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var item struct { + ID string `json:"ID"` + Names string `json:"Names"` + Status string `json:"Status"` + } + if err := json.Unmarshal([]byte(line), &item); err != nil { + return nil, 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 + case model.JobVM: + output, err := exec.CommandContext(ctx, "virsh", "list", "--all", "--name").Output() + if err != nil { + return nil, fmt.Errorf("environment: list virtual machines: %w", err) + } + var result []Workload + for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + 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) + } +} + 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/service/service.go b/internal/service/service.go index 9985e00..0f8fd8b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -70,6 +70,10 @@ 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) { + return s.workloads.Discover(ctx, kind) +} + func (s *Service) LastRun(jobID string) time.Time { var latest time.Time for _, run := range s.queue.Snapshot() { diff --git a/plugin/backupper.plg b/plugin/backupper.plg index bf22ce8..2de8e3f 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.5 +- Make backup job forms type-aware with Docker container and VM discovery lists. + ### 2026.06.14.4 - Add dashboard history charts for processed backup size and backed-up file count. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 5e7deb6..1de5296 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,10 +9,10 @@ Tag="backup restic snapshots restore" - +
-

Backupper 2026.06.14.4

Encrypted Restic backups for Unraid

+

Backupper 2026.06.14.5

Encrypted Restic backups for Unraid

Connecting...
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 204d2bc..e8ae580 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -78,6 +78,9 @@ .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-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; } #backupper-app .bu-button { appearance: none; @@ -181,6 +184,7 @@ @media (max-width: 700px) { #backupper-app { padding-left: 8px; padding-right: 8px; } .bu-form { grid-template-columns: 1fr; } + .bu-workload-options { grid-template-columns: 1fr; } .bu-chart-grid-layout { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } .bu-table { display: block; overflow-x: auto; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 4fe049f..89b7c7b 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: [], sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' }; + const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -243,23 +243,57 @@ function jobForm(job = {}) { state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path); - const source = (job.sources || []).filter(s=>s.workloadId).map(s=>`@${s.workloadId}`).join('\n'); + state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId); + state.workloads = []; 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

- -

Backup folders

Select folders exactly like files and folders in the snapshot browser.
- +
-
${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; enhanceTooltips(content); - renderSourceBrowser(); + renderJobSpecific(job.type || 'share', job); + } + + function renderJobSpecific(type, job = {}) { + const host = document.getElementById('bu-job-specific'); + if (!host) return; + if (type === 'share' || type === 'appdata') { + host.innerHTML = `

${type==='appdata'?'Appdata folders':'Share folders'}

Select one or more folders. Existing file exclude patterns remain available below.
`; + state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'; + state.sourceDirectories = []; state.sourceBrowserError = ''; + loadSourceDirectories(state.sourceBrowserPath); + } else if (type === 'docker' || type === 'vm') { + host.innerHTML = `

${type==='docker'?'Docker containers':'Virtual machines'}

Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.
Loading...
`; + loadWorkloads(type); + } else { + state.backupSelections = ['/boot']; + host.innerHTML = `

Unraid USB flash backup

The complete /boot configuration drive is backed up. No additional source selection is required.

`; + } + enhanceTooltips(host); + } + + function renderWorkloadBrowser(type) { + const host = document.getElementById('bu-workload-browser'); + 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.
`; + } + + async function loadWorkloads(type) { + try { + state.workloads = await api(`/v1/workloads/${type}`); state.workloadError = ''; + } catch (error) { + state.workloads = []; state.workloadError = `Workloads could not be loaded: ${error.message}`; + } + renderWorkloadBrowser(type); } function renderSourceBrowser() { @@ -382,9 +416,11 @@ async function submit(kind) { if (kind === 'submit-job') { const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id')); - const manual=lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v}); - const selected=state.backupSelections.map(path=>({path})); - const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:[...selected,...manual].filter((item,index,all)=>all.findIndex(other=>(other.path||`@${other.workloadId}`)===(item.path||`@${item.workloadId}`))===index),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))}; + const type=f.get('type'); let sources=[]; + if(type==='docker'||type==='vm') sources=state.workloadSelections.map(workloadId=>({workloadId})); + else if(type==='flash') sources=[]; + else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index); + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') { @@ -407,6 +443,10 @@ document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); content.addEventListener('change', e => { + if (e.target.name === 'type' && e.target.closest('#bu-job-form')) { + state.backupSelections=[]; state.workloadSelections=[]; + renderJobSpecific(e.target.value); + } if (e.target.classList.contains('bu-source-select')) { state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value); renderSourceBrowser(); @@ -415,6 +455,10 @@ state.restoreIncludes = e.target.checked ? [...new Set([...state.restoreIncludes,e.target.value])] : state.restoreIncludes.filter(path=>path!==e.target.value); render(); } + if (e.target.classList.contains('bu-workload-select')) { + state.workloadSelections = e.target.checked ? [...new Set([...state.workloadSelections,e.target.value])] : state.workloadSelections.filter(id=>id!==e.target.value); + renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker'); + } }); content.addEventListener('submit', e => { const form = e.target.closest('form');