From a10522865332d49b5869be84a20f6161c090010a Mon Sep 17 00:00:00 2001 From: crosstyan Date: Wed, 24 Jun 2026 15:35:10 +0800 Subject: [PATCH] Initial log streamer app --- .gitignore | 5 + package-lock.json | 36 ++++++ package.json | 14 +++ public/app.js | 186 +++++++++++++++++++++++++++++ public/index.html | 38 ++++++ public/styles.css | 215 ++++++++++++++++++++++++++++++++++ sample.log | 2 + server.js | 290 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 786 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 sample.log create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..904798f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +npm-debug.log* +*.log +!sample.log +.DS_Store diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5d8a214 --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..827b35f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..dab1c7f --- /dev/null +++ b/public/app.js @@ -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(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3f98a12 --- /dev/null +++ b/public/index.html @@ -0,0 +1,38 @@ + + + + + + Log Streamer + + + +
+
+
+

Log Streamer

+
Waiting for file...
+
+ +
+ Connecting + + + +
+
+ +
+ 0 lines visible + +
+ +

+    
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..7aaafde --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/sample.log b/sample.log new file mode 100644 index 0000000..b5ca1a3 --- /dev/null +++ b/sample.log @@ -0,0 +1,2 @@ +2026-06-24T15:28:00.000Z sample server booted +2026-06-24T15:28:01.000Z waiting for log lines diff --git a/server.js b/server.js new file mode 100644 index 0000000..08d495a --- /dev/null +++ b/server.js @@ -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); +});