Primer commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user