Files
log-streamer/public/app.js
T
2026-06-24 15:42:44 +08:00

414 lines
9.9 KiB
JavaScript

'use strict';
const MAX_VISIBLE_LINES = 5000;
const RECONNECT_DELAY_MS = 1000;
const output = document.querySelector('#logOutput');
const statusBadge = document.querySelector('#status');
const filePath = document.querySelector('#filePath');
const lineCount = document.querySelector('#lineCount');
const pendingCount = document.querySelector('#pendingCount');
const autoScroll = document.querySelector('#autoScroll');
const pauseButton = document.querySelector('#pauseButton');
const clearButton = document.querySelector('#clearButton');
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
const ANSI_FG_CLASSES = new Map([
[30, 'ansi-fg-black'],
[31, 'ansi-fg-red'],
[32, 'ansi-fg-green'],
[33, 'ansi-fg-yellow'],
[34, 'ansi-fg-blue'],
[35, 'ansi-fg-magenta'],
[36, 'ansi-fg-cyan'],
[37, 'ansi-fg-white'],
[90, 'ansi-fg-bright-black'],
[91, 'ansi-fg-bright-red'],
[92, 'ansi-fg-bright-green'],
[93, 'ansi-fg-bright-yellow'],
[94, 'ansi-fg-bright-blue'],
[95, 'ansi-fg-bright-magenta'],
[96, 'ansi-fg-bright-cyan'],
[97, 'ansi-fg-bright-white']
]);
const ANSI_BG_CLASSES = new Map([
[40, 'ansi-bg-black'],
[41, 'ansi-bg-red'],
[42, 'ansi-bg-green'],
[43, 'ansi-bg-yellow'],
[44, 'ansi-bg-blue'],
[45, 'ansi-bg-magenta'],
[46, 'ansi-bg-cyan'],
[47, 'ansi-bg-white'],
[100, 'ansi-bg-bright-black'],
[101, 'ansi-bg-bright-red'],
[102, 'ansi-bg-bright-green'],
[103, 'ansi-bg-bright-yellow'],
[104, 'ansi-bg-bright-blue'],
[105, 'ansi-bg-bright-magenta'],
[106, 'ansi-bg-bright-cyan'],
[107, 'ansi-bg-bright-white']
]);
let socket;
let reconnectTimer;
let paused = false;
let visibleLines = [];
let pendingChunks = [];
function setStatus(state, label) {
statusBadge.dataset.state = state;
statusBadge.textContent = label;
}
function isNearBottom() {
const distance = output.scrollHeight - output.scrollTop - output.clientHeight;
return distance < 24;
}
function scrollToBottom() {
output.scrollTop = output.scrollHeight;
}
function updateCounts() {
lineCount.textContent = String(visibleLines.length);
if (pendingChunks.length === 0) {
pendingCount.hidden = true;
pendingCount.textContent = '0 buffered';
return;
}
pendingCount.hidden = false;
pendingCount.textContent = `${pendingChunks.length} buffered`;
}
function createAnsiState() {
return {
fgClass: null,
bgClass: null,
fgColor: null,
bgColor: null,
bold: false,
dim: false,
italic: false,
underline: false
};
}
function hasAnsiStyle(state) {
return Boolean(
state.fgClass ||
state.bgClass ||
state.fgColor ||
state.bgColor ||
state.bold ||
state.dim ||
state.italic ||
state.underline
);
}
function appendAnsiSegment(fragment, state, text) {
if (!text) {
return;
}
if (!hasAnsiStyle(state)) {
fragment.append(document.createTextNode(text));
return;
}
const span = document.createElement('span');
if (state.fgClass) span.classList.add(state.fgClass);
if (state.bgClass) span.classList.add(state.bgClass);
if (state.bold) span.classList.add('ansi-bold');
if (state.dim) span.classList.add('ansi-dim');
if (state.italic) span.classList.add('ansi-italic');
if (state.underline) span.classList.add('ansi-underline');
if (state.fgColor) span.style.color = state.fgColor;
if (state.bgColor) span.style.backgroundColor = state.bgColor;
span.textContent = text;
fragment.append(span);
}
function readExtendedAnsiColor(params, index) {
const mode = params[index + 1];
if (mode === 5 && Number.isInteger(params[index + 2])) {
return { color: ansi256ToCss(params[index + 2]), nextIndex: index + 2 };
}
if (
mode === 2 &&
Number.isInteger(params[index + 2]) &&
Number.isInteger(params[index + 3]) &&
Number.isInteger(params[index + 4])
) {
const r = clampColor(params[index + 2]);
const g = clampColor(params[index + 3]);
const b = clampColor(params[index + 4]);
return { color: `rgb(${r} ${g} ${b})`, nextIndex: index + 4 };
}
return { color: null, nextIndex: index };
}
function ansi256ToCss(value) {
const color = clampColor(value);
if (color < 16) {
const palette = [
'#000000',
'#cd3131',
'#0dbc79',
'#e5e510',
'#2472c8',
'#bc3fbc',
'#11a8cd',
'#e5e5e5',
'#666666',
'#f14c4c',
'#23d18b',
'#f5f543',
'#3b8eea',
'#d670d6',
'#29b8db',
'#ffffff'
];
return palette[color];
}
if (color >= 232) {
const level = 8 + (color - 232) * 10;
return `rgb(${level} ${level} ${level})`;
}
const adjusted = color - 16;
const r = Math.floor(adjusted / 36);
const g = Math.floor((adjusted % 36) / 6);
const b = adjusted % 6;
const component = (part) => (part === 0 ? 0 : 55 + part * 40);
return `rgb(${component(r)} ${component(g)} ${component(b)})`;
}
function clampColor(value) {
return Math.max(0, Math.min(255, value));
}
function applyAnsiCodes(state, rawCodes) {
const codes = rawCodes === '' ? [0] : rawCodes.split(';').map((code) => Number.parseInt(code || '0', 10));
for (let index = 0; index < codes.length; index += 1) {
const code = Number.isNaN(codes[index]) ? 0 : codes[index];
if (code === 0) {
Object.assign(state, createAnsiState());
} else if (code === 1) {
state.bold = true;
state.dim = false;
} else if (code === 2) {
state.dim = true;
state.bold = false;
} else if (code === 3) {
state.italic = true;
} else if (code === 4) {
state.underline = true;
} else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) {
state.italic = false;
} else if (code === 24) {
state.underline = false;
} else if (code === 38) {
const extended = readExtendedAnsiColor(codes, index);
state.fgClass = null;
state.fgColor = extended.color;
index = extended.nextIndex;
} else if (code === 39) {
state.fgClass = null;
state.fgColor = null;
} else if (code === 48) {
const extended = readExtendedAnsiColor(codes, index);
state.bgClass = null;
state.bgColor = extended.color;
index = extended.nextIndex;
} else if (code === 49) {
state.bgClass = null;
state.bgColor = null;
} else if (ANSI_FG_CLASSES.has(code)) {
state.fgClass = ANSI_FG_CLASSES.get(code);
state.fgColor = null;
} else if (ANSI_BG_CLASSES.has(code)) {
state.bgClass = ANSI_BG_CLASSES.get(code);
state.bgColor = null;
}
}
}
function renderAnsiText(text) {
const fragment = document.createDocumentFragment();
const state = createAnsiState();
let lastIndex = 0;
let match;
ANSI_SGR_PATTERN.lastIndex = 0;
while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
appendAnsiSegment(fragment, state, text.slice(lastIndex, match.index));
applyAnsiCodes(state, match[1]);
lastIndex = ANSI_SGR_PATTERN.lastIndex;
}
appendAnsiSegment(fragment, state, text.slice(lastIndex));
return fragment;
}
function renderLog() {
output.replaceChildren(renderAnsiText(visibleLines.join('\n')));
}
function appendText(text) {
if (!text) {
return;
}
const shouldScroll = autoScroll.checked && isNearBottom();
const normalized = text.replace(/\r\n/g, '\n');
const nextLines = normalized.split('\n');
if (visibleLines.length === 0) {
visibleLines = nextLines;
} else {
visibleLines[visibleLines.length - 1] += nextLines[0];
visibleLines.push(...nextLines.slice(1));
}
if (visibleLines.length > MAX_VISIBLE_LINES) {
visibleLines = visibleLines.slice(-MAX_VISIBLE_LINES);
}
renderLog();
updateCounts();
if (shouldScroll) {
requestAnimationFrame(scrollToBottom);
}
}
function setSnapshot(lines) {
visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : [];
pendingChunks = [];
renderLog();
updateCounts();
if (autoScroll.checked) {
requestAnimationFrame(scrollToBottom);
}
}
function handleStreamText(text) {
if (paused) {
pendingChunks.push(text);
updateCounts();
return;
}
appendText(text);
}
function flushPending() {
if (pendingChunks.length === 0) {
return;
}
const chunks = pendingChunks.join('');
pendingChunks = [];
appendText(chunks);
}
function connect() {
clearTimeout(reconnectTimer);
setStatus(socket ? 'reconnecting' : 'connecting', socket ? 'Reconnecting' : 'Connecting');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${protocol}//${window.location.host}/stream`);
socket.addEventListener('open', () => {
setStatus('connected', 'Connected');
});
socket.addEventListener('message', (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === 'hello') {
filePath.textContent = message.file || 'Unknown file';
return;
}
if (message.type === 'snapshot') {
setSnapshot(message.lines);
return;
}
if (message.type === 'append') {
handleStreamText(message.text);
return;
}
if (message.type === 'truncate') {
setSnapshot([]);
appendText(`[stream reset] ${message.message}\n`);
return;
}
if (message.type === 'error') {
setStatus('error', 'Error');
appendText(`[stream error] ${message.message}\n`);
}
});
socket.addEventListener('close', () => {
setStatus('disconnected', 'Disconnected');
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
});
socket.addEventListener('error', () => {
setStatus('error', 'Error');
});
}
pauseButton.addEventListener('click', () => {
paused = !paused;
pauseButton.textContent = paused ? 'Resume' : 'Pause';
if (!paused) {
flushPending();
}
});
clearButton.addEventListener('click', () => {
visibleLines = [];
pendingChunks = [];
renderLog();
updateCounts();
});
autoScroll.addEventListener('change', () => {
if (autoScroll.checked) {
scrollToBottom();
}
});
connect();