Remove the Docker tab and stop overwriting folder.view3's file, which destroyed folders made in its own UI

This commit is contained in:
Gmer4Lfe
2026-08-17 05:11:00 -04:00
parent 6a15a9d99a
commit 3676526daf
5 changed files with 20 additions and 686 deletions
+2 -2
View File
@@ -210,7 +210,7 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab // Determine active tab
$tab = $_GET['tab'] ?? 'monitor'; $tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings']; $validTabs = ['monitor', 'scheduler', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
// The AI tab exists only on HOST1, and only while AI_ENABLED is true. Appended to $validTabs // The AI tab exists only on HOST1, and only while AI_ENABLED is true. Appended to $validTabs
// rather than filtered out of it, so the check below rejects ?tab=ai server-side as well — // rather than filtered out of it, so the check below rejects ?tab=ai server-side as well —
@@ -228,7 +228,7 @@ $_vv_ai = vv_ai_owner_ui_on();
if ($_vv_ai) $validTabs[] = 'ai'; if ($_vv_ai) $validTabs[] = 'ai';
if (!in_array($tab, $validTabs)) $tab = 'monitor'; if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Media Stack', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI']; $tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Media Stack', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI'];
// Cache stamp for the stylesheet and script below. Both are served straight off the plugin // Cache stamp for the stylesheet and script below. Both are served straight off the plugin
// directory at a path that never changes, so a browser holding an old copy keeps using it after // directory at a path that never changes, so a browser holding an old copy keeps using it after
-89
View File
@@ -1,89 +0,0 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Docker folder endpoint. GET returns the full container inventory with folder assignments;
// POST performs one folder operation — create, rename, delete, move a container, or sync
// the folder store against master.conf in either direction.
//
// OPERATIONAL MODEL
// Read and write share one URL, split on HTTP method. GET is the docker tab's poll and is
// always safe. POST carries an `action` naming exactly one library call. Anything that is
// neither GET nor POST is refused with 405 before a parameter is read.
//
// DESIGN PRINCIPLES
// The action list is a closed match expression, not a dispatch table.
// Six named actions map to six library functions. An unrecognised action falls to the
// default arm and returns an error — it cannot resolve to a callable, because no part
// of the request is ever used to build a function name.
//
// Grouping is metadata, never container control.
// This endpoint moves containers between folders in a JSON store. It does not start,
// stop, or recreate anything — that is docker_action.php, deliberately a separate file
// with a separate confirmation path in the UI.
//
// Validation belongs to the library.
// Folder ids and names are trimmed here and checked in include/docker.php, so the same
// rules apply whether a call arrives from this endpoint or from the conf sync.
//
// OPERATIONAL SAFEGUARDS
// Wrong method is refused with a status code, not just a body.
// 405 is set alongside the JSON error so a mistaken caller fails visibly rather than
// parsing an error object as data.
//
// Every parameter is optional and defaults to empty.
// ?? '' on all four inputs means a malformed POST reaches the library as blank strings
// and is rejected there, rather than raising an undefined-index warning into the JSON
// body and corrupting the response.
//
// The folder store is written atomically by the library.
// vv_dk_write_json() writes to .vv.tmp and rename()s, so a delete or move interrupted
// mid-write cannot leave a truncated store — which would scatter every container back
// to ungrouped.
//
// REQUEST
// GET full inventory, no parameters
// POST action=move_container container, folder_id
// POST action=create_folder name
// POST action=rename_folder folder_id, name
// POST action=delete_folder folder_id
// POST action=sync_conf_to_json | sync_json_to_conf no further parameters
//
// RESPONSE
// GET vv_dk_all() verbatim — containers, folders, icons, WebUI links
// POST {"ok":bool,"error":string|null} as returned by the invoked library call
//
// DEPENDS ON
// include/docker.php vv_dk_all(), vv_dk_move_container(), vv_dk_create_folder(),
// vv_dk_rename_folder(), vv_dk_delete_folder(),
// vv_dk_sync_conf_to_json(), vv_dk_sync_json_to_conf()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/docker.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode(vv_dk_all());
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'GET or POST only']);
exit;
}
$action = trim($_POST['action'] ?? '');
$container = trim($_POST['container'] ?? '');
$folderId = trim($_POST['folder_id'] ?? '');
$name = trim($_POST['name'] ?? '');
$result = match ($action) {
'move_container' => vv_dk_move_container($container, $folderId),
'create_folder' => vv_dk_create_folder($name),
'rename_folder' => vv_dk_rename_folder($folderId, $name),
'delete_folder' => vv_dk_delete_folder($folderId),
'sync_conf_to_json'=> vv_dk_sync_conf_to_json(),
'sync_json_to_conf'=> vv_dk_sync_json_to_conf(),
default => ['ok' => false, 'error' => 'Unknown action: ' . $action],
};
echo json_encode($result);
+5 -2
View File
@@ -122,8 +122,11 @@ const VV_UI_SECTION_SURFACES = [
// to go for a setting named after the page you are already looking at. // to go for a setting named after the page you are already looking at.
['match' => 'arr|sonarr|radarr|lidarr|media|play state|emby|jellyfin|plex', 'tab' => 'Media Stack', ['match' => 'arr|sonarr|radarr|lidarr|media|play state|emby|jellyfin|plex', 'tab' => 'Media Stack',
'route' => 'Media Stack tab → Media settings'], 'route' => 'Media Stack tab → Media settings'],
['match' => 'docker', 'tab' => 'Docker', // No 'docker' route: the Docker tab was removed. Its one job was folder grouping, which
'route' => 'Docker tab → Docker settings'], // folder.view3 does better, and Varaverk's copy overwrote folder.view3's docker.json
// wholesale on every write — so a folder created in that plugin's own UI was destroyed by
// the next Varaverk save. Docker settings fall through to the Settings catch-all rather
// than routing to a tab that no longer exists.
// NPM, lldap and Authelia are the three services the Auth tab drives, and Certificate Monitor // NPM, lldap and Authelia are the three services the Auth tab drives, and Certificate Monitor
// is what its Certs panel reports. The credentials in particular belong on the page that fails // is what its Certs panel reports. The credentials in particular belong on the page that fails
// without them: a blank NPM_USER surfaces there as a refused login, and the fix is two panels // without them: a blank NPM_USER surfaces there as a refused login, and the fix is two panels
+13 -8
View File
@@ -8,7 +8,7 @@
// DESIGN PRINCIPLES // DESIGN PRINCIPLES
// Varaverk owns its own folder store. // Varaverk owns its own folder store.
// docker_folders.json is the primary record and has no external dependency. The // docker_folders.json is the primary record and has no external dependency. The
// folder.view3 plugin is synced to only when it is actually installed, so Varaverk's // folder.view3 owns its own docker.json; Varaverk reads it and never writes it, so
// grouping survives that plugin being absent, removed, or reset. // grouping survives that plugin being absent, removed, or reset.
// //
// The conf map is the interface to the scripts. // The conf map is the interface to the scripts.
@@ -42,7 +42,7 @@
// //
// CONFIGURATION // CONFIGURATION
// VV_DOCKER_JSON /boot/config/plugins/varaverk/docker_folders.json — primary // VV_DOCKER_JSON /boot/config/plugins/varaverk/docker_folders.json — primary
// VV_FV3_JSON folder.view3's docker.json — synced only if present // VV_FV3_JSON folder.view3's docker.json — READ only, imported once at bootstrap
// HOST*_DOCKER_FOLDER_MAP conf mirror consumed by the onboarding scripts // HOST*_DOCKER_FOLDER_MAP conf mirror consumed by the onboarding scripts
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// Docker tab — folder management and container inventory // Docker tab — folder management and container inventory
@@ -76,12 +76,17 @@ function vv_dk_write_json(array $data): bool {
if (file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES)) === false) return false; if (file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES)) === false) return false;
if (!rename($tmp, $path)) return false; if (!rename($tmp, $path)) return false;
// Mirror to folder.view3 if installed — keeps both in sync for users who want both UIs // Deliberately does NOT write folder.view3's docker.json any more.
if (file_exists(dirname(VV_FV3_JSON))) { //
$t = VV_FV3_JSON . '.vv.tmp'; // This used to mirror the whole file across — a wholesale overwrite, not a merge — so any
@file_put_contents($t, json_encode($data, JSON_UNESCAPED_SLASHES)); // folder created in folder.view3's own UI was destroyed by the next Varaverk save. Two
@rename($t, VV_FV3_JSON); // writers, one file, and the one that clobbers wins. The evidence was a duplicated
} // "Jayred365-Fallback" on HOST1: one folder with folder.view3's 20-char id, another with a
// 12-char id, the same name, different contents.
//
// folder.view3 is now the single owner of that file. Varaverk still *reads* it (see
// vv_dk_read_json's bootstrap import) so the Monitor widget can group containers, but read
// is the whole relationship.
return true; return true;
} }
-585
View File
@@ -1,585 +0,0 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Docker tab. Container inventory grouped into Varaverk-owned folders, with start / stop /
// restart controls and WebUI links.
//
// DESIGN PRINCIPLES
// Pure view — inventory and folder state come from api/docker.php; actions go to
// api/docker_action.php. Folder storage and the conf mirror live in include/docker.php.
//
// Folders are Varaverk's own, not folder.view3's. The grouping survives that plugin being
// absent or reset; it is synced to only when installed.
//
// OPERATIONAL SAFEGUARDS
// Destructive actions are confirmed in the browser before the request is sent.
//
// Container actions are routed through a dedicated action endpoint that validates the name
// against the real inventory — the page never composes a docker command.
//
// Container names are escaped on render, so a name containing markup cannot inject.
//
// RENDERS
// Folder-grouped container grid, per-container state, WebUI links, action buttons
//
// DEPENDS ON
// api/docker.php polled every 60s → include/docker.php
// api/docker_action.php start/stop/restart
require_once dirname(__DIR__) . '/include/confui.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; }
.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>
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-docker', 'docker', 'Docker settings');
?>
<script>
(function() {
let _data = null;
let _editMode = false;
let _popTarget = null;
// ── API ───────────────────────────────────────────────────────────────────────
function _api(params, cb) {
const fd = new URLSearchParams();
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 URLSearchParams();
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 vvAlert((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 {
vvAlert('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 { vvAlert('Rename failed: '+(r.error||'?')); el.value = f?.name ?? name; }
});
});
});
// Delete folder
document.querySelectorAll('[data-delete-folder]').forEach(el => {
el.addEventListener('click', async e => {
e.stopPropagation();
const fid = el.dataset.deleteFolder;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (!f || !await vvConfirm(`Delete "${f.name}"? Containers will be ungrouped.`)) return;
_api({action:'delete_folder',folder_id:fid}, r => { if(r.ok) _reload(); else vvAlert('Delete failed: '+(r.error||'?')); });
});
});
// New folder
document.getElementById('vv-dk-add-folder')?.addEventListener('click', async () => {
const name = await vvPrompt('New folder name:');
if (name?.trim()) _api({action:'create_folder',name:name.trim()}, r => { if(r.ok) _reload(); else vvAlert(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', async () => {
const fid = el.dataset.fid;
_hidePopover();
if (fid==='__new__') {
const name = await vvPrompt('New folder name:');
if (!name?.trim()) return;
_api({action:'create_folder',name:name.trim()}, r => {
if (!r.ok) { vvAlert(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 vvAlert(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 vvAlert(r.error); });
}
document.getElementById('vv-dk-sync-c2j').addEventListener('click', _syncC2J);
document.getElementById('vv-dk-sync-j2c').addEventListener('click', async () => {
if (!await vvConfirm('Overwrite conf map with current JSON state?')) return;
_api({action:'sync_json_to_conf'}, r => { if(r.ok) _reload(); else vvAlert(r.error); });
});
// ── Load ──────────────────────────────────────────────────────────────────────
function _reload() {
fetch('/plugins/varaverk/api/docker.php')
.then(r=>r.json()).then(_render).catch(()=>{});
}
_reload();
setInterval(_reload, 60000);
})();
</script>