DEV Community

Cover image for I built a Web SSH dashboard because the ones I tried were ugly
flulwh
flulwh

Posted on

I built a Web SSH dashboard because the ones I tried were ugly

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
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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());
  });
});
Enter fullscreen mode Exit fullscreen mode

Three things I had to learn the hard way:

  • Resize is not free. If you don't forward term.onResize to stream.setWindow, vim and htop will 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 resize to overwrite everyone's COLUMNS/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 4401 and 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
Enter fullscreen mode Exit fullscreen mode

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'`),
]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
    },
  });
Enter fullscreen mode Exit fullscreen mode

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-connect leaks 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' plus allowTransparency: true is required for the MD3 surface to show through. Default xterm is opaque.
  • Vite WebSocket proxy. /ws/ssh has 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-line server.proxy in vite.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
Enter fullscreen mode Exit fullscreen mode

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)
zh en
Terminal (zh) Terminal (en)
zh en
Files (zh) Files (en)
zh 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)