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