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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user