Spaces:
Running
Running
| // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to OpenClaw. | |
| const http = require("http"); | |
| const https = require("https"); | |
| const fs = require("fs"); | |
| const net = require("net"); | |
| const PORT = 7861; | |
| const GATEWAY_PORT = 7860; | |
| const GATEWAY_HOST = "127.0.0.1"; | |
| const startTime = Date.now(); | |
| const LLM_MODEL = process.env.LLM_MODEL || "Not Set"; | |
| const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN; | |
| const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || ""); | |
| const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json"; | |
| const HF_BACKUP_ENABLED = !!(process.env.HF_USERNAME && process.env.HF_TOKEN); | |
| const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "600"; | |
| const DASHBOARD_BASE = "/dashboard"; | |
| const DASHBOARD_STATUS_PATH = `${DASHBOARD_BASE}/status`; | |
| const DASHBOARD_HEALTH_PATH = `${DASHBOARD_BASE}/health`; | |
| const DASHBOARD_UPTIMEROBOT_PATH = `${DASHBOARD_BASE}/uptimerobot/setup`; | |
| const DASHBOARD_APP_BASE = `${DASHBOARD_BASE}/app`; | |
| const APP_BASE = "/app"; | |
| const SPACE_VISIBILITY_TTL_MS = 10 * 60 * 1000; | |
| const spaceVisibilityCache = new Map(); | |
| function parseRequestUrl(url) { | |
| try { | |
| return new URL(url, "http://localhost"); | |
| } catch { | |
| return new URL("http://localhost/"); | |
| } | |
| } | |
| function isDashboardRoute(pathname) { | |
| return ( | |
| pathname === "/" || | |
| pathname === DASHBOARD_BASE || | |
| pathname === `${DASHBOARD_BASE}/` | |
| ); | |
| } | |
| function isDashboardAppRoute(pathname) { | |
| return ( | |
| pathname === DASHBOARD_APP_BASE || | |
| pathname.startsWith(`${DASHBOARD_APP_BASE}/`) | |
| ); | |
| } | |
| function isAppRoute(pathname) { | |
| return pathname === APP_BASE || pathname.startsWith(`${APP_BASE}/`); | |
| } | |
| function isLocalRoute(pathname) { | |
| return ( | |
| pathname === "/health" || | |
| pathname === "/status" || | |
| pathname === "/uptimerobot/setup" || | |
| pathname === DASHBOARD_HEALTH_PATH || | |
| pathname === DASHBOARD_STATUS_PATH || | |
| pathname === DASHBOARD_UPTIMEROBOT_PATH || | |
| isDashboardRoute(pathname) | |
| ); | |
| } | |
| function mapAppProxyPath(path) { | |
| if (path === DASHBOARD_APP_BASE) return APP_BASE; | |
| if (path.startsWith(`${DASHBOARD_APP_BASE}/`)) { | |
| return `${APP_BASE}${path.slice(DASHBOARD_APP_BASE.length)}`; | |
| } | |
| if (path === APP_BASE || path.startsWith(`${APP_BASE}/`)) { | |
| return path; | |
| } | |
| return path; | |
| } | |
| function appendForwarded(existingValue, nextValue) { | |
| const cleanNext = nextValue || ""; | |
| if (!existingValue) return cleanNext; | |
| if (Array.isArray(existingValue)) | |
| return `${existingValue.join(", ")}, ${cleanNext}`; | |
| return `${existingValue}, ${cleanNext}`; | |
| } | |
| function buildProxyHeaders(headers, remoteAddress) { | |
| return { | |
| ...headers, | |
| host: headers.host || `${GATEWAY_HOST}:${GATEWAY_PORT}`, | |
| "x-forwarded-for": appendForwarded( | |
| headers["x-forwarded-for"], | |
| remoteAddress, | |
| ), | |
| "x-forwarded-host": headers["x-forwarded-host"] || headers.host || "", | |
| "x-forwarded-proto": headers["x-forwarded-proto"] || "https", | |
| }; | |
| } | |
| function readSyncStatus() { | |
| try { | |
| if (fs.existsSync("/tmp/sync-status.json")) { | |
| return JSON.parse(fs.readFileSync("/tmp/sync-status.json", "utf8")); | |
| } | |
| } catch {} | |
| if (HF_BACKUP_ENABLED) { | |
| return { | |
| status: "configured", | |
| message: `Backup is enabled. Waiting for the next sync window (${SYNC_INTERVAL}s).`, | |
| }; | |
| } | |
| return { status: "unknown", message: "No sync data yet" }; | |
| } | |
| function normalizeChannelStatus(channel, configured) { | |
| return { | |
| configured: configured || !!channel, | |
| connected: !!(channel && channel.connected), | |
| }; | |
| } | |
| function readGuardianStatus() { | |
| if (!WHATSAPP_ENABLED) { | |
| return { configured: false, connected: false, pairing: false }; | |
| } | |
| try { | |
| if (fs.existsSync(WHATSAPP_STATUS_FILE)) { | |
| const parsed = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8")); | |
| return { | |
| configured: parsed.configured !== false, | |
| connected: parsed.connected === true, | |
| pairing: parsed.pairing === true, | |
| }; | |
| } | |
| } catch {} | |
| return { configured: true, connected: false, pairing: false }; | |
| } | |
| function decodeJwtPayload(token) { | |
| try { | |
| const parts = String(token || "").split("."); | |
| if (parts.length < 2) return null; | |
| const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); | |
| const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); | |
| return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function getSpaceRef(parsedUrl) { | |
| const signedToken = parsedUrl.searchParams.get("__sign"); | |
| if (!signedToken) return null; | |
| const payload = decodeJwtPayload(signedToken); | |
| const subject = payload && payload.sub; | |
| const match = | |
| typeof subject === "string" | |
| ? subject.match(/^\/spaces\/([^/]+)\/([^/]+)$/) | |
| : null; | |
| if (!match) return null; | |
| return { owner: match[1], repo: match[2] }; | |
| } | |
| function fetchStatusCode(url) { | |
| return new Promise((resolve, reject) => { | |
| const req = https.get( | |
| url, | |
| { | |
| headers: { | |
| "user-agent": "HuggingClaw/1.0", | |
| accept: "application/json", | |
| }, | |
| }, | |
| (res) => { | |
| res.resume(); | |
| resolve(res.statusCode || 0); | |
| }, | |
| ); | |
| req.on("error", reject); | |
| req.setTimeout(5000, () => { | |
| req.destroy(new Error("Request timed out")); | |
| }); | |
| }); | |
| } | |
| async function resolveSpaceIsPrivate(parsedUrl) { | |
| const ref = getSpaceRef(parsedUrl); | |
| if (!ref) return false; | |
| const cacheKey = `${ref.owner}/${ref.repo}`; | |
| const cached = spaceVisibilityCache.get(cacheKey); | |
| if (cached && Date.now() - cached.timestamp < SPACE_VISIBILITY_TTL_MS) { | |
| return cached.isPrivate; | |
| } | |
| try { | |
| const statusCode = await fetchStatusCode( | |
| `https://huggingface.co/api/spaces/${ref.owner}/${ref.repo}`, | |
| ); | |
| const isPrivate = | |
| statusCode === 401 || statusCode === 403 || statusCode === 404; | |
| spaceVisibilityCache.set(cacheKey, { isPrivate, timestamp: Date.now() }); | |
| return isPrivate; | |
| } catch { | |
| if (cached) return cached.isPrivate; | |
| return false; | |
| } | |
| } | |
| function renderChannelBadge(channel, configuredLabel) { | |
| if (channel && channel.connected) { | |
| return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'; | |
| } | |
| if (channel && channel.configured) { | |
| return `<div class="status-badge status-syncing">${configuredLabel}</div>`; | |
| } | |
| return '<div class="status-badge status-offline">Disabled</div>'; | |
| } | |
| function renderSyncBadge(syncData) { | |
| let badgeClass = "status-offline"; | |
| let pulseHtml = ""; | |
| if (syncData.status === "success" || syncData.status === "configured") { | |
| badgeClass = "status-online"; | |
| pulseHtml = '<div class="pulse"></div>'; | |
| } else if (syncData.status === "syncing") { | |
| badgeClass = "status-syncing"; | |
| pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>'; | |
| } | |
| return `<div class="status-badge ${badgeClass}">${pulseHtml}${String(syncData.status || "unknown").toUpperCase()}</div>`; | |
| } | |
| function renderDashboard(initialData) { | |
| const controlUiHref = `${APP_BASE}/`; | |
| const keepAwakeHtml = initialData.spacePrivate | |
| ? ` | |
| <div id="uptimerobot-private-note" class="helper-summary"> | |
| <strong>This Space is private.</strong> External monitors cannot reliably access private HF health URLs, so keep-awake setup is only available on public Spaces. | |
| </div> | |
| ` | |
| : ` | |
| <div id="uptimerobot-public-flow"> | |
| <div id="uptimerobot-summary" class="helper-summary"> | |
| One-time setup for public Spaces. Paste your UptimeRobot <strong>Main API key</strong> to create the monitor. | |
| </div> | |
| <button id="uptimerobot-toggle" class="helper-toggle" type="button"> | |
| Set Up Monitor | |
| </button> | |
| <div id="uptimerobot-shell" class="helper-shell hidden"> | |
| <div class="helper-copy"> | |
| Do <strong>not</strong> use the Read-only API key or a Monitor-specific API key. | |
| </div> | |
| <div class="helper-row"> | |
| <input | |
| id="uptimerobot-key" | |
| class="helper-input" | |
| type="password" | |
| placeholder="Paste your UptimeRobot Main API key" | |
| autocomplete="off" | |
| /> | |
| <button id="uptimerobot-btn" class="helper-button" type="button"> | |
| Create Monitor | |
| </button> | |
| </div> | |
| <div class="helper-note"> | |
| One-time setup. Your key is only used to create the monitor for this Space. | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| return ` | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>HuggingClaw Dashboard</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0f172a; | |
| --card-bg: rgba(30, 41, 59, 0.7); | |
| --accent: linear-gradient(135deg, #3b82f6, #8b5cf6); | |
| --text: #f8fafc; | |
| --text-dim: #94a3b8; | |
| --success: #10b981; | |
| --error: #ef4444; | |
| --warning: #f59e0b; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: 'Outfit', sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| display: flex; | |
| justify-content: center; | |
| align-items: flex-start; | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| overflow-y: auto; | |
| padding: 24px 0; | |
| background-image: | |
| radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%), | |
| radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%); | |
| } | |
| .dashboard { | |
| width: 90%; | |
| max-width: 600px; | |
| background: var(--card-bg); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 24px; | |
| padding: 40px; | |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); | |
| animation: fadeIn 0.8s ease-out; | |
| margin: 24px 0; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| header { | |
| text-align: center; | |
| margin-bottom: 40px; | |
| } | |
| h1 { | |
| font-size: 2.5rem; | |
| margin-bottom: 8px; | |
| background: var(--accent); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| font-weight: 600; | |
| } | |
| .subtitle { | |
| color: var(--text-dim); | |
| font-size: 0.9rem; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 20px; | |
| margin-bottom: 30px; | |
| } | |
| .stat-card { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| padding: 20px; | |
| border-radius: 16px; | |
| transition: transform 0.3s ease, border-color 0.3s ease; | |
| } | |
| .stat-card:hover { | |
| transform: translateY(-5px); | |
| border-color: rgba(59, 130, 246, 0.3); | |
| } | |
| .stat-label { | |
| color: var(--text-dim); | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| margin-bottom: 8px; | |
| display: block; | |
| } | |
| .stat-value { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| word-break: break-all; | |
| } | |
| .stat-btn { | |
| grid-column: span 2; | |
| background: var(--accent); | |
| color: #fff; | |
| padding: 16px; | |
| border-radius: 16px; | |
| text-align: center; | |
| text-decoration: none; | |
| font-weight: 600; | |
| margin-top: 10px; | |
| transition: transform 0.3s ease, box-shadow 0.3s ease; | |
| box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.4); | |
| } | |
| .stat-btn:hover { | |
| transform: scale(1.02); | |
| box-shadow: 0 15px 30px -5px rgba(59, 130, 246, 0.6); | |
| } | |
| .status-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| } | |
| .status-online { background: rgba(16, 185, 129, 0.1); color: var(--success); } | |
| .status-offline { background: rgba(239, 68, 68, 0.1); color: var(--error); } | |
| .status-syncing { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } | |
| .pulse { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); } | |
| 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); } | |
| 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } | |
| } | |
| .footer { | |
| text-align: center; | |
| color: var(--text-dim); | |
| font-size: 0.8rem; | |
| margin-top: 20px; | |
| } | |
| .sync-info { | |
| background: rgba(255, 255, 255, 0.02); | |
| padding: 15px; | |
| border-radius: 12px; | |
| font-size: 0.85rem; | |
| color: var(--text-dim); | |
| margin-top: 10px; | |
| } | |
| #sync-msg { color: var(--text); display: block; margin-top: 4px; } | |
| .card-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 8px; | |
| } | |
| .card-header .stat-label { | |
| margin-bottom: 0; | |
| } | |
| .helper-card { | |
| width: 100%; | |
| margin-top: 20px; | |
| } | |
| .helper-copy { | |
| color: var(--text-dim); | |
| font-size: 0.92rem; | |
| line-height: 1.6; | |
| margin-top: 10px; | |
| } | |
| .helper-copy strong { | |
| color: var(--text); | |
| } | |
| .helper-row { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .helper-input { | |
| flex: 1; | |
| min-width: 240px; | |
| background: rgba(255, 255, 255, 0.04); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| color: var(--text); | |
| border-radius: 12px; | |
| padding: 14px 16px; | |
| font: inherit; | |
| } | |
| .helper-input::placeholder { | |
| color: var(--text-dim); | |
| } | |
| .helper-button { | |
| background: var(--accent); | |
| color: #fff; | |
| border: 0; | |
| border-radius: 12px; | |
| padding: 14px 18px; | |
| font: inherit; | |
| font-weight: 600; | |
| cursor: pointer; | |
| min-width: 180px; | |
| } | |
| .helper-button:disabled { | |
| opacity: 0.6; | |
| cursor: wait; | |
| } | |
| .hidden { | |
| display: none !important; | |
| } | |
| .helper-note { | |
| margin-top: 10px; | |
| font-size: 0.82rem; | |
| color: var(--text-dim); | |
| } | |
| .helper-result { | |
| margin-top: 14px; | |
| padding: 12px 14px; | |
| border-radius: 12px; | |
| font-size: 0.9rem; | |
| display: none; | |
| } | |
| .helper-result.ok { | |
| display: block; | |
| background: rgba(16, 185, 129, 0.1); | |
| color: var(--success); | |
| } | |
| .helper-result.error { | |
| display: block; | |
| background: rgba(239, 68, 68, 0.1); | |
| color: var(--error); | |
| } | |
| .helper-shell { | |
| margin-top: 12px; | |
| } | |
| .helper-shell.hidden { | |
| display: none; | |
| } | |
| .helper-summary { | |
| margin-top: 14px; | |
| padding: 12px 14px; | |
| border-radius: 12px; | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--text-dim); | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| } | |
| .helper-summary strong { | |
| color: var(--text); | |
| } | |
| .helper-summary.success { | |
| background: rgba(16, 185, 129, 0.08); | |
| } | |
| .helper-toggle { | |
| margin-top: 14px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--text); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| border-radius: 12px; | |
| padding: 12px 16px; | |
| font: inherit; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| @media (max-width: 700px) { | |
| body { | |
| padding: 16px 0; | |
| } | |
| .dashboard { | |
| width: calc(100% - 24px); | |
| padding: 24px; | |
| border-radius: 18px; | |
| margin: 12px 0; | |
| } | |
| header { | |
| margin-bottom: 28px; | |
| } | |
| h1 { | |
| font-size: 2rem; | |
| } | |
| .stats-grid { | |
| grid-template-columns: 1fr; | |
| gap: 14px; | |
| margin-bottom: 20px; | |
| } | |
| .stat-btn { | |
| grid-column: span 1; | |
| } | |
| .stat-card { | |
| padding: 16px; | |
| } | |
| .card-header { | |
| align-items: flex-start; | |
| flex-direction: column; | |
| } | |
| .helper-row { | |
| flex-direction: column; | |
| } | |
| .helper-input, | |
| .helper-button { | |
| width: 100%; | |
| min-width: 0; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="dashboard"> | |
| <header> | |
| <h1>🦞 HuggingClaw</h1> | |
| <p class="subtitle">Space Dashboard</p> | |
| </header> | |
| <div class="stats-grid"> | |
| <div class="stat-card"> | |
| <span class="stat-label">Model</span> | |
| <span class="stat-value" id="model-id">${initialData.model}</span> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-label">Uptime</span> | |
| <span class="stat-value" id="uptime">${initialData.uptime}</span> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-label">WhatsApp</span> | |
| <span id="wa-status">${renderChannelBadge(initialData.whatsapp, "Ready to pair")}</span> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-label">Telegram</span> | |
| <span id="tg-status">${renderChannelBadge(initialData.telegram, "Configured")}</span> | |
| </div> | |
| <a href="${controlUiHref}" id="control-ui-link" class="stat-btn">Open Control UI</a> | |
| </div> | |
| <div class="stat-card" style="width: 100%;"> | |
| <div class="card-header"> | |
| <span class="stat-label">Workspace Sync Status</span> | |
| <div id="sync-badge-container">${renderSyncBadge(initialData.sync)}</div> | |
| </div> | |
| <div class="sync-info"> | |
| Last Sync Activity: <span id="sync-time">${initialData.sync.timestamp || "Never"}</span> | |
| <span id="sync-msg">${initialData.sync.message || "Waiting for first sync..."}</span> | |
| </div> | |
| </div> | |
| <div class="stat-card helper-card"> | |
| <span class="stat-label">Keep Space Awake</span> | |
| ${keepAwakeHtml} | |
| <div id="uptimerobot-result" class="helper-result"></div> | |
| </div> | |
| <div class="footer"> | |
| Live updates every 10s | |
| </div> | |
| </div> | |
| <script> | |
| function getDashboardBase() { | |
| const pathname = window.location.pathname || '/'; | |
| if (pathname === '/' || pathname === '') return ''; | |
| if (pathname === '${DASHBOARD_BASE}' || pathname === '${DASHBOARD_BASE}/') return '${DASHBOARD_BASE}'; | |
| return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; | |
| } | |
| function getCurrentSearch() { | |
| return window.location.search || ''; | |
| } | |
| async function updateStats() { | |
| try { | |
| const res = await fetch(getDashboardBase() + '/status' + getCurrentSearch()); | |
| const data = await res.json(); | |
| document.getElementById('model-id').textContent = data.model; | |
| document.getElementById('uptime').textContent = data.uptime; | |
| function renderChannelStatus(channel, configuredLabel) { | |
| if (channel && channel.connected) { | |
| return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'; | |
| } | |
| if (channel && channel.configured) { | |
| return '<div class="status-badge status-syncing">' + configuredLabel + '</div>'; | |
| } | |
| return '<div class="status-badge status-offline">Disabled</div>'; | |
| } | |
| document.getElementById('wa-status').innerHTML = renderChannelStatus(data.whatsapp, 'Ready to pair'); | |
| document.getElementById('tg-status').innerHTML = renderChannelStatus(data.telegram, 'Configured'); | |
| const syncData = data.sync; | |
| let badgeClass = 'status-offline'; | |
| let pulseHtml = ''; | |
| if (syncData.status === 'success' || syncData.status === 'configured') { | |
| badgeClass = 'status-online'; | |
| pulseHtml = '<div class="pulse"></div>'; | |
| } else if (syncData.status === 'syncing') { | |
| badgeClass = 'status-syncing'; | |
| pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>'; | |
| } | |
| document.getElementById('sync-badge-container').innerHTML = | |
| '<div class="status-badge ' + badgeClass + '">' + pulseHtml + syncData.status.toUpperCase() + '</div>'; | |
| document.getElementById('sync-time').textContent = syncData.timestamp || 'Never'; | |
| document.getElementById('sync-msg').textContent = syncData.message || 'Waiting for first sync...'; | |
| } catch (e) { | |
| console.error("Failed to fetch status", e); | |
| } | |
| } | |
| const monitorStateKey = 'huggingclaw_uptimerobot_setup_v1'; | |
| const KEEP_AWAKE_PRIVATE = ${initialData.spacePrivate ? "true" : "false"}; | |
| function setMonitorUiState(isConfigured) { | |
| const summary = document.getElementById('uptimerobot-summary'); | |
| const shell = document.getElementById('uptimerobot-shell'); | |
| const toggle = document.getElementById('uptimerobot-toggle'); | |
| if (!summary || !shell || !toggle) { | |
| return; | |
| } | |
| if (isConfigured) { | |
| summary.classList.add('success'); | |
| summary.innerHTML = '<strong>Already set up.</strong> Your UptimeRobot monitor should keep this public Space awake.'; | |
| shell.classList.add('hidden'); | |
| toggle.textContent = 'Set Up Again'; | |
| } else { | |
| summary.classList.remove('success'); | |
| summary.innerHTML = 'One-time setup for public Spaces. Paste your UptimeRobot <strong>Main API key</strong> to create the monitor.'; | |
| toggle.textContent = 'Set Up Monitor'; | |
| } | |
| } | |
| function restoreMonitorUiState() { | |
| try { | |
| const value = window.localStorage.getItem(monitorStateKey); | |
| setMonitorUiState(value === 'done'); | |
| } catch { | |
| setMonitorUiState(false); | |
| } | |
| } | |
| function toggleMonitorSetup() { | |
| const shell = document.getElementById('uptimerobot-shell'); | |
| shell.classList.toggle('hidden'); | |
| } | |
| async function setupUptimeRobot() { | |
| const input = document.getElementById('uptimerobot-key'); | |
| const button = document.getElementById('uptimerobot-btn'); | |
| const result = document.getElementById('uptimerobot-result'); | |
| const apiKey = input.value.trim(); | |
| if (!apiKey) { | |
| result.className = 'helper-result error'; | |
| result.textContent = 'Paste your UptimeRobot Main API key first.'; | |
| return; | |
| } | |
| button.disabled = true; | |
| button.textContent = 'Creating...'; | |
| result.className = 'helper-result'; | |
| result.textContent = ''; | |
| try { | |
| const res = await fetch(getDashboardBase() + '/uptimerobot/setup' + getCurrentSearch(), { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ apiKey }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.message || 'Failed to create monitor.'); | |
| } | |
| result.className = 'helper-result ok'; | |
| result.textContent = data.message || 'UptimeRobot monitor is ready.'; | |
| input.value = ''; | |
| try { | |
| window.localStorage.setItem(monitorStateKey, 'done'); | |
| } catch {} | |
| setMonitorUiState(true); | |
| document.getElementById('uptimerobot-shell').classList.add('hidden'); | |
| } catch (error) { | |
| result.className = 'helper-result error'; | |
| result.textContent = error.message || 'Failed to create monitor.'; | |
| } finally { | |
| button.disabled = false; | |
| button.textContent = 'Create Monitor'; | |
| } | |
| } | |
| updateStats(); | |
| setInterval(updateStats, 10000); | |
| document.getElementById('control-ui-link').setAttribute('href', getDashboardBase() + '/app/' + getCurrentSearch()); | |
| if (!KEEP_AWAKE_PRIVATE) { | |
| restoreMonitorUiState(); | |
| document.getElementById('uptimerobot-btn').addEventListener('click', setupUptimeRobot); | |
| document.getElementById('uptimerobot-toggle').addEventListener('click', toggleMonitorSetup); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| `; | |
| } | |
| function readRequestBody(req) { | |
| return new Promise((resolve, reject) => { | |
| let body = ""; | |
| req.on("data", (chunk) => { | |
| body += chunk; | |
| if (body.length > 1024 * 64) { | |
| reject(new Error("Request too large")); | |
| req.destroy(); | |
| } | |
| }); | |
| req.on("end", () => resolve(body)); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function postUptimeRobot(path, form) { | |
| const body = new URLSearchParams(form).toString(); | |
| return new Promise((resolve, reject) => { | |
| const request = https.request( | |
| { | |
| hostname: "api.uptimerobot.com", | |
| port: 443, | |
| method: "POST", | |
| path, | |
| headers: { | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| "Content-Length": Buffer.byteLength(body), | |
| }, | |
| }, | |
| (response) => { | |
| let raw = ""; | |
| response.setEncoding("utf8"); | |
| response.on("data", (chunk) => { | |
| raw += chunk; | |
| }); | |
| response.on("end", () => { | |
| try { | |
| resolve(JSON.parse(raw)); | |
| } catch { | |
| reject(new Error("Unexpected response from UptimeRobot")); | |
| } | |
| }); | |
| }, | |
| ); | |
| request.on("error", reject); | |
| request.write(body); | |
| request.end(); | |
| }); | |
| } | |
| async function createUptimeRobotMonitor(apiKey, host) { | |
| const cleanHost = String(host || "") | |
| .replace(/^https?:\/\//, "") | |
| .replace(/\/.*$/, ""); | |
| if (!cleanHost) { | |
| throw new Error("Missing Space host."); | |
| } | |
| const monitorUrl = `https://${cleanHost}/health`; | |
| const existing = await postUptimeRobot("/v2/getMonitors", { | |
| api_key: apiKey, | |
| format: "json", | |
| logs: "0", | |
| response_times: "0", | |
| response_times_limit: "1", | |
| }); | |
| const existingMonitor = Array.isArray(existing.monitors) | |
| ? existing.monitors.find((monitor) => monitor.url === monitorUrl) | |
| : null; | |
| if (existingMonitor) { | |
| return { | |
| created: false, | |
| message: `Monitor already exists for ${monitorUrl}`, | |
| }; | |
| } | |
| const created = await postUptimeRobot("/v2/newMonitor", { | |
| api_key: apiKey, | |
| format: "json", | |
| type: "1", | |
| friendly_name: `HuggingClaw ${cleanHost}`, | |
| url: monitorUrl, | |
| interval: "300", | |
| }); | |
| if (created.stat !== "ok") { | |
| const message = | |
| created?.error?.message || | |
| created?.message || | |
| "Failed to create UptimeRobot monitor."; | |
| throw new Error(message); | |
| } | |
| return { | |
| created: true, | |
| message: `Monitor created for ${monitorUrl}`, | |
| }; | |
| } | |
| function proxyHttp(req, res, proxyPath = req.url, proxyPort = GATEWAY_PORT) { | |
| const proxyReq = http.request( | |
| { | |
| hostname: GATEWAY_HOST, | |
| port: proxyPort, | |
| method: req.method, | |
| path: proxyPath, | |
| headers: buildProxyHeaders(req.headers, req.socket.remoteAddress), | |
| }, | |
| (proxyRes) => { | |
| res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); | |
| proxyRes.pipe(res); | |
| }, | |
| ); | |
| proxyReq.on("error", (error) => { | |
| res.writeHead(502, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| status: "error", | |
| message: "Gateway unavailable", | |
| detail: error.message, | |
| }), | |
| ); | |
| }); | |
| req.pipe(proxyReq); | |
| } | |
| function serializeUpgradeHeaders(req, remoteAddress) { | |
| const forwardedHeaders = []; | |
| for (let i = 0; i < req.rawHeaders.length; i += 2) { | |
| const name = req.rawHeaders[i]; | |
| const value = req.rawHeaders[i + 1]; | |
| const lower = name.toLowerCase(); | |
| if ( | |
| lower === "x-forwarded-for" || | |
| lower === "x-forwarded-host" || | |
| lower === "x-forwarded-proto" | |
| ) { | |
| continue; | |
| } | |
| forwardedHeaders.push(`${name}: ${value}`); | |
| } | |
| forwardedHeaders.push( | |
| `X-Forwarded-For: ${appendForwarded(req.headers["x-forwarded-for"], remoteAddress)}`, | |
| ); | |
| forwardedHeaders.push( | |
| `X-Forwarded-Host: ${req.headers["x-forwarded-host"] || req.headers.host || ""}`, | |
| ); | |
| forwardedHeaders.push( | |
| `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`, | |
| ); | |
| return forwardedHeaders; | |
| } | |
| function proxyUpgrade( | |
| req, | |
| socket, | |
| head, | |
| proxyPath = req.url, | |
| proxyPort = GATEWAY_PORT, | |
| ) { | |
| const proxySocket = net.connect(proxyPort, GATEWAY_HOST); | |
| proxySocket.on("connect", () => { | |
| const requestLines = [ | |
| `${req.method} ${proxyPath} HTTP/${req.httpVersion}`, | |
| ...serializeUpgradeHeaders(req, req.socket.remoteAddress), | |
| "", | |
| "", | |
| ]; | |
| proxySocket.write(requestLines.join("\r\n")); | |
| if (head && head.length > 0) { | |
| proxySocket.write(head); | |
| } | |
| socket.pipe(proxySocket).pipe(socket); | |
| }); | |
| proxySocket.on("error", () => { | |
| if (socket.writable) { | |
| socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); | |
| } | |
| socket.destroy(); | |
| }); | |
| socket.on("error", () => { | |
| proxySocket.destroy(); | |
| }); | |
| } | |
| const server = http.createServer((req, res) => { | |
| const parsedUrl = parseRequestUrl(req.url || "/"); | |
| const pathname = parsedUrl.pathname; | |
| const uptime = Math.floor((Date.now() - startTime) / 1000); | |
| const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`; | |
| if (pathname === "/health" || pathname === DASHBOARD_HEALTH_PATH) { | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| status: "ok", | |
| uptime, | |
| uptimeHuman, | |
| timestamp: new Date().toISOString(), | |
| }), | |
| ); | |
| return; | |
| } | |
| if (pathname === "/status" || pathname === DASHBOARD_STATUS_PATH) { | |
| void (async () => { | |
| const guardianStatus = readGuardianStatus(); | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| model: LLM_MODEL, | |
| whatsapp: { | |
| configured: guardianStatus.configured, | |
| connected: guardianStatus.connected, | |
| pairing: guardianStatus.pairing, | |
| }, | |
| telegram: normalizeChannelStatus(null, TELEGRAM_ENABLED), | |
| sync: readSyncStatus(), | |
| uptime: uptimeHuman, | |
| }), | |
| ); | |
| })(); | |
| return; | |
| } | |
| if ( | |
| pathname === "/uptimerobot/setup" || | |
| pathname === DASHBOARD_UPTIMEROBOT_PATH | |
| ) { | |
| if (req.method !== "POST") { | |
| res.writeHead(405, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ message: "Method not allowed" })); | |
| return; | |
| } | |
| void (async () => { | |
| try { | |
| const body = await readRequestBody(req); | |
| const parsed = JSON.parse(body || "{}"); | |
| const apiKey = String(parsed.apiKey || "").trim(); | |
| if (!apiKey) { | |
| res.writeHead(400, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| message: "Paste your UptimeRobot Main API key first.", | |
| }), | |
| ); | |
| return; | |
| } | |
| const result = await createUptimeRobotMonitor(apiKey, req.headers.host); | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify(result)); | |
| } catch (error) { | |
| res.writeHead(400, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| message: | |
| error && error.message | |
| ? error.message | |
| : "Failed to create UptimeRobot monitor.", | |
| }), | |
| ); | |
| } | |
| })(); | |
| return; | |
| } | |
| if (isDashboardRoute(pathname)) { | |
| void (async () => { | |
| const guardianStatus = readGuardianStatus(); | |
| const initialData = { | |
| model: LLM_MODEL, | |
| whatsapp: { | |
| configured: guardianStatus.configured, | |
| connected: guardianStatus.connected, | |
| pairing: guardianStatus.pairing, | |
| }, | |
| telegram: normalizeChannelStatus(null, TELEGRAM_ENABLED), | |
| sync: readSyncStatus(), | |
| uptime: uptimeHuman, | |
| spacePrivate: await resolveSpaceIsPrivate(parsedUrl), | |
| }; | |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | |
| res.end(renderDashboard(initialData)); | |
| })(); | |
| return; | |
| } | |
| if (isDashboardAppRoute(pathname) || isAppRoute(pathname)) { | |
| const proxyPath = mapAppProxyPath(pathname) + (parsedUrl.search || ""); | |
| proxyHttp(req, res, proxyPath, GATEWAY_PORT); | |
| return; | |
| } | |
| proxyHttp(req, res); | |
| }); | |
| server.on("upgrade", (req, socket, head) => { | |
| const pathname = parseRequestUrl(req.url || "/").pathname; | |
| if (isLocalRoute(pathname)) { | |
| socket.destroy(); | |
| return; | |
| } | |
| if (isDashboardAppRoute(pathname) || isAppRoute(pathname)) { | |
| const parsedUrl = parseRequestUrl(req.url || "/"); | |
| const proxyPath = mapAppProxyPath(pathname) + (parsedUrl.search || ""); | |
| proxyUpgrade(req, socket, head, proxyPath, GATEWAY_PORT); | |
| return; | |
| } | |
| proxyUpgrade(req, socket, head); | |
| }); | |
| server.listen(PORT, "0.0.0.0", () => { | |
| console.log( | |
| `Health server listening on port ${PORT}; proxying gateway traffic to ${GATEWAY_HOST}:${GATEWAY_PORT}`, | |
| ); | |
| }); | |