|unknown", // "items":[{"id","label","ok","detail","action"?}, …]} // action is present and non-null only when there is a remedy the UI can invoke. // // DEPENDS ON // include/config.php vv_detect_host(), vv_read_conf_raw(), vv_parse_conf_scalar(), // vv_setup_state_read(), CONF_DIR // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; $hostId = vv_detect_host(); $hostIdUp = strtoupper($hostId); $master = vv_read_conf_raw('master.conf'); $confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : ''; $items = []; // ── Identity ────────────────────────────────────────────────────────────────── preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1); $host1 = trim($m1[1] ?? ''); // "Server identity" is this server's, not HOST1's. It was hardcoded to read master.conf's HOST1 // and print "HOST1: ", so on the mirror the row that answers "which host am I" confidently // named the other machine — and reported ok purely because the owner's slot was filled in, which // says nothing about whether this host resolved to a slot at all. $myName = ''; if ($hostId !== 'unknown') { preg_match('/^\s*' . preg_quote($hostIdUp, '/') . '\s*=\s*"([^"]*)"/m', $master, $mSelf); $myName = trim($mSelf[1] ?? ''); } $items[] = [ 'id' => 'identity', 'label' => 'Server identity', 'ok' => $hostId !== 'unknown' && !empty($myName), 'detail' => ($hostId !== 'unknown' && $myName) ? "$hostIdUp: $myName" : 'This host does not match any HOST* entry in master.conf', ]; // ── Host conf ───────────────────────────────────────────────────────────────── $confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf'); $items[] = [ 'id' => 'host_conf', 'label' => 'Host configuration', 'ok' => $confExists, 'detail' => $confExists ? "$hostId.conf present" : ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"), ]; // ── Unraid API key ───────────────────────────────────────────────────────────── $apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY')); $items[] = [ 'id' => 'api_key', 'label' => 'Unraid API key', 'ok' => !empty($apiKey), 'detail' => $apiKey ? 'Key present' : 'Not set', 'action' => $apiKey ? null : 'create_key', ]; // ── SSH key ──────────────────────────────────────────────────────────────────── $sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY')); $sshOk = $sshPath && file_exists($sshPath); $items[] = [ 'id' => 'ssh_key', 'label' => 'SSH key', 'ok' => $sshOk, 'detail' => $sshOk ? basename($sshPath) : ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'), 'action' => $sshOk ? null : 'ssh_setup', ]; // ── Auto-populate (any service key or container detected) ────────────────────── $populated = false; foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) { if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') { $populated = true; break; } } $items[] = [ 'id' => 'populated', 'label' => 'Auto-populate', 'ok' => $populated, 'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet', 'action' => $populated ? null : 'run_populate', ]; // ── Emby API key ─────────────────────────────────────────────────────────────── $embyContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_CONTAINER')); if (!empty($embyContainer)) { $embyKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_API_KEY')); $items[] = [ 'id' => 'emby_key', 'label' => 'Emby API key', 'ok' => !empty($embyKey), 'detail' => $embyKey ? 'Key present' : 'Not set — Emby Dashboard → API Keys → + New Key', ]; } // ── Jellyfin API key ─────────────────────────────────────────────────────────── $jfContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_CONTAINER')); if (!empty($jfContainer)) { $jfKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_API_KEY')); $items[] = [ 'id' => 'jellyfin_key', 'label' => 'Jellyfin API key', 'ok' => !empty($jfKey), 'detail' => $jfKey ? 'Key present' : 'Not set — Jellyfin Dashboard → Administration → API Keys', ]; } // ── master.conf delivery (partner servers only) ─────────────────────────────────────────────── // // The question is whether this host HAS the owner's master.conf, not whether it went and // fetched one. There are two ways it arrives and only one of them used to count: // // pull — this host ran 'Pull from HOST1', which sets master_conf_pulled in the setup state. // push — HOST1 sent it during Phase 1 of onboarding, before this host even had Varaverk // installed. Nothing on this side runs, so no flag is written here. // // Keying solely off the pull flag meant a node whose conf had been seeded by Phase 1 — // populated, correct, naming both hosts — was told "Not yet pulled from HOST1" and offered a // button to fetch what it already had. // // The push leaves its own evidence: Phase 1 ends by writing _PHASE1_DONE into the // owner's setup state and pushing that file here, so the flag can only be present on this // machine because the owner completed a push AT this machine. That is the discriminator. // // A populated HOST1 line is deliberately NOT the test. The wizard's manual partner path writes // HOST1 and HOST2 into the LOCAL template conf from what the operator typed, which would make // a template look delivered and hide the one button that fixes it. if ($hostId !== 'host1' && $hostId !== 'unknown') { $state = vv_setup_state_read(); $pulled = !empty($state['master_conf_pulled']); // Both spellings, for the same reason the partnership item below accepts both. $pushed = !empty($state[$hostIdUp . '_PHASE1_DONE']) || !empty($state[$hostId . '_phase1_done']); $items[] = [ 'id' => 'master_conf', 'label' => 'master.conf', 'ok' => $pulled || $pushed, 'detail' => $pulled ? "Pulled from $host1" : ($pushed ? "Seeded by $host1 — identity read from it" : ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf')), 'action' => (!$pulled && !$pushed && $host1) ? 'pull_master' : null, ]; } // ── Partnership (only if a partner is configured) ────────────────────────────── preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2); $host2 = trim($m2[1] ?? ''); if (!empty($host2)) { $state = vv_setup_state_read(); $p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']); $p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']); $items[] = [ 'id' => 'partnership', 'label' => 'Partnership', 'ok' => $p1done && $p2done, 'detail' => ($p1done && $p2done) ? "Active with $host2" : ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"), 'action' => (!$p1done) ? 'onboard' : null, ]; } // ── Blocking vs deferrable ──────────────────────────────────────────────────── // Setup used to be complete only when every item was green, which made a media-server API key a // gate on finishing onboarding. A mirror that runs no Emby, or runs one the operator has not got // round to configuring, could never reach "complete" — and the checklist is what tells them // whether they are done. // // Blocking items are the ones the mesh genuinely cannot work without: who this host is, its conf, // its SSH key, the owner's master.conf, and the partnership itself. Everything else improves the // install without being load-bearing, and can be dismissed with a recorded decision. // // Deferring is per item and reversible, stored in the setup state so it survives a reload. The // item still shows — amber, "deferred" — rather than disappearing, because a dismissed item is a // decision to revisit, not a thing that stopped being true. $deferrable = ['api_key' => true, 'populated' => true, 'emby_key' => true, 'jellyfin_key' => true]; $state = vv_setup_state_read(); foreach ($items as &$item) { $canDefer = !empty($deferrable[$item['id']]); $item['blocking'] = !$canDefer; $item['deferred'] = $canDefer && !$item['ok'] && !empty($state['DEFER_' . strtoupper($item['id'])]); if ($item['deferred']) { $item['detail'] = ($item['detail'] ?? '') . ' — deferred'; $item['action'] = 'undefer'; } elseif ($canDefer && !$item['ok']) { // Keep the item's real action as the primary; the UI offers defer alongside it. $item['can_defer'] = true; } } unset($item); // Complete when every blocking item is green and every deferrable one is green or dismissed. $allOk = true; foreach ($items as $i) { if ($i['ok']) continue; if ($i['blocking'] || empty($i['deferred'])) { $allOk = false; break; } } echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);