Bun server backend with a React frontend. Watches .log files (newline-delimited JSON) across configurable sources — both local directories and remote SSH hosts — streams new entries to the browser over WebSocket, and provides filtering, querying, and column customization.
- 🔴 Live tail —
.logfiles (NDJSON) streamed to the browser in real time over WebSocket - 🌐 Local & SSH sources — watch local directories or remote hosts via
ssh2+tail -F, mixed freely - 🔍 Built-in query language —
level:error AND meta.status:>=500, full-text search,AND/ORcombinators - 🧱 Customizable columns — pick fields, reorder, persist layout per-file in localStorage
- ⚡ Virtual scrolling — handles tens of thousands of rows without breaking a sweat
- 🔁 Auto-reconnect — exponential-backoff for both SSH and WebSocket; disconnects replay missed bytes
- 🩺 Health indicators — per-source connection status and per-file activity dots
- ♻️ Hot reload — edit
sources.json, hit reload, no restart
- Bun ≥ 1.0
- For SSH sources: GNU
tailavailable on the remote host
bun install
cp sources.example.json sources.json # edit to match your environment
bun run devThis starts both the server (default port 3001) and UI dev server concurrently. Open the URL printed by Vite.
Log sources — local and SSH — are declared in ./sources.json at the project root. The file is gitignored; use sources.example.json as a template.
Each source can declare any combination of dirs (recursively scanned for .log files) and files (specific paths, any extension). At least one of the two must be non-empty. Legacy dir: string is still accepted as shorthand for dirs: [dir].
name is required and must be unique across sources. Add "isActive": false to keep a source defined in the file but not actually watched (handy for parking environments without deleting their config). Inactive sources are completely hidden from the UI.
SSH connections use tail -F over an exec channel for live tailing and SFTP for discovery and historical reads. Remote hosts must have GNU tail available. Auto-reconnect with exponential backoff (1s → 30s) handles transient disconnects; the UI shows a connection-status dot next to each source in the file picker. When the SSH server's per-connection channel limit (sshd MaxSessions, default 10) is reached, additional auxiliary ssh2.Client connections are spawned automatically and tail channels are distributed across them. Bytes consumed per file are tracked at source level so that on reconnect tail -c +N replays anything written during the disconnect window.
Host key verification — every SSH connection runs through a verifier. On first connection the host's SHA256 fingerprint is stored at <project>/.ssh/known_hosts.json (TOFU). Subsequent connections must present the same key, or the connection is refused and the source goes to error state with a clear mismatch message. To pin a key explicitly (skip TOFU), set "hostKey": "SHA256:..." on the source — get the fingerprint with ssh-keyscan host | ssh-keygen -lf -.
Edited sources.json and want it to take effect? Use the Reload sources button in the settings popup, or POST /api/reload. The server validates the new file first; if it's invalid, current sources keep running and the response returns the validation error. On success every stream is torn down and re-built (SSH reconnects, file watchers re-attach), the UI clears its log buffer and re-fetches.
PORT=3001 # backend server port
UI_PORT=5173 # Vite dev server port (dev mode only)
TIMESTAMP_FIELD=ts # JSON field used for timestamps
MAX_DIR_DEPTH=3 # how deep to recurse into source dirs (default 3)
LOG_DIR=./logs/_system # backend internal JSONL logs (empty => disabled)
LOG_LEVEL=info # debug|info|warn|error
LOG_TO_STDOUT=false # mirror internal logs to stdout
LOG_HTTP_REQUEST=false # HTTP access logging| Command | Description |
|---|---|
bun run dev |
Start server + UI dev server concurrently |
bun run dev:server |
Server only |
bun run dev:ui |
UI only (Vite with HMR) |
bun run build |
Production build of UI (ui/dist/) |
bun run start |
Production server (serves API + static UI) |
bun run lint |
ESLint |
bun run lint:fix |
ESLint with auto-fix |
bun run format |
Prettier write |
bun run format:check |
Prettier check |
bun run knip |
Dead code / unused dependency detection |
bun run test |
Run all tests (vitest) |
bun run test:watch |
Run tests in watch mode |
Monorepo with Bun workspaces — three packages:
├── shared/ @la06/shared — types, constants, utils shared between server and UI
├── server/ Bun-native HTTP + WebSocket server (no framework)
└── ui/ React 19 + Vite + Tailwind CSS v4
- Pluggable
LogSourceabstraction (server/sources/) with two implementations:- Local —
Bun.Globdiscovery,fs.watchtail with byte-offset + remainder buffer, recursive directory watcher with poll fallback - SSH —
ssh2client with persistent connection, SFTP for discovery and historical reads,tail -Fexec channel for live tailing, exponential-backoff auto-reconnect
- Local —
- NDJSON parsing with
_file/_source/_linesystem fields injected into every entry; non-JSON lines fall back to arawentry with a synthetic timestamp - Streams new entries to connected clients over WebSocket with batching (100ms)
- Broadcasts source connection-status changes to all clients in real time
- HTTP API:
GET /api/config,GET /api/files,GET /api/logs,GET /api/metrics,POST /api/reload - Per-file health: tail/watch failures surface as sticky errors broadcast to clients (
MSG_FILE_STATUS); UI shows a colored dot per file (active / idle / stale / error) with a tooltip
- Initial data load via REST, real-time updates via WebSocket with auto-reconnect
- Per-source connection status indicator in the file picker (green / yellow pulsing / red with error tooltip)
- Virtual scrolling via
@tanstack/react-virtual - Column layout persisted to localStorage
- Dark/light theme support
Filter logs with a built-in query language:
level:error # exact match
meta.status:>400 # numeric comparison
level:error OR level:warn # OR combinator
"connection refused" # full-text search
meta.method:POST AND meta.path:/api # AND combinator
Supported operators: : (equals), :> :>= :< :<= (comparison).
Each .log file should contain newline-delimited JSON (NDJSON):
{"ts":"2026-04-14T09:31:41.358Z","level":"info","message":"Server started","meta":{"port":3001}}
{"ts":"2026-04-14T09:31:42.100Z","level":"error","message":"Connection refused","meta":{"host":"db"}}Timestamps must be ISO 8601.
- Runtime: Bun
- Frontend: React 19, Vite, Tailwind CSS v4
- Language: TypeScript
- Testing: Vitest
- Git Hooks: Husky + lint-staged (ESLint + Prettier on commit)
This project is licensed under the MIT License.

{ "sources": [ { "type": "local", "name": "logs", "dirs": ["./logs"] }, { "type": "local", "name": "system", "files": ["/var/log/syslog", "/var/log/auth.log"], }, { "type": "ssh", "name": "prod-api", "host": "api.example.com", "port": 22, "user": "deploy", "dirs": ["/var/log/app", "/var/log/nginx"], "files": ["/var/log/syslog"], "auth": { "kind": "key", "path": "~/.ssh/id_rsa", "passphrase": "" }, }, { "type": "ssh", "name": "worker", "host": "worker.example.com", "user": "ops", "dirs": ["/srv/worker/logs"], "auth": { "kind": "password", "password": "replace-me" }, }, ], }