Files
Varaverk/Plugin/unraid/pages/docker.php
T
Gmer4Lfe 7d865b0a09 Ask with Unraid's own dialog, not the browser's
Every confirm() and prompt() in the plugin could be switched off from inside itself — one tick of
"prevent this page from creating additional dialogs" and all 32 of them returned false while
drawing nothing, across every tab, until a full reload. swal is already global on every webGUI
page and core uses it 370 times without a single confirm(), so this costs no new dependency.

vvConfirmRun() is the one that mattered: it returned a boolean to three callers testing !it, and
an unawaited promise is always truthy, so leaving those alone would have run every job without
asking. The wrapper's callback is a classic function expression on purpose — SweetAlert only
calls back on cancel when the callback's own source declares a parameter, and an arrow would
have hung the promise forever.
2026-08-09 21:44:43 -04:00

578 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Docker tab. Container inventory grouped into Varaverk-owned folders, with start / stop /
// restart controls and WebUI links.
//
// DESIGN PRINCIPLES
// Pure view — inventory and folder state come from api/docker.php; actions go to
// api/docker_action.php. Folder storage and the conf mirror live in include/docker.php.
//
// Folders are Varaverk's own, not folder.view3's. The grouping survives that plugin being
// absent or reset; it is synced to only when installed.
//
// OPERATIONAL SAFEGUARDS
// Destructive actions are confirmed in the browser before the request is sent.
//
// Container actions are routed through a dedicated action endpoint that validates the name
// against the real inventory — the page never composes a docker command.
//
// Container names are escaped on render, so a name containing markup cannot inject.
//
// RENDERS
// Folder-grouped container grid, per-container state, WebUI links, action buttons
//
// DEPENDS ON
// api/docker.php polled every 60s → include/docker.php
// api/docker_action.php start/stop/restart
?>
<style>
.vv-dk-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:0 2px;flex-wrap:wrap; }
.vv-dk-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
.vv-dk-ts { font-size:11px;color:#3a3a3a; }
.vv-dk-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
.vv-dk-btn:hover { background:#222;color:#bbb; }
.vv-dk-btn.active { background:#1a2a1a;border-color:#2d4a2d;color:#6fcf97; }
.vv-dk-btn.warn { border-color:#3a2800;background:#1f1500;color:#ffb74d; }
.vv-dk-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
/* Folder card */
.vv-dk-folder { background:#161616;border:1px solid #222;border-radius:6px;min-width:0;grid-column:span 4;overflow:hidden; }
.vv-dk-folder.wide{ grid-column:span 8; }
.vv-dk-folder.edit{ border-color:#2a3a2a; }
.vv-dk-folder-h { display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
.vv-dk-folder-name{ font-size:12px;font-weight:bold;color:#777;flex:1;min-width:0; }
.vv-dk-folder-name input { background:#0d0d0d;border:1px solid #333;border-radius:3px;color:#bbb;font-size:12px;padding:1px 6px;width:100%;box-sizing:border-box; }
.vv-dk-folder-count{ font-size:10px;color:#333;white-space:nowrap; }
.vv-dk-folder-del { font-size:13px;color:#444;cursor:pointer;padding:0 2px;line-height:1; }
.vv-dk-folder-del:hover { color:#ef5350; }
/* Container rows */
.vv-dk-ctr-list { max-height:480px;overflow-y:auto; }
.vv-dk-ctr { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:grid;grid-template-columns:auto 1fr;gap:0 10px;align-items:start; }
.vv-dk-ctr:last-child { border-bottom:none; }
.vv-dk-ctr:hover { background:#191919; }
.vv-dk-ctr.stopped { opacity:.6; }
.vv-dk-ctr.edit-mode { cursor:pointer; }
.vv-dk-ctr.edit-mode:hover { background:#141f14; }
.vv-dk-ctr-icon { width:28px;height:28px;border-radius:4px;object-fit:contain;background:#111;grid-row:span 3;align-self:center; }
.vv-dk-ctr-icon-ph{ width:28px;height:28px;border-radius:4px;background:#1a1a1a;border:1px solid #222;grid-row:span 3;align-self:center;display:flex;align-items:center;justify-content:center;font-size:11px;color:#333; }
.vv-dk-ctr-name-row{ display:flex;align-items:baseline;gap:8px;flex-wrap:wrap; }
.vv-dk-ctr-name { font-size:12px;font-weight:bold;color:#bbb; }
.vv-dk-ctr-name a { color:#bbb;text-decoration:none; }
.vv-dk-ctr-name a:hover { color:#6fcf97; }
.vv-dk-ctr-image { font-size:10px;color:#3a3a3a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px; }
.vv-dk-ctr-status { font-size:10px;font-weight:bold;letter-spacing:.04em; }
.vv-dk-ctr-status.running { color:#4caf50; }
.vv-dk-ctr-status.stopped { color:#555; }
.vv-dk-ctr-status.paused { color:#ffb74d; }
.vv-dk-ctr-status.exited { color:#ef5350; }
.vv-dk-meta-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:3px; }
.vv-dk-net-badge { font-size:10px;padding:1px 6px;border-radius:2px;background:#0d1a2a;color:#5c7cfa;border:1px solid #1a2a3a;white-space:nowrap; }
.vv-dk-port-badge { font-size:10px;padding:1px 6px;border-radius:2px;background:#1a1a0a;color:#cddc39;border:1px solid #2a2a1a;white-space:nowrap; }
.vv-dk-paths { margin-top:4px;display:flex;flex-direction:column;gap:1px; }
.vv-dk-path { font-size:10px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-dk-path .src { color:#3a3a3a; }
.vv-dk-path .arr { color:#2a2a2a;margin:0 3px; }
.vv-dk-path .dst { color:#2e3e2e; }
.vv-dk-paths-more { font-size:10px;color:#2a2a2a;cursor:pointer;margin-top:1px; }
.vv-dk-paths-more:hover { color:#555; }
/* Ungrouped */
.vv-dk-ungroup { grid-column:1/-1;background:#111;border:1px solid #1e1e1e;border-radius:6px;overflow:hidden; }
.vv-dk-ungroup-h { padding:8px 12px;border-bottom:1px solid #1a1a1a;background:#0d0d0d; }
.vv-dk-ungroup-ht { font-size:11px;color:#3a3a3a;font-weight:bold;text-transform:uppercase;letter-spacing:.05em; }
/* New folder placeholder */
.vv-dk-new-card { grid-column:span 4;background:#0d0d0d;border:1px dashed #222;border-radius:6px;min-height:80px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#2a2a2a;font-size:12px; }
.vv-dk-new-card:hover { border-color:#333;color:#555; }
/* Drift */
.vv-dk-drift { grid-column:1/-1;border:1px solid #3a2800;background:#1a1200;border-radius:6px;padding:10px;margin-bottom:12px; }
.vv-dk-drift-h { font-size:11px;color:#aa7020;font-weight:bold;margin-bottom:6px; }
.vv-dk-drift-row { font-size:11px;color:#7a5020;margin:2px 0; }
/* Popover */
.vv-dk-popover { position:fixed;z-index:9999;background:#1c1c1c;border:1px solid #333;border-radius:5px;padding:5px 0;min-width:170px;box-shadow:0 4px 20px #000c; }
.vv-dk-pop-item { padding:5px 14px;font-size:12px;color:#888;cursor:pointer;white-space:nowrap; }
.vv-dk-pop-item:hover { background:#252525;color:#ccc; }
.vv-dk-pop-item.current { color:#4caf50; }
.vv-dk-pop-item.sep { border-top:1px solid #222;margin-top:4px;padding-top:8px; }
.vv-dk-pop-item.blue { color:#5c9fd4; }
.vv-dk-pop-item.red { color:#ef5350; }
/* Action button + job badge */
.vv-dk-ctr-act-btn { font-size:11px;padding:0 5px;border-radius:3px;border:1px solid #222;background:transparent;color:#333;cursor:pointer;line-height:1.7;margin-left:auto;flex-shrink:0; }
.vv-dk-ctr-act-btn:hover { background:#1e1e1e;color:#777; }
.vv-dk-job-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;flex-shrink:0; }
.vv-dk-job-badge.pulling { background:#0d1a2a;color:#5c7cfa; }
.vv-dk-job-badge.rebuilding { background:#1a1a0a;color:#cddc39; }
.vv-dk-job-badge.restarting { background:#1a1a2a;color:#9c89d4; }
.vv-dk-job-badge.done-ok { background:#0d1a0d;color:#4caf50; }
.vv-dk-job-badge.done-err { background:#1a0d0d;color:#ef5350; }
/* Log panel */
.vv-dk-log-panel { padding:8px 12px;background:#080808;border-top:1px solid #161616; }
.vv-dk-log-pre { margin:0;font-size:10px;color:#4a4a4a;font-family:monospace;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow-y:auto; }
</style>
<div class="vv-dk-toolbar">
<span class="vv-dk-title">Docker</span>
<button class="vv-dk-btn warn" id="vv-dk-sync-c2j" title="Apply conf desired state to JSON">Sync conf → JSON</button>
<button class="vv-dk-btn prim" id="vv-dk-sync-j2c" title="Capture JSON state into conf">Sync JSON → conf</button>
<button class="vv-dk-btn" id="vv-dk-edit-toggle">Edit folders</button>
<span style="font-size:10px;color:#1a3a1a;" id="vv-dk-fv3"></span>
<span class="vv-dk-ts" id="vv-dk-ts"></span>
</div>
<div id="vv-dk-drift-banner"></div>
<div id="vv-dk-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<div class="vv-dk-popover" id="vv-dk-popover" style="display:none;"></div>
<script>
(function() {
let _data = null;
let _editMode = false;
let _popTarget = null;
// ── API ───────────────────────────────────────────────────────────────────────
function _api(params, cb) {
const fd = new URLSearchParams();
for (const [k,v] of Object.entries(params)) fd.append(k, v);
fetch('/plugins/varaverk/api/docker.php', {method:'POST', body:fd})
.then(r => r.json()).then(cb)
.catch(e => console.error('docker api', e));
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _shortImage(img) {
// strip registry host if present (e.g. lscr.io/linuxserver/sonarr → linuxserver/sonarr)
return img.replace(/^[a-z0-9.-]+\.[a-z]{2,}\//i, '');
}
// ── Container row ─────────────────────────────────────────────────────────────
function _ctrRow(c, folderId) {
const statusCls = c.running ? 'running' : (c.status || 'stopped');
const statusLbl = c.running ? 'RUNNING' : (c.status || 'STOPPED').toUpperCase();
const rowCls = 'vv-dk-ctr' + (c.running ? '' : ' stopped') + (_editMode ? ' edit-mode' : '');
const editAttr = _editMode ? `data-ctr="${_esc(c.name)}" data-folder="${folderId||''}" title="Move ${c.name}"` : '';
const ctrAttr = `data-ctr-name="${_esc(c.name)}"`;
const actBtn = !_editMode ? `<button class="vv-dk-ctr-act-btn" data-act-ctr="${_esc(c.name)}" title="Actions">···</button>` : '';
const jobBadge = !_editMode ? `<span class="vv-dk-job-badge" id="vv-dk-job-${_esc(c.name)}" style="display:none"></span>` : '';
// Icon or placeholder
const iconHtml = c.icon
? `<img class="vv-dk-ctr-icon" src="${_esc(c.icon)}" alt="" onerror="this.style.display='none'">`
: `<div class="vv-dk-ctr-icon-ph">◻</div>`;
// Name — link to WebUI if available
const nameHtml = c.webui
? `<a href="${_esc(c.webui)}" target="_blank">${_esc(c.name)}</a>`
: _esc(c.name);
// Networks + IPs
const nets = Object.entries(c.networks || {});
const netBadges = nets.map(([net, ip]) =>
`<span class="vv-dk-net-badge" title="${_esc(net)}">${_esc(ip)}</span>`).join('');
// Ports (cap at 4)
const ports = c.ports || [];
const portBadges = ports.slice(0,4).map(p =>
`<span class="vv-dk-port-badge">${_esc(p)}</span>`).join('');
const morePorts = ports.length > 4 ? `<span class="vv-dk-port-badge" style="color:#555">+${ports.length-4}</span>` : '';
// Paths (bind mounts, show 3 with expand)
const mounts = c.mounts || [];
const visibleMounts = mounts.slice(0, 3);
const hiddenCount = mounts.length - visibleMounts.length;
let pathsHtml = '';
if (visibleMounts.length) {
pathsHtml = `<div class="vv-dk-paths">` +
visibleMounts.map(m =>
`<div class="vv-dk-path"><span class="vv-dk-path src">${_esc(m.src)}</span><span class="vv-dk-path arr">→</span><span class="vv-dk-path dst">${_esc(m.dst)}</span></div>`
).join('') +
(hiddenCount > 0 ? `<div class="vv-dk-paths-more" data-expand="${_esc(c.name)}">+ ${hiddenCount} more path${hiddenCount>1?'s':''}</div>` : '') +
`</div>`;
}
return `<div class="${rowCls}" ${editAttr} ${ctrAttr}>
${iconHtml}
<div>
<div class="vv-dk-ctr-name-row">
<span class="vv-dk-ctr-name">${nameHtml}</span>
<span class="vv-dk-ctr-status ${statusCls}">${statusLbl}</span>
${jobBadge}${actBtn}
</div>
<div class="vv-dk-ctr-image">${_esc(_shortImage(c.image||''))}</div>
${(netBadges || portBadges) ? `<div class="vv-dk-meta-row">${netBadges}${portBadges}${morePorts}</div>` : ''}
${pathsHtml}
</div>
</div>
<div class="vv-dk-log-panel" id="vv-dk-log-${_esc(c.name)}" style="display:none">
<pre class="vv-dk-log-pre"></pre>
</div>`;
}
// ── Folder card ───────────────────────────────────────────────────────────────
function _folderCard(f) {
const wide = f.total > 7;
const cls = 'vv-dk-folder' + (wide ? ' wide' : '') + (_editMode ? ' edit' : '');
const running = f.running, total = f.total;
let nameHtml;
if (_editMode) {
nameHtml = `<span class="vv-dk-folder-name"><input type="text" value="${_esc(f.name)}" data-folder-id="${f.id}" class="vv-dk-rename-input"></span>`;
} else {
nameHtml = `<span class="vv-dk-folder-name">${_esc(f.name)}</span>`;
}
const delBtn = _editMode
? `<span class="vv-dk-folder-del" data-delete-folder="${f.id}" title="Delete folder">×</span>` : '';
const rows = (f.containers || []).map(c => _ctrRow(c, f.id)).join('');
return `<div class="${cls}" data-folder-id="${f.id}">
<div class="vv-dk-folder-h">
${nameHtml}
<span class="vv-dk-folder-count">${running}/${total} running</span>
${delBtn}
</div>
<div class="vv-dk-ctr-list">${rows || '<div style="padding:10px 12px;color:#2a2a2a;font-size:11px;">Empty</div>'}</div>
</div>`;
}
// ── Ungrouped section ─────────────────────────────────────────────────────────
function _ungroupedCard(containers) {
if (!containers.length) return '';
const rows = containers.map(c => _ctrRow(c, '')).join('');
return `<div class="vv-dk-ungroup">
<div class="vv-dk-ungroup-h">
<span class="vv-dk-ungroup-ht">Ungrouped (${containers.length})</span>
</div>
<div class="vv-dk-ctr-list">${rows}</div>
</div>`;
}
// ── Drift banner ──────────────────────────────────────────────────────────────
function _driftBanner(drift) {
const el = document.getElementById('vv-dk-drift-banner');
if (!drift || !drift.length) { el.innerHTML = ''; return; }
const rows = drift.map(d => {
const actual = d.actual ? `in <strong>${d.actual}</strong>` : 'ungrouped';
return `<div class="vv-dk-drift-row"><strong>${d.container}</strong> — conf wants <strong>${d.conf}</strong>, currently ${actual}</div>`;
}).join('');
el.innerHTML = `<div class="vv-dk-drift">
<div class="vv-dk-drift-h">⚠ Conf / JSON drift — ${drift.length} container${drift.length>1?'s':''} out of sync</div>
${rows}
<button class="vv-dk-btn warn" style="margin-top:8px;" id="vv-dk-fix-drift">Apply conf → JSON now</button>
</div>`;
document.getElementById('vv-dk-fix-drift')?.addEventListener('click', _syncC2J);
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
_data = data;
let html = '';
for (const f of data.folders || []) html += _folderCard(f);
if (_editMode) html += `<div class="vv-dk-new-card" id="vv-dk-add-folder">+ New folder</div>`;
html += _ungroupedCard(data.ungrouped || []);
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No containers found.</div>';
document.getElementById('vv-dk-grid').innerHTML = html;
_driftBanner(data.drift);
const ts = data.ts ? new Date(data.ts*1000).toLocaleString([],{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-dk-ts').textContent = ts ? 'Updated: '+ts : '';
const fv3El = document.getElementById('vv-dk-fv3');
if (fv3El) fv3El.textContent = data.fv3_synced ? '⇄ folder.view3' : '';
_bindEvents();
}
// ── Container actions ─────────────────────────────────────────────────────────
let _activeJobs = {}; // { ctrName: intervalId }
function _actApi(params, cb) {
const fd = new URLSearchParams();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fetch('/plugins/varaverk/api/docker_action.php', {method: 'POST', body: fd})
.then(r => r.json()).then(cb)
.catch(() => cb({ok: false, error: 'Request failed'}));
}
function _jobBadgeEl(name) {
return document.getElementById('vv-dk-job-' + name);
}
function _setBadge(name, cls, text, autohide) {
const el = _jobBadgeEl(name);
if (!el) return;
el.className = 'vv-dk-job-badge ' + cls;
el.textContent = text;
el.style.display = '';
if (autohide) setTimeout(() => { if (el.parentNode) el.style.display = 'none'; }, 4000);
}
function _pollJob(name, jobId) {
_actApi({action: 'job_status', job_id: jobId}, data => {
const st = data.status;
if (st === 'pulling') _setBadge(name, 'pulling', 'Pulling…', false);
if (st === 'rebuilding') _setBadge(name, 'rebuilding', 'Rebuilding…', false);
if (st === 'done') {
clearInterval(_activeJobs[name]);
delete _activeJobs[name];
if (data.ok) {
_setBadge(name, 'done-ok', data.message || 'Done', true);
} else {
_setBadge(name, 'done-err', data.error || 'Failed', true);
}
setTimeout(_reload, 1500);
}
});
}
function _startJob(name, jobId) {
if (_activeJobs[name]) clearInterval(_activeJobs[name]);
_setBadge(name, 'pulling', 'Pulling…', false);
_activeJobs[name] = setInterval(() => _pollJob(name, jobId), 2000);
}
function _doRestart(name) {
_setBadge(name, 'restarting', 'Restarting…', false);
_actApi({action: 'restart', name}, data => {
if (data.ok) {
_setBadge(name, 'done-ok', 'Restarted', true);
setTimeout(_reload, 1000);
} else {
_setBadge(name, 'done-err', 'Failed', true);
}
});
}
function _doStartStop(name, start) {
_actApi({action: start ? 'start' : 'stop', name}, data => {
if (data.ok) setTimeout(_reload, 800);
else alert((start ? 'Start' : 'Stop') + ' failed: ' + (data.output || data.error || '?'));
});
}
function _doPullRebuild(name) {
_actApi({action: 'pull_rebuild', name}, data => {
if (data.ok && data.job_id) {
_startJob(name, data.job_id);
} else {
alert('Update failed: ' + (data.error || '?'));
}
});
}
function _toggleLog(name) {
const panel = document.getElementById('vv-dk-log-' + name);
if (!panel) return;
if (panel.style.display !== 'none') {
panel.style.display = 'none';
return;
}
const pre = panel.querySelector('.vv-dk-log-pre');
pre.textContent = 'Loading…';
panel.style.display = '';
_actApi({action: 'logs', name}, data => {
pre.textContent = data.ok ? (data.logs || '(no output)') : ('Error: ' + (data.error || '?'));
pre.scrollTop = pre.scrollHeight;
});
}
function _showActionsMenu(name, running, x, y) {
const pop = document.getElementById('vv-dk-popover');
let html = '';
if (running) {
html += `<div class="vv-dk-pop-item red" data-act="stop" data-act-n="${_esc(name)}">Stop</div>`;
html += `<div class="vv-dk-pop-item" data-act="restart" data-act-n="${_esc(name)}">Restart</div>`;
} else {
html += `<div class="vv-dk-pop-item current" data-act="start" data-act-n="${_esc(name)}">Start</div>`;
}
html += `<div class="vv-dk-pop-item sep blue" data-act="pull_rebuild" data-act-n="${_esc(name)}">Update (Pull &amp; Rebuild)</div>`;
html += `<div class="vv-dk-pop-item sep" data-act="logs" data-act-n="${_esc(name)}">View Logs</div>`;
pop.innerHTML = html;
pop.style.display = 'block';
const vw = window.innerWidth, vh = window.innerHeight;
pop.style.left = Math.min(x, vw - 200) + 'px';
pop.style.top = Math.min(y + 8, vh - 160) + 'px';
pop.querySelectorAll('[data-act]').forEach(el => {
el.addEventListener('click', () => {
const act = el.dataset.act, n = el.dataset.actN;
_hidePopover();
if (act === 'start' || act === 'stop') _doStartStop(n, act === 'start');
else if (act === 'restart') _doRestart(n);
else if (act === 'pull_rebuild') _doPullRebuild(n);
else if (act === 'logs') _toggleLog(n);
});
});
}
// ── Events ────────────────────────────────────────────────────────────────────
function _bindEvents() {
// Action menu button (non-edit mode)
document.querySelectorAll('[data-act-ctr]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const name = btn.dataset.actCtr;
const allCtrs = [...(_data.folders||[]).flatMap(f=>f.containers), ...(_data.ungrouped||[])];
const c = allCtrs.find(x => x.name === name);
_showActionsMenu(name, c?.running ?? false, e.clientX, e.clientY);
});
});
// Container click in edit mode → folder picker
document.querySelectorAll('.vv-dk-ctr.edit-mode').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
_popTarget = { container: el.dataset.ctr, currentFolderId: el.dataset.folder };
_showPopover(e.clientX, e.clientY);
});
});
// "show more paths" expand
document.querySelectorAll('[data-expand]').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const cname = el.dataset.expand;
const allCtrs = [...(_data.folders||[]).flatMap(f=>f.containers), ...(_data.ungrouped||[])];
const c = allCtrs.find(x => x.name === cname);
if (!c) return;
const paths = document.querySelector(`.vv-dk-paths-more[data-expand="${CSS.escape(cname)}"]`)?.closest('.vv-dk-paths');
if (!paths || !c.mounts) return;
paths.innerHTML = c.mounts.map(m =>
`<div class="vv-dk-path"><span class="src">${_esc(m.src)}</span><span class="arr">→</span><span class="dst">${_esc(m.dst)}</span></div>`
).join('');
});
});
// Rename inputs
document.querySelectorAll('.vv-dk-rename-input').forEach(el => {
el.addEventListener('keydown', e => { if (e.key==='Enter') el.blur(); });
el.addEventListener('blur', () => {
const fid = el.dataset.folderId, name = el.value.trim();
if (!fid || !name) return;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (f && name===f.name) return;
el.disabled = true; el.style.opacity = '0.5';
_api({action:'rename_folder',folder_id:fid,name}, r => {
el.disabled = false; el.style.opacity = '';
if (r.ok) _reload(); else { alert('Rename failed: '+(r.error||'?')); el.value = f?.name ?? name; }
});
});
});
// Delete folder
document.querySelectorAll('[data-delete-folder]').forEach(el => {
el.addEventListener('click', async e => {
e.stopPropagation();
const fid = el.dataset.deleteFolder;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (!f || !await vvConfirm(`Delete "${f.name}"? Containers will be ungrouped.`)) return;
_api({action:'delete_folder',folder_id:fid}, r => { if(r.ok) _reload(); else alert('Delete failed: '+(r.error||'?')); });
});
});
// New folder
document.getElementById('vv-dk-add-folder')?.addEventListener('click', async () => {
const name = await vvPrompt('New folder name:');
if (name?.trim()) _api({action:'create_folder',name:name.trim()}, r => { if(r.ok) _reload(); else alert(r.error); });
});
}
// ── Popover ───────────────────────────────────────────────────────────────────
function _showPopover(x, y) {
if (!_data || !_popTarget) return;
const pop = document.getElementById('vv-dk-popover');
const folders = _data.folders || [];
let html = folders.map(f => {
const cur = f.id === _popTarget.currentFolderId;
return `<div class="vv-dk-pop-item${cur?' current':''}" data-fid="${f.id}">${_esc(f.name)}${cur?' ✓':''}</div>`;
}).join('');
html += `<div class="vv-dk-pop-item sep" data-fid="">Ungrouped</div>`;
html += `<div class="vv-dk-pop-item blue sep" data-fid="__new__">+ New folder…</div>`;
pop.innerHTML = html;
pop.style.display = 'block';
const vw=window.innerWidth, vh=window.innerHeight;
pop.style.left = Math.min(x, vw-180)+'px';
pop.style.top = Math.min(y+8, vh-180)+'px';
pop.querySelectorAll('[data-fid]').forEach(el => {
el.addEventListener('click', async () => {
const fid = el.dataset.fid;
_hidePopover();
if (fid==='__new__') {
const name = await vvPrompt('New folder name:');
if (!name?.trim()) return;
_api({action:'create_folder',name:name.trim()}, r => {
if (!r.ok) { alert(r.error); return; }
_api({action:'move_container',container:_popTarget.container,folder_id:r.id}, r2 => { if(r2.ok) _reload(); });
});
} else {
_api({action:'move_container',container:_popTarget.container,folder_id:fid}, r => { if(r.ok) _reload(); else alert(r.error); });
}
});
});
}
function _hidePopover() {
document.getElementById('vv-dk-popover').style.display='none';
_popTarget=null;
}
document.addEventListener('click', e => {
if (!document.getElementById('vv-dk-popover').contains(e.target)) _hidePopover();
});
// ── Toolbar ───────────────────────────────────────────────────────────────────
document.getElementById('vv-dk-edit-toggle').addEventListener('click', function() {
_editMode = !_editMode;
this.textContent = _editMode ? 'Done editing' : 'Edit folders';
this.classList.toggle('active', _editMode);
if (_data) _render(_data);
});
function _syncC2J() {
_api({action:'sync_conf_to_json'}, r => { if(r.ok) _reload(); else alert(r.error); });
}
document.getElementById('vv-dk-sync-c2j').addEventListener('click', _syncC2J);
document.getElementById('vv-dk-sync-j2c').addEventListener('click', async () => {
if (!await vvConfirm('Overwrite conf map with current JSON state?')) return;
_api({action:'sync_json_to_conf'}, r => { if(r.ok) _reload(); else alert(r.error); });
});
// ── Load ──────────────────────────────────────────────────────────────────────
function _reload() {
fetch('/plugins/varaverk/api/docker.php')
.then(r=>r.json()).then(_render).catch(()=>{});
}
_reload();
setInterval(_reload, 60000);
})();
</script>