Drop the failover and shared-services pickers from the Partnership page

They describe what the partner runs during an outage, which is the Fallback tab's subject, not this page's.
This commit is contained in:
Gmer4Lfe
2026-08-17 15:30:52 -04:00
parent 0447fa6f86
commit 1a836dac75
2 changed files with 8 additions and 391 deletions
-205
View File
@@ -1,205 +0,0 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The two container lists the Partnership page edits:
//
// cover FALLBACK_<me>_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<n>_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=<json {container: tier}> rewrites all four tier arrays
// POST action=services stack=<json [container, …]> 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-<Name>.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/common.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/common.php'; // vv_docker_containers()
$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-<Name>.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'] ?? '';
// Containers this host runs, PLUS whatever the lists already name. The conf legitimately holds
// entries for containers that are not here right now — one removed since it was added, one
// living on a partner, one simply stopped. Validating only against the running set would refuse
// to save a list the operator had not touched: FALLBACK_HOST1_TIER4 still carries "LidaTube",
// which no longer exists here, so opening the card and pressing Save would fail on a line
// nobody had edited.
//
// New names still have to be real. The guard is against inventing containers, not against
// keeping ones already recorded.
$known = [];
foreach (vv_docker_containers() as $c) {
$n = is_array($c) ? ($c['name'] ?? '') : (string)$c;
if ($n !== '') $known[strtolower($n)] = $n;
}
$existingRaw = vv_read_conf_raw($myConf);
foreach ($TIERS as $t) {
foreach (vv_parse_conf_list($existingRaw, $tierVar($t)) as $n) {
$n = trim($n);
if ($n !== '' && !isset($known[strtolower($n)])) $known[strtolower($n)] = $n;
}
}
foreach (vv_parse_conf_list($existingRaw, $svcVar) as $x) {
$n = $fromXml($x);
if ($n !== '' && !isset($known[strtolower($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']);
+7 -185
View File
@@ -120,47 +120,21 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Row 2 — what has moved, and what you can do about it -->
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:stretch;flex-wrap:wrap;">
<div class="vv-card" id="vv-pt-xfer-card" style="flex:1 1 260px;min-width:0;display:flex;flex-direction:column;">
<!-- Data moved between the servers -->
<div class="vv-card" id="vv-pt-xfer-card" style="margin-bottom:12px;">
<h3>Data Transferred</h3>
<div id="vv-pt-xfer-body" style="flex:1;color:#444;font-size:12px;">Loading…</div>
</div>
<div class="vv-card" id="vv-pt-actions-card" style="flex:2 1 340px;min-width:0;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<div id="vv-pt-xfer-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Row 3 — sync state, and the two lists that decide what the partner carries -->
<!-- Mirror sync + Actions side by side -->
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:stretch;flex-wrap:wrap;">
<div class="vv-card" id="vv-pt-sync-card" style="flex:1 1 220px;min-width:0;display:flex;flex-direction:column;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="flex:1;color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Failover coverage: which of THIS host's containers the partner starts when this host is
down, and after how long. Backed by FALLBACK_<me>_TIER1-4 in this host's own conf, which
is what fallback.sh on the partner actually reads — so the picker edits the real list
rather than a second one that could disagree with it. -->
<div class="vv-card" id="vv-pt-cover-card" style="flex:1 1 300px;min-width:0;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Failover Coverage</h3>
<button id="vv-pt-cover-save" class="vv-pt-action-btn run"
onclick="vvPtSaveCover(this)" style="display:none;font-size:11px;">Save</button>
</div>
<div id="vv-pt-cover-body" style="flex:1;color:#444;font-size:12px;margin-top:8px;">Loading…</div>
</div>
<!-- Services deployed to the partner that are neither auth nor arr. These are pushed as XML
templates during onboard, so they run there rather than only starting during an outage. -->
<div class="vv-card" id="vv-pt-svc-card" style="flex:1 1 300px;min-width:0;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Shared Services</h3>
<button id="vv-pt-svc-save" class="vv-pt-action-btn run"
onclick="vvPtSaveSvc(this)" style="display:none;font-size:11px;">Save</button>
</div>
<div id="vv-pt-svc-body" style="flex:1;color:#444;font-size:12px;margin-top:8px;">Loading…</div>
<div class="vv-card" id="vv-pt-actions-card" style="flex:2 1 300px;min-width:0;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
</div>
@@ -778,157 +752,6 @@ function _renderXfer(x) {
</div>`;
}
// ── Failover coverage + shared services ───────────────────────────────────────
// Both cards read one endpoint and edit real conf arrays: the tiers fallback.sh consults during
// an outage, and the services stack onboard pushes. Loaded once — they are conf, not telemetry,
// and re-fetching them on the 10s poll would fight whatever the operator is mid-way through
// selecting.
let _vvLists = null, _vvListsLoaded = false;
function vvPtLoadLists() {
if (_vvListsLoaded) return;
_vvListsLoaded = true;
fetch('/plugins/varaverk/api/partnership_lists.php?_=' + Date.now())
.then(r => r.json())
.then(d => { if (d.ok) { _vvLists = d; _renderCover(); _renderSvc(); } })
.catch(() => {});
}
function _vvListRow(name, present, right) {
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:3px 0;border-bottom:1px solid #151515;">
<span style="font-size:11px;color:${present ? '#888' : '#5a4a2a'};overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;" title="${vvEscAttr(name)}${present ? '' : ' — not on this host'}">
${vvEscHtml(name)}${present ? '' : ' <span style="color:#5a4a2a;">·absent</span>'}</span>
${right}</div>`;
}
function _renderCover() {
const el = document.getElementById('vv-pt-cover-body');
if (!el || !_vvLists) return;
const cover = _vvLists.cover || {};
const have = new Set(_vvLists.containers || []);
const names = Object.keys(cover).sort((a,b) => (cover[a]-cover[b]) || a.localeCompare(b));
let html = `<div style="font-size:9px;color:#3a3a3a;margin-bottom:6px;line-height:1.5;">
What the partner starts when this host is down. Tier 1 is immediate; 24 wait for
HOST*_TIER&lt;n&gt;_DELAY.</div>`;
html += names.length
? names.map(n => _vvListRow(n, have.has(n),
`<select class="vv-pt-cover-sel" data-name="${vvEscAttr(n)}" onchange="vvPtCoverChanged()"
style="font-size:10px;background:#0d0d0d;color:#aaa;border:1px solid #2a2a2a;border-radius:2px;">
${[1,2,3,4].map(t => `<option value="${t}"${cover[n]===t?' selected':''}>T${t}</option>`).join('')}
<option value="0">remove</option>
</select>`)).join('')
: `<div style="font-size:11px;color:#444;padding:6px 0;">Nothing covered.</div>`;
const opts = (_vvLists.containers || []).filter(c => !(c in cover));
html += `<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
<select id="vv-pt-cover-add" style="flex:1;min-width:0;font-size:10px;background:#0d0d0d;color:#aaa;
border:1px solid #2a2a2a;border-radius:2px;">
<option value="">+ add container…</option>
${opts.map(c => `<option value="${vvEscAttr(c)}">${vvEscHtml(c)}</option>`).join('')}
</select>
<button class="vv-pt-action-btn info" style="font-size:10px;" onclick="vvPtCoverAdd()">Add</button>
</div>`;
el.innerHTML = html;
}
function vvPtCoverAdd() {
const sel = document.getElementById('vv-pt-cover-add');
if (!sel || !sel.value || !_vvLists) return;
_vvLists.cover = _vvLists.cover || {};
_vvLists.cover[sel.value] = 1;
_renderCover();
vvPtCoverChanged();
}
function vvPtCoverChanged() {
document.getElementById('vv-pt-cover-save').style.display = '';
}
async function vvPtSaveCover(btn) {
const map = {};
document.querySelectorAll('.vv-pt-cover-sel').forEach(s => {
const t = parseInt(s.value, 10);
if (t >= 1 && t <= 4) map[s.dataset.name] = t; // 0 = remove, simply not sent
});
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/partnership_lists.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'cover', tiers: JSON.stringify(map),
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save'; return; }
btn.textContent = '✓'; _vvListsLoaded = false; vvPtLoadLists();
setTimeout(() => { btn.disabled = false; btn.textContent = 'Save'; btn.style.display = 'none'; }, 1500);
} catch (e) { btn.disabled = false; btn.textContent = 'Save'; vvAlert('Error: ' + e); }
}
function _renderSvc() {
const el = document.getElementById('vv-pt-svc-body');
if (!el || !_vvLists) return;
const cur = _vvLists.services || [];
const have = new Set(_vvLists.containers || []);
const xml = new Set(_vvLists.have_xml || []);
let html = `<div style="font-size:9px;color:#3a3a3a;margin-bottom:6px;line-height:1.5;">
Non-auth, non-arr services deployed to the partner at onboard and run there continuously.
Needs a my-&lt;name&gt;.xml template to push.</div>`;
html += cur.length
? cur.map(n => _vvListRow(n, have.has(n),
`<button class="vv-pt-action-btn warn" style="font-size:9px;opacity:.6;"
onclick="vvPtSvcRemove('${vvEscAttr(n)}')">✕</button>`)).join('')
: `<div style="font-size:11px;color:#444;padding:6px 0;">No shared services.</div>`;
const opts = (_vvLists.containers || []).filter(c => !cur.includes(c) && xml.has(c));
html += `<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
<select id="vv-pt-svc-add" style="flex:1;min-width:0;font-size:10px;background:#0d0d0d;color:#aaa;
border:1px solid #2a2a2a;border-radius:2px;">
<option value="">+ add service…</option>
${opts.map(c => `<option value="${vvEscAttr(c)}">${vvEscHtml(c)}</option>`).join('')}
</select>
<button class="vv-pt-action-btn info" style="font-size:10px;" onclick="vvPtSvcAdd()">Add</button>
</div>`;
el.innerHTML = html;
}
function vvPtSvcAdd() {
const sel = document.getElementById('vv-pt-svc-add');
if (!sel || !sel.value || !_vvLists) return;
_vvLists.services = (_vvLists.services || []).concat([sel.value]);
_renderSvc();
document.getElementById('vv-pt-svc-save').style.display = '';
}
function vvPtSvcRemove(name) {
if (!_vvLists) return;
_vvLists.services = (_vvLists.services || []).filter(n => n !== name);
_renderSvc();
document.getElementById('vv-pt-svc-save').style.display = '';
}
async function vvPtSaveSvc(btn) {
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/partnership_lists.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'services', stack: JSON.stringify(_vvLists.services || []),
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save'; return; }
btn.textContent = '✓'; _vvListsLoaded = false; vvPtLoadLists();
setTimeout(() => { btn.disabled = false; btn.textContent = 'Save'; btn.style.display = 'none'; }, 1500);
} catch (e) { btn.disabled = false; btn.textContent = 'Save'; vvAlert('Error: ' + e); }
}
function _renderSync(sync) {
const jobs = sync.jobs || {};
const gates = sync.gates || {};
@@ -1580,7 +1403,6 @@ function _render(data) {
_renderOfflineWarn(cfg);
// Mirror sync health
vvPtLoadLists();
_renderXfer(data.xfer || {});
document.getElementById('vv-pt-sync-body').innerHTML = _renderSync(data.sync || {});