I built a Web SSH dashboard because the ones I tried were ugly
I keep a terminal in a browser tab open most of the day. The tools I tried all had the same problem: the ones that worked looked like a 2010 admin panel, and the ones that looked nice didn't really work. So I wrote my own. It's called Liquid SSH Dashboard, and the v1.1.0 release just went out with full Chinese / English support.
It's React + TypeScript on the front, Node + ssh2 on the back. It does SSH, SFTP, and live CPU / memory / disk charts. Nothing inside is hard-coded. You register a user on first launch and add hosts yourself.
How the pieces talk
┌──────────────────┐ WebSocket (xterm) ┌──────────────────┐
│ React + Vite │ ◄────────────────────► │ Node.js (Koa) │
│ + xterm.js │ │ + ssh2 + ws │
│ + Tailwind MD3 │ REST (JWT auth) │ │
│ + Zustand │ ◄────────────────────► │ servers.json │
│ + i18next │ SFTP over HTTP │ users.json │
└──────────────────┘ └──────────────────┘
│
▼
Real SSH hosts
src/ is the React app (Vite 5, Tailwind with MD3 tokens, Zustand, xterm.js, Recharts, react-i18next). server/ is a thin Koa server that wraps ssh2, signs JWTs, and persists users and host configs to two JSON files. There are no defaults in those files. The repo ships with empty servers.json and an empty users.json.
SSH over WebSocket
This part is mostly just plumbing, but a few things bit me.
Frontend, after the user picks a host, opens a WebSocket and pumps xterm.js through it:
const ws = new WebSocket(sshUrl(serverId, term.cols, term.rows));
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
term.onResize(({ cols, rows }) => {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
});
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'output') term.write(msg.data);
};
Backend, on ready, opens an ssh2 shell and bridges the two streams:
import { Client } from 'ssh2';
c.on('ready', () => {
c.shell({ cols, rows }, (err, stream) => {
if (err) return ws.close(1011, err.message);
ws.on('message', (raw) => {
const m = JSON.parse(raw.toString());
if (m.type === 'input') stream.write(m.data);
if (m.type === 'resize') stream.setWindow(m.rows, m.cols, 0, 0);
});
stream.on('data', (chunk) => {
ws.send(JSON.stringify({ type: 'output', data: chunk.toString('utf8') }));
});
stream.on('close', () => ws.close());
});
});
Three things I had to learn the hard way:
-
Resize is not free. If you don't forward
term.onResizetostream.setWindow,vimandhtopwill render wrong as soon as the user resizes the window. The first version didn't, and I only noticed on a vertical monitor. -
One socket per tab. Each terminal tab owns its own ssh2 connection. Sharing one ssh2 client across tabs looks tempting but causes a single
resizeto overwrite everyone'sCOLUMNS/LINES, which is a nightmare in tmux. -
Auth before upgrade. JWT is verified server-side before the WS upgrade goes through. An invalid token closes the socket with
4401and the frontend just shows a reconnect button.
SFTP
REST, not WebSocket. The endpoints look like this:
GET /api/servers/:id/files?path=/var/log
POST /api/servers/:id/files/upload (multipart)
GET /api/servers/:id/files/download?path=...
POST /api/servers/:id/files/mkdir
PATCH /api/servers/:id/files/rename
DELETE /api/servers/:id/files
Uploads go through one multipart/form-data POST. The request body pipes straight into sftp.createWriteStream, so a 2 GB upload doesn't sit in memory. The UI is a path bar at the top, a file list in the middle, a right-click menu for open / download / rename / delete / new folder. Boring, but it works.
Load charts without an agent
I don't want a custom agent on every host. The backend just runs three commands over the existing SSH connection:
const [{ stdout: cpuOut }, { stdout: memOut }, { stdout: diskOut }] = await Promise.all([
execCmd(ssh, 'cat /proc/loadavg'),
execCmd(ssh, `free -b | awk 'NR==2'`),
execCmd(ssh, `df -B1 / | awk 'NR==2'`),
]);
The frontend polls every 10 s with a useServerLoads hook and Recharts draws a 60-point trend line. If a host has no credentials configured, the panel just says "credentials not configured" instead of silently failing with a red error.
The theme
I went with Material Design 3 dark, not the glassmorphism / aurora stuff that AI landing pages are full of. The whole theme is a handful of CSS variables:
:root {
--md-primary: #6750A4;
--md-surface-container: #211F26;
--md-surface-container-high: #2B2930;
--md-on-surface: #E6E1E5;
--md-on-surface-variant: #CAC4D0;
}
Component classes use a thin ring-1 ring-white/10 plus a low-opacity white fill. Flat, dark, no glow. The class names still start with glass- because I built the first version around a glass aesthetic and never renamed them. Don't read too much into it.
Bilingual UI (v1.1.0)
i18next with localStorage persistence. The browser's preferred language is the fallback.
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: { zh: { translation: zh }, en: { translation: en } },
fallbackLng: 'zh',
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
lookupLocalStorage: 'lsd.lang',
},
});
There are 15 mirrored namespaces (common, nav, login, dashboard, servers, terminal, files, monitoring, settings, about, commandPalette, serverCard, realTerminal, aiHelper, errors). All user-facing strings go through a typed useT() hook, so missing a key is a compile error, not a silent fallback. A language dropdown sits in the top-right of the navbar.
One detail that mattered: the About page (which shows GitHub repo info) and the Monitoring charts also switch toLocaleDateString(locale) to match the active language. Otherwise an English UI with 2026/8/25 looks broken.
Bugs I hit
-
koa-connectleaks ctx. I used it for a multipart parser and saw memory climb over a long session. Swapped to native Koa middleware. Memory flatlined. -
xterm.js transparency.
background: '#00000000'plusallowTransparency: trueis required for the MD3 surface to show through. Default xterm is opaque. -
Vite WebSocket proxy.
/ws/sshhas to be proxied by Vite itself during dev. I routed it through the backend first and lost a bunch of resize frames. The fix was a one-lineserver.proxyinvite.config.ts.
Running it
git clone https://github.com/flulwh/liquid-ssh-dashboard.git
cd liquid-ssh-dashboard
npm install && (cd server && npm install)
npm run dev:all
scripts/dev.mjs starts the Vite frontend on localhost:5173 and the Node backend on localhost:8787. Ctrl+C stops both. Works on Windows, macOS, and Linux.
Screenshots
| Dashboard (zh) | Dashboard (en) |
|---|---|
![]() |
![]() |
| Terminal (zh) | Terminal (en) |
|---|---|
![]() |
![]() |
| Files (zh) | Files (en) |
|---|---|
![]() |
![]() |
The other 8 (servers, monitoring, settings, about) are in the v1.1.0 release.
What's next
- ja / ko translations
- Wire the AI helper to actually run commands, not just suggest them
- Docker image and a Helm chart
- Terminal split panes and a couple of theme presets
The repo is at github.com/flulwh/liquid-ssh-dashboard. Issues and PRs welcome.






Top comments (0)