headroom-gui 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/assets/screenshot.png +0 -0
- package/bin/cli.js +277 -0
- package/index.html +1068 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# headroom-gui
|
|
2
|
+
|
|
3
|
+
A monitoring dashboard for [Headroom](https://github.com/chopratejas/headroom) — the AI-agent context compression proxy that reduces LLM token usage by 60–95%.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## What is Headroom?
|
|
8
|
+
|
|
9
|
+
Headroom sits between your AI agent and the LLM. It intercepts tool call results and compresses them before they reach the model, cutting costs and extending effective context windows. Compression is reversible (CCR mode) — the original is cached for 5 minutes and retrieved on demand.
|
|
10
|
+
|
|
11
|
+
## What is headroom-gui?
|
|
12
|
+
|
|
13
|
+
A web dashboard that connects to a running Headroom instance and displays live metrics:
|
|
14
|
+
|
|
15
|
+
- **Overview** — at-a-glance health status and key numbers
|
|
16
|
+
- **Liveness / Readiness** — animated status indicators for `/livez` and `/readyz`
|
|
17
|
+
- **Health** — aggregate health checks for all subsystems
|
|
18
|
+
- **Stats** — tokens saved, cost savings, compression ratios, latency histograms
|
|
19
|
+
- **History** — time-series area charts (session / hourly / daily / weekly / monthly)
|
|
20
|
+
- **Metrics** — parsed Prometheus output with visual bar indicators
|
|
21
|
+
|
|
22
|
+
Auto-refreshes every 5 seconds. Zero runtime dependencies — uses only Node.js built-ins.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- **Node.js** ≥ 18
|
|
27
|
+
- **Headroom** running (see below)
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
**1. Start Headroom first**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
headroom proxy
|
|
35
|
+
# default: http://127.0.0.1:8787
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**2. Start the dashboard**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx headroom-gui start
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Open **http://localhost:3000** in your browser.
|
|
45
|
+
|
|
46
|
+
**3. Stop the dashboard**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx headroom-gui stop
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `headroom-gui start` | Start dashboard in background |
|
|
57
|
+
| `headroom-gui stop` | Stop background server |
|
|
58
|
+
| `headroom-gui status` | Show running state |
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
| Flag | Default | Description |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `--port <n>` | `3000` | Port for the GUI server |
|
|
65
|
+
| `--proxy-port <n>` | `8787` | Port Headroom is listening on |
|
|
66
|
+
| `--proxy-host <h>` | `127.0.0.1` | Host Headroom is listening on |
|
|
67
|
+
|
|
68
|
+
**Example — Headroom on a non-default port:**
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
headroom proxy --port 9000
|
|
72
|
+
headroom-gui start --proxy-port 9000
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Example — Dashboard on a different port:**
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
headroom-gui start --port 4000
|
|
79
|
+
# open http://localhost:4000
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Environment Variables
|
|
83
|
+
|
|
84
|
+
| Variable | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `HEADROOM_GUI_PORT` | GUI port (overridden by `--port`) |
|
|
87
|
+
| `HEADROOM_PROXY_PORT` | Headroom port (overridden by `--proxy-port`) |
|
|
88
|
+
| `HEADROOM_PROXY_HOST` | Headroom host (overridden by `--proxy-host`) |
|
|
89
|
+
|
|
90
|
+
## Architecture
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Browser ──→ headroom-gui (Node.js HTTP, :3000)
|
|
94
|
+
│
|
|
95
|
+
├── GET / → serves index.html (Tailwind SPA)
|
|
96
|
+
└── GET /api/* → proxied to Headroom (:8787)
|
|
97
|
+
│
|
|
98
|
+
├── /livez
|
|
99
|
+
├── /readyz
|
|
100
|
+
├── /health
|
|
101
|
+
├── /stats
|
|
102
|
+
├── /stats-history
|
|
103
|
+
└── /metrics
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The built-in proxy eliminates CORS issues — the browser only ever talks to the same origin (localhost:3000).
|
|
107
|
+
|
|
108
|
+
State files are stored in `~/.headroom-gui/` (PID, port, log).
|
|
109
|
+
|
|
110
|
+
## Publish to npm
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm publish --access public
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Users can then run `npx headroom-gui start` without installing globally.
|
|
Binary file
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawn, execSync } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const url = require('url');
|
|
11
|
+
|
|
12
|
+
const IS_WIN = process.platform === 'win32';
|
|
13
|
+
const STATE_DIR = path.join(os.homedir(), '.headroom-gui');
|
|
14
|
+
const PID_FILE = path.join(STATE_DIR, 'server.pid');
|
|
15
|
+
const PORT_FILE = path.join(STATE_DIR, 'server.port');
|
|
16
|
+
const URL_FILE = path.join(STATE_DIR, 'server.url');
|
|
17
|
+
const LOG_FILE = path.join(STATE_DIR, 'server.log');
|
|
18
|
+
const INDEX = path.join(__dirname, '..', 'index.html');
|
|
19
|
+
|
|
20
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
|
|
25
|
+
function flag(name) {
|
|
26
|
+
const i = argv.indexOf(name);
|
|
27
|
+
return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cmd = argv[0];
|
|
31
|
+
const guiPort = parseInt(flag('--port')) || parseInt(process.env.HEADROOM_GUI_PORT) || 3000;
|
|
32
|
+
const proxyPort = parseInt(flag('--proxy-port')) || parseInt(process.env.HEADROOM_PROXY_PORT) || 8787;
|
|
33
|
+
const proxyHost = flag('--proxy-host') || process.env.HEADROOM_PROXY_HOST || '127.0.0.1';
|
|
34
|
+
const headroomUrl = `http://${proxyHost}:${proxyPort}`;
|
|
35
|
+
|
|
36
|
+
// ── command dispatch ─────────────────────────────────────────────────────────
|
|
37
|
+
switch (cmd) {
|
|
38
|
+
case 'start': cmdStart(); break;
|
|
39
|
+
case 'stop': cmdStop(); break;
|
|
40
|
+
case 'status': cmdStatus(); break;
|
|
41
|
+
case '_serve': cmdServe(); break;
|
|
42
|
+
default:
|
|
43
|
+
console.log([
|
|
44
|
+
'',
|
|
45
|
+
' headroom-gui <command> [options]',
|
|
46
|
+
'',
|
|
47
|
+
' Commands:',
|
|
48
|
+
' start [options] Start GUI in background',
|
|
49
|
+
' stop Stop background server',
|
|
50
|
+
' status Show running state',
|
|
51
|
+
'',
|
|
52
|
+
' Options:',
|
|
53
|
+
' --port <n> GUI server port (default: 3000)',
|
|
54
|
+
' --proxy-port <n> Headroom API port (default: 8787)',
|
|
55
|
+
' --proxy-host <h> Headroom API host (default: 127.0.0.1)',
|
|
56
|
+
'',
|
|
57
|
+
' Env overrides:',
|
|
58
|
+
' HEADROOM_GUI_PORT GUI port',
|
|
59
|
+
' HEADROOM_PROXY_PORT Headroom port',
|
|
60
|
+
' HEADROOM_PROXY_HOST Headroom host',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n'));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── internal server (spawned as detached child) ──────────────────────────────
|
|
67
|
+
function cmdServe() {
|
|
68
|
+
const p = parseInt(process.env.GUI_PORT) || 3000;
|
|
69
|
+
const hUrl = process.env.HEADROOM_URL || 'http://127.0.0.1:8787';
|
|
70
|
+
const parsed = url.parse(hUrl);
|
|
71
|
+
const proxySecure = parsed.protocol === 'https:';
|
|
72
|
+
const proxyHost_ = parsed.hostname;
|
|
73
|
+
const proxyPort_ = parseInt(parsed.port) || (proxySecure ? 443 : 80);
|
|
74
|
+
|
|
75
|
+
const server = http.createServer((req, res) => {
|
|
76
|
+
const reqUrl = req.url.split('?')[0];
|
|
77
|
+
|
|
78
|
+
if (reqUrl === '/' || reqUrl === '/index.html') {
|
|
79
|
+
try {
|
|
80
|
+
const html = fs.readFileSync(INDEX);
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
83
|
+
'Cache-Control': 'no-cache',
|
|
84
|
+
});
|
|
85
|
+
res.end(html);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
res.writeHead(500);
|
|
88
|
+
res.end('index.html not found: ' + e.message);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (req.url.startsWith('/api')) {
|
|
94
|
+
proxyToHeadroom(req, res, proxyHost_, proxyPort_, proxySecure, req.url.slice(4) || '/');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
res.writeHead(404);
|
|
99
|
+
res.end('Not found');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.on('error', e => {
|
|
103
|
+
fs.appendFileSync(LOG_FILE, 'Error: ' + e.message + '\n');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.listen(p, '127.0.0.1', () => {
|
|
108
|
+
fs.appendFileSync(LOG_FILE, 'Listening on http://127.0.0.1:' + p + '\n');
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function proxyToHeadroom(clientReq, clientRes, host, port, secure, targetPath) {
|
|
113
|
+
// targetPath already includes query string (sliced from req.url)
|
|
114
|
+
const options = {
|
|
115
|
+
host,
|
|
116
|
+
port,
|
|
117
|
+
path: targetPath || '/',
|
|
118
|
+
method: clientReq.method,
|
|
119
|
+
headers: Object.assign({}, clientReq.headers, { host }),
|
|
120
|
+
};
|
|
121
|
+
delete options.headers['origin'];
|
|
122
|
+
delete options.headers['referer'];
|
|
123
|
+
|
|
124
|
+
const transport = secure ? https : http;
|
|
125
|
+
const proxy = transport.request(options, upRes => {
|
|
126
|
+
clientRes.writeHead(upRes.statusCode, Object.assign({
|
|
127
|
+
'Access-Control-Allow-Origin': '*',
|
|
128
|
+
}, upRes.headers));
|
|
129
|
+
upRes.pipe(clientRes);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
proxy.on('error', e => {
|
|
133
|
+
if (!clientRes.headersSent) {
|
|
134
|
+
clientRes.writeHead(502);
|
|
135
|
+
clientRes.end('Headroom unreachable: ' + e.message);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
clientReq.pipe(proxy);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── start ────────────────────────────────────────────────────────────────────
|
|
143
|
+
function cmdStart() {
|
|
144
|
+
checkHeadroom(headroomUrl, (reachable) => {
|
|
145
|
+
if (!reachable) {
|
|
146
|
+
console.error([
|
|
147
|
+
'',
|
|
148
|
+
' Headroom is not running at ' + headroomUrl,
|
|
149
|
+
'',
|
|
150
|
+
' Start it first:',
|
|
151
|
+
' headroom proxy',
|
|
152
|
+
'',
|
|
153
|
+
' Running on a different port?',
|
|
154
|
+
' headroom-gui start --proxy-port <port>',
|
|
155
|
+
'',
|
|
156
|
+
].join('\n'));
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (fs.existsSync(PID_FILE)) {
|
|
161
|
+
const pid = readPid();
|
|
162
|
+
if (pid && isRunning(pid)) {
|
|
163
|
+
const p = readPort();
|
|
164
|
+
console.log('\n Already running PID ' + pid + ' http://localhost:' + p + '\n');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
cleanup();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(LOG_FILE, '');
|
|
171
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
172
|
+
|
|
173
|
+
const child = spawn(process.execPath, [__filename, '_serve'], {
|
|
174
|
+
detached: true,
|
|
175
|
+
stdio: ['ignore', logFd, logFd],
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
env: Object.assign({}, process.env, {
|
|
178
|
+
GUI_PORT: String(guiPort),
|
|
179
|
+
HEADROOM_URL: headroomUrl,
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
child.unref();
|
|
183
|
+
fs.closeSync(logFd);
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
186
|
+
fs.writeFileSync(PORT_FILE, String(guiPort));
|
|
187
|
+
fs.writeFileSync(URL_FILE, headroomUrl);
|
|
188
|
+
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
if (!isRunning(child.pid)) {
|
|
191
|
+
const log = (() => { try { return fs.readFileSync(LOG_FILE, 'utf8').trim(); } catch(e) { return ''; } })();
|
|
192
|
+
console.error('\n Failed to start.');
|
|
193
|
+
if (log) console.error(' ' + log.split('\n').join('\n '));
|
|
194
|
+
console.error(' Log: ' + LOG_FILE + '\n');
|
|
195
|
+
cleanup();
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
console.log([
|
|
199
|
+
'',
|
|
200
|
+
' Headroom Monitor',
|
|
201
|
+
' http://localhost:' + guiPort,
|
|
202
|
+
' PID : ' + child.pid,
|
|
203
|
+
' API : ' + headroomUrl,
|
|
204
|
+
' Log : ' + LOG_FILE,
|
|
205
|
+
' Stop : headroom-gui stop',
|
|
206
|
+
'',
|
|
207
|
+
].join('\n'));
|
|
208
|
+
}, 1200);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── stop ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
function cmdStop() {
|
|
214
|
+
if (!fs.existsSync(PID_FILE)) {
|
|
215
|
+
console.log('Not running.');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const pid = readPid();
|
|
219
|
+
if (!pid) { cleanup(); console.log('Not running.'); return; }
|
|
220
|
+
|
|
221
|
+
let killed = false;
|
|
222
|
+
if (IS_WIN) {
|
|
223
|
+
try { execSync('taskkill /PID ' + pid + ' /T /F', { stdio: 'ignore' }); killed = true; } catch(e) {}
|
|
224
|
+
}
|
|
225
|
+
if (!killed) {
|
|
226
|
+
try { process.kill(pid, 'SIGTERM'); killed = true; } catch(e) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
cleanup();
|
|
230
|
+
console.log('Stopped' + (killed ? ' (PID ' + pid + ')' : ' (already gone)'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
234
|
+
function cmdStatus() {
|
|
235
|
+
if (!fs.existsSync(PID_FILE)) { console.log('Not running.'); return; }
|
|
236
|
+
const pid = readPid();
|
|
237
|
+
const p = readPort();
|
|
238
|
+
const hUrl = readUrl();
|
|
239
|
+
if (pid && isRunning(pid)) {
|
|
240
|
+
console.log([
|
|
241
|
+
'',
|
|
242
|
+
' Running',
|
|
243
|
+
' GUI : http://localhost:' + p + ' (PID ' + pid + ')',
|
|
244
|
+
' API : ' + hUrl,
|
|
245
|
+
'',
|
|
246
|
+
].join('\n'));
|
|
247
|
+
} else {
|
|
248
|
+
console.log('Not running (stale state). Run: headroom-gui stop');
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── utils ────────────────────────────────────────────────────────────────────
|
|
254
|
+
function readPid() { try { return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()); } catch(e) { return null; } }
|
|
255
|
+
function readPort() { try { return parseInt(fs.readFileSync(PORT_FILE, 'utf8').trim()); } catch(e) { return 3000; } }
|
|
256
|
+
function readUrl() { try { return fs.readFileSync(URL_FILE, 'utf8').trim(); } catch(e) { return 'http://127.0.0.1:8787'; } }
|
|
257
|
+
|
|
258
|
+
function cleanup() {
|
|
259
|
+
[PID_FILE, PORT_FILE, URL_FILE].forEach(f => { try { fs.unlinkSync(f); } catch(e) {} });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isRunning(pid) {
|
|
263
|
+
try { process.kill(pid, 0); return true; }
|
|
264
|
+
catch(e) { return e.code === 'EPERM'; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function checkHeadroom(base, cb) {
|
|
268
|
+
const parsed = url.parse(base + '/livez');
|
|
269
|
+
const options = { host: parsed.hostname, port: parsed.port, path: '/livez', method: 'GET' };
|
|
270
|
+
const req = http.request(options, res => {
|
|
271
|
+
res.resume();
|
|
272
|
+
cb(res.statusCode < 500);
|
|
273
|
+
});
|
|
274
|
+
req.setTimeout(3000, () => { req.destroy(); cb(false); });
|
|
275
|
+
req.on('error', () => cb(false));
|
|
276
|
+
req.end();
|
|
277
|
+
}
|