Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s

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:
2026-07-19 19:24:32 +00:00
co-authored by Claude Sonnet 5
parent 9d46547ef4
commit 7e2ed451e3
14 changed files with 911 additions and 134 deletions
+66 -6
View File
@@ -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,
+13 -11
View File
@@ -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;
});
});
}
+100 -34
View File
@@ -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>&rarr;</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">&times;</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">&times;</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);
+22 -18
View File
@@ -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;
});
});
}
});
+7 -5
View File
@@ -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;
});
});
});
+3 -2
View File
@@ -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);
+19 -15
View File
@@ -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);
+215 -28
View File
@@ -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">&#11014; 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 &amp; 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;">&#11014; 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">&#8249; Prev</button>' +
'<span id="store-pager-label"></span>' +
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next &#8250;</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 });
})();
+7 -5
View File
@@ -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;
});
});
});