Files
unraid-podman/webui/plugins/podman/javascript/templates.js
T
maggesandClaude Sonnet 5 7e2ed451e3
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s
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>
2026-07-19 19:24:32 +00:00

406 lines
17 KiB
JavaScript

/**
* javascript/templates.js
*
* 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) {
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>';
}
// --- My Templates --------------------------------------------------------
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 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, install one from the Store, or import an XML template.</div>';
}
function load() {
return P.get('templates', 'list').then(function (data) {
allTemplates = data;
renderTemplatesGrid();
}).catch(function (err) {
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleTemplatesGridClick(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;
P.toast('Could not load template: ' + err.message, 'error');
});
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;
P.toast('Export failed: ' + err.message, 'error');
});
return;
}
if (btn.dataset.action === 'delete') {
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';
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 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>' +
'<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);
});
}
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) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
// --- 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 });
})();