327 lines
13 KiB
JavaScript
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;
|
|
})();
|