Add type-aware backup job forms
This commit is contained in:
@@ -1 +0,0 @@
|
||||
ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76 backupper-2026.06.14.4-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c backupper-2026.06.14.5-x86_64-1.txz
|
||||
Vendored
+5
-2
@@ -2,13 +2,16 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Backupper Project">
|
||||
<!ENTITY version "2026.06.14.4">
|
||||
<!ENTITY version "2026.06.14.5">
|
||||
<!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 "ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76">
|
||||
<!ENTITY packageSHA256 "9b133d15689d1d9a7b3532e2fc3ec1a3fd96124ec8b9466451b6a097d911820c">
|
||||
]>
|
||||
<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.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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Backupper Project">
|
||||
<!ENTITY version "2026.06.14.4">
|
||||
<!ENTITY version "2026.06.14.5">
|
||||
<!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.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.
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/backupper';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606144">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606145">
|
||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div><h1>Backupper <span class="bu-version">2026.06.14.4</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
||||
<div><h1>Backupper <span class="bu-version">2026.06.14.5</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.4.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.5.js"></script>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+54
-10
@@ -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 => `<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">
|
||||
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
||||
<label>Type<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option ${v===job.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Schedule (cron)<input name="cron" value="${esc(job.schedule?.cron || '0 2 * * *')}"></label>
|
||||
<label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label>
|
||||
<label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<div class="wide"><h3>Backup folders</h3><div class="bu-muted">Select folders exactly like files and folders in the snapshot browser.</div><div id="bu-source-browser"></div></div>
|
||||
<label class="wide">Additional manual paths or workload IDs<textarea name="sources">${esc(source)}</textarea></label>
|
||||
<div id="bu-job-specific" class="wide"></div>
|
||||
<label class="wide">Exclude patterns, one per line<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
||||
<label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label>
|
||||
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
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 = `<h3>${type==='appdata'?'Appdata folders':'Share folders'}</h3><div class="bu-muted">Select one or more folders. Existing file exclude patterns remain available below.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Additional manual paths<textarea name="sources"></textarea></label>`;
|
||||
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
|
||||
state.sourceDirectories = []; state.sourceBrowserError = '';
|
||||
loadSourceDirectories(state.sourceBrowserPath);
|
||||
} else if (type === 'docker' || type === 'vm') {
|
||||
host.innerHTML = `<h3>${type==='docker'?'Docker containers':'Virtual machines'}</h3><div class="bu-muted">Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Loading...</div></div><div class="bu-workload-options"><label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label><label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
|
||||
loadWorkloads(type);
|
||||
} else {
|
||||
state.backupSelections = ['/boot'];
|
||||
host.innerHTML = `<div class="bu-info-card"><h3>Unraid USB flash backup</h3><p>The complete <strong>/boot</strong> configuration drive is backed up. No additional source selection is required.</p></div>`;
|
||||
}
|
||||
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=>`<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>`;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user