- Replace pill view with per-container rows showing full detail - docker inspect batch call: networks+IPs, port mappings, bind mounts - Container icon from template XML, name links to WebUI if configured - Network IP badges (blue), port mapping badges (yellow) - Bind mount paths shown src→dst, collapsible expand for >3 paths - Large folders (>7 containers) auto-span full width - Status badge: RUNNING/STOPPED/PAUSED/EXITED with colour - Stopped containers dimmed, icons load from template XMLs
390 lines
19 KiB
PHP
390 lines
19 KiB
PHP
<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; }
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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}"` : '';
|
||
|
||
// 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}>
|
||
${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>
|
||
</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>`;
|
||
}
|
||
|
||
// ── 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();
|
||
}
|
||
|
||
// ── Events ────────────────────────────────────────────────────────────────────
|
||
|
||
function _bindEvents() {
|
||
// 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;
|
||
_api({action:'rename_folder',folder_id:fid,name}, r => { if(r.ok) _reload(); else alert('Rename failed: '+(r.error||'?')); });
|
||
});
|
||
});
|
||
|
||
// 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>
|