/** * 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/" 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
. // ----------------------------------------------------------------------- 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; })();