Show a launched job's progress, stop a refused rerun from overwriting the live record, and label deployed containers so Unraid owns them
This commit is contained in:
@@ -1023,8 +1023,11 @@ echo ""
|
|||||||
echo "━━━ $ICON_GEAR Step 11 — Service Discovery ($MIRROR) ━━━"
|
echo "━━━ $ICON_GEAR Step 11 — Service Discovery ($MIRROR) ━━━"
|
||||||
|
|
||||||
POPULATE_OK=false
|
POPULATE_OK=false
|
||||||
if [[ "$MIRROR_REACHABLE" != true ]]; then
|
# MIRROR_IP, not MIRROR_REACHABLE — the latter is partnership_offboard.sh's variable and does not
|
||||||
warn "$MIRROR unreachable — skipping discovery, run Deployment/conf_populate.sh there later"
|
# 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
|
POPULATE_OK=skipped
|
||||||
elif [[ "$DRY_RUN" == true ]]; then
|
elif [[ "$DRY_RUN" == true ]]; then
|
||||||
warn "DRY RUN — would run conf_populate.sh --no-push on $MIRROR"
|
warn "DRY RUN — would run conf_populate.sh --no-push on $MIRROR"
|
||||||
|
|||||||
@@ -279,7 +279,10 @@ deploy_container_from_xml() {
|
|||||||
[[ "$_transformed_xml" != "$xml_file" ]] && _gpu_tmp="$_transformed_xml"
|
[[ "$_transformed_xml" != "$xml_file" ]] && _gpu_tmp="$_transformed_xml"
|
||||||
xml_file="$_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>([^<]*)<\/WebUI>/, a){print a[1];exit}' "$xml_file")
|
||||||
|
icon=$( awk 'match($0,/<Icon>([^<]*)<\/Icon>/, a){print a[1];exit}' "$xml_file")
|
||||||
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
|
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
|
||||||
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
|
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
|
||||||
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
|
network=$( awk 'match($0,/<Network>([^<]+)<\/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 stop %q 2>/dev/null || true\n" "$name"
|
||||||
printf "docker rm %q 2>/dev/null || true\n" "$name"
|
printf "docker rm %q 2>/dev/null || true\n" "$name"
|
||||||
echo ""
|
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 "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"
|
[[ -n "$network" ]] && printf " --network=%q" "$network"
|
||||||
[[ "$privileged" == "true" ]] && printf " --privileged"
|
[[ "$privileged" == "true" ]] && printf " --privileged"
|
||||||
[[ -n "$extra" ]] && printf " %s" "$extra"
|
[[ -n "$extra" ]] && printf " %s" "$extra"
|
||||||
|
|||||||
@@ -53,6 +53,40 @@
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||||||
|
|
||||||
|
// ?id=<Category/name.sh> — 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 = [];
|
$result = [];
|
||||||
foreach (array_keys(vv_schedule_load()) as $id) {
|
foreach (array_keys(vv_schedule_load()) as $id) {
|
||||||
$statFile = vv_job_stat_path($id);
|
$statFile = vv_job_stat_path($id);
|
||||||
|
|||||||
@@ -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 = `<span style="color:#4a9eff;">⟳ running</span>`
|
||||||
|
+ (step ? ` <span style="color:#666;">· ${vvEscHtml(step)}</span>` : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 = `<span style="color:${col};">${lbl}</span>`
|
||||||
|
+ ` <a href="?tab=scheduler" class="localURL" style="color:#556;margin-left:6px;">log</a>`;
|
||||||
|
if (_vvPtReload) _vvPtReload();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
_vvJobPoll[id] = setInterval(tick, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
function vvPtStartOnboard(hostId) {
|
function vvPtStartOnboard(hostId) {
|
||||||
_vvOnboarding[hostId] = true;
|
_vvOnboarding[hostId] = true;
|
||||||
delete _vvDeleteKeys[hostId];
|
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;
|
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.disabled = true;
|
||||||
btn.textContent = '⟳ Starting…';
|
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')
|
_vvPtRun('Partnership/partnership_onboard.sh')
|
||||||
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
|
.then(d => {
|
||||||
.catch(e => vvAlert('Error: ' + e))
|
if (!d.ok) throw new Error(d.error ?? 'Unknown error');
|
||||||
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard (Mirror)'; }, 4000));
|
btn.textContent = '⟳ Running…';
|
||||||
|
vvPtWatchJob('Partnership/partnership_onboard.sh', prog);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
prog.innerHTML = `<span style="color:#f44336;">failed to start — ${vvEscHtml(String(e.message || e))}</span>`;
|
||||||
|
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 = '<span style="color:#4a9eff;">⟳ starting…</span>';
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vvPtPushConf(btn, hostId) {
|
async function vvPtPushConf(btn, hostId) {
|
||||||
|
|||||||
@@ -138,6 +138,28 @@ if [[ "$MANUAL" == false && -f "$MANUAL_TS_FILE" ]]; then
|
|||||||
rm -f "$MANUAL_TS_FILE"
|
rm -f "$MANUAL_TS_FILE"
|
||||||
fi
|
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)
|
START=$(date +%s)
|
||||||
printf '{"id":"%s","status":"running","start":%s,"pid":%s}\n' \
|
printf '{"id":"%s","status":"running","start":%s,"pid":%s}\n' \
|
||||||
"$JOB_ID" "$START" "$$" > "$STAT_FILE"
|
"$JOB_ID" "$START" "$$" > "$STAT_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user