Bringing UNIX to the Browser: Streaming ‘tail -f’ Output to a Web Interface (Part 2)
Let’s focus on the frontend implementation and finish with an operational end-to-end application
Introduction
This is the second part of the ‘tail -f’ article. We are going to focus on the frontend implementation this time. So, by the end of this article, we will have an operational end-to-end application.
The original backend implementation can be found here.
Picking a Technology Stack
Any modern web frontend framework should work to perform this task. Feel free to pick the right one for yourself. We’ll be using ReactJS on the frontend, due to its popularity and my familiarity. 🙂
The next part of our stack, a somewhat unassuming player, is the Textarea HTML element. ‘Why use Textarea?’ you might ask. ‘Why not some snazzy, new-fangled UI component?’ Well, here’s the deal: Textarea is simple but effective. Built to handle multiline text input, it’s perfect for mimicking the line-by-line nature of log files. Auto-scrolling? No problem. Textarea’s got that covered, too, making log tracking a breeze.
Of course, it’s not all sunshine and roses. Textarea can bog down when overloaded with too much text, and the styling options are somewhat limited. But given our objective — to mirror the ‘tail -f’ functionality in a web UI — Textarea’s simplicity and built-in features prove to be the winning factors.
Setting Up the Development Environment
I always have an updated ReactJS boilerplate to kickstart new frontend projects swiftly. Feel free to check out my GitHub repository here. If you’re not a ReactJS fan or wish to use a different framework, go ahead! The fundamental work should remain the same or bear a striking resemblance.
Connecting to our WebSocket Backend
Let’s connect the frontend to the backend by establishing a WebSocket connection. It’s important to note the WebSocket URI scheme is either ‘ws’ or ‘wss’ for secure connections, much like ‘HTTP’ and ‘HTTPS’ for traditional HTTP connections. We create a new WebSocket instance and provide our server’s URI.
// app.js
import React from 'react';
import LogDisplay from './components/LogDisplay';
function App() {
return (
<div className="App">
<LogDisplay />
</div>
);
}
export default App;
import React, { useEffect, useRef } from 'react';
function LogDisplay() {
const socketRef = useRef(null);
useEffect(() => {
socketRef.current = new WebSocket('ws://localhost:8000/ws');
socketRef.current.onmessage = (event) => {
const message = event.data;
console.log(message);
// update textarea here with the new message
};
return () => {
socketRef.current.close();
};
}, []);
return <textarea readOnly={true}>{/* Update this area with the received log data */}</textarea>;
}
export default LogDisplay;
We initiated by setting up our central application component and importing the necessary modules. The main application function rendered a LogDisplay component.
At this point, when we test it in the browser and point it to localhost:3000. It would show empty text box with no content inside. If we check our debugger console in the browser, it should show some log messages about what’s been received from the backend. If you are having trouble seeing these log messages, make sure the port and endpoints are aligned with the setup on the server.
If you see a warning that Websocket is closing and reopening on the console, you might be experiencing the known issue where React’s useEffect() hook is called twice. This Stack Overflow answer may help you.
Next, we focused on the LogDisplay component. We utilized React’s hooks to manage our WebSocket connection. We created a ref with useRef to store our WebSocket instance and instantiated our WebSocket inside the useEffect hook. We also set up an event listener for incoming messages.
To ensure the WebSocket connection is closed when the LogDisplay component unmounts, we added a cleanup function in the return of the useEffect hook.
Lastly, we rendered a textarea to display the incoming log data, setting it to readOnly to prevent user modification.
Now, setting up a message event listener for our WebSocket instance is crucial. This is what enables us to receive data from the server. In our case, we’ll receive log updates, which we’ll push to our component’s state. As a result, each new log update received will trigger a state change, and a subsequent component rerender, effectively displaying the new log data in our Textarea.
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => {
... // to be implemented
};
Updating the Textarea UI on Messages Received
With our WebSocket connection in place and receiving data, we need to reflect these changes in our UI. We’ll be using React’s state to accomplish this. The received log data will be stored in our component’s state’s ‘logs’ (long) string. (I used an array to store the logs instead, which can archive the same result by using logs.join(‘n’) at the textarea value part)
const [logs, setLogs] = useState('');
Next, we need to ensure that our Textarea is linked to our component’s state so it displays the logs received. We can use React’s powerful two-way data binding to set the value of the Textarea to our state’s ‘logs’ string. Remember, in React, it’s important to ensure that state changes trigger UI updates. This is a fundamental part of its design.
<textarea value={logs} readOnly />
Lastly, to make our UI more user-friendly, we must ensure that our Textarea automatically scrolls to display the latest logs. We can accomplish this by using the scrollTop property of the Textarea. This value should be updated every time new logs are added to the state, which will scroll Textarea to the bottom.
this.textAreaRef.current.scrollTop = this.textAreaRef.current.scrollHeight;
Combining these updates and adding some code cleanups. We have this in our LogDisplay component.
import React, { useEffect, useRef, useState } from 'react';
export default function LogDisplayComponent() {
const [logs, setLogs] = useState('');
let ws = useRef(null);
useEffect(() => {
ws.current = new WebSocket('ws://localhost:8000/ws');
ws.current.onmessage = (event) => {
setLogs(
(prevLogs) => `${prevLogs}n${event.data}`
);
};
ws.current.onclose = () => console.log('ws closed');
ws.current.onerror = () => console.log('ws error');
return () => {
ws.current.close();
};
}, []);
return (
<div>
<textarea value={logs} readOnly style={{ width: '80vw', height: '80vh' }} />
</div>
);
}
We can go ahead and test this on our browser. Looks like this worked. But it feels like it’s incomplete without having a way to ‘clear’ the log. So, let’s add this button to our UI.
import React, { useEffect, useRef, useState } from 'react';
export default function LogDisplayComponent() {
const [logs, setLogs] = useState('');
let ws = useRef(null);
const clearLogs = () => {
setLogs('');
};
useEffect(() => {
ws.current = new WebSocket('ws://localhost:8000/ws');
ws.current.onmessage = (event) => {
setLogs(
(prevLogs) => `${prevLogs}
${event.data}`
);
};
ws.current.onclose = () => console.log('ws closed');
ws.current.onerror = () => console.log('ws error');
return () => {
ws.current.close();
};
}, []);
return (
<div>
<textarea value={logs} readOnly style={{ width: '80vw', height: '80vh', display: 'block' }} />
<button onClick={clearLogs}>Clear</button>
</div>
);
}
Conclusion
In this tutorial, we’ve come a long way. We’ve set up a full-fledged system for tailing file logs on a React web frontend, laid the groundwork with our React setup, and built the Textarea element to display our logs. After all that, we established a WebSocket connection to the backend to receive real-time log updates.
We handled potential connection errors and kept the user experience smooth, even during edge cases. Lastly, we provided a way for users to clear logs, giving them control over their UI.
However, there’s always room for improvement and adaptability. This setup can be enhanced in many ways — adding features such as log filtering or categorizing, letting users customize their UI, or even expanding the setup to handle multiple file logs simultaneously. Our setup is flexible and can be easily adapted to other real-time data display needs beyond file logs. Here’s the full source code on GitHub.
Keep exploring and pushing the limits of what can be done with React and WebSockets!
Bringing UNIX to the Browser: Streaming ‘tail -f’ Output to a Web Interface was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.