Files
2026-07-24 21:37:03 +02:00

129 lines
4.2 KiB
JavaScript

/* ==========================================================================
boehmitools · Runner
Gemeinsame Oberflächenlogik für Plugins, die ein Kommandozeilen-Tool
kapseln: Lauf starten, Ausgabe live streamen, Status anzeigen, abbrechen.
const runner = BT.Runner({ log: el, status: el, onFinish: fn });
runner.start({ apply: false }); // POST <mount>/api/run
runner.attach(jobId); // an laufenden Lauf andocken
Setzt boehmi.js voraus.
========================================================================== */
(function () {
"use strict";
const LABEL = {
running: ["busy", "läuft …"],
done: ["ok", "erfolgreich abgeschlossen"],
failed: ["error", "fehlgeschlagen"],
cancelled: ["warn", "abgebrochen"],
};
function classify(entry) {
if (entry.stream === "meta") return "l-meta";
if (entry.stream === "stderr") return "l-err";
const t = entry.text;
if (/^(FEHLER|Abbruch|Fehlgeschlagen)|✗|Traceback/i.test(t)) return "l-err";
if (/^(Warnung|Hinweis)|⚠/i.test(t)) return "l-warn";
if (/(erfolgreich|✓|OK\b|angelegt|aktualisiert)/i.test(t)) return "l-ok";
return "";
}
function Runner(cfg) {
// Nicht const: Oberflächen, die ihre Karten neu zeichnen, hängen den
// Runner per rebind() wieder an die frischen Elemente.
let logEl = cfg.log;
let statusEl = cfg.status;
// Plugins mit mehreren Läufen (prüfen / vorschlagen / übernehmen) geben
// ihren eigenen Endpunkt an. Vorgabe bleibt der bisherige.
const endpoint = cfg.endpoint || "/api/run";
let source = null;
let job = null;
function setStatus(kind, text, extra) {
if (!statusEl) return;
statusEl.className = "bt-status " + (kind || "");
statusEl.innerHTML = `<span class="bt-dot"></span><b>${BT.escape(text)}</b>` +
(extra ? ` <span class="bt-muted">${BT.escape(extra)}</span>` : "");
}
function clear() {
if (logEl) logEl.textContent = "";
}
function append(entry) {
if (!logEl) return;
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
const span = document.createElement("span");
const cls = classify(entry);
if (cls) span.className = cls;
span.textContent = entry.text + "\n";
logEl.appendChild(span);
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}
function close() {
if (source) { source.close(); source = null; }
}
function attach(id) {
close();
source = new EventSource(BT.url(`/api/jobs/${id}/events`));
setStatus("busy", "läuft …");
source.addEventListener("line", (e) => append(JSON.parse(e.data)));
source.addEventListener("end", (e) => {
const info = JSON.parse(e.data);
job = info;
const [kind, text] = LABEL[info.status] || ["", info.status];
setStatus(kind, text, `${info.duration}s · Exit-Code ${info.returncode}`);
close();
if (cfg.onFinish) cfg.onFinish(info);
});
source.onerror = () => {
if (source && source.readyState === EventSource.CLOSED) {
close();
setStatus("warn", "Verbindung zum Protokoll unterbrochen");
}
};
}
async function start(body) {
clear();
setStatus("busy", "wird gestartet …");
try {
job = await BT.api(endpoint, { method: "POST", body: JSON.stringify(body || {}) });
attach(job.id);
if (cfg.onStart) cfg.onStart(job);
return job;
} catch (err) {
setStatus("error", "Start fehlgeschlagen", err.message);
BT.toast(err.message, "err");
throw err;
}
}
async function cancel() {
if (!job) return;
try {
await BT.api(`/api/jobs/${job.id}/cancel`, { method: "POST" });
BT.toast("Abbruch angefordert.");
} catch (err) {
BT.toast(err.message, "err");
}
}
function rebind(next) {
if (next.log) logEl = next.log;
if (next.status) statusEl = next.status;
}
return {
start, attach, cancel, clear, setStatus, rebind,
get job() { return job; },
get busy() { return Boolean(source); },
};
}
window.BT.Runner = Runner;
})();