π₯οΈπ Real-time log viewer and search dashboard built with React, Vite and WebSockets.
The companion frontend for logstream-server. Streams live log entries over WebSocket and provides a full-featured search and inspection UI β no build-time configuration required.
- Real-time log stream β new entries appear instantly via WebSocket, with a live/buffered toggle and slide-in animation
- Full-text search and filters β filter by application, environment, level, category, trace ID, batch ID, date range, or message substring, all persisted across page refreshes
- Expandable rows β click any entry to reveal the full message, context as a searchable property list (Find / Exclude per value), user agent, sender IP, and one-click batch/trace navigation
- Stats drawer β session-level counts by log level, top apps by volume, and total entries received
- Row virtualisation β large result sets (200+ rows) are windowed for smooth scrolling
- Zero build-time config β all connection settings (API URL, WebSocket URL, UI secret) are entered via a settings screen and stored in
localStorage; no.envfile required - Auth-aware β separate read key (
UI_SECRET) and write key (API_SECRET) enforced by the backend; 401 responses and WebSocket auth failures redirect to the settings screen without retry loops - Themes β 16 themes (Blue, Green, Red, Orange, Pink, Violet, Indigo, Ocean, Graphite, Sepia, Lime, Catppuccin, Solarized, Nord, Dracula and High contrast), each with a light and a dark variant (or following the system), picked from the π¨ button and remembered per browser; new themes are one file each
Taken with sample events, in the default (Green, dark) theme.
Live dashboard, filtered to errors and critical events:
Expanded event, with the full message, context properties (Find / Exclude / Copy per value) and batch and trace links:
Advanced filters, for context text, key = value context filters and message templates:
Below 768 px, the filters fold behind a Filters button that counts the active ones, events become two-line cards, and the columns that don't fit move into the expanded event.
| Dashboard | Filters open | Expanded event |
|---|---|---|
![]() |
![]() |
![]() |
| Layer | Choice |
|---|---|
| Framework | React 18 + Vite + TypeScript |
| Styling | Tailwind CSS (no component library) |
| Data fetching | TanStack Query (react-query) |
| Date formatting | date-fns |
| State | React Context + useState (no Redux) |
| Config persistence | localStorage |
- Node.js 18 or later
- A running logstream-server instance
git clone https://github.com/guibranco/logstream-ui.git
cd logstream-ui
npm install
npm run devOpen http://localhost:5173 in your browser. On first load you will see the Connection Settings screen.
There are no .env files or build-time variables. All settings are entered once via the UI and stored in your browser's localStorage under the key logservice:config.
The settings screen appears automatically on first visit and can be re-opened at any time from the β button in the header.
| Field | Description | Example |
|---|---|---|
| API Base URL | The logstream-server HTTP address | https://logs.straccini.com |
| WebSocket URL | The WebSocket endpoint | wss://logs.straccini.com/ws |
| UI Secret | The read-only bearer token (UI_SECRET from the server's .env) |
your-ui-secret-here |
The WebSocket URL is auto-suggested as you type the API URL (https://foo.com β wss://foo.com/ws).
Security note: The UI secret is stored in
localStorageso it survives browser restarts. It is sent as aBearertoken inAuthorizationheaders for HTTP requests and as a?token=query parameter on the WebSocket connection URL (browser WebSocket APIs do not support custom headers). It is never included in the visible browser address bar.
Open β Settings β scroll to the bottom β Clear all saved settings and disconnect. Confirm the dialog to wipe the config and return to the blank settings form.
Alternatively, click Disconnect in the header to clear and return to the settings screen immediately.
The backend enforces two separate keys β the UI only ever uses the read key:
| Key | Who uses it | What it protects |
|---|---|---|
API_SECRET (write key) |
Your backend services | POST /api/logs |
UI_SECRET (read key) |
This UI | GET /api/logs, GET /api/logs/:id, WebSocket |
The health endpoint (GET /api/health) is public and requires no token.
The UI connects to wss://host/ws?token=<UI_SECRET>. On success the server sends { type: "connected" }. If the token is wrong the server sends { type: "error" } and closes the connection β the UI detects this and shows a persistent error banner without retrying (to avoid an infinite auth-fail loop).
Any 401 response clears the stored config and redirects to the settings screen with the message "Session expired or secret rejected β update your settings."
The π¨ button in the header (also on the settings screen) picks the theme and the mode: Light, Dark or System (follows the operating system, switching live). The choice is stored in localStorage under logservice:theme; the default is Green, dark. A small script in index.html re-applies the last theme before the app loads, so a light theme does not flash dark.
| Theme | Neutral | Primary (buttons, links, focus) | Secondary (filters, highlights) |
|---|---|---|---|
| Blue | gray |
blue |
sky |
| Green (default) | zinc |
emerald |
green |
| Red | stone |
red |
rose |
| Orange | stone |
orange |
amber |
| Pink | mauve |
pink |
fuchsia |
| Violet | slate |
violet |
purple |
| Indigo | slate |
indigo |
violet |
| Ocean | mist |
teal |
cyan |
| Graphite | neutral |
neutral |
zinc |
| Sepia | taupe |
amber |
yellow |
| Lime | olive |
lime |
yellow |
| Catppuccin | Mocha / Latte | mauve | blue |
| Solarized | base03β¦base3 | blue | cyan |
| Nord | Polar Night / Snow Storm | Frost (nord9, nord10) | Frost (nord8) |
| Dracula | Dracula / Alucard | purple | pink |
| High contrast | black / white | blue |
cyan |
Catppuccin, Solarized, Nord and Dracula use their official hex values (light variants: Catppuccin Latte, Solarized light, Dracula's Alucard; Nord has no official light variant, so its light one uses Snow Storm for the page and Polar Night for text).
Components use three colour roles, always written for a dark UI:
gray-*β the neutral scale (backgrounds, borders, text;text-gray-50is the strongest text)primary-*β the main accentsecondary-*β the second accent
Status and log-level colours (red, amber, green, sky for infoβ¦) stay Tailwind's own and do not change with the theme. Use text-white only on solid coloured buttons (bg-primary-600, bg-red-600β¦), never on a neutral surface.
The active theme writes those roles as CSS variables (--color-gray-950, --color-primary-600β¦) on <html>. For a light variant it flips the scales: the neutral scale end to end (bg-gray-950 becomes the lightest surface, text-gray-100 dark text), and accent and status colours around the middle (-300/-400 text becomes -700/-600, -900 tints become -200, while -500 to -700 keep their value so solid buttons look the same in both modes).
Add a file to src/themes/presets/; it is picked up automatically:
// src/themes/presets/sunset.ts
import colors from 'tailwindcss/colors';
import { defineTheme } from '../types';
export default defineTheme({
id: 'sunset', // stored in preferences; keep it stable
name: 'Sunset',
description: 'Stone with rose and amber accents',
order: 16, // position in the picker (optional)
dark: {
neutral: colors.stone,
primary: colors.rose,
secondary: colors.amber, // optional, defaults to primary
},
light: {
neutral: colors.stone,
primary: colors.rose,
secondary: colors.amber,
overrides: {
'gray-950': colors.white, // any variable, any CSS colour, applied last
},
},
});Give each scale in its natural order (50 lightest, 950 darkest); the light variant is flipped for you. Scales do not have to come from Tailwind: any object with the keys 50 to 950 works. overrides sets single variables after the mapping ('primary-600': 'oklch(52% 0.15 160)'). tests/themes/registry.test.ts checks every preset has complete light and dark variants, and tests/themes/contrast.test.ts checks both variants stay readable (white button text and accent text at least 3:1, body text at least 7:1); a light accent such as amber or lime usually needs primary-500/primary-600 overridden with darker shades to pass.
To use an existing palette's hex values, build the scales with scale() from src/themes/palette.ts. Give it 50, 950 and any shades you have; the rest are interpolated:
import { scale } from '../palette';
const mauve = scale({ '50': '#f6f0fe', '400': '#cba6f7', '600': '#8839ef', '950': '#2b0d55' });A neutral scale can also be written out in full (all eleven shades), as catppuccin.ts and high-contrast.ts do. Remember the light variant is flipped: its 50 is the page and its 900 the body text.
| Element | Description |
|---|---|
| Pulse dot | WebSocket status β green (connected), amber (reconnecting), red (disconnected / auth error) |
| Connection count | Live WebSocket client count from the server |
| Live toggle | When ON, new entries prepend in real-time. When OFF, incoming entries buffer and show a "N new entries" badge |
| Clear live | Clears the in-memory live buffer without affecting search results |
| β Settings | Opens the settings screen pre-filled with current values |
| Disconnect | Clears all saved settings and returns to the settings screen |
All filters are combined and sent as a single GET /api/logs query. The filter state persists in localStorage["logservice:filters"] across page refreshes and is mirrored in the page URL, so the address bar is a shareable link to the current search. Opening a link that carries filters shows those results instead of the live tail, and takes precedence over the saved filters.
| Filter | Match type |
|---|---|
| Search | Substring on message |
| App Key | Exact |
| App ID | Exact |
| Level | Multi-select pills |
| Category | Partial |
| Date From / To | ISO 8601 range |
| Trace ID | Exact |
| Batch ID | Exact |
| Context contains (Advanced) | Free text across all context keys and values (context_search, min. 3 chars) |
| Context filters (Advanced) | key = value / key β value rows (context[key] / context_not[key]), dots for nested keys, max. 10 |
| Message template (Advanced) | Exact (message_template): every event logged from the same template, whatever its values |
Advanced filters is a collapsible section under the main filters. It opens automatically when context filters are active. How "Context contains" matches depends on the server storage, and the hint under the field says which mode is active (from GET /api/info): MariaDB matches whole words, file storage also matches inside words. Active context and template filters appear as chips; click Γ on a chip to remove that filter.
Timestamp Β· Level Β· App Key Β· App ID Β· Category Β· Message Β· Trace ID Β· Actions
- Timestamp β shown as relative time ("2 min ago"); full ISO on hover
- Level β colour-coded pill badge (see level colours below)
- Message β truncated to 80 chars; full text in expanded row. When the event has a message template, the values rendered into it are coloured by type, as in Seq (strings, numbers, booleans/null, objects), with the property name on hover
- Trace ID β first 8 chars +
β¦; click to copy the full ID to clipboard - A π after the message marks an entry that carries an exception (see Logging exceptions)
Click any row to reveal:
-
Full message text, with template values coloured as in the list
-
Exception (when the context has one), with its stack trace and inner exceptions and a copy button; see Logging exceptions
-
Message template (when the server stored one), placeholders highlighted, with a copy button and View all with this template, which filters by it
-
category, with a copy button and View all in category, which filters by it -
batch_idβ click to auto-fill the Batch ID filter -
user_agent -
sender_ipβ the client IP recorded by the server, with a copy button -
contextβ a property list, one row per value, with nested objects flattened to dotted paths (order.id). Each row has:- Find β show only entries where the property equals this value (
context[order.id]=42) - Exclude β hide entries where the property equals this value (
context_not[order.id]=42) - Copy β copy the value
Active context filters appear as chips under the filter bar; click Γ to remove one. Arrays are shown as JSON and can't be filtered. Requires a logstream-server version with context filters.
- Find β show only entries where the property equals this value (
-
View all in batch β sets the batch_id filter and re-runs the search
-
View trace β sets the trace_id filter and re-runs the search
Like Seq, the UI shows an exception apart from the other properties: under the message, with the type in red, the stack trace below it and each inner exception after a --->. LogStream has no dedicated field for it, so the exception goes in the entry's context under the key exception, in either shape:
- text β what the runtime prints for the exception, inner exceptions included:
ex.ToString()in .NET,(string) $ein PHP,err.stackin Node; - object β
{ "type", "message", "stack_trace", "inner" }.stack_traceis a string or an array of frames (strings, or{ "class", "function", "file", "line" }); the inner exception (inner,inner_exception,previousorcause) has the same shape, or is text.
Prefer the object: exception.type and exception.message stay in the property list, so Find / Exclude work on them (context[exception.type]=System.TimeoutException), while the stack traces only appear in the exception panel. Send the entry with level error (or critical) and keep the message short, as Seq does: the message says what failed, the exception says why.
POST /api/logs
Content-Type: application/json
X-Api-Key: β¦
X-Api-Token: β¦
{
"app_id": "production",
"level": "error",
"category": "payments",
"message": "Charge failed for invoice {invoice_id}",
"context": {
"invoice_id": 1234,
"exception": {
"type": "System.InvalidOperationException",
"message": "Gateway refused the charge",
"stack_trace": " at Billing.ChargeService.ChargeAsync(Invoice invoice) in /src/Billing/ChargeService.cs:line 42\n at Billing.Jobs.ChargeJob.RunAsync() in /src/Billing/Jobs/ChargeJob.cs:line 18",
"inner": {
"type": "System.Net.Http.HttpRequestException",
"message": "Response status code does not indicate success: 502 (Bad Gateway).",
"stack_trace": " at Billing.GatewayClient.PostAsync(String path) in /src/Billing/GatewayClient.cs:line 77"
}
}
}
}.NET β turn the exception into the object, walking InnerException:
static Dictionary<string, object?>? ToLogException(Exception? ex, int depth = 0) =>
ex is null || depth >= 10
? null
: new()
{
["type"] = ex.GetType().FullName,
["message"] = ex.Message,
["stack_trace"] = ex.StackTrace,
["inner"] = ToLogException(ex.InnerException, depth + 1),
};
try
{
await chargeService.ChargeAsync(invoice);
}
catch (Exception ex)
{
await logStream.SendAsync(new
{
app_id = "production",
level = "error",
category = "payments",
message = "Charge failed for invoice {invoice_id}",
context = new Dictionary<string, object?>
{
["invoice_id"] = invoice.Id,
["exception"] = ToLogException(ex),
},
});
throw;
}For an AggregateException, log ex.Flatten().InnerExceptions one entry each, or send ex.ToString() as text, which lists them all.
PHP β the same with getPrevious(); getTrace() gives the frames as arrays the UI formats (at Class.method in file:line):
function toLogException(?\Throwable $e, int $depth = 0): ?array
{
if ($e === null || $depth >= 10) {
return null;
}
return [
'type' => get_class($e),
'message' => $e->getMessage(),
'stack_trace' => array_merge(
[['function' => '{throw}', 'file' => $e->getFile(), 'line' => $e->getLine()]],
$e->getTrace(),
),
'inner' => toLogException($e->getPrevious(), $depth + 1),
];
}
try {
$charges->charge($invoice);
} catch (\Throwable $e) {
$logger->error('Charge failed for invoice {invoice_id}', [
'invoice_id' => $invoice->id,
'exception' => toLogException($e),
]);
throw $e;
}getTrace() frames include args when zend.exception_ignore_args is off; drop them (array_map(fn ($f) => array_diff_key($f, ['args' => 1]), β¦)) so arguments such as passwords never reach the logs. This also follows the PSR-3 convention of passing the exception under the exception key.
Node / TypeScript β err.stack already starts with Name: message; cause is the inner exception:
function toLogException(err: unknown, depth = 0): unknown {
if (err == null || depth >= 10) return undefined;
if (!(err instanceof Error)) return String(err); // something that isn't an Error was thrown
return {
type: err.name,
message: err.message,
stack_trace: err.stack, // its "Name: message" first line is skipped by the UI
inner: toLogException(err.cause, depth + 1),
};
}A stack trace is usually a few kilobytes; at thousands of errors a minute, consider a retention policy on level=error so they don't fill the database.
| Level | Text | Background |
|---|---|---|
debug |
gray-400 |
gray-800 |
info |
sky-400 |
sky-900 |
notice |
teal-400 |
teal-900 |
warning |
amber-400 |
amber-900 |
error |
rose-400 |
rose-900 |
critical |
fuchsia-400 |
fuchsia-900 |
Click Stats βΈ (floating button) to open a side drawer showing:
- Entry counts per level with proportional CSS bars
- Top 5
app_keyvalues seen in the live stream - Total live entries received this session
All stats are derived from the in-memory live buffer β they reset on page reload.
src/
βββ types.ts LogEntry, SearchFilters, LogServiceConfig
βββ store/
β βββ configStore.ts localStorage read / write / clear helpers
βββ context/
β βββ AuthContext.tsx Active config + openSettings() + signOut()
β βββ ThemeContext.tsx Selected theme and mode, applied to <html>
βββ themes/
β βββ types.ts ThemeDefinition, defineTheme()
β βββ engine.ts Theme β CSS variables (light flips the scales)
β βββ palette.ts scale() for non-Tailwind palettes, contrast()
β βββ registry.ts Loads every file in presets/
β βββ themeStore.ts Theme preference in localStorage
β βββ presets/ One file per theme
βββ hooks/
β βββ useWebSocket.ts Auto-reconnecting WS hook with backoff
β βββ useLogs.ts TanStack Query wrapper for GET /api/logs
βββ components/
β βββ SettingsScreen.tsx First-visit and edit-mode settings form
β βββ Header.tsx Sticky top bar
β βββ ThemePicker.tsx Theme and light/dark/system menu
β βββ FilterBar.tsx Sticky search + filter controls
β βββ LogTable.tsx Virtualised log entry table
β βββ LogRow.tsx Single table row
β βββ LogDetail.tsx Expanded row detail panel
β βββ LevelBadge.tsx Colour-coded level pill
β βββ StatsDrawer.tsx Session stats slide-in panel
β βββ ContextProperties.tsx Context as a searchable property list
β βββ ExceptionDetails.tsx Exception and stack trace panel (context.exception)
β βββ Skeleton.tsx Loading placeholder rows
β βββ AuthErrorBanner.tsx Persistent auth failure banner
βββ App.tsx
βββ main.tsx
npm run buildThe output is in dist/. Serve it with any static host β Nginx, Caddy, Vercel, Netlify, GitHub Pages, or an S3 bucket.
Example Nginx config to serve the built app at the root alongside the API proxy:
# Serve the built React app
location / {
root /var/www/logstream-ui/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy the API and WebSocket (same domain, no CORS)
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}# Start the dev server with hot reload
npm run dev
# Type check
npm run type-check
# Lint
npm run lint
# Build + preview the production bundle locally
npm run build && npm run previewOn first load, enter these values in the settings screen:
| Field | Value |
|---|---|
| API Base URL | http://localhost:8081 |
| WebSocket URL | ws://localhost:8080/ws |
| UI Secret | the UI_SECRET from your local .env |
- logstream-server β the PHP 8.3 + ReactPHP backend this UI connects to
- API documentation β live Swagger UI
MIT





