Files
MP-Manager/static/js/live_terminal.js
2026-05-30 14:31:19 -06:00

327 lines
13 KiB
JavaScript

/**
* live_terminal.js — Terminal de salida en vivo, batched + buffered.
*
* Reemplaza el flujo legacy donde cada evento SSE producia un appendChild +
* scrollTop + posible removeChild (reflow por linea, O(n^2) acumulado por los
* innerHTML += dispersos). Esta clase:
*
* - Acumula eventos en un array `lines` (single source of truth).
* - Cada evento programa un flush con setTimeout(60ms) + requestAnimationFrame.
* - El flush vuelca el batch al DOM en un solo DocumentFragment + un solo
* scroll + un solo trim. N reflows -> 1 reflow por batch.
* - El boton "Copiar" lee del array, no del DOM.
*
* API (expuesta como `window.LiveTerminal`):
*
* const term = new LiveTerminal(screenEl, {
* maxLines: 3000, // cap visible. Lo que sobra se descarta
* // del DOM y del array (truncated++).
* onError: (text) => { ... }, // callback para lineas clasificadas como
* // error y que no son [SISTEMA] (para
* // alimentar la pestania Errores).
* classifier: (text) => ({ // opcional. Por default usa el clasificador
* lineClass, downloadUrl // de scripts. Para Playwright puedes pasar
* }), // uno distinto.
* onStats: (stats) => { ... }, // recibe { total, visible, truncated }
* // tras cada flush para pintar contadores.
* onEof: () => { ... }, // se invoca al recibir [EOF].
* onStreamError: (err) => {}, // se invoca cuando EventSource.onerror
* // se dispara (para que el caller decida
* // reconexion / cierre / etc).
* });
*
* term.attachStream(taskId); // abre el EventSource y conecta el flujo
* term.appendSystem(text, "info" | "error" | "success"); // mensajes [SISTEMA]
* term.getCopyText(); // string completo del buffer
* term.getStats(); // { total, visible, truncated, sinceLastFlush }
* term.reset(); // limpia DOM + array, mantiene el EventSource
* term.detach(); // cierra el EventSource (mantiene el buffer)
* term.destroy(); // detach + reset + remueve listeners
*
* Cualquier marcador "[DOWNLOAD] /api/exports/<file>" se renderiza como un
* link de descarga (preserva el comportamiento original).
*/
(function () {
"use strict";
// -----------------------------------------------------------------------
// Clasificador default — replica la logica del flujo legacy de scripts.
// -----------------------------------------------------------------------
function defaultClassifier(line) {
const downloadMatch = line.match(/^\[DOWNLOAD\]\s+(\/api\/exports\/[^\s]+)\s*$/);
if (downloadMatch) {
return { lineClass: "download", downloadUrl: downloadMatch[1] };
}
if (line.indexOf("ERROR") !== -1 ||
line.indexOf("Exception") !== -1 ||
line.indexOf("Failed") !== -1) {
return { lineClass: "error" };
}
if (line.indexOf("COMPLETADA CON ÉXITO") !== -1 ||
line.indexOf("exitosa") !== -1) {
return { lineClass: "success" };
}
if (line.indexOf("===") !== -1 || line.indexOf("INICIANDO") !== -1) {
return { lineClass: "info" };
}
return { lineClass: "" };
}
// -----------------------------------------------------------------------
// Construye un nodo DOM para un entry, SIEMPRE con createElement+textContent.
// Cero innerHTML. Devuelve un <div class="terminal-line ...">.
// -----------------------------------------------------------------------
function renderLine(entry) {
const div = document.createElement("div");
let cls = "terminal-line";
if (entry.lineClass) cls += " " + entry.lineClass;
div.className = cls;
if (entry.downloadUrl) {
const url = entry.downloadUrl;
const filename = decodeURIComponent(url.split("/").pop() || "archivo");
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.target = "_blank";
a.rel = "noopener";
a.className = "terminal-download-link";
a.textContent = "📥 Descargar " + filename;
div.appendChild(a);
} else {
div.textContent = entry.text;
}
return div;
}
// -----------------------------------------------------------------------
// LiveTerminal — instanciable, reusable entre la terminal de scripts y la
// de Playwright.
// -----------------------------------------------------------------------
function LiveTerminal(screenEl, opts) {
opts = opts || {};
if (!screenEl) {
throw new Error("LiveTerminal: screenEl es requerido.");
}
this.screen = screenEl;
this.maxLines = opts.maxLines || 3000;
this.onError = typeof opts.onError === "function" ? opts.onError : null;
this.onStats = typeof opts.onStats === "function" ? opts.onStats : null;
this.onEof = typeof opts.onEof === "function" ? opts.onEof : null;
this.onStreamError = typeof opts.onStreamError === "function" ? opts.onStreamError : null;
this.classifier = typeof opts.classifier === "function" ? opts.classifier : defaultClassifier;
this.lines = []; // [{ text, lineClass, downloadUrl?, system?, ts }]
this.pending = []; // batch entrante sin pintar
this.truncated = 0; // contador acumulado de descartadas
this.rafScheduled = false;
this.userScrolledUp = false;
this.eventSource = null;
this.taskId = null;
this.lastByte = 0; // para reconexion por tail
// Listener para detectar si el usuario se aleja del fondo
this._scrollHandler = this._onScroll.bind(this);
this.screen.addEventListener("scroll", this._scrollHandler, { passive: true });
}
LiveTerminal.prototype._onScroll = function () {
const s = this.screen;
const atBottom = s.scrollTop + s.clientHeight >= s.scrollHeight - 40;
this.userScrolledUp = !atBottom;
};
// Empuja una entry al pending y agenda flush si no esta agendado.
LiveTerminal.prototype._enqueue = function (entry) {
this.pending.push(entry);
// Onerror sincrono: el callback se llama AQUI, no en el flush, para que
// el badge de errores y el panel se actualicen al instante.
if (entry.lineClass === "error" && !entry.system && this.onError) {
try { this.onError(entry.text); } catch (e) { /* swallow */ }
}
if (!this.rafScheduled) {
this.rafScheduled = true;
const self = this;
setTimeout(function () {
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { self._flush(); });
} else {
self._flush();
}
}, 60);
}
};
// Vacia el batch al DOM en un solo fragment + un solo trim + un solo scroll.
LiveTerminal.prototype._flush = function () {
this.rafScheduled = false;
if (!this.pending.length) return;
// 1. Merge al array maestro
const incoming = this.pending;
this.pending = [];
for (let i = 0; i < incoming.length; i++) {
this.lines.push(incoming[i]);
}
// 2. Trim del array maestro (un solo splice)
let trimmedThisFlush = 0;
if (this.lines.length > this.maxLines) {
trimmedThisFlush = this.lines.length - this.maxLines;
this.lines.splice(0, trimmedThisFlush);
this.truncated += trimmedThisFlush;
}
// 3. Limpiar placeholder inicial si todavia esta
const placeholder = this.screen.querySelector(".terminal-placeholder");
if (placeholder) placeholder.remove();
// 4. Render del batch en un solo DocumentFragment
const frag = document.createDocumentFragment();
for (let j = 0; j < incoming.length; j++) {
frag.appendChild(renderLine(incoming[j]));
}
this.screen.appendChild(frag);
// 5. Trim del DOM. Si el usuario tiene texto seleccionado dentro del
// screen, posponemos el trim al siguiente flush (no perder seleccion).
const sel = window.getSelection ? window.getSelection() : null;
const selInScreen = sel && sel.toString() && sel.anchorNode &&
this.screen.contains(sel.anchorNode);
if (!selInScreen) {
const excess = this.screen.childElementCount - this.maxLines;
if (excess > 0) {
for (let k = 0; k < excess; k++) {
const first = this.screen.firstElementChild;
if (!first) break;
first.remove();
}
}
}
// 6. Auto-scroll una sola vez por flush
if (!this.userScrolledUp) {
this.screen.scrollTop = this.screen.scrollHeight;
}
// 7. Notificar stats
if (this.onStats) {
try {
this.onStats({
total: this.lines.length + this.truncated,
visible: this.lines.length,
truncated: this.truncated,
flushed: incoming.length,
});
} catch (e) { /* swallow */ }
}
};
// Procesa una linea cruda del stream (con [EOF] / [DOWNLOAD] / clasificacion).
LiveTerminal.prototype._handleStreamLine = function (rawLine) {
if (rawLine === "[EOF]") {
// Forzar flush inmediato antes de avisar al caller.
this._flush();
if (this.onEof) {
try { this.onEof(); } catch (e) { /* swallow */ }
}
return;
}
const cls = this.classifier(rawLine) || {};
this._enqueue({
text: rawLine,
lineClass: cls.lineClass || "",
downloadUrl: cls.downloadUrl || null,
ts: Date.now(),
});
};
// ---- API publica ------------------------------------------------------
LiveTerminal.prototype.appendSystem = function (text, lineClass) {
this._enqueue({
text: text,
lineClass: lineClass || "info",
system: true,
ts: Date.now(),
});
};
LiveTerminal.prototype.attachStream = function (taskId, opts) {
opts = opts || {};
// Si ya hay un EventSource abierto, cerrarlo.
this.detach();
this.taskId = taskId;
this.lastByte = 0;
const url = opts.url || ("/api/scripts/stream/" + encodeURIComponent(taskId));
const es = new EventSource(url);
this.eventSource = es;
const self = this;
es.onmessage = function (ev) {
self._handleStreamLine(ev.data);
};
es.onerror = function (e) {
if (self.onStreamError) {
try { self.onStreamError(e, es); } catch (err) { /* swallow */ }
}
};
return es;
};
LiveTerminal.prototype.detach = function () {
if (this.eventSource) {
try { this.eventSource.close(); } catch (e) { /* swallow */ }
this.eventSource = null;
}
};
LiveTerminal.prototype.reset = function () {
this.pending = [];
this.lines = [];
this.truncated = 0;
this.rafScheduled = false;
this.userScrolledUp = false;
// Limpiar DOM sin innerHTML para mantener la consistencia.
while (this.screen.firstChild) this.screen.removeChild(this.screen.firstChild);
if (this.onStats) {
try {
this.onStats({ total: 0, visible: 0, truncated: 0, flushed: 0 });
} catch (e) { /* swallow */ }
}
};
LiveTerminal.prototype.destroy = function () {
this.detach();
this.screen.removeEventListener("scroll", this._scrollHandler);
this.reset();
};
LiveTerminal.prototype.getCopyText = function () {
// Lee del array, no del DOM. Cero force-layout.
const out = [];
for (let i = 0; i < this.lines.length; i++) {
out.push(this.lines[i].text);
}
return out.join("\n");
};
LiveTerminal.prototype.getStats = function () {
return {
total: this.lines.length + this.truncated,
visible: this.lines.length,
truncated: this.truncated,
pending: this.pending.length,
};
};
LiveTerminal.prototype.scrollToBottom = function () {
this.userScrolledUp = false;
this.screen.scrollTop = this.screen.scrollHeight;
};
// Expone tambien el clasificador default por si alguien quiere extenderlo.
LiveTerminal.defaultClassifier = defaultClassifier;
window.LiveTerminal = LiveTerminal;
})();