Varaverk: Docker tab — folder management with conf + folder.view3 JSON dual-write

- New Docker tab (between Scheduler and Watchdog)
- Reads /boot/config/plugins/folder.view3/docker.json — fully compatible with folder.view3 plugin
- Writes changes to both docker.json AND HOST*_DOCKER_FOLDER_MAP in host*.conf simultaneously
- Edit mode: rename folders (inline input), delete folders, move containers via popover picker, create new folders
- Drift banner: highlights containers where conf desired state doesn't match json actual state
- Sync conf→JSON: apply conf desired state to json (fixes drift after onboard)
- Sync JSON→conf: capture manual json edits back into conf
- Ungrouped section shows all containers not assigned to any folder
- Onboard scripts can read HOST*_DOCKER_FOLDER_MAP to auto-place new containers
This commit is contained in:
Gmer4Lfe
2026-05-28 22:39:44 -04:00
parent fb051b60c1
commit e95fd30e13
4 changed files with 661 additions and 2 deletions
+311
View File
@@ -0,0 +1,311 @@
<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.danger{ border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
.vv-dk-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
.vv-dk-folder { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0;grid-column:span 2; }
.vv-dk-folder.edit{ border-color:#2a3a2a; }
.vv-dk-folder-h { display:flex;align-items:center;gap:6px;margin-bottom:8px; }
.vv-dk-folder-name{ font-size:12px;font-weight:bold;color:#888;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-dk-folder-name input { background:#111;border:1px solid #333;border-radius:3px;color:#bbb;font-size:12px;padding:1px 5px;width:100%;box-sizing:border-box; }
.vv-dk-folder-count { font-size:10px;color:#3a3a3a;white-space:nowrap; }
.vv-dk-pills { display:flex;flex-wrap:wrap;gap:4px; }
.vv-dk-pill { display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 7px;border-radius:3px;border:1px solid #242424;background:#1a1a1a;color:#777;max-width:100%;cursor:default;position:relative; }
.vv-dk-pill.running { border-color:#1a3a1a;background:#0d1f0d;color:#6fcf97; }
.vv-dk-pill.stopped { border-color:#242424;color:#555; }
.vv-dk-pill.edit-mode { cursor:pointer; }
.vv-dk-pill.edit-mode:hover { border-color:#2a4a2a;background:#141f14; }
.vv-dk-dot { width:5px;height:5px;border-radius:50%;flex-shrink:0; }
.vv-dk-pill-name { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px; }
.vv-dk-ungroup { grid-column:1/-1;background:#111;border:1px solid #202020;border-radius:6px;padding:10px; }
.vv-dk-ungroup-h { font-size:11px;color:#3a3a3a;font-weight:bold;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px; }
.vv-dk-drift { grid-column:1/-1;border:1px solid #3a2800;background:#1a1200;border-radius:6px;padding:10px; }
.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; }
.vv-dk-del-btn { font-size:11px;color:#555;cursor:pointer;padding:0 3px;flex-shrink:0; }
.vv-dk-del-btn:hover { color:#ef5350; }
.vv-dk-rename-btn{ font-size:11px;color:#555;cursor:pointer;padding:0 3px;flex-shrink:0; }
.vv-dk-rename-btn:hover { color:#ffb74d; }
.vv-dk-new-folder{ background:#111;border:1px dashed #282828;border-radius:6px;padding:10px;grid-column:span 2;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#3a3a3a;font-size:12px;min-height:60px; }
.vv-dk-new-folder:hover { border-color:#3a3a3a;color:#666; }
/* Folder picker popover */
.vv-dk-popover { position:fixed;z-index:9999;background:#1a1a1a;border:1px solid #333;border-radius:5px;padding:6px 0;min-width:160px;box-shadow:0 4px 16px #000a; }
.vv-dk-pop-item { padding:5px 12px;font-size:12px;color:#888;cursor:pointer;white-space:nowrap; }
.vv-dk-pop-item:hover { background:#222;color:#bbb; }
.vv-dk-pop-item.ungrouped { color:#555;border-top:1px solid #222;margin-top:4px;padding-top:8px; }
.vv-dk-pop-item.create-new { color:#5c9fd4;border-top:1px solid #222;margin-top:4px;padding-top:8px; }
</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 folder.view3 JSON">Sync conf JSON</button>
<button class="vv-dk-btn prim" id="vv-dk-sync-j2c" title="Capture current JSON state back to conf">Sync JSON conf</button>
<button class="vv-dk-btn" id="vv-dk-edit-toggle">Edit folders</button>
<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>
<!-- Folder picker popover (shared) -->
<div class="vv-dk-popover" id="vv-dk-popover" style="display:none;"></div>
<script>
(function() {
let _data = null;
let _editMode = false;
let _popTarget = null; // { container, currentFolderId }
// ── API ───────────────────────────────────────────────────────────────────────
function _api(params, cb) {
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.append(k, v);
fetch('/plugins/varaverk/api/docker.php', { method: 'POST', body: form })
.then(r => r.json())
.then(cb)
.catch(e => console.error('docker api error', e));
}
// ── Render ────────────────────────────────────────────────────────────────────
function _pill(c, editMode, folderId) {
const dot = c.running ? '#4caf50' : '#333';
const cls = 'vv-dk-pill ' + (c.running ? 'running' : 'stopped') + (editMode ? ' edit-mode' : '');
const attr = editMode
? `data-ctr="${c.name}" data-folder="${folderId || ''}" title="Click to move ${c.name}"`
: `title="${c.status || c.name}"`;
return `<span class="${cls}" ${attr}>
<span class="vv-dk-dot" style="background:${dot}"></span>
<span class="vv-dk-pill-name">${c.name}</span>
</span>`;
}
function _folderCard(f, editMode) {
const running = f.running;
const total = f.total;
const cls = 'vv-dk-folder' + (editMode ? ' edit' : '');
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 editControls = editMode
? `<span class="vv-dk-del-btn" data-delete-folder="${f.id}" title="Delete folder">×</span>`
: '';
const pills = f.containers.map(c => _pill(c, editMode, 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}</span>
${editControls}
</div>
<div class="vv-dk-pills">${pills || '<span style="color:#2a2a2a;font-size:11px;">empty</span>'}</div>
</div>`;
}
function _ungroupedCard(containers, editMode) {
if (!containers.length) return '';
const pills = containers.map(c => _pill(c, editMode, '')).join('');
return `<div class="vv-dk-ungroup">
<div class="vv-dk-ungroup-h">Ungrouped (${containers.length})</div>
<div class="vv-dk-pills">${pills}</div>
</div>`;
}
function _newFolderCard() {
return `<div class="vv-dk-new-folder" id="vv-dk-add-folder">+ New folder</div>`;
}
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" style="margin-bottom:12px;">
<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', _syncConfToJson);
}
function _render(data) {
_data = data;
let html = '';
for (const f of data.folders || []) html += _folderCard(f, _editMode);
if (_editMode) html += _newFolderCard();
html += _ungroupedCard(data.ungrouped || [], _editMode);
document.getElementById('vv-dk-grid').innerHTML = html || '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No folders configured.</div>';
_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 : '';
_bindEvents();
}
// ── Events ────────────────────────────────────────────────────────────────────
function _bindEvents() {
// Pill click (edit mode) — show folder picker
document.querySelectorAll('.vv-dk-pill.edit-mode').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
_popTarget = { container: el.dataset.ctr, currentFolderId: el.dataset.folder };
_showPopover(e.clientX, e.clientY);
});
});
// Rename input — blur or Enter
document.querySelectorAll('.vv-dk-rename-input').forEach(el => {
el.addEventListener('keydown', e => { if (e.key === 'Enter') el.blur(); });
el.addEventListener('blur', () => {
const folderId = el.dataset.folderId;
const newName = el.value.trim();
if (!newName || !folderId) return;
const folder = (_data.folders || []).find(f => f.id === folderId);
if (folder && newName === folder.name) return;
_api({ action: 'rename_folder', folder_id: folderId, name: newName }, r => {
if (r.ok) _reload(); else alert('Rename failed: ' + (r.error || '?'));
});
});
});
// Delete folder
document.querySelectorAll('[data-delete-folder]').forEach(el => {
el.addEventListener('click', () => {
const folderId = el.dataset.deleteFolder;
const folder = (_data.folders || []).find(f => f.id === folderId);
if (!folder) return;
if (!confirm(`Delete folder "${folder.name}"? Containers will be ungrouped.`)) return;
_api({ action: 'delete_folder', folder_id: folderId }, 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 || !name.trim()) return;
_api({ action: 'create_folder', name: name.trim() }, r => {
if (r.ok) _reload(); else alert('Create failed: ' + (r.error || '?'));
});
});
}
// ── Folder picker 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 isCurrent = f.id === _popTarget.currentFolderId;
return `<div class="vv-dk-pop-item${isCurrent ? '" style="color:#4caf50' : ''}" data-folder-id="${f.id}">${_esc(f.name)}${isCurrent ? ' ✓' : ''}</div>`;
}).join('');
html += `<div class="vv-dk-pop-item ungrouped" data-folder-id="">Ungrouped</div>`;
html += `<div class="vv-dk-pop-item create-new" data-folder-id="__new__">+ New folder…</div>`;
pop.innerHTML = html;
pop.style.display = 'block';
// Position
const vw = window.innerWidth, vh = window.innerHeight;
pop.style.left = Math.min(x, vw - 200) + 'px';
pop.style.top = Math.min(y + 8, vh - 200) + 'px';
pop.querySelectorAll('[data-folder-id]').forEach(el => {
el.addEventListener('click', () => {
const fid = el.dataset.folderId;
_hidePopover();
if (fid === '__new__') {
const name = prompt('New folder name:');
if (!name || !name.trim()) return;
_api({ action: 'create_folder', name: name.trim() }, r => {
if (!r.ok) { alert('Create failed: ' + (r.error||'?')); return; }
_api({ action: 'move_container', container: _popTarget.container, folder_id: r.id }, r2 => {
if (r2.ok) _reload(); else alert('Move failed: ' + (r2.error||'?'));
});
});
} else {
_api({ action: 'move_container', container: _popTarget.container, folder_id: fid }, r => {
if (r.ok) _reload(); else alert('Move failed: ' + (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 actions ───────────────────────────────────────────────────────────
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 _syncConfToJson() {
_api({ action: 'sync_conf_to_json' }, r => {
if (r.ok) _reload();
else alert('Sync failed: ' + (r.error || '?'));
});
}
document.getElementById('vv-dk-sync-c2j').addEventListener('click', _syncConfToJson);
document.getElementById('vv-dk-sync-j2c').addEventListener('click', () => {
if (!confirm('Capture current folder.view3 JSON state into conf? This overwrites the conf map.')) return;
_api({ action: 'sync_json_to_conf' }, r => {
if (r.ok) _reload();
else alert('Sync failed: ' + (r.error || '?'));
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _reload() {
fetch('/plugins/varaverk/api/docker.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
_reload();
setInterval(_reload, 60000);
})();
</script>