Create Container form: - GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA excluded since it needs a different runtime) - device paths strictly validated server-side against the host's own detected list. - Macvlan network support: selecting a macvlan network reveals a static IP field and hides port mappings (meaningless once the container has its own LAN address), matching Unraid Docker Manager's "Custom: br0" behavior. Networks panel gained a matching macvlan network-creation flow, with the parent-interface dropdown read from Unraid's own network.cfg so it lists exactly what Docker Manager itself offers. Containers panel: - Edit: reopens the create form pre-filled from the container's current config (image/ports/volumes/env/network/restart policy/GPU/static IP); saving stops+removes the old container and recreates it under the same settings, since podman/Docker have no in-place "modify" API for most of this. - Update: same stop/remove/recreate flow, but pulls the current image first. "Check for Updates" compares each in-use image's local digest against its origin registry (Docker Hub/GHCR/self-hosted registries all verified live) with no podman-side feature backing it - implemented via the registry's own HTTP API. A small log-modal shows progress for both actions instead of a silent wait. - Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted real image pulls/container creates mid-request; bumped to 600s (nginx already allows up to 640s for this plugin's requests). Images panel: - "Prune unused" (removes every image with zero containers referencing it, not just dangling ones - confirmation copy says so explicitly since this is more aggressive than it sounds) and per-image "Tag". Also several real UI bugs found via live screenshots: unused-image prune having no visible effect until reloaded, table action-button columns drifting row to row (a bare "display:flex" on a <td> was fighting the table layout algorithm), Templates category badges dumping raw multi-tag strings from real Unraid templates, and low-contrast search/filter controls that were nearly invisible against the card background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
9.3 KiB
JavaScript
219 lines
9.3 KiB
JavaScript
/**
|
|
* 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.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const P = window.Podman;
|
|
let allTemplates = [];
|
|
|
|
function iconHtml(t) {
|
|
if (t.icon) {
|
|
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
|
|
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
|
|
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
|
|
}
|
|
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
|
}
|
|
|
|
function cardHtml(t) {
|
|
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
|
return '' +
|
|
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
|
|
iconHtml(t) +
|
|
'<div class="podman-template-body">' +
|
|
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
|
|
'<div class="podman-row-sub mono">' + P.escapeHtml(t.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="use">Use</button>' +
|
|
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
|
|
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
function render() {
|
|
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>';
|
|
}
|
|
|
|
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();
|
|
}).catch(function (err) {
|
|
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function handleCardClick(e) {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
const name = btn.closest('.podman-template-card').dataset.name;
|
|
|
|
if (btn.dataset.action === 'use') {
|
|
btn.disabled = true;
|
|
P.get('templates', 'get', { name: name }).then(function (config) {
|
|
btn.disabled = false;
|
|
P.openCreateContainerModal(config);
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
alert('Could not load template: ' + err.message);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (btn.dataset.action === 'export') {
|
|
btn.disabled = true;
|
|
P.get('templates', 'export', { name: name }).then(function (data) {
|
|
btn.disabled = false;
|
|
const blob = new Blob([data.xml], { type: 'application/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name + '.xml';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
alert('Export failed: ' + err.message);
|
|
});
|
|
return;
|
|
}
|
|
|
|
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;
|
|
alert('Delete failed: ' + err.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
function openImportModal() {
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'podman-modal-backdrop';
|
|
backdrop.innerHTML = '' +
|
|
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
|
'<div class="podman-modal-head"><h3>Import Template</h3></div>' +
|
|
'<form class="podman-modal-body">' +
|
|
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
|
|
'<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 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>' +
|
|
'<div class="podman-modal-actions">' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
|
|
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Import pasted XML</button>' +
|
|
'</div></div>';
|
|
|
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
|
|
|
let localTemplates = [];
|
|
function renderLocalList(filter) {
|
|
const list = backdrop.querySelector('#ti-local-list');
|
|
const visible = filter
|
|
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
|
|
: localTemplates;
|
|
if (!visible.length) {
|
|
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = visible.map(function (t) {
|
|
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
|
|
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
|
|
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
P.get('templates', 'list_local').then(function (data) {
|
|
localTemplates = data;
|
|
renderLocalList('');
|
|
}).catch(function () {
|
|
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
|
|
});
|
|
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
|
|
renderLocalList(e.target.value.trim().toLowerCase());
|
|
});
|
|
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
|
|
const btn = e.target.closest('[data-action="import-local"]');
|
|
if (!btn) return;
|
|
const file = btn.closest('.podman-local-template-item').dataset.file;
|
|
btn.disabled = true;
|
|
P.post('templates', 'import_local', { file: file }).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
showError(err.message);
|
|
});
|
|
});
|
|
|
|
backdrop.querySelector('#ti-xml').focus();
|
|
|
|
function close() { backdrop.remove(); }
|
|
|
|
function showError(message) {
|
|
let box = backdrop.querySelector('.podman-modal-error');
|
|
if (!box) {
|
|
box = document.createElement('div');
|
|
box.className = 'podman-modal-error';
|
|
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
|
}
|
|
box.textContent = message;
|
|
}
|
|
|
|
function submit() {
|
|
const xml = backdrop.querySelector('#ti-xml').value.trim();
|
|
if (!xml) {
|
|
showError('Paste a template XML first.');
|
|
return;
|
|
}
|
|
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
|
submitBtn.disabled = true;
|
|
P.post('templates', 'import', { xml: xml }).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
submitBtn.disabled = false;
|
|
showError(err.message);
|
|
});
|
|
}
|
|
|
|
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
|
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
|
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) {
|
|
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
|
|
});
|
|
}
|
|
|
|
P.registerPanel('templates', { init: load, refresh: load });
|
|
})();
|