LL-Dash is a self-hosted control room for running llama.cpp (and strata) models on your own
GPUs. It launches llama-server (or the optional Strata engine) on any mix of
local cards and remote RPC workers. It records the speed of every request,
whichever client sent it, and shows live hardware telemetry. It also runs speed
benchmarks and keeps a catalog of models and launch configs with their measured
results.
It has four parts:
- a Node.js server (
server4.js, port 3000); - a browser UI that needs no build step;
- a small Python telemetry helper (
monitor.py); - a terminal client (
dash-cli/).
It is designed for one Linux machine with one operator. Read Security and remote access before you expose it to a network.
Status: personal lab software, developed and used on a small number of machines. Expect rough edges on hardware it has not run on.
- Launch
llama-serverfrom any of several configured llama.cpp builds, on an ordered list of local GPUs and RPC workers. Each device can have its own share, and the split mode is layer or tensor. The raw command box is exactly what runs. - Saved launch profiles, shared by the web UI and the CLI. Devices are saved by card as well as id, so a profile follows its card when device ids shift, or when it moves to another machine.
- RPC workers. LL-Dash starts and stops
ggml-rpc-serveron other machines over SSH, and by default carries RPC through an SSH forward. - Strata engine (optional). Launches configs from one or more Strata installs, with per-launch overrides.
- Request capture from any client. Every request to the model gets a row: token counts, t/s, wall time, draft acceptance, and a hardware time series for that request.
- Live telemetry for NVIDIA and AMD GPUs, the host's CPU, RAM and network, and the GPUs of RPC workers.
- History, kept in a CSV file and in a SQLite database.
- Speed benchmarking:
llama-benchruns, launch sweeps of the real server (including speculative decoding), and public coding suites. - Results catalog: one record per model, with speed by launch config and context depth, history and side-by-side comparisons. GGUF files are identified by their content.
- Built-in chat with attachments, thinking control, and server-side web search and fetch tools.
- A terminal client (
dash-cli/) with Monitor, Launch and Chat modes. - Remote access through a Cloudflare tunnel, behind a password gate.
- Optional extras:
- views and a run queue for an external coding-benchmark harness;
- a separate, read-only public results site.
Required
- Linux. The code reads
/proc,/sys/class/hwmonand/sys/bus/pci, and usesss(iproute2) andflock. - Node.js 20. This is the version it is developed on; there is no
enginesfield.better-sqlite3is a native module, sonpm installeither downloads a prebuilt binary or builds one (that needs a C++ toolchain and Python). - Python 3 for
monitor.py, standard library only. - One or more llama.cpp builds that provide
llama-server.llama-benchis needed for the Bench tab.- Each RPC worker needs
ggml-rpc-server, built from the same llama.cpp commit as the master.
For GPU telemetry
nvidia-smifor NVIDIA cards, andamdgpu_topfor AMD cards. Other GPUs can be launched on but show no readings.
Optional
| Tool | Needed for |
|---|---|
| pm2 | keeping the server running |
ssh with non-interactive (key-based) login |
RPC workers and worker telemetry |
cloudflared |
remote access |
gh, logged in |
the posts editor |
| a Strata install | the Strata engine |
evalplus, or the Aider benchmark checkout plus Docker |
the public coding suites |
Node.js 22+ (for wrangler) |
deploying the static public site |
LL-Dash keeps its runtime data next to the checkout, not inside it, so give it a parent directory of its own:
mkdir -p ~/llm-lab && cd ~/llm-lab
git clone <repository-url> dashboard
cd dashboard
npm install
cp dashboard.config.example.json dashboard.config.json # then edit it (see Configuration)
node server4.js # open http://localhost:3000After the first run, the layout is:
~/llm-lab/
dashboard/ this repository
logs/ device-list cache and a code stamp (small, per machine)
logs/ created at runtime: request CSV, SQLite database, launch
profiles, bench transcript, derived Strata configs
models/ optional: GGUF files or symlinks to them (scanned first)
To keep it running in the background, see Under pm2. For a tour of the interface, see docs/web-ui.md.
dashboard.config.json holds every per-machine setting.
- It is gitignored, and every key is optional.
dashboard.config.example.jsonis a neutral starting point. It works unchanged with llama.cpp built in~/llama.cpp/buildand models in~/models.- If the file is missing or invalid, LL-Dash falls back to one build at
~/llama.cpp/build/bin/llama-server. - TOPOLOGY.md ("Configuration") is the full reference.
| Key | What it sets |
|---|---|
pathRoots |
{ "NAME": "path" }: roots usable as ${NAME} in any path here and in saved profiles. |
modelsRoot |
Where this machine keeps its models. It is scanned, available as ${MODELS}, and is the base for relative model paths. |
llamaServerBuilds |
[{ id, label, path }]: the llama-server binaries in the Build selector. |
modelDirs |
More directories to scan for GGUF files. |
rpcWorkers |
Other machines' GPUs, by name. See RPC workers. |
strataInstalls |
Strata install directories, with the current one first. Empty means no Strata. |
llamaHost |
The address llama-server listens on. The default is 127.0.0.1. |
remoteAccess |
The tunnel and password gate. See below. |
deviceNames, machineNames, modelDisplayNames, reliabilityNotes |
Display names and hardware notes for recorded runs. |
The example file also lists keys used only by the optional benchmark integration. Leave those empty if you don't use it.
Paths.
- A leading
~and${NAME}(frompathRoots, otherwise the environment) are expanded. Nothing else in a path is changed. - An absolute path is used exactly as written.
tools/migrate-profile-paths.jsshows the portable spelling of an existing profile store.
Example (every value is a placeholder):
{
"pathRoots": { "LLAMA": "~/llama.cpp" },
"modelsRoot": "~/models",
"llamaServerBuilds": [
{ "id": "all", "label": "CUDA + ROCm + Vulkan", "path": "${LLAMA}/build-all/bin/llama-server" },
{ "id": "vulkan", "label": "Vulkan only", "path": "${LLAMA}/build-vulkan/bin/llama-server" }
],
"rpcWorkers": {
"worker1": { "ssh": "user@worker-host", "localPort": 50052, "devices": "Vulkan0" }
},
"remoteAccess": {
"tunnelConfig": "~/.cloudflared/<tunnel>.yml",
"url": "https://dash.example.com",
"autoStart": true,
"passwordFile": "~/.config/ai-dashboard/tunnel-password"
}
}Where models are found. These locations are scanned recursively, in this order. A file reachable by more than one path is listed once.
../models/data/models(fixed in the code; skipped if it doesn't exist)- the Hugging Face cache (
$HF_HOME, else$HUGGINGFACE_HUB_CACHE, else~/.cache/huggingface/hub) modelsRoot- each entry of
modelDirs
Split GGUFs appear as one entry, and mmproj files are paired with their models.
Environment variables
| Variable | Effect |
|---|---|
HF_HOME, HUGGINGFACE_HUB_CACHE |
The Hugging Face cache to scan. |
STRATA_DIR |
One more Strata install, listed first. |
POSTS_REPO, POSTS_CLONE |
The posts repository (owner/name) and its local clone, for the posts editor. |
MONITOR_BIND |
monitor.py's listen address. The default is 127.0.0.1. |
MONITOR_PORT |
monitor.py's port. The dashboard always dials 8081, so this is only for trying a second copy. |
SWEEP_STATE_FILE |
Where the headless sweep keeps its state. The default is sweep-state.json in the checkout. |
| Port | What | Bound to |
|---|---|---|
| 3000 | Dashboard UI and API (server4.js). Fixed. |
all interfaces |
| 8080 | llama-server (default). A --port in the command overrides it. |
127.0.0.1, unless llamaHost is set |
| 8081 | monitor.py telemetry |
127.0.0.1 (MONITOR_BIND overrides) |
| 50052 | RPC worker (ggml-rpc-server) on the worker; also each worker's local forward port |
the worker's loopback (link: "ssh") or all interfaces (link: "direct") |
| 3100 | Public site (public/server.js, optional) |
all interfaces (PUBLIC_HOST) |
| 3101 | Public live API (public/live_server.py, optional) |
127.0.0.1 |
Strata servers default to port 8095. Whenever the model server runs on a port
other than 8080, the dashboard forwards 127.0.0.1:8080 to it, for clients
that are hard-wired to 8080.
ecosystem.config.js defines three apps:
| App | Script | What |
|---|---|---|
dashboard |
server4.js |
The dashboard. Start this one. |
public |
public/server.js |
The optional public results site (port 3100) |
live |
public/live_server.py |
The optional live API for the static public site (port 3101) |
pm2 start ecosystem.config.js --only dashboard # a plain `pm2 start ecosystem.config.js` starts all three
pm2 status # state, PID, uptime, restart count (↺, cumulative)
pm2 logs dashboard # server output, including the echoed llama-server log
pm2 restart dashboard # e.g. after editing server4.js or lib/
pm2 stop dashboard # pm2 won't auto-restart an app you stopped
pm2 save # so `pm2 resurrect` / `pm2 startup` brings it backEach app has a crash-loop guard: restart_delay: 2000, min_uptime: 5000,
max_restarts: 20. If an app keeps dying within 5 s, pm2 gives up after 20
restarts.
monitor.py is not a pm2 app; server4.js starts and stops it. To run in the
foreground instead, use node server4.js.
At startup the server does the following, in order:
- Loads the configuration.
- Loads each build's last known device list from
logs/device-cache.json. Builds with no cached list are probed later, when the GPUs are free. - Stops orphans left on ports 8080 and 8081 by a previous dashboard. It only
stops a model server or
monitor.py; nothing else is touched. - Starts
monitor.py. - Starts the tunnel, if
remoteAccess.autoStartis set.
On SIGINT or SIGTERM, it stops what it started: llama-server, llama-bench,
monitor.py and cloudflared.
A restart therefore unloads a model the dashboard launched. That covers a pm2 restart, a crash and a manual restart. Load the model again afterwards.
The Stop button sends SIGTERM, then SIGKILL after 3 s. A server that exits before it is ready shows its last error lines as "Launch failed".
With no tunnel running, LL-Dash has no authentication. It assumes a trusted machine and network.
| Component | Exposure |
|---|---|
| Dashboard (port 3000) | All interfaces, CORS *. The launch command is run as given, so anyone who can reach the port can run any program as the user the dashboard runs as. They can also start and stop processes on configured workers over SSH, and read the request history. |
monitor.py (port 8081) |
Loopback only (MONITOR_BIND overrides). It answers only the dashboard's telemetry POST, and GET returns 404. |
llama-server |
127.0.0.1 by default. Setting llamaHost (for example "0.0.0.0") exposes it, also without authentication. |
ggml-rpc-server |
No authentication. With link: "ssh" (the default), it listens on the worker's loopback only, behind the SSH forward. With link: "direct", it listens on all of the worker's interfaces. |
Because the dashboard sends CORS *, a firewall alone is not enough. Any web
page open in a browser that can reach the dashboard, including a browser on
the same machine, can send it requests.
Recommendations
- Firewall port 3000 from any network you don't trust. If you set
MONITOR_BINDorllamaHostto a non-loopback address, firewall those ports too. - Don't browse untrusted sites in a browser that can reach an ungated dashboard. Alternatively, keep the password gate armed (see below).
- From another machine, prefer an SSH port forward
(
ssh -L 3000:localhost:3000 <host>) or the tunnel.
POST /api/tunnel publishes the dashboard through Cloudflare, in one of two
ways:
- Quick tunnel (no account needed): runs
cloudflared tunnel --url http://127.0.0.1:3000and gets a randomhttps://<random>.trycloudflare.comURL. You supply the password in the start request; it must be at least 4 characters. - Named tunnel: set
remoteAccess.tunnelConfigto acloudflaredconfig file, andremoteAccess.urlto its public URL.- With
autoStart: trueand apasswordFile(keep that file mode 600), the tunnel starts with the dashboard and is retried every 60 s if it fails or exits. - Stopping it through the CLI or the API leaves it stopped until the next dashboard start.
- With
Starting a tunnel arms a password gate in front of every request. That
includes static files, REST and SSE, and local requests as well as remote
ones, because cloudflared forwards from localhost and the two look the same.
- Credentials: send
Authorization: Bearer <password>(CLI, scripts), or anmc_sessioncookie set by the gate's login page (POST /api/tunnel/login). - Unauthenticated requests get the login page (browsers) or a
401JSON response. - The password is checked with PBKDF2-HMAC-SHA256 (200,000 iterations) against a salt generated when the tunnel starts. The dashboard never writes the password to disk.
- Browser sessions are signed tokens valid for 30 days. They survive a
dashboard restart; the signing key lives in
~/.config/ai-dashboard/session-secret.json(mode 600). Stopping the tunnel, or changing the password, logs every session out. - The login has no rate limit or lockout. Use a long password.
remoteAccess.allowIpslists peers that may skip the password. It matches the socket's remote address, never a forwarded header. Loopback entries are ignored, because tunnel traffic arrives from127.0.0.1.remoteAccess.failRedirect(optional) sends a browser to another URL after a wrong password.- Stopping the tunnel drops the gate. Use
llama-cli tunnel stop, orPOST /api/tunnel {"action":"stop"}.
cloudflared must be on the PATH, or you can set remoteAccess.cloudflared.
The dashboard's shutdown handler stops it.
The model chooses the URLs that the chat's web_fetch tool reads, so the tool
is restricted:
- Only plain
http/httpsURLs without credentials are allowed. - Every hop, redirects included, must resolve to a public address. Loopback, private, link-local, CGNAT and multicast ranges are refused, and the connection is pinned to the address that was checked.
- At most 5 redirects, 5 MB and 15 s.
- Only HTML, XML, text and JSON are read.
server4.js backend: HTTP + SSE on :3000; launches and supervises model servers
monitor.py telemetry helper on 127.0.0.1:8081
index.html, script.js the main page (no build step)
ui/ page modules (Results, Bench views, pickers, posts editor, shared helpers)
lib/ server modules, most pure and unit-tested
db/schema.sql SQLite schema
dash-cli/ terminal client
public/ public results site and its static build
bench-tools/, tools/ sweep client, depth sweep, profile and weights utilities
test/ node:test unit tests
vendor/ Chart.js, marked, prebuilt Tailwind CSS, inlined fonts
docs/development.md has the full module list.
| Page | Covers |
|---|---|
| docs/web-ui.md | Each tab, both sidebars, the chat and the posts editor |
| docs/launching-models.md | Device lists, the raw command box, launch guards, profiles, device probes, builds, RPC workers, Strata |
| docs/benchmarking.md | llama-bench, launch sweeps, public suites, and the optional benchmark integration |
| docs/building-benchmark-tasks.md | Building and maintaining tasks for a private coding benchmark: prompts, hidden tests, validation, reading early passes and fails, keeping it private |
| docs/terminal-client.md | dash-cli overview (dash-cli/README.md is the full manual) |
| docs/public-site.md | The public results site, its static build and posts |
| docs/development.md | Tests, CSS and fonts, offline use, tools, HTTP API overview, project layout |
| API.md | The HTTP and SSE contract |
| TOPOLOGY.md | Hardware model, profile portability, run labels, full configuration reference |
- Linux only.
- GPU telemetry covers AMD and NVIDIA only.
- Fan RPM and memory temperature are AMD-only.
- Worker telemetry over SSH is NVIDIA-only.
- Identical cards are paired by listing order, both across backends and in telemetry.
- Ports 3000, 8080 and 8081 are fixed defaults.
- Some defaults still reflect the machines this was written on. Examples:
the fixed
/data/modelsscan, and the hard-coded paths inbench-tools/depth_sweep.py. TOPOLOGY.md ("Still machine-specific") has the full list.