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>
171 lines
6.4 KiB
JavaScript
171 lines
6.4 KiB
JavaScript
/**
|
|
* javascript/compose.js
|
|
*
|
|
* Compose panel: project list + an editable YAML view + save/up/down/pull/
|
|
* delete, backed by ajax/compose.php. See that file's header comment —
|
|
* this is the one panel whose backend shells out to the `podman compose`
|
|
* CLI, because no REST equivalent for Compose exists in libpod.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const P = window.Podman;
|
|
let projects = [];
|
|
let selected = null;
|
|
|
|
const STARTER_YAML =
|
|
'services:\n' +
|
|
' app:\n' +
|
|
' image: docker.io/library/nginx:alpine\n' +
|
|
' ports:\n' +
|
|
' - "8080:80"\n';
|
|
|
|
function statusChip(status) {
|
|
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
|
|
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
|
|
}
|
|
|
|
function renderSidebar() {
|
|
P.el('compose-sidebar').innerHTML = projects.map(function (p) {
|
|
return '<div class="podman-compose-proj' + (p.name === selected ? ' active' : '') + '" data-name="' + P.escapeHtml(p.name) + '">' +
|
|
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
|
|
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
|
|
'</div>';
|
|
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
|
|
}
|
|
|
|
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
|
|
// — disabled (rather than left clickable and erroring) whenever nothing
|
|
// is selected, e.g. right after deleting the last project.
|
|
function setToolbarEnabled(enabled) {
|
|
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
|
|
P.el(id).disabled = !enabled;
|
|
});
|
|
P.el('compose-yaml').disabled = !enabled;
|
|
}
|
|
|
|
function loadYaml(name) {
|
|
P.el('compose-title').textContent = name + ' / compose.yaml';
|
|
P.el('compose-yaml').value = 'Loading…';
|
|
return P.get('compose', 'get', { project: name }).then(function (data) {
|
|
P.el('compose-yaml').value = data.yaml;
|
|
}).catch(function (err) {
|
|
P.el('compose-yaml').value = 'Error: ' + err.message;
|
|
});
|
|
}
|
|
|
|
function selectProject(name) {
|
|
selected = name;
|
|
setToolbarEnabled(true);
|
|
renderSidebar();
|
|
loadYaml(name);
|
|
}
|
|
|
|
function loadProjects() {
|
|
return P.get('compose', 'list').then(function (data) {
|
|
projects = data;
|
|
if (selected && !projects.some(function (p) { return p.name === selected; })) {
|
|
selected = null;
|
|
}
|
|
if (!selected && projects.length > 0) selected = projects[0].name;
|
|
renderSidebar();
|
|
if (selected) {
|
|
setToolbarEnabled(true);
|
|
loadYaml(selected);
|
|
} else {
|
|
setToolbarEnabled(false);
|
|
P.el('compose-title').textContent = '—';
|
|
P.el('compose-yaml').value = '';
|
|
}
|
|
}).catch(function (err) {
|
|
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function runAction(action) {
|
|
if (!selected) return;
|
|
const btn = P.el('compose-action-' + action);
|
|
btn.disabled = true;
|
|
// `podman compose` output can run to many lines — a log modal (same
|
|
// pattern as Settings' service Start/Stop/Restart) fits that; a toast
|
|
// has to stay short and auto-dismiss.
|
|
const modal = P.openLogModal('podman compose ' + action + ' — ' + selected);
|
|
P.post('compose', action, { project: selected }).then(function (data) {
|
|
(data.output || 'Done.').split('\n').forEach(function (line) { modal.log(line); });
|
|
modal.done();
|
|
return loadProjects();
|
|
}).catch(function (err) {
|
|
modal.log('Failed: ' + err.message);
|
|
modal.done('Close');
|
|
}).finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function saveYaml() {
|
|
if (!selected) return;
|
|
const btn = P.el('compose-action-save');
|
|
btn.disabled = true;
|
|
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
|
|
P.toast('Saved ' + selected + '.', 'success');
|
|
return loadProjects();
|
|
}).catch(function (err) {
|
|
P.toast('Save failed: ' + err.message, 'error');
|
|
}).finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function deleteProject() {
|
|
if (!selected) return;
|
|
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
|
|
const btn = P.el('compose-action-delete');
|
|
btn.disabled = true;
|
|
const name = selected;
|
|
P.post('compose', 'remove', { project: selected }).then(function () {
|
|
selected = null;
|
|
P.toast('Deleted ' + name + '.', 'success');
|
|
return loadProjects();
|
|
}).catch(function (err) {
|
|
P.toast('Delete failed: ' + err.message, 'error');
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function openNewProjectModal() {
|
|
P.openFormModal({
|
|
title: 'New Compose Project',
|
|
submitLabel: 'Create',
|
|
fields: [
|
|
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
|
|
],
|
|
onSubmit: function (values) {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
|
|
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
|
|
}
|
|
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
|
|
selected = values.name;
|
|
return loadProjects();
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
P.el('compose-sidebar').addEventListener('click', function (e) {
|
|
const item = e.target.closest('.podman-compose-proj[data-name]');
|
|
if (item) selectProject(item.dataset.name);
|
|
});
|
|
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
|
|
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
|
|
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
|
|
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
|
|
P.el('compose-action-save').addEventListener('click', saveYaml);
|
|
P.el('compose-action-delete').addEventListener('click', deleteProject);
|
|
|
|
setToolbarEnabled(false);
|
|
return loadProjects();
|
|
}
|
|
|
|
P.registerPanel('compose', { init: init, refresh: loadProjects });
|
|
})();
|