Files
Varaverk/Plugin/unraid/pages/docker.php
T
Gmer4Lfe f42ecc8464 Add docker actions, arr profile enforcer, monitor caching, and web file symlink
Web files now served via symlink to the git repo so git pull changes survive
reboots without rebuilding the txz. Also includes: docker pull/rebuild/restart
with live log streaming, arr_profile_enforcer for Sonarr/Radarr quality
profiles, monitor page cache fix (background writer now in cron), and
ARR_KIDS/SONARR/RADARR profile name vars in master.conf.
2026-06-19 11:09:40 -04:00

550 lines
26 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.
<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 FormData();
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 FormData();
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', e => {
e.stopPropagation();
const fid = el.dataset.deleteFolder;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (!f || !confirm(`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', () => {
const name = prompt('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', () => {
const fid = el.dataset.fid;
_hidePopover();
if (fid==='__new__') {
const name = prompt('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', () => {
if (!confirm('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>