diff --git a/Plugin/unraid/api/partnership_lists.php b/Plugin/unraid/api/partnership_lists.php new file mode 100644 index 0000000..6a32098 --- /dev/null +++ b/Plugin/unraid/api/partnership_lists.php @@ -0,0 +1,185 @@ +_TIER1-4 in this host's own conf — which of this host's containers +// the partner starts when this host goes dark, and in which delay tier. +// services HOST_PARTNERSHIP_SERVICES_STACK — XML templates pushed to the mirror during +// onboard, for services that are neither auth nor arr. +// +// OPERATIONAL MODEL +// GET returns this host's docker containers plus the current membership of both lists. +// POST action=cover tiers= rewrites all four tier arrays +// POST action=services stack= rewrites the services stack +// +// Both write through vv_conf_edit(), the same locked read-modify-write every other conf +// endpoint uses, and both push the result — a partner holding the old list is a partner that +// will act on the old list. +// +// DESIGN PRINCIPLES +// Edit the array fallback.sh actually reads, not a parallel one. +// The coverage tiers already exist and already carry the timing. A second "what to fail +// over" list would be a second answer to the same question, and the two would drift. +// +// The services stack stores XML template filenames, not container names. +// That is what onboard pushes. The picker speaks container names because that is what the +// operator recognises, and the mapping to my-.xml happens here, once. +// +// OPERATIONAL SAFEGUARDS +// POST only for writes, so Unraid's CSRF guard applies. See README-unraid.md. +// +// Names are validated against the containers this host actually runs. A tier or stack entry +// for a container that does not exist here is a line fallback.sh will fail on during an +// outage, which is the worst possible time to discover a typo. +// +// A tier value outside 1-4 is rejected rather than clamped — silently moving a container from +// tier 9 to tier 4 would give it a 24-hour delay nobody asked for. +// +// Writing an empty list is allowed. "Cover nothing" is a legitimate choice and the only way to +// express it. +// +// DEPENDS ON +// include/confform.php vv_conf_edit() +// include/docker.php vv_docker_containers() +// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_push_master_conf() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +header('Content-Type: application/json'); +require_once dirname(__DIR__) . '/include/confform.php'; +require_once dirname(__DIR__) . '/include/docker.php'; + +$hostId = vv_detect_host(); // host1 / host2 +$hostUp = strtoupper($hostId); // HOST1 +$myConf = $hostId . '.conf'; + +$TIERS = [1, 2, 3, 4]; +$tierVar = fn(int $t) => "FALLBACK_{$hostUp}_TIER{$t}"; +$svcVar = "{$hostUp}_PARTNERSHIP_SERVICES_STACK"; + +// Container name ⇄ template filename. Unraid writes my-.xml; the list stores that. +$toXml = fn(string $c) => 'my-' . $c . '.xml'; +$fromXml = fn(string $x) => preg_replace('/^my-|\.xml$/', '', trim($x)); + +// ── Read ───────────────────────────────────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $raw = vv_read_conf_raw($myConf); + $mRaw = vv_read_conf_raw('master.conf'); + + $cover = []; + foreach ($TIERS as $t) { + // Tiers live in host*.conf on this host; fall back to master.conf for installs that + // kept them there. Reading both costs nothing and avoids an empty card on either shape. + $vals = vv_parse_conf_list($raw, $tierVar($t)) ?: vv_parse_conf_list($mRaw, $tierVar($t)); + foreach ($vals as $c) { $c = trim($c); if ($c !== '') $cover[$c] = $t; } + } + + $services = []; + foreach (vv_parse_conf_list($raw, $svcVar) as $x) { + $n = $fromXml($x); + if ($n !== '') $services[] = $n; + } + + $containers = []; + foreach (vv_docker_containers() as $c) { + $n = is_array($c) ? ($c['name'] ?? '') : (string)$c; + if ($n !== '') $containers[] = $n; + } + sort($containers, SORT_NATURAL | SORT_FLAG_CASE); + + // Templates that exist, so the services picker can say which choices onboard could actually + // push. A container with no XML cannot be deployed to the partner however it is ticked. + $haveXml = []; + foreach (glob('/boot/config/plugins/dockerMan/templates-user/my-*.xml') ?: [] as $p) { + $haveXml[] = $fromXml(basename($p)); + } + + echo json_encode([ + 'ok' => true, + 'host' => $hostUp, + 'containers' => $containers, + 'have_xml' => $haveXml, + 'cover' => (object)$cover, + 'services' => $services, + 'tier_vars' => array_map($tierVar, $TIERS), + 'svc_var' => $svcVar, + ]); + exit; +} + +// ── Write ──────────────────────────────────────────────────────────────────────────────────── +$action = $_POST['action'] ?? ''; + +$known = []; +foreach (vv_docker_containers() as $c) { + $n = is_array($c) ? ($c['name'] ?? '') : (string)$c; + if ($n !== '') $known[strtolower($n)] = $n; +} + +// Rewrites one `NAME=(` … `)` block in place, preserving the leading indent the conf uses. +$rewrite = function (string $cur, string $var, array $items): ?string { + $body = ''; + foreach ($items as $i) $body .= " \"" . $i . "\"\n"; + $pattern = '/^([ \t]*)' . preg_quote($var, '/') . '=\((?:[^)]*)\)/m'; + if (preg_match($pattern, $cur)) { + return preg_replace_callback($pattern, + fn($m) => $m[1] . $var . "=(\n" . $body . $m[1] . ")", $cur, 1); + } + return null; // absent: refuse rather than append into an unknown section +}; + +if ($action === 'cover') { + $map = json_decode((string)($_POST['tiers'] ?? ''), true); + if (!is_array($map)) { echo json_encode(['ok' => false, 'error' => 'tiers must be an object']); exit; } + + $byTier = array_fill_keys($TIERS, []); + foreach ($map as $name => $tier) { + $t = (int)$tier; + if (!in_array($t, $TIERS, true)) { + echo json_encode(['ok' => false, 'error' => "Tier $tier is not 1-4 (for $name)"]); exit; + } + if (!isset($known[strtolower((string)$name)])) { + echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit; + } + $byTier[$t][] = $known[strtolower((string)$name)]; + } + + $ok = vv_conf_edit($myConf, function (string $cur) use ($byTier, $TIERS, $tierVar, $rewrite): ?string { + foreach ($TIERS as $t) { + $next = $rewrite($cur, $tierVar($t), $byTier[$t]); + if ($next === null) return null; + $cur = $next; + } + return $cur; + }, [], array_map($tierVar, $TIERS)); + + if (!$ok) { echo json_encode(['ok' => false, 'error' => vv_conf_last_error() ?: 'Write failed']); exit; } + vv_push_master_conf(); + echo json_encode(['ok' => true, 'counts' => array_map('count', $byTier)]); + exit; +} + +if ($action === 'services') { + $list = json_decode((string)($_POST['stack'] ?? ''), true); + if (!is_array($list)) { echo json_encode(['ok' => false, 'error' => 'stack must be an array']); exit; } + + $xml = []; + foreach ($list as $name) { + if (!isset($known[strtolower((string)$name)])) { + echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit; + } + $real = $known[strtolower((string)$name)]; + $path = '/boot/config/plugins/dockerMan/templates-user/' . $toXml($real); + if (!is_file($path)) { + echo json_encode(['ok' => false, 'error' => "No template " . $toXml($real) . " — the partner could not deploy it"]); exit; + } + $xml[] = $toXml($real); + } + + $ok = vv_conf_edit($myConf, fn(string $cur): ?string => $rewrite($cur, $svcVar, $xml), [], [$svcVar]); + if (!$ok) { echo json_encode(['ok' => false, 'error' => vv_conf_last_error() ?: 'Write failed']); exit; } + vv_push_master_conf(); + echo json_encode(['ok' => true, 'count' => count($xml)]); + exit; +} + +echo json_encode(['ok' => false, 'error' => 'Unknown action']); diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index b190c00..92b916d 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -120,21 +120,47 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
Loading…
- -
-

Data Transferred

-
Loading…
+ +
+
+

Data Transferred

+
Loading…
+
+
+

Actions

+
Loading…
+
- +

Mirror Sync

Loading…
-
-

Actions

-
Loading…
+ + +
+
+

Failover Coverage

+ +
+
Loading…
+
+ + +
+
+

Shared Services

+ +
+
Loading…
@@ -142,7 +168,7 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
'chat', + 'profile' => 'varaverk', 'compact' => true, 'title' => 'Assistant', 'height' => '300px', @@ -1459,8 +1485,10 @@ setInterval(vvPtLoad, 10000); if (typeof VvAiChat === 'function' && document.getElementById('vv-pt-ai-chat')) { VvAiChat({ prefix: 'vv-pt-ai', - profile: 'chat', - resumeProfile: 'chat', + // The Varaverk assistant, not general chat: every question asked on this page is about this + // installation, and the strict profile is the one that can answer from it. + profile: 'varaverk', + resumeProfile: 'varaverk', empty: 'Ask about the partnership — sync state, fallback tiers, what the mirror is running, ' + 'why a transfer moved nothing.', });