|
|
@@ -13,6 +13,7 @@
|
|
|
--led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
|
|
|
--glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
|
|
|
--btn: #21262d; --btn-hover: #30363d;
|
|
|
+ --a-red: #f85149; --a-green: #3fb950; --a-yellow: #d29922; --a-blue: #58a6ff; --a-magenta: #bc8cff; --a-cyan: #39c5cf; --a-gray: #8b949e;
|
|
|
}
|
|
|
:root[data-theme="light"] {
|
|
|
--bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
|
|
|
@@ -21,6 +22,7 @@
|
|
|
--led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #8c959f;
|
|
|
--glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
|
|
|
--btn: #f6f8fa; --btn-hover: #eaeef2;
|
|
|
+ --a-red: #cf222e; --a-green: #1a7f37; --a-yellow: #9a6700; --a-blue: #0969da; --a-magenta: #8250df; --a-cyan: #1b7c83; --a-gray: #656d76;
|
|
|
}
|
|
|
* { box-sizing: border-box; }
|
|
|
html, body { height: 100%; }
|
|
|
@@ -200,6 +202,19 @@
|
|
|
font: 11.5px/1.5 var(--mono); white-space: pre-wrap;
|
|
|
word-break: break-word; color: var(--fg);
|
|
|
}
|
|
|
+ /* ANSI SGR rendering: theme-aware palette for the container logs */
|
|
|
+ .logpre .a31, .logpre .a91 { color: var(--a-red); }
|
|
|
+ .logpre .a32, .logpre .a92 { color: var(--a-green); }
|
|
|
+ .logpre .a33, .logpre .a93 { color: var(--a-yellow); }
|
|
|
+ .logpre .a34, .logpre .a94 { color: var(--a-blue); }
|
|
|
+ .logpre .a35, .logpre .a95 { color: var(--a-magenta); }
|
|
|
+ .logpre .a36, .logpre .a96 { color: var(--a-cyan); }
|
|
|
+ .logpre .a30, .logpre .a90 { color: var(--a-gray); }
|
|
|
+ .logpre .a37, .logpre .a97 { color: var(--fg); }
|
|
|
+ .logpre .b { font-weight: 600; }
|
|
|
+ .logpre .u { text-decoration: underline; }
|
|
|
+ .logpre .it { font-style: italic; }
|
|
|
+ .logpre .dim2 { color: var(--muted); }
|
|
|
|
|
|
/* --- raw content windows --------------------------------------------------- */
|
|
|
.rawwin .win-body { display: flex; flex-direction: column; padding: 0; }
|
|
|
@@ -1637,13 +1652,73 @@ function trimPre(pre) {
|
|
|
pre.textContent = t.split("\n").slice(-MAX_PRE_LINES).join("\n");
|
|
|
}
|
|
|
}
|
|
|
+/*ANSI-LT:START*/
|
|
|
+// ANSI SGR → themed spans. Everything else (cursor movement, OSC titles)
|
|
|
+// is stripped; HTML in the log stays escaped because the text is escaped
|
|
|
+// first and only our own spans are added.
|
|
|
+function ansiToHtml(text) {
|
|
|
+ text = String(text)
|
|
|
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|\x1b\x07)/g, "") // OSC (titles etc.)
|
|
|
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, (m) => (m.endsWith("m") ? m : "")) // non-SGR CSI
|
|
|
+ .replace(/\x1b\[[0-9;?]*$/, ""); // unterminated sequence at the tail
|
|
|
+ const drop = (cls, rm) => cls.filter((c) => c !== rm);
|
|
|
+ let out = "", cls = [], last = 0;
|
|
|
+ const open = () => (cls.length ? `<span class="${cls.join(" ")}">` : "");
|
|
|
+ const re = /\x1b\[([0-9;]*)m/g;
|
|
|
+ let m;
|
|
|
+ while ((m = re.exec(text))) {
|
|
|
+ out += open() + esc(text.slice(last, m.index)) + (cls.length ? "</span>" : "");
|
|
|
+ for (const part of (m[1] || "0").split(";")) {
|
|
|
+ const n = +part || 0;
|
|
|
+ if (n === 0) cls = [];
|
|
|
+ else if (n === 1) cls.push("b");
|
|
|
+ else if (n === 2) cls.push("dim2");
|
|
|
+ else if (n === 3) cls.push("it");
|
|
|
+ else if (n === 4) cls.push("u");
|
|
|
+ else if (n === 22) cls = drop(drop(cls, "b"), "dim2");
|
|
|
+ else if (n === 23) cls = drop(cls, "it");
|
|
|
+ else if (n === 24) cls = drop(cls, "u");
|
|
|
+ else if (n === 39) cls = cls.filter((c) => !/^a(3[0-7]|9[0-7])$/.test(c));
|
|
|
+ else if ((n >= 30 && n <= 37) || (n >= 90 && n <= 97))
|
|
|
+ cls = cls.filter((c) => !/^a(3[0-7]|9[0-7])$/.test(c)).concat("a" + n); // fg replaces fg
|
|
|
+ // 40-47/100-107 bg and 38/48 extended colors: rare in logs, dropped
|
|
|
+ }
|
|
|
+ last = m.index + m[0].length;
|
|
|
+ }
|
|
|
+ out += open() + esc(text.slice(last));
|
|
|
+ if (cls.length) out += "</span>";
|
|
|
+ return out;
|
|
|
+}
|
|
|
+/*ANSI-LT:END*/
|
|
|
+
|
|
|
+// carriage returns carry progress-bar frames — the last frame wins, so the
|
|
|
+// log keeps the final bar state instead of a wall of rewrites
|
|
|
+const lastCRFrame = (line) => line.split("\r").pop();
|
|
|
+
|
|
|
function appendLogLine(w, line) {
|
|
|
- if (w.paused) { w.buf += line + "\n"; return; }
|
|
|
- preSet(w, w.pre.textContent + line + "\n");
|
|
|
+ if (w.paused) { w.buf += lastCRFrame(line) + "\n"; return; }
|
|
|
+ w.raw += lastCRFrame(line) + "\n";
|
|
|
+ trimRaw(w);
|
|
|
+ scheduleRender(w);
|
|
|
}
|
|
|
function preSet(w, text) {
|
|
|
- w.pre.textContent = text;
|
|
|
- trimPre(w.pre);
|
|
|
+ w.raw = text.split("\n").map(lastCRFrame).join("\n");
|
|
|
+ trimRaw(w);
|
|
|
+ renderLog(w);
|
|
|
+}
|
|
|
+// the raw text is authoritative (the copy button uses the rendered text);
|
|
|
+// cap it like the old DOM trim did
|
|
|
+function trimRaw(w) {
|
|
|
+ const lines = w.raw.split("\n");
|
|
|
+ if (lines.length > MAX_PRE_LINES + 1) w.raw = lines.slice(-MAX_PRE_LINES).join("\n");
|
|
|
+}
|
|
|
+let renderTimer = null;
|
|
|
+function scheduleRender(w) {
|
|
|
+ if (w.renderTimer) return;
|
|
|
+ w.renderTimer = setTimeout(() => { w.renderTimer = null; renderLog(w); }, 120);
|
|
|
+}
|
|
|
+function renderLog(w) {
|
|
|
+ w.pre.innerHTML = ansiToHtml(w.raw);
|
|
|
if (!w.paused) w.pre.scrollTop = w.pre.scrollHeight;
|
|
|
}
|
|
|
function onLog(ev) {
|
|
|
@@ -1686,7 +1761,7 @@ async function openLog(cluster, node, recipe) {
|
|
|
if (w) detachLog(w[0]);
|
|
|
},
|
|
|
});
|
|
|
- const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), buf: "", paused: false };
|
|
|
+ const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), raw: "", buf: "", paused: false, renderTimer: null };
|
|
|
win.body.querySelector(".logcopy").onclick = async () => {
|
|
|
try { await navigator.clipboard.writeText(w.pre.textContent); } catch {}
|
|
|
};
|
|
|
@@ -1694,7 +1769,7 @@ async function openLog(cluster, node, recipe) {
|
|
|
const pauseCb = win.body.querySelector(".logpause");
|
|
|
pauseCb.onchange = () => {
|
|
|
w.paused = pauseCb.checked;
|
|
|
- if (!w.paused && w.buf) { preSet(w, w.pre.textContent + w.buf); w.buf = ""; }
|
|
|
+ if (!w.paused && w.buf) { w.raw += w.buf; w.buf = ""; trimRaw(w); renderLog(w); }
|
|
|
};
|
|
|
const sel = win.body.querySelector("select");
|
|
|
for (const x of n.containers) {
|