diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 1551c56..c53d9d2 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -1023,8 +1023,11 @@ echo "" echo "━━━ $ICON_GEAR Step 11 — Service Discovery ($MIRROR) ━━━" POPULATE_OK=false -if [[ "$MIRROR_REACHABLE" != true ]]; then - warn "$MIRROR unreachable — skipping discovery, run Deployment/conf_populate.sh there later" +# MIRROR_IP, not MIRROR_REACHABLE — the latter is partnership_offboard.sh's variable and does not +# exist in this script, so the test was always true against an empty string and Step 11 reported +# "skipped (unreachable)" on a mirror it had just deployed twelve containers to. +if [[ -z "${MIRROR_IP:-}" ]]; then + warn "$MIRROR has no resolved IP — skipping discovery, run Deployment/conf_populate.sh there later" POPULATE_OK=skipped elif [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would run conf_populate.sh --no-push on $MIRROR" diff --git a/Plugin/unraid/Partnership/containers.sh b/Plugin/unraid/Partnership/containers.sh index 6c9aaff..c02b918 100755 --- a/Plugin/unraid/Partnership/containers.sh +++ b/Plugin/unraid/Partnership/containers.sh @@ -279,7 +279,10 @@ deploy_container_from_xml() { [[ "$_transformed_xml" != "$xml_file" ]] && _gpu_tmp="$_transformed_xml" xml_file="$_transformed_xml" - local name repo network extra privileged + local name repo network extra privileged webui icon + # WebUI and Icon become Unraid labels below — see the docker create line for why. + webui=$( awk 'match($0,/([^<]*)<\/WebUI>/, a){print a[1];exit}' "$xml_file") + icon=$( awk 'match($0,/([^<]*)<\/Icon>/, a){print a[1];exit}' "$xml_file") name=$( awk 'match($0,/([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file") repo=$( awk 'match($0,/([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file") network=$( awk 'match($0,/([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file") @@ -318,7 +321,19 @@ deploy_container_from_xml() { printf "docker stop %q 2>/dev/null || true\n" "$name" printf "docker rm %q 2>/dev/null || true\n" "$name" echo "" + # Unraid's Docker Manager decides what it owns by label, not by template presence. The + # XML is SCPed to the mirror's templates-user above, but without these three the WebGUI + # lists the container as third-party: no Edit button, no WebUI link, no icon — the + # operator can see it running and cannot do anything with it. + # + # The values go in verbatim, placeholders and all: Unraid stores the literal + # "http://[IP]:[PORT:8989]/..." form in the label and substitutes at render time, so + # resolving them here would produce a link that stops being right the moment the + # container's port mapping changes. printf "docker create --name %q --restart=unless-stopped" "$name" + printf " --label %q" "net.unraid.docker.managed=dockerman" + [[ -n "$webui" ]] && printf " --label %q" "net.unraid.docker.webui=${webui}" + [[ -n "$icon" ]] && printf " --label %q" "net.unraid.docker.icon=${icon}" [[ -n "$network" ]] && printf " --network=%q" "$network" [[ "$privileged" == "true" ]] && printf " --privileged" [[ -n "$extra" ]] && printf " %s" "$extra" diff --git a/Plugin/unraid/api/status.php b/Plugin/unraid/api/status.php index 7f60276..a3fbc98 100644 --- a/Plugin/unraid/api/status.php +++ b/Plugin/unraid/api/status.php @@ -53,6 +53,40 @@ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; +// ?id= — one job, including jobs that are not on the schedule. +// +// The loop below only knows about scheduled jobs, so a job like Partnership/partnership_onboard.sh +// — launched on demand, never cronned — was invisible to every status caller even though +// run_job.sh writes it a stat file like any other. The UI had nothing to poll, which is why +// pressing Onboard produced no visible change for the minutes it then ran. +if (isset($_GET['id'])) { + $id = trim($_GET['id']); + if (!preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) { + echo json_encode(['ok' => false, 'error' => 'Invalid job id']); + exit; + } + $statFile = vv_job_stat_path($id); + if (!file_exists($statFile)) { + echo json_encode(['ok' => true, 'id' => $id, 'status' => 'never_run']); + exit; + } + $stat = json_decode(@file_get_contents($statFile) ?: '{}', true) ?: []; + $status = $stat['status'] ?? 'unknown'; + // Same liveness rule as the loop: a dead runner reports error, never "still running". + if ($status === 'running' && !empty($stat['pid']) && !file_exists("/proc/{$stat['pid']}")) { + $status = 'error'; + } + echo json_encode([ + 'ok' => true, + 'id' => $id, + 'status' => $status, + 'start' => $stat['start'] ?? null, + 'end' => $stat['end'] ?? null, + 'exit' => $stat['exit'] ?? null, + ]); + exit; +} + $result = []; foreach (array_keys(vv_schedule_load()) as $id) { $statFile = vv_job_stat_path($id); diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index aebb858..73c9cfc 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -193,6 +193,55 @@ function _vvPtRun(id, extraArgs) { }); } +// ── Live progress for a launched job ────────────────────────────────────────── +// api/run.php returns {ok:true} the instant the job is *launched*, not when it finishes, and +// onboard then runs for minutes. Nothing surfaced that, so pressing Onboard looked like pressing +// nothing — and the natural response is to press it again, which is worse: the second run is +// refused by the script's own lock and its failure stat overwrites the live run's record. +// +// Polls the job's stat for liveness and its log for the step banner run_job.sh is capturing, and +// renders the last "━━━ Step … ━━━" line as the current activity. +const _vvJobPoll = {}; + +function vvPtWatchJob(id, mountEl) { + if (_vvJobPoll[id]) clearInterval(_vvJobPoll[id]); + const enc = encodeURIComponent(id); + + const tick = () => { + fetch(`/plugins/varaverk/api/status.php?id=${enc}&_=${Date.now()}`) + .then(r => r.json()) + .then(s => { + if (!s.ok) return; + if (s.status === 'running') { + return fetch(`/plugins/varaverk/api/log.php?id=${enc}&_=${Date.now()}`) + .then(r => r.json()) + .then(l => { + const lines = (l.content || '').split('\n'); + // Last step banner wins — that is where the run currently is. + let step = ''; + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(/━━━\s*(?:[^\s]+\s+)?(Step [^━]+?)\s*━━━/); + if (m) { step = m[1].trim(); break; } + } + mountEl.innerHTML = `⟳ running` + + (step ? ` · ${vvEscHtml(step)}` : ''); + }); + } + clearInterval(_vvJobPoll[id]); + delete _vvJobPoll[id]; + const col = s.status === 'ok' ? '#4caf50' : (s.status === 'warn' ? '#ff9800' : '#f44336'); + const lbl = s.status === 'ok' ? 'complete ✅' + : (s.status === 'never_run' ? 'did not start ⚠' : `${s.status} (exit ${s.exit ?? '?'})`); + mountEl.innerHTML = `${lbl}` + + ` log`; + if (_vvPtReload) _vvPtReload(); + }) + .catch(() => {}); + }; + tick(); + _vvJobPoll[id] = setInterval(tick, 4000); +} + function vvPtStartOnboard(hostId) { _vvOnboarding[hostId] = true; delete _vvDeleteKeys[hostId]; @@ -253,10 +302,33 @@ async function vvPtOnboard(btn) { if (!await vvConfirm('Run full partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.\n\nUse Phase 1 + Phase 2 buttons for step-by-step control.')) return; btn.disabled = true; btn.textContent = '⟳ Starting…'; + // Stays disabled while the job runs. Re-enabling after a fixed timeout invited the second + // click that overwrites the live run's job record with its own lock refusal. + const prog = _vvJobProgressEl(btn); _vvPtRun('Partnership/partnership_onboard.sh') - .then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); }) - .catch(e => vvAlert('Error: ' + e)) - .finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard (Mirror)'; }, 4000)); + .then(d => { + if (!d.ok) throw new Error(d.error ?? 'Unknown error'); + btn.textContent = '⟳ Running…'; + vvPtWatchJob('Partnership/partnership_onboard.sh', prog); + }) + .catch(e => { + prog.innerHTML = `failed to start — ${vvEscHtml(String(e.message || e))}`; + btn.disabled = false; + btn.textContent = '▶ Onboard'; + }); +} + +// One status line per button, inserted after it and reused across polls. +function _vvJobProgressEl(btn) { + let el = btn.parentElement.querySelector('.vv-jobprog'); + if (!el) { + el = document.createElement('div'); + el.className = 'vv-jobprog'; + el.style.cssText = 'font-size:10px;margin-top:5px;white-space:nowrap;'; + btn.parentElement.appendChild(el); + } + el.innerHTML = '⟳ starting…'; + return el; } async function vvPtPushConf(btn, hostId) { diff --git a/Plugin/unraid/run_job.sh b/Plugin/unraid/run_job.sh index 9e4b696..ad54fcf 100755 --- a/Plugin/unraid/run_job.sh +++ b/Plugin/unraid/run_job.sh @@ -138,6 +138,28 @@ if [[ "$MANUAL" == false && -f "$MANUAL_TS_FILE" ]]; then rm -f "$MANUAL_TS_FILE" fi +# Refuse to start when this job is already running, and leave its record untouched. +# +# The wrapped scripts take their own locks, so a second invocation was already refused — but it +# was refused *after* run_job.sh had overwritten the stat file with its own pid, and it then wrote +# its instant exit-1 over the live run's record. The job kept working while every status reader +# showed it failed. Observed for real: an onboard mid-way through deploying containers reported +# {"status":"warn","exit":1} because the operator, seeing no progress, had clicked twice. +# +# api/run.php has always had this guard; run_job.sh did not, and cron and the remote phase-2 +# trigger both reach run_job.sh directly without passing through it. +if [[ -f "$STAT_FILE" ]]; then + _prev_status=$(sed -n 's/.*"status":"\([^"]*\)".*/\1/p' "$STAT_FILE" 2>/dev/null) + _prev_pid=$(sed -n 's/.*"pid":\([0-9]*\).*/\1/p' "$STAT_FILE" 2>/dev/null) + if [[ "$_prev_status" == "running" && -n "$_prev_pid" && -d "/proc/$_prev_pid" ]]; then + printf '\n── %s [REFUSED — already running as PID %s] ────────\n' \ + "$(date '+%Y-%m-%d %H:%M:%S')" "$_prev_pid" >> "$LOG_FILE" + echo "$JOB_ID already running (PID $_prev_pid) — not starting a second run" >&2 + exit 0 + fi + unset _prev_status _prev_pid +fi + START=$(date +%s) printf '{"id":"%s","status":"running","start":%s,"pid":%s}\n' \ "$JOB_ID" "$START" "$$" > "$STAT_FILE"