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:
@@ -9,9 +9,9 @@ $pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'partnership', 'fallback', 'watchdog', 'arrs'];
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'watchdog' => 'Watchdog', 'arrs' => 'Arrs'];
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
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);
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
// Docker tab — folder management and container inventory
|
||||
// Reads/writes /boot/config/plugins/folder.view3/docker.json (shared with folder.view3 plugin)
|
||||
// Reads/writes HOST*_DOCKER_FOLDER_MAP in host*.conf (used by onboard scripts)
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
define('VV_DOCKER_JSON', '/boot/config/plugins/folder.view3/docker.json');
|
||||
|
||||
// ── JSON helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function vv_dk_read_json(): array {
|
||||
if (!file_exists(VV_DOCKER_JSON)) return [];
|
||||
return json_decode(file_get_contents(VV_DOCKER_JSON), true) ?: [];
|
||||
}
|
||||
|
||||
function vv_dk_write_json(array $data): bool {
|
||||
$path = VV_DOCKER_JSON;
|
||||
$tmp = $path . '.vv.tmp';
|
||||
if (file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES)) === false) return false;
|
||||
return rename($tmp, $path);
|
||||
}
|
||||
|
||||
function vv_dk_gen_id(): string {
|
||||
return substr(str_replace(['+', '/', '='], ['', '', ''], base64_encode(random_bytes(15))), 0, 20);
|
||||
}
|
||||
|
||||
// ── Conf helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Read HOST*_DOCKER_FOLDER_MAP from raw conf text.
|
||||
// Returns ['ContainerName' => 'Folder Name', ...]
|
||||
function vv_dk_read_conf_map(string $raw, string $id): array {
|
||||
$varname = "{$id}_DOCKER_FOLDER_MAP";
|
||||
// Match: declare -A VARNAME=( ... ) — may span multiple lines
|
||||
if (!preg_match('/declare\s+-A\s+' . preg_quote($varname, '/') . '\s*=\s*\(\s*(.*?)\s*\)/s', $raw, $m))
|
||||
return [];
|
||||
preg_match_all('/\["([^"]+)"\]\s*=\s*"([^"]*)"/', $m[1], $pairs);
|
||||
$out = [];
|
||||
foreach ($pairs[1] as $i => $k) $out[$k] = $pairs[2][$i];
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Write HOST*_DOCKER_FOLDER_MAP back into raw conf text. Returns updated text.
|
||||
function vv_dk_write_conf_map(string $raw, string $id, array $map): string {
|
||||
$varname = "{$id}_DOCKER_FOLDER_MAP";
|
||||
ksort($map);
|
||||
|
||||
$lines = [" declare -A {$varname}=("];
|
||||
foreach ($map as $container => $folder) {
|
||||
$lines[] = ' ["' . $container . '"]="' . $folder . '"';
|
||||
}
|
||||
$lines[] = ' )';
|
||||
$block = implode("\n", $lines);
|
||||
|
||||
// Replace existing block
|
||||
$pattern = '/[ \t]*declare\s+-A\s+' . preg_quote($varname, '/') . '\s*=\s*\(.*?\)/s';
|
||||
if (preg_match($pattern, $raw)) {
|
||||
return preg_replace($pattern, $block, $raw);
|
||||
}
|
||||
|
||||
// Append before the "End Of HOST*" comment if present, otherwise at end
|
||||
$endMarker = "# ── End Of {$id}";
|
||||
$altMarker = "# ──────────────────────── End Of {$id}";
|
||||
foreach ([$altMarker, $endMarker] as $marker) {
|
||||
$pos = strpos($raw, $marker);
|
||||
if ($pos !== false) {
|
||||
$header = "\n# ── Docker Folder Map — managed by Varaverk Docker tab ──────────────────────\n";
|
||||
return substr($raw, 0, $pos) . $header . $block . "\n\n" . substr($raw, $pos);
|
||||
}
|
||||
}
|
||||
return $raw . "\n# ── Docker Folder Map ────────────────────────────────────────────────────────\n" . $block . "\n";
|
||||
}
|
||||
|
||||
// ── Container inventory ───────────────────────────────────────────────────────
|
||||
|
||||
function vv_dk_containers(): array {
|
||||
$raw = shell_exec("docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.RunningFor}}' 2>/dev/null") ?: '';
|
||||
$out = [];
|
||||
foreach (explode("\n", trim($raw)) as $line) {
|
||||
if (!$line) continue;
|
||||
[$name, $status, $image, $age] = array_pad(explode("\t", $line, 4), 4, '');
|
||||
$name = trim($name);
|
||||
if (!$name) continue;
|
||||
$running = str_starts_with(trim($status), 'Up');
|
||||
$out[$name] = [
|
||||
'name' => $name,
|
||||
'running' => $running,
|
||||
'status' => trim($status),
|
||||
'image' => trim($image),
|
||||
'age' => trim($age),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ── Main data builder ─────────────────────────────────────────────────────────
|
||||
|
||||
function vv_dk_all(): array {
|
||||
$jsonData = vv_dk_read_json();
|
||||
$containers = vv_dk_containers();
|
||||
$currentHost= vv_detect_host();
|
||||
$myId = strtoupper($currentHost);
|
||||
$myRaw = vv_read_conf_raw($currentHost . '.conf');
|
||||
$confMap = vv_dk_read_conf_map($myRaw, $myId);
|
||||
|
||||
// Build folder list enriched with live container data
|
||||
$assigned = [];
|
||||
$folders = [];
|
||||
foreach ($jsonData as $fid => $f) {
|
||||
$ctrs = [];
|
||||
foreach ($f['containers'] ?? [] as $cname) {
|
||||
$assigned[$cname] = true;
|
||||
$ctrs[] = array_merge(['name' => $cname, 'running' => false, 'status' => '', 'image' => '', 'age' => ''],
|
||||
$containers[$cname] ?? []);
|
||||
}
|
||||
usort($ctrs, fn($a, $b) => $b['running'] <=> $a['running'] ?: strcmp($a['name'], $b['name']));
|
||||
$running = count(array_filter($ctrs, fn($c) => $c['running']));
|
||||
$folders[] = [
|
||||
'id' => $fid,
|
||||
'name' => $f['name'] ?? 'Unnamed',
|
||||
'icon' => $f['icon'] ?? '',
|
||||
'total' => count($ctrs),
|
||||
'running' => $running,
|
||||
'containers'=> $ctrs,
|
||||
];
|
||||
}
|
||||
usort($folders, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
// Ungrouped: in docker ps but not in any folder
|
||||
$ungrouped = [];
|
||||
foreach ($containers as $name => $c) {
|
||||
if (isset($assigned[$name])) continue;
|
||||
$ungrouped[] = $c;
|
||||
}
|
||||
usort($ungrouped, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
// Drift: containers in conf map but assigned to a different folder in json, or missing from json
|
||||
$drift = [];
|
||||
foreach ($confMap as $cname => $desiredFolder) {
|
||||
// Find where it actually is in json
|
||||
$actualFolder = null;
|
||||
foreach ($jsonData as $f) {
|
||||
if (in_array($cname, $f['containers'] ?? [], true)) {
|
||||
$actualFolder = $f['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($actualFolder !== $desiredFolder) {
|
||||
$drift[] = ['container' => $cname, 'conf' => $desiredFolder, 'actual' => $actualFolder];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ts' => time(),
|
||||
'host' => $myId,
|
||||
'folders' => $folders,
|
||||
'ungrouped' => $ungrouped,
|
||||
'conf_map' => $confMap,
|
||||
'drift' => $drift,
|
||||
'json_path' => VV_DOCKER_JSON,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Write actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Move a container to a folder (or '' to ungroup). Writes json + conf.
|
||||
function vv_dk_move_container(string $container, string $targetFolderId): array {
|
||||
if (!$container) return ['ok' => false, 'error' => 'No container'];
|
||||
$data = vv_dk_read_json();
|
||||
|
||||
// Remove from all folders
|
||||
foreach ($data as &$f) {
|
||||
$f['containers'] = array_values(array_filter($f['containers'] ?? [], fn($c) => $c !== $container));
|
||||
if (isset($f['containerImages'][$container])) unset($f['containerImages'][$container]);
|
||||
}
|
||||
unset($f);
|
||||
|
||||
// Add to target folder
|
||||
if ($targetFolderId && isset($data[$targetFolderId])) {
|
||||
if (!in_array($container, $data[$targetFolderId]['containers'] ?? [], true)) {
|
||||
$data[$targetFolderId]['containers'][] = $container;
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
|
||||
|
||||
// Update conf
|
||||
_vv_dk_sync_conf_from_json($data);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Create a new folder. Returns new folder id.
|
||||
function vv_dk_create_folder(string $name): array {
|
||||
$name = trim($name);
|
||||
if (!$name) return ['ok' => false, 'error' => 'Name required'];
|
||||
$data = vv_dk_read_json();
|
||||
$id = vv_dk_gen_id();
|
||||
$data[$id] = [
|
||||
'name' => $name,
|
||||
'icon' => '',
|
||||
'settings' => [],
|
||||
'regex' => '',
|
||||
'containers' => [],
|
||||
'containerImages' => [],
|
||||
'hidden_preview' => [],
|
||||
'actions' => [],
|
||||
];
|
||||
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
|
||||
return ['ok' => true, 'id' => $id];
|
||||
}
|
||||
|
||||
// Rename a folder. Updates json + conf (folder name strings in map).
|
||||
function vv_dk_rename_folder(string $folderId, string $newName): array {
|
||||
$newName = trim($newName);
|
||||
if (!$newName || !$folderId) return ['ok' => false, 'error' => 'Missing params'];
|
||||
$data = vv_dk_read_json();
|
||||
if (!isset($data[$folderId])) return ['ok' => false, 'error' => 'Folder not found'];
|
||||
$oldName = $data[$folderId]['name'];
|
||||
$data[$folderId]['name'] = $newName;
|
||||
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
|
||||
|
||||
// Update conf: replace old folder name with new name in the map
|
||||
$currentHost = vv_detect_host();
|
||||
$myId = strtoupper($currentHost);
|
||||
$raw = vv_read_conf_raw($currentHost . '.conf');
|
||||
$map = vv_dk_read_conf_map($raw, $myId);
|
||||
foreach ($map as &$v) {
|
||||
if ($v === $oldName) $v = $newName;
|
||||
}
|
||||
unset($v);
|
||||
$updated = vv_dk_write_conf_map($raw, $myId, $map);
|
||||
vv_write_conf_raw($currentHost . '.conf', $updated);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Delete a folder (containers become ungrouped). Writes json + conf.
|
||||
function vv_dk_delete_folder(string $folderId): array {
|
||||
if (!$folderId) return ['ok' => false, 'error' => 'No folder id'];
|
||||
$data = vv_dk_read_json();
|
||||
if (!isset($data[$folderId])) return ['ok' => false, 'error' => 'Folder not found'];
|
||||
$folderName = $data[$folderId]['name'];
|
||||
unset($data[$folderId]);
|
||||
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
|
||||
|
||||
// Remove from conf map
|
||||
$currentHost = vv_detect_host();
|
||||
$myId = strtoupper($currentHost);
|
||||
$raw = vv_read_conf_raw($currentHost . '.conf');
|
||||
$map = vv_dk_read_conf_map($raw, $myId);
|
||||
$map = array_filter($map, fn($v) => $v !== $folderName);
|
||||
$updated = vv_dk_write_conf_map($raw, $myId, $map);
|
||||
vv_write_conf_raw($currentHost . '.conf', $updated);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Sync conf desired state → json (apply conf map, fix drift)
|
||||
function vv_dk_sync_conf_to_json(): array {
|
||||
$currentHost = vv_detect_host();
|
||||
$myId = strtoupper($currentHost);
|
||||
$raw = vv_read_conf_raw($currentHost . '.conf');
|
||||
$confMap = vv_dk_read_conf_map($raw, $myId);
|
||||
$data = vv_dk_read_json();
|
||||
|
||||
// Build folder name → id map
|
||||
$nameToId = [];
|
||||
foreach ($data as $fid => $f) $nameToId[$f['name']] = $fid;
|
||||
|
||||
foreach ($confMap as $container => $folderName) {
|
||||
// Create folder if missing
|
||||
if (!isset($nameToId[$folderName])) {
|
||||
$newId = vv_dk_gen_id();
|
||||
$data[$newId] = ['name' => $folderName, 'icon' => '', 'settings' => [],
|
||||
'regex' => '', 'containers' => [], 'containerImages' => [],
|
||||
'hidden_preview' => [], 'actions' => []];
|
||||
$nameToId[$folderName] = $newId;
|
||||
}
|
||||
$targetId = $nameToId[$folderName];
|
||||
|
||||
// Remove from all folders
|
||||
foreach ($data as &$f) {
|
||||
$f['containers'] = array_values(array_filter($f['containers'] ?? [], fn($c) => $c !== $container));
|
||||
}
|
||||
unset($f);
|
||||
|
||||
// Add to target
|
||||
if (!in_array($container, $data[$targetId]['containers'], true))
|
||||
$data[$targetId]['containers'][] = $container;
|
||||
}
|
||||
|
||||
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
|
||||
return ['ok' => true, 'applied' => count($confMap)];
|
||||
}
|
||||
|
||||
// Sync json current state → conf (capture manual json edits)
|
||||
function vv_dk_sync_json_to_conf(): array {
|
||||
$data = vv_dk_read_json();
|
||||
$currentHost = vv_detect_host();
|
||||
$myId = strtoupper($currentHost);
|
||||
$raw = vv_read_conf_raw($currentHost . '.conf');
|
||||
_vv_dk_sync_conf_from_json($data, $raw, $currentHost, $myId);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Internal: rebuild conf map from current json state and write it
|
||||
function _vv_dk_sync_conf_from_json(array $data, string $raw = '', string $host = '', string $id = ''): void {
|
||||
if (!$host) $host = vv_detect_host();
|
||||
if (!$id) $id = strtoupper($host);
|
||||
if (!$raw) $raw = vv_read_conf_raw($host . '.conf');
|
||||
|
||||
$map = [];
|
||||
foreach ($data as $f) {
|
||||
$name = $f['name'] ?? '';
|
||||
foreach ($f['containers'] ?? [] as $c) $map[$c] = $name;
|
||||
}
|
||||
$updated = vv_dk_write_conf_map($raw, $id, $map);
|
||||
vv_write_conf_raw($host . '.conf', $updated);
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _reload() {
|
||||
fetch('/plugins/varaverk/api/docker.php')
|
||||
.then(r => r.json())
|
||||
.then(_render)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
_reload();
|
||||
setInterval(_reload, 60000);
|
||||
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user