Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Replaces every native confirm() with a shared P.confirm() modal (a hung native dialog was found live to block the whole tab, including auto-refresh, and once even double-confirmed an unrelated deletion). Also fixes Edit Container silently resetting to Bridge/blanking the Static IP for any container on a custom network, and a context menu losing its anchor to a mid-read auto-refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,9 +129,9 @@ window.Podman = (function () {
|
||||
// --- Toast notifications ----------------------------------------------------
|
||||
//
|
||||
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
|
||||
// "Save failed: ..."). Confirmations stay native confirm() — a toast is
|
||||
// for telling the user something happened, not for asking them a
|
||||
// yes/no question. Command output that can run to hundreds of lines
|
||||
// "Save failed: ..."). Confirmations use confirmModal() below instead —
|
||||
// a toast is for telling the user something happened, not for asking
|
||||
// them a yes/no question. Command output that can run to hundreds of lines
|
||||
// (e.g. `podman compose up`) stays in the existing openLogModal()
|
||||
// pattern instead — a toast has to stay short and auto-dismiss, which
|
||||
// doesn't fit a scrolling log.
|
||||
@@ -180,6 +180,60 @@ window.Podman = (function () {
|
||||
setTimeout(dismiss, TOAST_DURATION_MS[kind]);
|
||||
}
|
||||
|
||||
// --- Confirm dialog ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* In-app replacement for browser-native confirm() — a native confirm()
|
||||
* blocks the entire tab (including this plugin's own ~2s auto-refresh,
|
||||
* and any browser automation driving the page) until dismissed, can't
|
||||
* be styled/themed, and — found live — is easy to lose track of: a
|
||||
* hung dialog blocked screenshots/JS entirely, and a follow-up
|
||||
* keypress meant to dismiss just one of them ended up confirming a
|
||||
* second, unrelated one too. Returns a Promise<boolean> (true =
|
||||
* confirmed) instead of blocking synchronously.
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.title='Confirm']
|
||||
* @param {string} [opts.confirmLabel='Confirm']
|
||||
* @param {string} [opts.cancelLabel='Cancel']
|
||||
* @param {boolean} [opts.danger=false] solid red confirm button, for
|
||||
* destructive/data-losing actions (delete, remove, format, ...).
|
||||
*/
|
||||
function confirmModal(message, opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal" role="alertdialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title || 'Confirm') + '</h3></div>' +
|
||||
'<div class="podman-modal-body"><p class="podman-confirm-message"></p></div>' +
|
||||
'<div class="podman-modal-actions">' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">' + escapeHtml(opts.cancelLabel || 'Cancel') + '</button>' +
|
||||
'<button type="button" class="podman-btn ' + (opts.danger ? 'podman-btn-ghost podman-btn-danger' : 'podman-btn-primary') + '" data-role="confirm">' +
|
||||
escapeHtml(opts.confirmLabel || 'Confirm') + '</button>' +
|
||||
'</div></div>';
|
||||
backdrop.querySelector('.podman-confirm-message').textContent = message;
|
||||
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
backdrop.querySelector('[data-role="confirm"]').focus();
|
||||
|
||||
function settle(result) {
|
||||
backdrop.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') settle(false);
|
||||
}
|
||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { settle(false); });
|
||||
backdrop.querySelector('[data-role="confirm"]').addEventListener('click', function () { settle(true); });
|
||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) settle(false); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Modal form dialog -----------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -480,14 +534,19 @@ window.Podman = (function () {
|
||||
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
|
||||
// get polled — most panels (Settings, Compose, Terminal, ...) have
|
||||
// in-progress forms or connections an unexpected refresh would disrupt.
|
||||
// Paused while the tab is hidden (nothing to look at) and while any
|
||||
// modal is open (a full-panel re-render mid-edit would be jarring),
|
||||
// rather than fighting those cases with more state.
|
||||
// Paused while the tab is hidden (nothing to look at), while any modal
|
||||
// is open (a full-panel re-render mid-edit would be jarring), and while
|
||||
// a context menu is open — found live: a re-render replaces every row's
|
||||
// DOM node wholesale, so a menu opened from a row (its anchor button)
|
||||
// still LOOKS open but is now anchored to a detached element; opening a
|
||||
// submenu from it (e.g. "Move to Folder") then positions itself
|
||||
// relative to that stale anchor instead of anywhere sensible.
|
||||
const AUTO_REFRESH_INTERVAL_MS = 2000;
|
||||
|
||||
function autoRefreshTick() {
|
||||
if (document.hidden) return;
|
||||
if (document.querySelector('.podman-modal-backdrop')) return;
|
||||
if (document.querySelector('.podman-context-menu')) return;
|
||||
|
||||
const activeBtn = document.querySelector('.podman-subnav button.active');
|
||||
if (!activeBtn) return;
|
||||
@@ -512,6 +571,7 @@ window.Podman = (function () {
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
toast: toast,
|
||||
confirm: confirmModal,
|
||||
openFormModal: openFormModal,
|
||||
openLogModal: openLogModal,
|
||||
openContextMenu: openContextMenu,
|
||||
|
||||
@@ -117,17 +117,19 @@
|
||||
|
||||
function deleteProject() {
|
||||
if (!selected) return;
|
||||
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
|
||||
const btn = P.el('compose-action-delete');
|
||||
btn.disabled = true;
|
||||
const name = selected;
|
||||
P.post('compose', 'remove', { project: selected }).then(function () {
|
||||
selected = null;
|
||||
P.toast('Deleted ' + name + '.', 'success');
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
P.confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
const btn = P.el('compose-action-delete');
|
||||
btn.disabled = true;
|
||||
const name = selected;
|
||||
P.post('compose', 'remove', { project: selected }).then(function () {
|
||||
selected = null;
|
||||
P.toast('Deleted ' + name + '.', 'success');
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,11 @@
|
||||
}
|
||||
|
||||
function deleteFolder(f) {
|
||||
if (!confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.')) return;
|
||||
folders = folders.filter(function (x) { return x.id !== f.id; });
|
||||
saveFolders().then(renderTable);
|
||||
P.confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
folders = folders.filter(function (x) { return x.id !== f.id; });
|
||||
saveFolders().then(renderTable);
|
||||
});
|
||||
}
|
||||
|
||||
function assignToFolder(containerName, folderId) {
|
||||
@@ -189,7 +191,7 @@
|
||||
return '' +
|
||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
||||
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||
'<td><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
|
||||
'<td><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="menu">' +
|
||||
containerIconHtml(c) + '<span class="text">' + P.escapeHtml(c.name) + '</span></button>' + webuiLink + updateBadge + '</div></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||
'<td>' + cpuMem + '</td>' +
|
||||
@@ -292,9 +294,9 @@
|
||||
|
||||
const volumes = (d.Mounts || []).reduce(function (list, m) {
|
||||
if (m.Type === 'bind') {
|
||||
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
|
||||
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination, readOnly: m.RW === false });
|
||||
} else if (m.Type === 'volume') {
|
||||
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
|
||||
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination, readOnly: m.RW === false });
|
||||
}
|
||||
return list;
|
||||
}, []);
|
||||
@@ -316,26 +318,49 @@
|
||||
.map(function (dev) { return dev.PathOnHost; })
|
||||
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
|
||||
|
||||
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
|
||||
// in openCreateContainerModal) — the container's actual address on
|
||||
// that network, so editing one doesn't blank out an IP it was
|
||||
// deliberately given.
|
||||
const netName = hostCfg.NetworkMode;
|
||||
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
|
||||
// Anything under /dev/ that ISN'T one of the GPU paths above — the
|
||||
// plugin's own generic device-passthrough field (see build_container_
|
||||
// spec()'s comment on why this is host-path-equals-container-path only).
|
||||
const devices = (hostCfg.Devices || [])
|
||||
.map(function (dev) { return dev.PathOnHost; })
|
||||
.filter(function (path) { return path && !/^\/dev\/dri\/(card|renderD)\d+$/.test(path); })
|
||||
.map(function (path) { return { path: path }; });
|
||||
|
||||
// HostConfig.NetworkMode is only reliable for "host"/"none" — a
|
||||
// container attached to a CUSTOM network (e.g. a macvlan like "Lan")
|
||||
// still reports NetworkMode as the generic "bridge", regardless of
|
||||
// what it's actually on (verified live: a running container on "Lan"
|
||||
// showed NetworkMode:"bridge" while NetworkSettings.Networks only had
|
||||
// a "Lan" entry, not a "bridge" one at all). The real network's name
|
||||
// is that one NetworkSettings.Networks key instead — except when it's
|
||||
// podman's own literal default bridge network, named "podman", which
|
||||
// maps back to our own "bridge" nsmode option. Getting this wrong
|
||||
// silently reset the Network dropdown to Bridge on every edit and
|
||||
// blanked out the Static IP field, even for a container that had one.
|
||||
const networksMap = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
|
||||
const networkKeys = Object.keys(networksMap);
|
||||
let networkMode = hostCfg.NetworkMode || 'bridge';
|
||||
let netInfo = null;
|
||||
if (networkMode === 'bridge' && networkKeys.length === 1 && networkKeys[0] !== 'podman') {
|
||||
networkMode = networkKeys[0];
|
||||
netInfo = networksMap[networkMode];
|
||||
}
|
||||
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
|
||||
|
||||
return {
|
||||
name: (d.Name || c.name || '').replace(/^\//, ''),
|
||||
image: cfg.Image || c.image,
|
||||
networkMode: hostCfg.NetworkMode || 'bridge',
|
||||
networkMode: networkMode,
|
||||
staticIp: staticIp,
|
||||
pod: c.podName || '',
|
||||
privileged: !!hostCfg.Privileged,
|
||||
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
|
||||
user: cfg.User || '',
|
||||
ports: ports,
|
||||
volumes: volumes,
|
||||
env: env,
|
||||
gpuDevices: gpuDevices,
|
||||
devices: devices,
|
||||
icon: c.icon || '',
|
||||
webUrl: c.webUrl || '',
|
||||
};
|
||||
@@ -390,7 +415,9 @@
|
||||
env: prefill.env,
|
||||
restartPolicy: prefill.restartPolicy,
|
||||
gpuDevices: prefill.gpuDevices,
|
||||
devices: prefill.devices,
|
||||
privileged: prefill.privileged,
|
||||
user: prefill.user,
|
||||
icon: prefill.icon,
|
||||
webuiUrl: prefill.webUrl,
|
||||
startAfterCreate: true,
|
||||
@@ -906,6 +933,16 @@
|
||||
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
||||
'<span>→</span>' +
|
||||
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
||||
'<label class="podman-row-checkbox-label" title="Mount read-only">' +
|
||||
'<input type="checkbox" data-field="readOnly"> RO</label>' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function deviceRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<input type="text" class="mono" data-field="path" placeholder="/dev/ttyACM0">' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -927,7 +964,12 @@
|
||||
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
||||
if (values) {
|
||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||
if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field];
|
||||
if (values[input.dataset.field] === undefined) return;
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = !!values[input.dataset.field];
|
||||
} else {
|
||||
input.value = values[input.dataset.field];
|
||||
}
|
||||
});
|
||||
}
|
||||
groupEl.appendChild(row);
|
||||
@@ -937,7 +979,7 @@
|
||||
return Array.from(groupEl.children).map(function (row) {
|
||||
const values = {};
|
||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||
values[input.dataset.field] = input.value.trim();
|
||||
values[input.dataset.field] = input.type === 'checkbox' ? input.checked : input.value.trim();
|
||||
});
|
||||
return values;
|
||||
});
|
||||
@@ -997,8 +1039,15 @@
|
||||
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
||||
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
||||
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
||||
'<div class="podman-modal-field"><label>Run as user (optional)</label>' +
|
||||
'<input type="text" class="mono" id="cc-user" placeholder="99:100">' +
|
||||
'<div class="hint">Overrides the image\'s own default user — needed when a bind-mounted directory is owned by a specific UID:GID (Unraid\'s own containers commonly use "99:100").</div></div>' +
|
||||
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
|
||||
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
|
||||
'<div class="podman-modal-field"><label>Device passthrough (optional)</label>' +
|
||||
'<div class="podman-row-group" id="cc-devices"></div>' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" data-add="device">+ Add device</button>' +
|
||||
'<div class="hint">A host device path (e.g. a USB serial adapter) mounted at the same path inside the container — for a GPU, use the field above instead.</div></div>' +
|
||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||
@@ -1025,6 +1074,7 @@
|
||||
if (prefill.webUrl) backdrop.querySelector('#cc-weburl').value = prefill.webUrl;
|
||||
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
|
||||
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
|
||||
if (prefill.user) backdrop.querySelector('#cc-user').value = prefill.user;
|
||||
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
|
||||
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
|
||||
|
||||
@@ -1049,16 +1099,19 @@
|
||||
const portsGroup = backdrop.querySelector('#cc-ports');
|
||||
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
||||
const envGroup = backdrop.querySelector('#cc-env');
|
||||
const devicesGroup = backdrop.querySelector('#cc-devices');
|
||||
// A template may carry zero, one, or several rows of each kind — always
|
||||
// leave at least one (blank) row so the user has somewhere to type,
|
||||
// matching the blank-form behavior.
|
||||
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
|
||||
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
|
||||
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
|
||||
(prefill.devices && prefill.devices.length ? prefill.devices : [{}]).forEach(function (row) { addRow(devicesGroup, deviceRowHtml, row); });
|
||||
|
||||
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
||||
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
||||
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
||||
backdrop.querySelector('[data-add="device"]').addEventListener('click', function () { addRow(devicesGroup, deviceRowHtml); });
|
||||
|
||||
// Populate the network dropdown with any existing custom (non-default)
|
||||
// podman networks, in addition to the built-in bridge/host/none modes
|
||||
@@ -1140,13 +1193,18 @@
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (editing && !confirm(
|
||||
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
||||
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
|
||||
)) {
|
||||
return;
|
||||
if (editing) {
|
||||
P.confirm(
|
||||
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
||||
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?',
|
||||
{ confirmLabel: 'Continue' }
|
||||
).then(function (ok) { if (ok) proceed(); });
|
||||
} else {
|
||||
proceed();
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
const image = backdrop.querySelector('#cc-image').value.trim();
|
||||
if (!image) {
|
||||
showError('"Image" is required.');
|
||||
@@ -1173,6 +1231,7 @@
|
||||
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
|
||||
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
||||
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
||||
const devices = readRows(devicesGroup).filter(function (r) { return r.path; });
|
||||
|
||||
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
|
||||
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
|
||||
@@ -1213,7 +1272,9 @@
|
||||
env: env,
|
||||
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
||||
gpuDevices: gpuDevices,
|
||||
devices: devices,
|
||||
privileged: privileged,
|
||||
user: backdrop.querySelector('#cc-user').value.trim(),
|
||||
icon: backdrop.querySelector('#cc-icon').value.trim(),
|
||||
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
||||
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
||||
@@ -1233,6 +1294,7 @@
|
||||
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
|
||||
category: backdrop.querySelector('#cc-template-category').value.trim(),
|
||||
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
|
||||
webUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
||||
}).catch(function (err) {
|
||||
P.toast('Container created, but saving the template failed: ' + err.message, 'warn');
|
||||
});
|
||||
@@ -1263,11 +1325,13 @@
|
||||
});
|
||||
};
|
||||
if (action === 'remove') {
|
||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
||||
doIt({ force: true });
|
||||
P.confirm('Remove this container? This does not remove its volumes.', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||
if (ok) doIt({ force: true });
|
||||
});
|
||||
} else if (action === 'kill') {
|
||||
if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return;
|
||||
doIt({});
|
||||
P.confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.', { danger: true, confirmLabel: 'Kill' }).then(function (ok) {
|
||||
if (ok) doIt({});
|
||||
});
|
||||
} else {
|
||||
doIt({});
|
||||
}
|
||||
@@ -1318,16 +1382,18 @@
|
||||
if (btn.dataset.action === 'menu') {
|
||||
openRowMenu(c, btn);
|
||||
} else if (btn.dataset.action === 'update') {
|
||||
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
|
||||
btn.disabled = true;
|
||||
const modal = P.openLogModal('Updating ' + c.name);
|
||||
updateContainer(c, modal.log).then(function () {
|
||||
modal.done();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
modal.log('Failed: ' + err.message);
|
||||
modal.done();
|
||||
btn.disabled = false;
|
||||
P.confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.', { confirmLabel: 'Update' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
const modal = P.openLogModal('Updating ' + c.name);
|
||||
updateContainer(c, modal.log).then(function () {
|
||||
modal.done();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
modal.log('Failed: ' + err.message);
|
||||
modal.done();
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
openDetailModal(c);
|
||||
|
||||
@@ -72,20 +72,22 @@
|
||||
return;
|
||||
}
|
||||
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
|
||||
if (!confirm(
|
||||
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
|
||||
'This removes any tagged image with zero containers using it, not just dangling ones.'
|
||||
)) return;
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'prune').then(function (result) {
|
||||
btn.disabled = false;
|
||||
P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success');
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
P.toast('Prune failed: ' + err.message, 'error');
|
||||
P.confirm(
|
||||
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')? ' +
|
||||
'This removes any tagged image with zero containers using it, not just dangling ones.',
|
||||
{ danger: true, confirmLabel: 'Remove' }
|
||||
).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'prune').then(function (result) {
|
||||
btn.disabled = false;
|
||||
P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success');
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
P.toast('Prune failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,11 +112,13 @@
|
||||
}
|
||||
|
||||
if (btn.dataset.action === 'remove') {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
P.confirm('Remove this image?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,11 +161,13 @@
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove network "' + name + '"?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
P.confirm('Remove network "' + name + '"?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -190,8 +190,9 @@
|
||||
label: 'Remove',
|
||||
danger: true,
|
||||
onClick: function () {
|
||||
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
|
||||
handleAction(pod.name, 'remove', { force: true });
|
||||
P.confirm('Remove pod "' + pod.name + '" and all its member containers?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||
if (ok) handleAction(pod.name, 'remove', { force: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
P.openContextMenu(btn, items);
|
||||
|
||||
@@ -189,17 +189,19 @@
|
||||
|
||||
submitBtn.addEventListener('click', function () {
|
||||
if (!select.value || !confirmBox.checked) return;
|
||||
if (!confirm('Format ' + select.value + '? This cannot be undone.')) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Formatting…';
|
||||
P.post('disks', 'format', { device: select.value }).then(function (data) {
|
||||
close();
|
||||
P.el('settings-storage-path').value = data.mountPath;
|
||||
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
|
||||
}).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Format Disk';
|
||||
P.toast('Format failed: ' + err.message, 'error');
|
||||
P.confirm('Format ' + select.value + '? This cannot be undone.', { danger: true, confirmLabel: 'Format' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Formatting…';
|
||||
P.post('disks', 'format', { device: select.value }).then(function (data) {
|
||||
close();
|
||||
P.el('settings-storage-path').value = data.mountPath;
|
||||
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
|
||||
}).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Format Disk';
|
||||
P.toast('Format failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,12 +255,14 @@
|
||||
P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus);
|
||||
P.el('settings-service-start-btn').addEventListener('click', function () { runServiceAction('service_start', 'Starting Podman'); });
|
||||
P.el('settings-service-stop-btn').addEventListener('click', function () {
|
||||
if (!confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).')) return;
|
||||
runServiceAction('service_stop', 'Stopping Podman');
|
||||
P.confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).', { confirmLabel: 'Stop' }).then(function (ok) {
|
||||
if (ok) runServiceAction('service_stop', 'Stopping Podman');
|
||||
});
|
||||
});
|
||||
P.el('settings-service-restart-btn').addEventListener('click', function () {
|
||||
if (!confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.')) return;
|
||||
runServiceAction('service_restart', 'Restarting Podman');
|
||||
P.confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.', { confirmLabel: 'Restart' }).then(function (ok) {
|
||||
if (ok) runServiceAction('service_restart', 'Restarting Podman');
|
||||
});
|
||||
});
|
||||
P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal);
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
/**
|
||||
* javascript/templates.js
|
||||
*
|
||||
* Templates panel: reusable container configs saved as XML (Unraid
|
||||
* Docker-template-compatible schema — see ajax/templates.php's header
|
||||
* comment for why). "Use template" hands off to containers.js's Create
|
||||
* Container modal, pre-filled; templates are themselves created from
|
||||
* that same modal's "Save as template" checkbox, not from here.
|
||||
* The "Apps" panel: two sub-views toggled by a segmented control —
|
||||
* "Store" (browses/searches Community Applications' own public app feed
|
||||
* directly, see ajax/templates.php's ca_feed_search()) and "My Templates"
|
||||
* (this plugin's own saved, reusable container configs, XML in the same
|
||||
* schema Unraid's own Docker Manager templates use). "Use"/"Install" both
|
||||
* hand off to containers.js's Create Container modal, pre-filled;
|
||||
* templates are themselves created either from that same modal's "Save as
|
||||
* template" checkbox, or by installing a Store app (which saves it as a
|
||||
* template too, so it shows up under My Templates afterward).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let allTemplates = [];
|
||||
let activeSubview = 'store';
|
||||
let storeResults = [];
|
||||
let storeSearchTimer = null;
|
||||
|
||||
function iconHtml(t) {
|
||||
if (t.icon) {
|
||||
@@ -21,6 +28,8 @@
|
||||
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
||||
}
|
||||
|
||||
// --- My Templates --------------------------------------------------------
|
||||
|
||||
function cardHtml(t) {
|
||||
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
||||
return '' +
|
||||
@@ -38,36 +47,23 @@
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
function renderTemplatesGrid() {
|
||||
const grid = P.el('templates-grid');
|
||||
grid.innerHTML = allTemplates.length
|
||||
? allTemplates.map(cardHtml).join('')
|
||||
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, or import an XML template.</div>';
|
||||
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, install one from the Store, or import an XML template.</div>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const container = P.el('podman-panel-templates');
|
||||
if (!P.el('templates-grid')) {
|
||||
container.innerHTML = '' +
|
||||
'<div class="podman-card">' +
|
||||
'<div class="podman-toolbar">' +
|
||||
'<strong style="flex:1;">Reusable container configs</strong>' +
|
||||
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn">⬆ Import Template</button>' +
|
||||
'</div>' +
|
||||
'<div class="podman-template-grid" id="templates-grid"></div>' +
|
||||
'</div>';
|
||||
P.el('templates-import-btn').addEventListener('click', openImportModal);
|
||||
P.el('templates-grid').addEventListener('click', handleCardClick);
|
||||
}
|
||||
return P.get('templates', 'list').then(function (data) {
|
||||
allTemplates = data;
|
||||
render();
|
||||
renderTemplatesGrid();
|
||||
}).catch(function (err) {
|
||||
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function handleCardClick(e) {
|
||||
function handleTemplatesGridClick(e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const name = btn.closest('.podman-template-card').dataset.name;
|
||||
@@ -103,15 +99,103 @@
|
||||
}
|
||||
|
||||
if (btn.dataset.action === 'delete') {
|
||||
if (!confirm('Delete template "' + name + '"? This does not affect any running containers.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
P.confirm('Delete template "' + name + '"? This does not affect any running containers.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Store -----------------------------------------------------------------
|
||||
|
||||
function storeCardHtml(a) {
|
||||
const overview = a.overview && a.overview.length > 110 ? a.overview.slice(0, 107) + '…' : (a.overview || '');
|
||||
return '' +
|
||||
'<div class="podman-template-card" data-template-url="' + P.escapeHtml(a.templateUrl) + '">' +
|
||||
iconHtml(a) +
|
||||
'<div class="podman-template-body">' +
|
||||
'<div class="podman-template-name">' + P.escapeHtml(a.name) + '</div>' +
|
||||
'<div class="podman-row-sub mono">' + P.escapeHtml(a.image) + '</div>' +
|
||||
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="podman-template-actions">' +
|
||||
'<button class="podman-btn podman-btn-primary" data-action="install">Install</button>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
let storeQuery = '';
|
||||
let storeSort = 'newest';
|
||||
let storePage = 1;
|
||||
let storeTotalPages = 1;
|
||||
|
||||
function renderStoreGrid() {
|
||||
const grid = P.el('store-grid');
|
||||
grid.innerHTML = storeResults.length
|
||||
? storeResults.map(storeCardHtml).join('')
|
||||
: '<div class="podman-empty-note">No matches.</div>';
|
||||
}
|
||||
|
||||
function renderStorePager() {
|
||||
P.el('store-pager').style.display = storeTotalPages > 1 ? '' : 'none';
|
||||
P.el('store-pager-label').textContent = 'Page ' + storePage + ' of ' + storeTotalPages;
|
||||
P.el('store-pager-prev').disabled = storePage <= 1;
|
||||
P.el('store-pager-next').disabled = storePage >= storeTotalPages;
|
||||
}
|
||||
|
||||
// The sort toggle only means anything while browsing (no search term) —
|
||||
// a search is always alphabetical (see ca_feed_search()'s own comment on
|
||||
// why "newest"/downloads-based ordering doesn't make sense for a filtered
|
||||
// result set), so the toggle is hidden rather than left present but inert.
|
||||
function updateSortToggleVisibility() {
|
||||
P.el('store-sort-toggle').style.display = storeQuery ? 'none' : '';
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
const grid = P.el('store-grid');
|
||||
grid.innerHTML = '<div class="podman-empty-note">Loading…</div>';
|
||||
updateSortToggleVisibility();
|
||||
return P.get('templates', 'apps_search', { q: storeQuery, page: storePage, sort: storeSort }).then(function (data) {
|
||||
storeResults = data.results;
|
||||
storePage = data.page;
|
||||
storeTotalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
||||
renderStoreGrid();
|
||||
renderStorePager();
|
||||
}).catch(function (err) {
|
||||
grid.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function handleStoreGridClick(e) {
|
||||
const btn = e.target.closest('button[data-action="install"]');
|
||||
if (!btn) return;
|
||||
const templateUrl = btn.closest('.podman-template-card').dataset.templateUrl;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing…';
|
||||
P.post('templates', 'import_url', { url: templateUrl }).then(function (result) {
|
||||
return P.get('templates', 'get', { name: result.name });
|
||||
}).then(function (config) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Install';
|
||||
P.openCreateContainerModal(config);
|
||||
load(); // refreshes "My Templates" in the background — it's now saved there too
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Install';
|
||||
P.toast('Install failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// --- Import Template modal ---------------------------------------------
|
||||
|
||||
/**
|
||||
* A modal (not its own tab — tried that, but a modal is enough room for
|
||||
* this and keeps the nav from growing another entry for what's really a
|
||||
* secondary action off "My Templates").
|
||||
*/
|
||||
function openImportModal() {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
@@ -123,6 +207,11 @@
|
||||
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
|
||||
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="podman-modal-field"><label>Or import from a URL</label>' +
|
||||
'<div style="display:flex; gap:6px;">' +
|
||||
'<input type="url" id="ti-url" placeholder="https://raw.githubusercontent.com/.../template.xml" style="flex:1;">' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" data-role="fetch-url">Fetch & Import</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
|
||||
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></div>' +
|
||||
'</form>' +
|
||||
@@ -205,8 +294,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
function submitUrl() {
|
||||
const url = backdrop.querySelector('#ti-url').value.trim();
|
||||
if (!url) {
|
||||
showError('Enter a URL first.');
|
||||
return;
|
||||
}
|
||||
const fetchBtn = backdrop.querySelector('[data-role="fetch-url"]');
|
||||
fetchBtn.disabled = true;
|
||||
P.post('templates', 'import_url', { url: url }).then(function () {
|
||||
close();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
fetchBtn.disabled = false;
|
||||
showError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
||||
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
||||
backdrop.querySelector('[data-role="fetch-url"]').addEventListener('click', submitUrl);
|
||||
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
@@ -214,5 +321,85 @@
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('templates', { init: load, refresh: load });
|
||||
// --- Shell / sub-view toggle --------------------------------------------
|
||||
|
||||
function switchSubview(view) {
|
||||
activeSubview = view;
|
||||
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.view === view);
|
||||
});
|
||||
P.el('apps-store-view').style.display = view === 'store' ? '' : 'none';
|
||||
P.el('apps-templates-view').style.display = view === 'templates' ? '' : 'none';
|
||||
P.el('apps-store-search').style.display = view === 'store' ? '' : 'none';
|
||||
P.el('templates-import-btn').style.display = view === 'templates' ? '' : 'none';
|
||||
if (view === 'store') {
|
||||
updateSortToggleVisibility();
|
||||
} else {
|
||||
P.el('store-sort-toggle').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const container = P.el('podman-panel-templates');
|
||||
container.innerHTML = '' +
|
||||
'<div class="podman-card">' +
|
||||
'<div class="podman-toolbar">' +
|
||||
'<div class="podman-segmented" id="apps-subview-toggle">' +
|
||||
'<button class="active" data-view="store">Store</button>' +
|
||||
'<button data-view="templates">My Templates</button>' +
|
||||
'</div>' +
|
||||
'<input class="podman-search" id="apps-store-search" type="text" placeholder="Search Community Applications…">' +
|
||||
'<div class="podman-segmented" id="store-sort-toggle">' +
|
||||
'<button class="active" data-sort="newest">Newest</button>' +
|
||||
'<button data-sort="alpha">A-Z</button>' +
|
||||
'</div>' +
|
||||
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn" style="display:none;">⬆ Import Template</button>' +
|
||||
'</div>' +
|
||||
'<div id="apps-store-view">' +
|
||||
'<div class="podman-template-grid" id="store-grid"></div>' +
|
||||
'<div class="podman-pager" id="store-pager" style="display:none;">' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-prev">‹ Prev</button>' +
|
||||
'<span id="store-pager-label"></span>' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next ›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="apps-templates-view" style="display:none;"><div class="podman-template-grid" id="templates-grid"></div></div>' +
|
||||
'</div>';
|
||||
|
||||
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
|
||||
b.addEventListener('click', function () { switchSubview(b.dataset.view); });
|
||||
});
|
||||
P.el('apps-store-search').addEventListener('input', function (e) {
|
||||
storeQuery = e.target.value.trim();
|
||||
storePage = 1;
|
||||
if (storeSearchTimer) clearTimeout(storeSearchTimer);
|
||||
storeSearchTimer = setTimeout(loadStore, 400);
|
||||
});
|
||||
document.querySelectorAll('#store-sort-toggle button').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
storeSort = b.dataset.sort;
|
||||
storePage = 1;
|
||||
document.querySelectorAll('#store-sort-toggle button').forEach(function (x) { x.classList.toggle('active', x === b); });
|
||||
loadStore();
|
||||
});
|
||||
});
|
||||
P.el('store-pager-prev').addEventListener('click', function () {
|
||||
if (storePage <= 1) return;
|
||||
storePage -= 1;
|
||||
loadStore();
|
||||
});
|
||||
P.el('store-pager-next').addEventListener('click', function () {
|
||||
if (storePage >= storeTotalPages) return;
|
||||
storePage += 1;
|
||||
loadStore();
|
||||
});
|
||||
P.el('store-grid').addEventListener('click', handleStoreGridClick);
|
||||
P.el('templates-grid').addEventListener('click', handleTemplatesGridClick);
|
||||
P.el('templates-import-btn').addEventListener('click', openImportModal);
|
||||
|
||||
loadStore();
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('templates', { init: init, refresh: load });
|
||||
})();
|
||||
|
||||
@@ -70,11 +70,13 @@
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
P.confirm('Remove volume "' + name + '"? This deletes its data.', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user