Initial log streamer app

This commit is contained in:
2026-06-24 15:35:10 +08:00
commit a105228653
8 changed files with 786 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
npm-debug.log*
*.log
!sample.log
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
{
"name": "log-streamer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "log-streamer",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "log-streamer",
"version": "1.0.0",
"private": true,
"description": "Small WebSocket log file streamer.",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js sample.log"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+186
View File
@@ -0,0 +1,186 @@
'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');
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 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);
}
output.textContent = visibleLines.join('\n');
updateCounts();
if (shouldScroll) {
requestAnimationFrame(scrollToBottom);
}
}
function setSnapshot(lines) {
visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : [];
pendingChunks = [];
output.textContent = visibleLines.join('\n');
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 = [];
output.textContent = '';
updateCounts();
});
autoScroll.addEventListener('change', () => {
if (autoScroll.checked) {
scrollToBottom();
}
});
connect();
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log Streamer</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="toolbar" aria-label="Log stream controls">
<div class="identity">
<h1>Log Streamer</h1>
<div id="filePath" class="file-path">Waiting for file...</div>
</div>
<div class="controls">
<span id="status" class="status" data-state="connecting">Connecting</span>
<label class="toggle">
<input id="autoScroll" type="checkbox" checked>
<span>Auto-scroll</span>
</label>
<button id="pauseButton" type="button">Pause</button>
<button id="clearButton" type="button">Clear</button>
</div>
</section>
<section class="meta" aria-live="polite">
<span><strong id="lineCount">0</strong> lines visible</span>
<span id="pendingCount" hidden>0 buffered</span>
</section>
<pre id="logOutput" class="log-output" aria-label="Live log output"></pre>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+215
View File
@@ -0,0 +1,215 @@
:root {
color-scheme: dark;
--background: #101112;
--surface: #17191b;
--surface-2: #202326;
--border: #33383d;
--text: #eceff1;
--muted: #a7adb4;
--muted-2: #727982;
--accent: #13c07b;
--warning: #d59c36;
--danger: #e15e5e;
--control: #24282c;
--control-hover: #2d3237;
--mono: "SFMono-Regular", "Cascadia Code", "Liberation Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
}
button,
input {
font: inherit;
}
.shell {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
min-height: 100%;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 72px;
padding: 14px 18px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.identity {
min-width: 0;
}
h1 {
margin: 0 0 5px;
font-size: 17px;
font-weight: 650;
line-height: 1.2;
}
.file-path {
max-width: min(74vw, 920px);
overflow: hidden;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.status {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 7px;
color: var(--muted);
background: var(--surface-2);
white-space: nowrap;
}
.status::before {
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: var(--muted-2);
content: "";
}
.status[data-state="connected"]::before {
background: var(--accent);
}
.status[data-state="reconnecting"]::before,
.status[data-state="connecting"]::before {
background: var(--warning);
}
.status[data-state="error"]::before,
.status[data-state="disconnected"]::before {
background: var(--danger);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
color: var(--muted);
white-space: nowrap;
user-select: none;
}
.toggle input {
width: 15px;
height: 15px;
accent-color: var(--accent);
}
button {
min-height: 32px;
padding: 0 11px;
border: 1px solid var(--border);
border-radius: 7px;
color: var(--text);
background: var(--control);
cursor: pointer;
}
button:hover {
background: var(--control-hover);
}
button:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.meta {
display: flex;
align-items: center;
gap: 16px;
min-height: 38px;
padding: 0 18px;
color: var(--muted);
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.meta strong {
color: var(--text);
font-weight: 650;
}
.log-output {
min-height: 0;
margin: 0;
padding: 16px 18px 28px;
overflow: auto;
background: #0b0c0d;
color: #d7dbdf;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
tab-size: 2;
white-space: pre-wrap;
word-break: break-word;
}
.log-output:empty::before {
color: var(--muted-2);
content: "No log lines yet.";
}
@media (max-width: 760px) {
.toolbar {
align-items: stretch;
flex-direction: column;
gap: 12px;
}
.file-path {
max-width: 100%;
}
.controls {
justify-content: flex-start;
}
.status,
button {
min-height: 34px;
}
}
+2
View File
@@ -0,0 +1,2 @@
2026-06-24T15:28:00.000Z sample server booted
2026-06-24T15:28:01.000Z waiting for log lines
+290
View File
@@ -0,0 +1,290 @@
'use strict';
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const http = require('node:http');
const path = require('node:path');
const { URL } = require('node:url');
const WebSocket = require('ws');
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
const TAIL_LINES = Number.parseInt(process.env.TAIL_LINES || '200', 10);
const WATCH_INTERVAL_MS = Number.parseInt(process.env.WATCH_INTERVAL_MS || '500', 10);
const PUBLIC_DIR = path.join(__dirname, 'public');
const LOG_FILE = path.resolve(process.env.LOG_FILE || process.argv[2] || 'sample.log');
let offset = 0;
let changeQueue = Promise.resolve();
const mimeTypes = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8']
]);
async function assertLogFile(filePath) {
let stat;
try {
stat = await fsp.stat(filePath);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Log file does not exist: ${filePath}`);
}
throw error;
}
if (!stat.isFile()) {
throw new Error(`Log path is not a file: ${filePath}`);
}
return stat;
}
async function readLastLines(filePath, maxLines) {
const stat = await fsp.stat(filePath);
if (stat.size === 0 || maxLines <= 0) {
return [];
}
const chunkSize = 64 * 1024;
const buffers = [];
let position = stat.size;
let newlineCount = 0;
const file = await fsp.open(filePath, 'r');
try {
while (position > 0 && newlineCount <= maxLines) {
const readSize = Math.min(chunkSize, position);
position -= readSize;
const buffer = Buffer.allocUnsafe(readSize);
await file.read(buffer, 0, readSize, position);
buffers.unshift(buffer);
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 10) {
newlineCount += 1;
}
}
}
} finally {
await file.close();
}
let text = Buffer.concat(buffers).toString('utf8').replace(/\r\n/g, '\n');
const readFromStart = position === 0;
if (!readFromStart) {
const firstNewline = text.indexOf('\n');
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1);
}
if (text.endsWith('\n')) {
text = text.slice(0, -1);
}
if (!text) {
return [];
}
return text.split('\n').slice(-maxLines);
}
function sendJson(socket, payload) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function broadcast(server, payload) {
const body = JSON.stringify(payload);
for (const client of server.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(body);
}
}
}
function resolveStaticPath(requestUrl) {
const parsed = new URL(requestUrl, `http://localhost:${PORT}`);
const pathname = decodeURIComponent(parsed.pathname);
const requestedPath = pathname === '/' ? '/index.html' : pathname;
const filePath = path.resolve(PUBLIC_DIR, `.${requestedPath}`);
if (filePath !== PUBLIC_DIR && !filePath.startsWith(`${PUBLIC_DIR}${path.sep}`)) {
return null;
}
return filePath;
}
async function serveStatic(request, response) {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' });
response.end('Method not allowed');
return;
}
const filePath = resolveStaticPath(request.url);
if (!filePath) {
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad request');
return;
}
try {
const stat = await fsp.stat(filePath);
if (!stat.isFile()) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
const contentType = mimeTypes.get(path.extname(filePath)) || 'application/octet-stream';
response.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Cache-Control': 'no-store'
});
if (request.method === 'HEAD') {
response.end();
return;
}
fs.createReadStream(filePath).pipe(response);
} catch (error) {
if (error.code === 'ENOENT') {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
console.error(error);
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Internal server error');
}
}
async function readAppendedBytes(from, to) {
if (to <= from) {
return '';
}
const stream = fs.createReadStream(LOG_FILE, {
encoding: 'utf8',
start: from,
end: to - 1
});
let text = '';
for await (const chunk of stream) {
text += chunk;
}
return text;
}
function queueFileChange(wss, current) {
changeQueue = changeQueue
.then(async () => {
if (current.size < offset) {
offset = current.size;
broadcast(wss, {
type: 'truncate',
message: 'File was truncated or rotated; stream reset.'
});
return;
}
if (current.size === offset) {
return;
}
const text = await readAppendedBytes(offset, current.size);
offset = current.size;
if (text) {
broadcast(wss, { type: 'append', text });
}
})
.catch((error) => {
console.error(error);
broadcast(wss, { type: 'error', message: error.message });
});
}
async function sendInitialState(socket) {
const stat = await fsp.stat(LOG_FILE);
const lines = await readLastLines(LOG_FILE, TAIL_LINES);
sendJson(socket, {
type: 'hello',
file: LOG_FILE,
size: stat.size,
tailLines: TAIL_LINES
});
sendJson(socket, { type: 'snapshot', lines });
}
async function main() {
const stat = await assertLogFile(LOG_FILE);
offset = stat.size;
const server = http.createServer(serveStatic);
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', (socket) => {
sendInitialState(socket).catch((error) => {
sendJson(socket, { type: 'error', message: error.message });
socket.close(1011, 'Initial read failed');
});
});
server.on('upgrade', (request, socket, head) => {
const parsed = new URL(request.url, `http://localhost:${PORT}`);
if (parsed.pathname !== '/stream') {
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (websocket) => {
wss.emit('connection', websocket, request);
});
});
fs.watchFile(LOG_FILE, { interval: WATCH_INTERVAL_MS }, (current) => {
queueFileChange(wss, current);
});
server.listen(PORT, () => {
console.log(`Log streamer running at http://localhost:${PORT}`);
console.log(`Streaming ${LOG_FILE}`);
});
function shutdown() {
fs.unwatchFile(LOG_FILE);
wss.close();
server.close(() => process.exit(0));
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});