Spaces:
Running
Running
Improve dashboard and browser API auth flow
Browse files- health-server.js +169 -37
health-server.js
CHANGED
|
@@ -182,6 +182,11 @@ function requireAuth(req, res) {
|
|
| 182 |
return false;
|
| 183 |
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
async function handleLogin(req, res, parsed) {
|
| 186 |
const nextPath = sanitizeNext(parsed.searchParams.get("next") || `${APP_BASE}/`);
|
| 187 |
|
|
@@ -303,8 +308,16 @@ async function statusPayload() {
|
|
| 303 |
return {
|
| 304 |
ok: gateway,
|
| 305 |
uptime: formatUptime(Date.now() - startTime),
|
|
|
|
| 306 |
gateway,
|
| 307 |
dashboard,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
telegram: {
|
| 309 |
configured: !!process.env.TELEGRAM_BOT_TOKEN,
|
| 310 |
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
|
|
@@ -320,19 +333,113 @@ async function statusPayload() {
|
|
| 320 |
}
|
| 321 |
|
| 322 |
function badge(label, state) {
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
}
|
| 326 |
|
| 327 |
function renderDashboard(data) {
|
| 328 |
-
const syncStatus = String(data.backup?.status || "unknown")
|
| 329 |
-
const
|
| 330 |
-
const
|
| 331 |
-
const
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
: process.env.UPTIMEROBOT_API_KEY
|
| 334 |
-
? "UptimeRobot setup is pending or failed; check logs."
|
| 335 |
: "Add UPTIMEROBOT_API_KEY to create a keep-awake monitor.";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
return `<!doctype html>
|
| 338 |
<html lang="en">
|
|
@@ -341,26 +448,44 @@ function renderDashboard(data) {
|
|
| 341 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 342 |
<title>HuggingMess</title>
|
| 343 |
<style>
|
| 344 |
-
:root { color-scheme: dark; --bg:#
|
| 345 |
* { box-sizing:border-box; }
|
| 346 |
-
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); }
|
| 347 |
-
main { width:min(
|
| 348 |
-
header { display:flex; justify-content:space-between; gap:
|
| 349 |
-
h1 { margin:0; font-size:clamp(2rem,
|
| 350 |
-
.subtitle { margin-top:
|
| 351 |
-
.
|
| 352 |
-
.
|
| 353 |
-
.
|
| 354 |
-
.
|
| 355 |
-
.
|
| 356 |
-
|
| 357 |
-
.
|
| 358 |
-
.
|
| 359 |
-
.
|
| 360 |
-
.
|
| 361 |
-
.
|
| 362 |
-
.
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
</style>
|
| 365 |
</head>
|
| 366 |
<body>
|
|
@@ -368,18 +493,21 @@ function renderDashboard(data) {
|
|
| 368 |
<header>
|
| 369 |
<div>
|
| 370 |
<h1>HuggingMess</h1>
|
| 371 |
-
<div class="subtitle">Hermes Agent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
</div>
|
| 373 |
-
<div class="row">${badge("Gateway", data.gateway)}${badge("Dashboard", data.dashboard)}${badge("Backup", data.backup?.status !== "disabled")}</div>
|
| 374 |
</header>
|
| 375 |
-
<section class="
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
<div class="
|
| 380 |
-
<
|
| 381 |
-
<div class="card wide"><div class="label">Keep Awake</div><div class="value">${keepAlive}</div></div>
|
| 382 |
-
<div class="card wide"><div class="label">Entrypoints</div><div class="row">${dashboardLink}${apiLink}<a class="button secondary" href="/status">Status JSON</a></div></div>
|
| 383 |
</section>
|
| 384 |
</main>
|
| 385 |
</body>
|
|
@@ -471,6 +599,10 @@ const server = http.createServer(async (req, res) => {
|
|
| 471 |
|
| 472 |
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 473 |
if (!isAuthorized(req)) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
res.writeHead(401, {
|
| 475 |
"content-type": "application/json",
|
| 476 |
"cache-control": "no-store",
|
|
|
|
| 182 |
return false;
|
| 183 |
}
|
| 184 |
|
| 185 |
+
function wantsHtml(req) {
|
| 186 |
+
const accept = String(req.headers.accept || "");
|
| 187 |
+
return accept.includes("text/html");
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
async function handleLogin(req, res, parsed) {
|
| 191 |
const nextPath = sanitizeNext(parsed.searchParams.get("next") || `${APP_BASE}/`);
|
| 192 |
|
|
|
|
| 308 |
return {
|
| 309 |
ok: gateway,
|
| 310 |
uptime: formatUptime(Date.now() - startTime),
|
| 311 |
+
startedAt: new Date(startTime).toISOString(),
|
| 312 |
gateway,
|
| 313 |
dashboard,
|
| 314 |
+
authConfigured: !!API_SERVER_KEY,
|
| 315 |
+
ports: {
|
| 316 |
+
public: PORT,
|
| 317 |
+
gateway: GATEWAY_PORT,
|
| 318 |
+
dashboard: DASHBOARD_PORT,
|
| 319 |
+
telegramWebhook: TELEGRAM_WEBHOOK_PORT,
|
| 320 |
+
},
|
| 321 |
telegram: {
|
| 322 |
configured: !!process.env.TELEGRAM_BOT_TOKEN,
|
| 323 |
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
|
|
|
|
| 333 |
}
|
| 334 |
|
| 335 |
function badge(label, state) {
|
| 336 |
+
return `<span class="badge ${state ? "ok" : "off"}">${escapeHtml(label)}</span>`;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function toneBadge(label, tone = "neutral") {
|
| 340 |
+
return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function valueOrUnset(value, fallback = "Not set") {
|
| 344 |
+
return value ? escapeHtml(value) : `<span class="muted">${escapeHtml(fallback)}</span>`;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
|
| 348 |
+
return `<article class="tile ${tone}">
|
| 349 |
+
<div class="tile-head">
|
| 350 |
+
<span class="tile-title">${escapeHtml(title)}</span>
|
| 351 |
+
<span class="tile-dot"></span>
|
| 352 |
+
</div>
|
| 353 |
+
<div class="tile-value">${value}</div>
|
| 354 |
+
${detail ? `<div class="tile-detail">${detail}</div>` : ""}
|
| 355 |
+
${meta ? `<div class="tile-meta">${meta}</div>` : ""}
|
| 356 |
+
</article>`;
|
| 357 |
}
|
| 358 |
|
| 359 |
function renderDashboard(data) {
|
| 360 |
+
const syncStatus = String(data.backup?.status || "unknown");
|
| 361 |
+
const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
|
| 362 |
+
const telegramTone = data.telegram.configured ? (data.telegram.webhookListening || !data.telegram.webhook ? "ok" : "warn") : "warn";
|
| 363 |
+
const keepAliveTone = data.uptimerobot?.configured ? "ok" : process.env.UPTIMEROBOT_API_KEY ? "warn" : "neutral";
|
| 364 |
+
const publicBase = process.env.SPACE_HOST ? `https://${process.env.SPACE_HOST}` : `http://localhost:${PORT}`;
|
| 365 |
+
const apiCurl = `curl -H "Authorization: Bearer $GATEWAY_TOKEN" ${publicBase}/v1/models`;
|
| 366 |
+
const gatewayDetail = data.gateway
|
| 367 |
+
? `OpenAI-compatible API is listening on internal port <code>${data.ports.gateway}</code>.`
|
| 368 |
+
: `Gateway API is not reachable on internal port <code>${data.ports.gateway}</code>.`;
|
| 369 |
+
const appDetail = data.dashboard
|
| 370 |
+
? `Hermes dashboard is listening on internal port <code>${data.ports.dashboard}</code>.`
|
| 371 |
+
: `Hermes dashboard is not reachable on internal port <code>${data.ports.dashboard}</code>.`;
|
| 372 |
+
const authDetail = data.authConfigured
|
| 373 |
+
? `Protected by <code>GATEWAY_TOKEN</code> with a token-only login page.`
|
| 374 |
+
: `No <code>GATEWAY_TOKEN</code> is set; public app routes are unlocked.`;
|
| 375 |
+
const telegramDetail = data.telegram.configured
|
| 376 |
+
? `${data.telegram.webhook ? "Webhook mode" : "Polling mode"}${data.telegram.proxy ? ` through Cloudflare proxy` : ""}.`
|
| 377 |
+
: "Add TELEGRAM_BOT_TOKEN to enable Telegram.";
|
| 378 |
+
const backupDetail = data.backup?.message ? escapeHtml(data.backup.message) : "No backup status has been written yet.";
|
| 379 |
+
const backupMeta = data.backup?.timestamp ? `Last update ${escapeHtml(data.backup.timestamp)}` : "";
|
| 380 |
+
const keepAliveDetail = data.uptimerobot?.configured
|
| 381 |
+
? `Monitoring <code>${escapeHtml(data.uptimerobot.url || "/health")}</code>.`
|
| 382 |
: process.env.UPTIMEROBOT_API_KEY
|
| 383 |
+
? "UptimeRobot setup is pending or failed; check Space logs."
|
| 384 |
: "Add UPTIMEROBOT_API_KEY to create a keep-awake monitor.";
|
| 385 |
+
const tiles = [
|
| 386 |
+
renderTile({
|
| 387 |
+
title: "Gateway",
|
| 388 |
+
value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
|
| 389 |
+
detail: gatewayDetail,
|
| 390 |
+
tone: data.gateway ? "ok" : "off",
|
| 391 |
+
meta: `<code>/v1/models</code> requires token auth.`,
|
| 392 |
+
}),
|
| 393 |
+
renderTile({
|
| 394 |
+
title: "Hermes App",
|
| 395 |
+
value: toneBadge(data.dashboard ? "Ready" : "Starting", data.dashboard ? "ok" : "warn"),
|
| 396 |
+
detail: appDetail,
|
| 397 |
+
tone: data.dashboard ? "ok" : "warn",
|
| 398 |
+
meta: `<code>/app/</code> opens in a new window.`,
|
| 399 |
+
}),
|
| 400 |
+
renderTile({
|
| 401 |
+
title: "Auth",
|
| 402 |
+
value: toneBadge(data.authConfigured ? "Token set" : "Unlocked", data.authConfigured ? "ok" : "warn"),
|
| 403 |
+
detail: authDetail,
|
| 404 |
+
tone: data.authConfigured ? "ok" : "warn",
|
| 405 |
+
meta: data.authConfigured ? "Browser visits use the login page; API clients use Bearer auth." : "Set GATEWAY_TOKEN before sharing this Space.",
|
| 406 |
+
}),
|
| 407 |
+
renderTile({
|
| 408 |
+
title: "Runtime",
|
| 409 |
+
value: escapeHtml(data.uptime),
|
| 410 |
+
detail: `Public port <code>${data.ports.public}</code>. Started <code>${escapeHtml(data.startedAt)}</code>.`,
|
| 411 |
+
tone: "neutral",
|
| 412 |
+
meta: `Health endpoint: <code>/health</code>`,
|
| 413 |
+
}),
|
| 414 |
+
renderTile({
|
| 415 |
+
title: "Model",
|
| 416 |
+
value: `<code>${valueOrUnset(data.model)}</code>`,
|
| 417 |
+
detail: `Provider <code>${valueOrUnset(data.provider || "auto")}</code>.`,
|
| 418 |
+
tone: data.model ? "ok" : "warn",
|
| 419 |
+
meta: "For Gemini: LLM_MODEL=google/gemini-2.5-flash",
|
| 420 |
+
}),
|
| 421 |
+
renderTile({
|
| 422 |
+
title: "Telegram",
|
| 423 |
+
value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone),
|
| 424 |
+
detail: telegramDetail,
|
| 425 |
+
tone: telegramTone,
|
| 426 |
+
meta: data.telegram.webhookUrl ? `<code>${escapeHtml(data.telegram.webhookUrl)}</code>` : "",
|
| 427 |
+
}),
|
| 428 |
+
renderTile({
|
| 429 |
+
title: "Backup",
|
| 430 |
+
value: toneBadge(syncStatus.toUpperCase(), syncTone),
|
| 431 |
+
detail: backupDetail,
|
| 432 |
+
tone: syncTone,
|
| 433 |
+
meta: backupMeta,
|
| 434 |
+
}),
|
| 435 |
+
renderTile({
|
| 436 |
+
title: "Keep Awake",
|
| 437 |
+
value: toneBadge(data.uptimerobot?.configured ? "Monitor active" : "Not configured", keepAliveTone),
|
| 438 |
+
detail: keepAliveDetail,
|
| 439 |
+
tone: keepAliveTone,
|
| 440 |
+
meta: process.env.UPTIMEROBOT_API_KEY ? "UPTIMEROBOT_API_KEY detected." : "",
|
| 441 |
+
}),
|
| 442 |
+
].join("");
|
| 443 |
|
| 444 |
return `<!doctype html>
|
| 445 |
<html lang="en">
|
|
|
|
| 448 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 449 |
<title>HuggingMess</title>
|
| 450 |
<style>
|
| 451 |
+
:root { color-scheme: dark; --bg:#101010; --panel:#171717; --panel2:#1e1e1e; --line:#303030; --text:#f3f4f6; --muted:#a1a1aa; --soft:#d4d4d8; --good:#4ade80; --warn:#fbbf24; --bad:#fb7185; --accent:#67e8f9; }
|
| 452 |
* { box-sizing:border-box; }
|
| 453 |
+
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:14px; }
|
| 454 |
+
main { width:min(1180px, calc(100% - 32px)); margin:0 auto; padding:24px 0 32px; }
|
| 455 |
+
header { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:18px; border-bottom:1px solid var(--line); padding-bottom:18px; }
|
| 456 |
+
h1 { margin:0; font-size:clamp(2rem, 4vw, 3.2rem); line-height:1; letter-spacing:0; }
|
| 457 |
+
.subtitle { margin-top:10px; color:var(--muted); max-width:700px; line-height:1.45; font-size:.95rem; }
|
| 458 |
+
.top-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; min-width:300px; }
|
| 459 |
+
.overview { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
|
| 460 |
+
.tile { border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:14px; min-height:142px; display:flex; flex-direction:column; gap:10px; }
|
| 461 |
+
.tile.ok { border-color:rgba(74,222,128,.28); }
|
| 462 |
+
.tile.warn { border-color:rgba(251,191,36,.28); }
|
| 463 |
+
.tile.off { border-color:rgba(251,113,133,.32); }
|
| 464 |
+
.tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
| 465 |
+
.tile-title { color:var(--muted); font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; font-weight:800; }
|
| 466 |
+
.tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
|
| 467 |
+
.tile.ok .tile-dot { background:var(--good); }
|
| 468 |
+
.tile.warn .tile-dot { background:var(--warn); }
|
| 469 |
+
.tile.off .tile-dot { background:var(--bad); }
|
| 470 |
+
.tile-value { font-size:1.05rem; font-weight:760; overflow-wrap:anywhere; }
|
| 471 |
+
.tile-detail { color:var(--soft); line-height:1.45; font-size:.86rem; }
|
| 472 |
+
.tile-meta { color:var(--muted); line-height:1.4; font-size:.78rem; margin-top:auto; overflow-wrap:anywhere; }
|
| 473 |
+
.panel { border:1px solid var(--line); background:var(--panel2); border-radius:8px; padding:14px; margin-top:10px; }
|
| 474 |
+
.panel-title { color:var(--muted); font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; font-weight:800; margin-bottom:10px; }
|
| 475 |
+
code { background:#0d0d0d; border:1px solid var(--line); border-radius:6px; padding:2px 5px; color:var(--text); font-size:.9em; }
|
| 476 |
+
pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; }
|
| 477 |
+
.row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
| 478 |
+
.badge { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:.75rem; font-weight:800; line-height:1; }
|
| 479 |
+
.badge.ok { color:var(--good); border-color:rgba(74,222,128,.36); background:rgba(74,222,128,.08); }
|
| 480 |
+
.badge.warn { color:var(--warn); border-color:rgba(251,191,36,.34); background:rgba(251,191,36,.08); }
|
| 481 |
+
.badge.off { color:var(--bad); border-color:rgba(251,113,133,.36); background:rgba(251,113,133,.08); }
|
| 482 |
+
.badge.neutral { color:var(--soft); }
|
| 483 |
+
.muted { color:var(--muted); }
|
| 484 |
+
.button { display:inline-flex; align-items:center; justify-content:center; min-height:34px; padding:0 11px; border-radius:7px; color:#081012; background:var(--accent); text-decoration:none; font-weight:800; font-size:.86rem; }
|
| 485 |
+
.button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); }
|
| 486 |
+
.button.subtle { color:var(--soft); background:transparent; border:1px solid var(--line); }
|
| 487 |
+
@media (max-width: 980px) { .overview { grid-template-columns:repeat(2, minmax(0, 1fr)); } header { display:block; } .top-actions { justify-content:flex-start; margin-top:14px; min-width:0; } }
|
| 488 |
+
@media (max-width: 620px) { main { width:min(100% - 20px, 1180px); padding-top:16px; } .overview { grid-template-columns:1fr; } h1 { font-size:2rem; } }
|
| 489 |
</style>
|
| 490 |
</head>
|
| 491 |
<body>
|
|
|
|
| 493 |
<header>
|
| 494 |
<div>
|
| 495 |
<h1>HuggingMess</h1>
|
| 496 |
+
<div class="subtitle">Hermes Agent on Hugging Face Spaces: app gateway, OpenAI-compatible API, Telegram webhook, Cloudflare proxy, backup, and keep-awake state in one place.</div>
|
| 497 |
+
</div>
|
| 498 |
+
<div class="top-actions">
|
| 499 |
+
<a class="button" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open App</a>
|
| 500 |
+
<a class="button secondary" href="/v1/models" target="_blank" rel="noopener noreferrer">Models</a>
|
| 501 |
+
<a class="button secondary" href="/status">Status JSON</a>
|
| 502 |
+
<a class="button subtle" href="${LOGOUT_PATH}">Logout</a>
|
| 503 |
</div>
|
|
|
|
| 504 |
</header>
|
| 505 |
+
<section class="overview">
|
| 506 |
+
${tiles}
|
| 507 |
+
</section>
|
| 508 |
+
<section class="panel">
|
| 509 |
+
<div class="panel-title">API Access</div>
|
| 510 |
+
<pre>${escapeHtml(apiCurl)}</pre>
|
|
|
|
|
|
|
| 511 |
</section>
|
| 512 |
</main>
|
| 513 |
</body>
|
|
|
|
| 599 |
|
| 600 |
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 601 |
if (!isAuthorized(req)) {
|
| 602 |
+
if (wantsHtml(req)) {
|
| 603 |
+
redirect(res, loginUrl(`${path}${parsed.search}`));
|
| 604 |
+
return;
|
| 605 |
+
}
|
| 606 |
res.writeHead(401, {
|
| 607 |
"content-type": "application/json",
|
| 608 |
"cache-control": "no-store",
|