Suriyong commited on
Commit
d098d63
·
verified ·
1 Parent(s): 87ec04c

Add health-server.js

Browse files
Files changed (1) hide show
  1. health-server.js +408 -0
health-server.js ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to OpenClaw.
2
+ const http = require("http");
3
+ const fs = require("fs");
4
+ const net = require("net");
5
+
6
+ const PORT = 7861;
7
+ const GATEWAY_PORT = 7860;
8
+ const GATEWAY_HOST = "127.0.0.1";
9
+ const startTime = Date.now();
10
+ const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
11
+ const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
12
+ const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
13
+ const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
14
+ const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
15
+ const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "180";
16
+ const APP_BASE = "/app";
17
+ const SYNC_STATUS_FILE = "/tmp/sync-status.json";
18
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
19
+ "/tmp/huggingclaw-cloudflare-keepalive-status.json";
20
+
21
+ function parseRequestUrl(url) {
22
+ try {
23
+ return new URL(url, "http://localhost");
24
+ } catch {
25
+ return new URL("http://localhost/");
26
+ }
27
+ }
28
+
29
+ function getSyncStatus() {
30
+ try {
31
+ if (fs.existsSync(SYNC_STATUS_FILE)) {
32
+ return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
33
+ }
34
+ } catch {}
35
+ if (HF_BACKUP_ENABLED) {
36
+ return {
37
+ status: "configured",
38
+ message: `Backup is enabled. Waiting for sync window (${SYNC_INTERVAL}s).`,
39
+ };
40
+ }
41
+ return { status: "unknown", message: "No sync data yet" };
42
+ }
43
+
44
+ function readGuardianStatus() {
45
+ if (!WHATSAPP_ENABLED) {
46
+ return { configured: false, connected: false, pairing: false };
47
+ }
48
+ try {
49
+ if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
50
+ const parsed = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
51
+ return {
52
+ configured: parsed.configured !== false,
53
+ connected: parsed.connected === true,
54
+ pairing: parsed.pairing === true,
55
+ };
56
+ }
57
+ } catch {}
58
+ return { configured: true, connected: false, pairing: false };
59
+ }
60
+
61
+ function getKeepaliveStatus() {
62
+ try {
63
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
64
+ return JSON.parse(
65
+ fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"),
66
+ );
67
+ }
68
+ } catch {}
69
+ return null;
70
+ }
71
+
72
+ function probeGatewayHealth(timeoutMs = 1500) {
73
+ return new Promise((resolve) => {
74
+ const request = http.get(
75
+ {
76
+ hostname: GATEWAY_HOST,
77
+ port: GATEWAY_PORT,
78
+ path: "/health",
79
+ timeout: timeoutMs,
80
+ },
81
+ (response) => {
82
+ response.resume();
83
+ resolve(response.statusCode >= 200 && response.statusCode < 400);
84
+ },
85
+ );
86
+ request.on("timeout", () => {
87
+ request.destroy();
88
+ resolve(false);
89
+ });
90
+ request.on("error", () => resolve(false));
91
+ });
92
+ }
93
+
94
+ function formatUptime(ms) {
95
+ const total = Math.floor(ms / 1000);
96
+ const days = Math.floor(total / 86400);
97
+ const hours = Math.floor((total % 86400) / 3600);
98
+ const minutes = Math.floor((total % 3600) / 60);
99
+ if (days) return `${days}d ${hours}h ${minutes}m`;
100
+ if (hours) return `${hours}h ${minutes}m`;
101
+ return `${minutes}m`;
102
+ }
103
+
104
+ function escapeHtml(value) {
105
+ return String(value)
106
+ .replace(/&/g, "&amp;")
107
+ .replace(/</g, "&lt;")
108
+ .replace(/>/g, "&gt;")
109
+ .replace(/"/g, "&quot;");
110
+ }
111
+
112
+ function toneBadge(label, tone = "neutral") {
113
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
114
+ }
115
+
116
+ function renderTile({
117
+ title,
118
+ value,
119
+ detail = "",
120
+ tone = "neutral",
121
+ meta = "",
122
+ }) {
123
+ return `<article class="tile ${tone}">
124
+ <div class="tile-head">
125
+ <span class="tile-title">${escapeHtml(title)}</span>
126
+ <span class="tile-dot"></span>
127
+ </div>
128
+ <div class="tile-value">${value}</div>
129
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
130
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
131
+ </article>`;
132
+ }
133
+
134
+ function renderDashboard(data) {
135
+ const syncStatus = String(data.sync?.status || "unknown");
136
+ const syncTone = ["success", "restored", "synced", "configured"].includes(
137
+ syncStatus,
138
+ )
139
+ ? "ok"
140
+ : syncStatus === "disabled"
141
+ ? "warn"
142
+ : "neutral";
143
+ const backupDetail = data.sync?.message
144
+ ? escapeHtml(data.sync.message)
145
+ : "No status yet";
146
+
147
+ const keepaliveConfigured = data.keepalive?.configured === true;
148
+ const keepaliveStatus = String(
149
+ data.keepalive?.status ||
150
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
151
+ );
152
+ const keepAliveTone = keepaliveConfigured
153
+ ? "ok"
154
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
155
+ ? "warn"
156
+ : "neutral";
157
+ const keepAliveDetail = keepaliveConfigured
158
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
159
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
160
+ ? "Worker pending or failed"
161
+ : "Not configured";
162
+
163
+ const tiles = [
164
+ renderTile({
165
+ title: "Gateway",
166
+ value: toneBadge(
167
+ data.gatewayReady ? "Online" : "Offline",
168
+ data.gatewayReady ? "ok" : "off",
169
+ ),
170
+ detail: `Internal Port ${GATEWAY_PORT}`,
171
+ tone: data.gatewayReady ? "ok" : "off",
172
+ }),
173
+ renderTile({
174
+ title: "Model",
175
+ value: `<code>${escapeHtml(LLM_MODEL)}</code>`,
176
+ detail: `Primary LLM Configured`,
177
+ tone: "neutral",
178
+ }),
179
+ renderTile({
180
+ title: "Runtime",
181
+ value: escapeHtml(data.uptimeHuman),
182
+ detail: `Public Port ${PORT}`,
183
+ tone: "neutral",
184
+ }),
185
+ renderTile({
186
+ title: "Telegram",
187
+ value: toneBadge(
188
+ TELEGRAM_ENABLED ? "Enabled" : "Disabled",
189
+ TELEGRAM_ENABLED ? "ok" : "neutral",
190
+ ),
191
+ detail: TELEGRAM_ENABLED ? "Bot Channel active" : "Not configured",
192
+ tone: TELEGRAM_ENABLED ? "ok" : "neutral",
193
+ }),
194
+ renderTile({
195
+ title: "Backup",
196
+ value: toneBadge(syncStatus.toUpperCase(), syncTone),
197
+ detail: backupDetail,
198
+ tone: syncTone,
199
+ meta: data.sync?.timestamp
200
+ ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>`
201
+ : "",
202
+ }),
203
+ renderTile({
204
+ title: "Keep Awake",
205
+ value: toneBadge(
206
+ keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
207
+ keepAliveTone,
208
+ ),
209
+ detail: keepAliveDetail,
210
+ tone: keepAliveTone,
211
+ }),
212
+ ].join("");
213
+
214
+ return `<!doctype html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="utf-8" />
218
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
219
+ <title>HuggingClaw</title>
220
+ <style>
221
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; }
222
+ * { box-sizing:border-box; }
223
+ body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
224
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
225
+ header { text-align:center; margin-bottom:22px; }
226
+ h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
227
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
228
+ .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:#fff; color:#000; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; transition: opacity 0.15s ease; }
229
+ .hero-action:hover { opacity: 0.9; }
230
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
231
+ .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
232
+ .tile.ok { border-color:rgba(34,197,94,.22); }
233
+ .tile.warn { border-color:rgba(245,197,66,.24); }
234
+ .tile.off { border-color:rgba(251,113,133,.28); }
235
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
236
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
237
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
238
+ .tile.ok .tile-dot { background:var(--good); }
239
+ .tile.warn .tile-dot { background:var(--warn); }
240
+ .tile.off .tile-dot { background:var(--bad); }
241
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
242
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
243
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
244
+
245
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
246
+ .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
247
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
248
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
249
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
250
+ .badge.neutral { color:var(--soft); }
251
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
252
+ footer .live { color:var(--good); }
253
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
254
+ </style>
255
+ </head>
256
+ <body>
257
+ <main>
258
+ <header>
259
+ <h1>🦞 HuggingClaw</h1>
260
+ <div class="subtitle">OpenClaw Gateway Dashboard</div>
261
+ </header>
262
+ <a class="hero-action" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Control UI -></a>
263
+ <section class="overview">
264
+ ${tiles}
265
+ </section>
266
+ <footer><span class="live">Live</span> status - Health endpoint: <code>/health</code></footer>
267
+ </main>
268
+ <script>
269
+ document.querySelectorAll('.local-time').forEach(el => {
270
+ const date = new Date(el.getAttribute('data-iso'));
271
+ if (!isNaN(date)) {
272
+ el.textContent = 'At ' + date.toLocaleTimeString();
273
+ }
274
+ });
275
+ </script>
276
+ </body>
277
+ </html>`;
278
+ }
279
+
280
+ const server = http.createServer(async (req, res) => {
281
+ const url = parseRequestUrl(req.url);
282
+ const pathname = url.pathname;
283
+
284
+ // 1. Dashboard Routes
285
+ if (pathname === "/health") {
286
+ const gatewayReady = await probeGatewayHealth();
287
+ res.writeHead(gatewayReady ? 200 : 503, {
288
+ "Content-Type": "application/json",
289
+ });
290
+ return res.end(
291
+ JSON.stringify({
292
+ status: gatewayReady ? "ok" : "degraded",
293
+ gatewayReady,
294
+ uptime: formatUptime(Date.now() - startTime),
295
+ sync: getSyncStatus(),
296
+ keepalive: getKeepaliveStatus(),
297
+ }),
298
+ );
299
+ }
300
+
301
+ if (pathname === "/status") {
302
+ const gatewayReady = await probeGatewayHealth();
303
+ res.writeHead(200, { "Content-Type": "application/json" });
304
+ return res.end(
305
+ JSON.stringify({
306
+ model: LLM_MODEL,
307
+ uptime: formatUptime(Date.now() - startTime),
308
+ gatewayReady,
309
+ sync: getSyncStatus(),
310
+ whatsapp: readGuardianStatus(),
311
+ keepalive: getKeepaliveStatus(),
312
+ }),
313
+ );
314
+ }
315
+
316
+ if (pathname === "/" || pathname === "/dashboard") {
317
+ const gatewayReady = await probeGatewayHealth();
318
+ res.writeHead(200, { "Content-Type": "text/html" });
319
+ return res.end(
320
+ renderDashboard({
321
+ uptimeHuman: formatUptime(Date.now() - startTime),
322
+ gatewayReady,
323
+ sync: getSyncStatus(),
324
+ whatsapp: readGuardianStatus(),
325
+ keepalive: getKeepaliveStatus(),
326
+ }),
327
+ );
328
+ }
329
+
330
+ // 2. OpenClaw Proxy Logic
331
+ const proxyHeaders = {
332
+ ...req.headers,
333
+ host: `${GATEWAY_HOST}:${GATEWAY_PORT}`,
334
+ "x-forwarded-for": req.socket.remoteAddress,
335
+ "x-forwarded-host": req.headers.host,
336
+ "x-forwarded-proto": "https",
337
+ };
338
+
339
+ const proxyReq = http.request(
340
+ {
341
+ hostname: GATEWAY_HOST,
342
+ port: GATEWAY_PORT,
343
+ path: pathname + url.search,
344
+ method: req.method,
345
+ headers: proxyHeaders,
346
+ },
347
+ (proxyRes) => {
348
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
349
+ proxyRes.pipe(res);
350
+ proxyRes.on("error", (err) => {
351
+ console.error("proxyRes error:", err);
352
+ res.end();
353
+ });
354
+ },
355
+ );
356
+
357
+ req.on("error", (err) => {
358
+ console.error("req error:", err);
359
+ proxyReq.destroy();
360
+ });
361
+
362
+ res.on("error", (err) => {
363
+ console.error("res error:", err);
364
+ proxyReq.destroy();
365
+ });
366
+
367
+ proxyReq.on("error", (err) => {
368
+ console.error("proxyReq error:", err);
369
+ if (!res.headersSent) {
370
+ res.writeHead(503, { "Content-Type": "application/json" });
371
+ res.end(
372
+ JSON.stringify({
373
+ status: "starting",
374
+ message: "Gateway is initializing... or connection failed",
375
+ }),
376
+ );
377
+ } else {
378
+ res.end();
379
+ }
380
+ });
381
+
382
+ req.pipe(proxyReq);
383
+ });
384
+
385
+ server.on("upgrade", (req, socket, head) => {
386
+ const url = parseRequestUrl(req.url);
387
+ const proxyPath = url.pathname;
388
+ const proxySocket = net.connect(GATEWAY_PORT, GATEWAY_HOST, () => {
389
+ proxySocket.write(
390
+ `${req.method} ${proxyPath}${url.search} HTTP/${req.httpVersion}\r\n`,
391
+ );
392
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
393
+ proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
394
+ }
395
+ proxySocket.write("\r\n");
396
+ if (head && head.length) proxySocket.write(head);
397
+ proxySocket.pipe(socket).pipe(proxySocket);
398
+ });
399
+ proxySocket.on("error", () => socket.destroy());
400
+ });
401
+
402
+ server.timeout = 0;
403
+ server.keepAliveTimeout = 65000;
404
+ server.listen(PORT, "0.0.0.0", () =>
405
+ console.log(
406
+ `🦞 HuggingClaw Dashboard on ${PORT} -> Gateway on ${GATEWAY_PORT}`,
407
+ ),
408
+ );