Files
Varaverk/Plugin/unraid/pages/docker.php
T
Gmer4Lfe e95fd30e13 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
2026-05-28 22:39:44 -04:00

312 lines
14 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.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>