Files
Varaverk/Plugin/unraid/pages/docker.php
T
Gmer4Lfe 00a8a64c4e Docker tab: own config file, folder.view3 becomes optional sync target
- Primary store moved to /boot/config/plugins/varaverk/docker_folders.json
- No dependency on folder.view3 plugin being installed
- Auto-imports folder.view3 JSON on first run if it exists (one-time bootstrap)
- Mirrors writes to folder.view3 JSON only if that plugin's directory is present
- Toolbar shows '⇄ folder.view3' badge when sync is active
2026-05-28 22:41:10 -04:00

316 lines
15 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 style="font-size:10px;color:#2a4a2a;" 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>
<!-- 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 : '';
const fv3El = document.getElementById('vv-dk-fv3');
if (fv3El) fv3El.textContent = data.fv3_synced ? '⇄ folder.view3' : '';
_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>