Files
unraid-podman/webui/plugins/podman/javascript/templates.js
T
maggesandClaude Sonnet 5 23898ff62e
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 6s
Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
Dashboard:
- Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks)
  separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage
  meter rows) instead of forcing both into one tile grid, which produced
  awkward spanning-tile/dead-cell layouts.
- Fixed CPU usage never changing (libpod's own cpuUtilization is computed
  once and never resampled) by computing it from /proc/stat deltas instead.
- Fixed memory usage reading far too high by using /proc/meminfo's
  MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree.
- Added an Autostart Queue table reusing podman-autostart.sh's own
  failure-counter files.
- Dashboard and Containers now auto-refresh every ~2s (paused when the
  tab is hidden or a modal is open).

Toasts:
- Real success/warn/error/info toast notifications replacing every
  alert() used for one-way feedback, across every panel.

Container detail modal:
- 5 new tabs: Resources, Logs, Console, Events, Healthcheck.

Containers panel:
- Folders to group containers (name + icon), stored in the plugin's own
  folders.json — a folder's header always shows an icon+name+status chip
  per member, matching Unraid's own Docker page folders. "Move to
  Folder" becomes "Remove from Folder" once a container is already
  grouped.
- Containers can carry an icon URL and a WebUI URL (small button next to
  the name), both stored as container labels and auto-filled from
  templates where applicable.
- Settings: an "Add container" control for the Autostart order table.

Fixes:
- Context menus now measure their own rendered size and flip above the
  anchor when there isn't room below, instead of running off-screen.
- Containers table now uses table-layout:fixed with explicit column
  widths — auto layout was shifting every column (and the header) on
  every folder expand/collapse, and briefly again when a flex wrapper
  was mistakenly placed directly on a <td>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:32:34 +00:00

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">&#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();
}).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;
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') {
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');
});
}
}
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 });
})();