somratpro commited on
Commit
4b5ca71
·
1 Parent(s): 6b586fa

replace UptimeRobot keep-alive implementation with a Cloudflare Workers cron-triggered proxy system

Browse files
Files changed (7) hide show
  1. .env.example +3 -3
  2. Dockerfile +2 -2
  3. README.md +7 -5
  4. cloudflare-keepalive-setup.py +212 -0
  5. health-server.js +259 -551
  6. setup-uptimerobot.sh +0 -91
  7. start.sh +3 -3
.env.example CHANGED
@@ -109,9 +109,9 @@ BETTER_AUTH_SECRET=your-random-secret-here-minimum-32-characters
109
  # Monitoring & Uptime
110
  # ============================================================================
111
 
112
- # UptimeRobot keep-alive: add your Main API key (NOT Read-only or Monitor-specific).
113
- # Monitor is created automatically at boot. Status shown on the dashboard.
114
- # UPTIMEROBOT_API_KEY=ur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
115
 
116
  # Optional: Webhook URL for restart/failure alerts
117
  # WEBHOOK_URL=https://uptime-robot-webhook-url
 
109
  # Monitoring & Uptime
110
  # ============================================================================
111
 
112
+ # Cloudflare proxy & keep-alive: add your Cloudflare API token.
113
+ # A Worker is created automatically at boot for both outbound proxying and keep-alive.
114
+ # CLOUDFLARE_WORKERS_TOKEN=your_cloudflare_token_here
115
 
116
  # Optional: Webhook URL for restart/failure alerts
117
  # WEBHOOK_URL=https://uptime-robot-webhook-url
Dockerfile CHANGED
@@ -127,9 +127,9 @@ COPY paperclip-sync.py /app/
127
  COPY cloudflare-proxy.js /app/
128
  COPY cloudflare-proxy-setup.py /app/
129
  COPY cloudflare-worker.js /app/
130
- COPY setup-uptimerobot.sh /app/
131
 
132
- RUN chmod +x /app/start.sh /app/setup-uptimerobot.sh
133
 
134
  # Create non-root user for running Paperclip + agent CLIs
135
  # Claude Code refuses --dangerously-skip-permissions when running as root
 
127
  COPY cloudflare-proxy.js /app/
128
  COPY cloudflare-proxy-setup.py /app/
129
  COPY cloudflare-worker.js /app/
130
+ COPY cloudflare-keepalive-setup.py /app/
131
 
132
+ RUN chmod +x /app/start.sh /app/cloudflare-keepalive-setup.py
133
 
134
  # Create non-root user for running Paperclip + agent CLIs
135
  # Claude Code refuses --dangerously-skip-permissions when running as root
README.md CHANGED
@@ -16,8 +16,8 @@ secrets:
16
  description: Google Gemini API key for Gemini-powered agents.
17
  - name: OPENAI_API_KEY
18
  description: OpenAI API key for GPT-powered agents.
19
- - name: UPTIMEROBOT_API_KEY
20
- description: UptimeRobot API key for automatic monitor setup.
21
  ---
22
 
23
  <!-- Badges -->
@@ -50,7 +50,7 @@ secrets:
50
  - ⚡ **One-click deploy:** Duplicate the Space and add your API key — nothing else needed to get started.
51
  - 💾 **Persistent Database:** PostgreSQL database auto-backed up to a private HF Dataset and restored on every restart — no data loss.
52
  - 📊 **Visual Dashboard:** Real-time status dashboard at `/` with Paperclip service health, backup status, and uptime.
53
- - ⏰ **Keep-Alive:** Add `UPTIMEROBOT_API_KEY` as a Space secret and the monitor is created automatically at boot — no manual setup.
54
  - 🌐 **Cloudflare Proxy:** Auto-provisions a Cloudflare Worker proxy for blocked outbound connections.
55
  - 🔒 **Secure by Default:** Auth secrets randomly generated on first boot and persisted across restarts.
56
  - 🏠 **100% HF-Native:** Runs entirely on Hugging Face's free infrastructure.
@@ -110,6 +110,7 @@ No secrets are strictly required to start, but you need at least one LLM key to
110
  | `BETTER_AUTH_SECRET` | auto-generated | Auth secret (auto-persisted on first boot) |
111
  | `PAPERCLIP_AGENT_JWT_SECRET` | auto-generated | Agent JWT secret (auto-persisted on first boot) |
112
  | `SYNC_MAX_FILE_BYTES` | `52428800` | Max backup size in bytes (50MB default) |
 
113
 
114
  ## 🤖 LLM Providers
115
 
@@ -162,7 +163,7 @@ HuggingClip automatically backs up your Paperclip PostgreSQL database to a priva
162
 
163
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
164
 
165
- Add your [UptimeRobot](https://uptimerobot.com/) **Main API key** (not the Read-only or Monitor-specific key) as a Space secret named `UPTIMEROBOT_API_KEY`. HuggingClip will automatically create a monitor for `https://your-space.hf.space/health` at boot. The dashboard shows the current status (configured, setting up, or failed).
166
 
167
  ## 💻 Local Development
168
 
@@ -241,7 +242,7 @@ Verify `HF_TOKEN` is set and has write access. Check the dashboard backup status
241
  `HF_TOKEN` is not set. Add it and the next restart will restore from backup. The backup also needs to have been run at least once before the restart.
242
 
243
  **Space keeps sleeping**
244
- Add `UPTIMEROBOT_API_KEY` as a Space secret to enable automatic keep-awake monitoring.
245
 
246
  **Paperclip unreachable (502 errors)**
247
  Wait 60–90s after boot for Paperclip to initialize. If it stays unreachable, check logs for PostgreSQL connection errors or memory issues.
@@ -258,6 +259,7 @@ Similar projects by [@somratpro](https://github.com/somratpro) — all free, one
258
 
259
  | Project | What it runs | HF Space | GitHub |
260
  | :--- | :--- | :--- | :--- |
 
261
  | **HuggingClaw** | OpenClaw — Claude Code in the browser | [Space](https://huggingface.co/spaces/somratpro/HuggingClaw) | [Repo](https://github.com/somratpro/huggingclaw) |
262
  | **Hugging8n** | n8n — workflow & automation platform | [Space](https://huggingface.co/spaces/somratpro/Hugging8n) | [Repo](https://github.com/somratpro/hugging8n) |
263
 
 
16
  description: Google Gemini API key for Gemini-powered agents.
17
  - name: OPENAI_API_KEY
18
  description: OpenAI API key for GPT-powered agents.
19
+ - name: CLOUDFLARE_WORKERS_TOKEN
20
+ description: "Cloudflare API token auto-creates a Worker proxy and KeepAlive monitor."
21
  ---
22
 
23
  <!-- Badges -->
 
50
  - ⚡ **One-click deploy:** Duplicate the Space and add your API key — nothing else needed to get started.
51
  - 💾 **Persistent Database:** PostgreSQL database auto-backed up to a private HF Dataset and restored on every restart — no data loss.
52
  - 📊 **Visual Dashboard:** Real-time status dashboard at `/` with Paperclip service health, backup status, and uptime.
53
+ - ⏰ **Keep-Alive:** Uses `CLOUDFLARE_WORKERS_TOKEN` to automatically set up a cron-triggered keep-awake worker at boot.
54
  - 🌐 **Cloudflare Proxy:** Auto-provisions a Cloudflare Worker proxy for blocked outbound connections.
55
  - 🔒 **Secure by Default:** Auth secrets randomly generated on first boot and persisted across restarts.
56
  - 🏠 **100% HF-Native:** Runs entirely on Hugging Face's free infrastructure.
 
110
  | `BETTER_AUTH_SECRET` | auto-generated | Auth secret (auto-persisted on first boot) |
111
  | `PAPERCLIP_AGENT_JWT_SECRET` | auto-generated | Agent JWT secret (auto-persisted on first boot) |
112
  | `SYNC_MAX_FILE_BYTES` | `52428800` | Max backup size in bytes (50MB default) |
113
+ | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set to `false` to disable the automatic Cloudflare KeepAlive worker |
114
 
115
  ## 🤖 LLM Providers
116
 
 
163
 
164
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
165
 
166
+ Your Space will automatically be kept awake by a background Cloudflare Worker when you configure the `CLOUDFLARE_WORKERS_TOKEN` secret. The worker uses a cron trigger to regularly ping your Space's `/health` endpoint. The dashboard displays the current keep-alive worker status.
167
 
168
  ## 💻 Local Development
169
 
 
242
  `HF_TOKEN` is not set. Add it and the next restart will restore from backup. The backup also needs to have been run at least once before the restart.
243
 
244
  **Space keeps sleeping**
245
+ Add `CLOUDFLARE_WORKERS_TOKEN` as a Space secret to enable automatic keep-awake monitoring via Cloudflare Workers.
246
 
247
  **Paperclip unreachable (502 errors)**
248
  Wait 60–90s after boot for Paperclip to initialize. If it stays unreachable, check logs for PostgreSQL connection errors or memory issues.
 
259
 
260
  | Project | What it runs | HF Space | GitHub |
261
  | :--- | :--- | :--- | :--- |
262
+ | **HuggingMess** | Hermes — Self-hosted agent gateway | [Space](https://huggingface.co/spaces/somratpro/HuggingMess) | [Repo](https://github.com/somratpro/huggingmess) |
263
  | **HuggingClaw** | OpenClaw — Claude Code in the browser | [Space](https://huggingface.co/spaces/somratpro/HuggingClaw) | [Repo](https://github.com/somratpro/huggingclaw) |
264
  | **Hugging8n** | n8n — workflow & automation platform | [Space](https://huggingface.co/spaces/somratpro/Hugging8n) | [Repo](https://github.com/somratpro/hugging8n) |
265
 
cloudflare-keepalive-setup.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse a Cloudflare Worker for Space keep-awake."""
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ API_BASE = "https://api.cloudflare.com/client/v4"
15
+ KEEPALIVE_STATUS_FILE = Path("/tmp/huggingclip-cloudflare-keepalive-status.json")
16
+
17
+
18
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
19
+ req = urllib.request.Request(
20
+ f"{API_BASE}{path}",
21
+ data=body,
22
+ method=method,
23
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
24
+ )
25
+ with urllib.request.urlopen(req, timeout=30) as response:
26
+ payload = json.loads(response.read().decode("utf-8"))
27
+ if not payload.get("success"):
28
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
29
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
30
+ return payload["result"]
31
+
32
+
33
+ def slugify(value: str) -> str:
34
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
35
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
36
+ return (cleaned or "huggingclip-proxy")[:63].rstrip("-")
37
+
38
+
39
+ def get_space_host() -> str:
40
+ space_host = os.environ.get("SPACE_HOST", "").strip()
41
+ if space_host:
42
+ return space_host
43
+
44
+ author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
45
+ repo = os.environ.get("SPACE_REPO_NAME", "").strip()
46
+ if author and repo:
47
+ return f"{author}-{repo}.hf.space".lower()
48
+
49
+ return ""
50
+
51
+
52
+ def derive_keepalive_worker_name() -> str:
53
+ explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
54
+ if explicit:
55
+ return slugify(explicit)
56
+ space_host = get_space_host()
57
+ if space_host:
58
+ return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
59
+ return "huggingclip-keepalive"
60
+
61
+
62
+ def render_keepalive_worker(target_url: str) -> str:
63
+ return f"""addEventListener("fetch", (event) => {{
64
+ event.respondWith(handleRequest(event.request));
65
+ }});
66
+
67
+ addEventListener("scheduled", (event) => {{
68
+ event.waitUntil(ping("cron"));
69
+ }});
70
+
71
+ const TARGET_URL = {json.dumps(target_url)};
72
+
73
+ async function ping(source) {{
74
+ const startedAt = new Date().toISOString();
75
+ try {{
76
+ const response = await fetch(TARGET_URL, {{
77
+ method: "GET",
78
+ headers: {{
79
+ "user-agent": "HuggingClip Cloudflare KeepAlive",
80
+ "cache-control": "no-cache"
81
+ }},
82
+ cf: {{ cacheTtl: 0, cacheEverything: false }}
83
+ }});
84
+ return {{
85
+ ok: response.ok,
86
+ status: response.status,
87
+ source,
88
+ target: TARGET_URL,
89
+ timestamp: startedAt
90
+ }};
91
+ }} catch (error) {{
92
+ return {{
93
+ ok: false,
94
+ status: 0,
95
+ source,
96
+ target: TARGET_URL,
97
+ timestamp: startedAt,
98
+ error: error.message
99
+ }};
100
+ }}
101
+ }}
102
+
103
+ async function handleRequest(request) {{
104
+ const url = new URL(request.url);
105
+ if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
106
+ const result = await ping("manual");
107
+ return new Response(JSON.stringify(result, null, 2), {{
108
+ status: result.ok ? 200 : 502,
109
+ headers: {{ "content-type": "application/json; charset=utf-8" }}
110
+ }});
111
+ }}
112
+ return new Response("Not found", {{ status: 404 }});
113
+ }}
114
+ """
115
+
116
+
117
+ def write_keepalive_status(payload: dict) -> None:
118
+ payload = {
119
+ **payload,
120
+ "timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
121
+ }
122
+ KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
123
+ try:
124
+ KEEPALIVE_STATUS_FILE.chmod(0o600)
125
+ except OSError:
126
+ pass
127
+
128
+
129
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
130
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
131
+ if not account_id:
132
+ accounts = cf_request("GET", "/accounts", api_token)
133
+ if not accounts:
134
+ raise RuntimeError("No Cloudflare account is available for this token.")
135
+ account_id = accounts[0]["id"]
136
+
137
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
138
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
139
+ if not subdomain:
140
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
141
+ return account_id, subdomain
142
+
143
+
144
+ def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
145
+ enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
146
+ if enabled in {"0", "false", "no", "off"}:
147
+ write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
148
+ return
149
+
150
+ space_host = get_space_host()
151
+ if not space_host:
152
+ write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
153
+ return
154
+
155
+ cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
156
+ space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
157
+ target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
158
+ worker_name = derive_keepalive_worker_name()
159
+ worker_source = render_keepalive_worker(target_url)
160
+
161
+ cf_request(
162
+ "PUT",
163
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
164
+ api_token,
165
+ body=worker_source.encode("utf-8"),
166
+ content_type="application/javascript",
167
+ )
168
+ cf_request(
169
+ "POST",
170
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
171
+ api_token,
172
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
173
+ )
174
+ cf_request(
175
+ "PUT",
176
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
177
+ api_token,
178
+ body=json.dumps([{"cron": cron}]).encode("utf-8"),
179
+ )
180
+
181
+ worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
182
+ write_keepalive_status(
183
+ {
184
+ "configured": True,
185
+ "status": "configured",
186
+ "workerName": worker_name,
187
+ "workerUrl": worker_url,
188
+ "targetUrl": target_url,
189
+ "cron": cron,
190
+ "message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
191
+ }
192
+ )
193
+
194
+
195
+ def main() -> int:
196
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
197
+
198
+ if not api_token:
199
+ return 0
200
+
201
+ try:
202
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
203
+ setup_keepalive_worker(api_token, account_id, subdomain)
204
+ return 0
205
+ except Exception as exc:
206
+ print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
207
+ write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
208
+ return 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
health-server.js CHANGED
@@ -3,20 +3,13 @@ const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
5
 
6
- const PORT = 7861; // always public-facing port, never read from PORT (that's for Paperclip)
7
- const PAPERCLIP_HOST = "127.0.0.1";
8
- const PAPERCLIP_PORT = 3100;
9
  const startTime = Date.now();
10
-
11
- const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
12
- const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "86400";
13
-
14
- const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingclip-uptimerobot-status.json";
15
- const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
16
-
17
- // ============================================================================
18
- // URL helpers
19
- // ============================================================================
20
 
21
  function parseRequestUrl(url) {
22
  try {
@@ -26,591 +19,306 @@ function parseRequestUrl(url) {
26
  }
27
  }
28
 
29
- function isLocalRoute(pathname) {
30
- return pathname === "/health" || pathname === "/status";
31
- }
32
-
33
- // ============================================================================
34
- // UptimeRobot helpers
35
- // ============================================================================
36
-
37
- function getUptimeRobotStatus() {
38
  try {
39
- if (fs.existsSync(UPTIMEROBOT_STATUS_FILE)) {
40
- return JSON.parse(fs.readFileSync(UPTIMEROBOT_STATUS_FILE, "utf8"));
41
  }
42
  } catch {}
43
- return null;
 
 
 
 
 
 
44
  }
45
 
46
- // ============================================================================
47
- // Status helpers
48
- // ============================================================================
49
-
50
- function readSyncStatus() {
51
  try {
52
- if (fs.existsSync("/tmp/sync-status.json")) {
53
- return JSON.parse(fs.readFileSync("/tmp/sync-status.json", "utf8"));
54
  }
55
  } catch {}
56
- if (HF_BACKUP_ENABLED) {
57
- return {
58
- db_status: "unknown",
59
- last_sync_time: null,
60
- last_error: null,
61
- sync_count: 0,
62
- status: "configured",
63
- message: `Backup enabled. Waiting for first sync (every ${SYNC_INTERVAL}s).`,
64
- };
65
- }
66
- return { db_status: "unknown", last_sync_time: null, last_error: null, sync_count: 0 };
67
  }
68
 
69
- function readInviteUrl() {
70
  try {
71
- if (fs.existsSync("/tmp/invite-url.txt")) {
72
- return fs.readFileSync("/tmp/invite-url.txt", "utf8").trim();
73
  }
74
  } catch {}
75
  return null;
76
  }
77
 
78
- function checkPaperclipHealth() {
79
  return new Promise((resolve) => {
80
- const timeout = setTimeout(() => resolve({ status: "unreachable", reason: "timeout" }), 5000);
81
- http.get(`http://${PAPERCLIP_HOST}:${PAPERCLIP_PORT}/api/health`, (res) => {
82
- clearTimeout(timeout);
83
- resolve({ status: res.statusCode < 500 ? "running" : "error", statusCode: res.statusCode });
84
- res.resume();
85
- }).on("error", (err) => {
86
- clearTimeout(timeout);
87
- resolve({ status: "unreachable", reason: err.message });
 
 
 
 
 
 
 
88
  });
 
89
  });
90
  }
91
 
92
- function formatUptime(seconds) {
93
- const h = Math.floor(seconds / 3600);
94
- const m = Math.floor((seconds % 3600) / 60);
95
- return `${h}h ${m}m`;
 
 
 
 
96
  }
97
 
98
- // ============================================================================
99
- // Dashboard HTML
100
- // ============================================================================
101
-
102
- function renderDashboard(initialData) {
103
- const uptimerobotStatus = getUptimeRobotStatus();
104
- let keepAwakeHtml;
105
- if (uptimerobotStatus?.configured) {
106
- keepAwakeHtml = `<div class="helper-summary success">
107
- <span class="status-badge status-online"><div class="pulse"></div>Configured</span>
108
- <span>UptimeRobot monitor active for <code>${uptimerobotStatus.url || "your /health endpoint"}</code>.</span>
109
- </div>`;
110
- } else if (uptimerobotStatus?.configured === false) {
111
- keepAwakeHtml = `<div class="helper-summary error">
112
- <span class="status-badge status-error">Failed</span>
113
- <span>Monitor setup failed. Check Space logs.</span>
114
- </div>`;
115
- } else if (UPTIMEROBOT_API_KEY_SET) {
116
- keepAwakeHtml = `<div class="helper-summary"><span class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Setting up</span> Setting up UptimeRobot monitor...</div>`;
117
- } else {
118
- keepAwakeHtml = `<div class="helper-summary">
119
- <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
120
- </div>`;
121
- }
122
-
123
- const syncStatus = initialData.sync;
124
- const hasBackup = HF_BACKUP_ENABLED;
125
- const lastSync = syncStatus.last_sync_time
126
- ? new Date(syncStatus.last_sync_time).toLocaleString()
127
- : "Never";
128
- const syncError = syncStatus.last_error || null;
129
- const syncOk = hasBackup && !syncError && syncStatus.last_sync_time;
130
-
131
- const syncBadge = !hasBackup
132
- ? `<div class="status-badge status-offline">Disabled</div>`
133
- : syncError
134
- ? `<div class="status-badge status-error">Error</div>`
135
- : syncStatus.last_sync_time
136
- ? `<div class="status-badge status-online"><div class="pulse"></div>Enabled</div>`
137
- : `<div class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Pending</div>`;
138
 
139
- const paperclipBadge = initialData.paperclipRunning
140
- ? `<div class="status-badge status-online"><div class="pulse"></div>Running</div>`
141
- : `<div class="status-badge status-offline">Unreachable</div>`;
142
 
143
- const inviteUrl = initialData.inviteUrl;
144
- const setupBannerHtml = inviteUrl ? `
145
- <div class="setup-banner">
146
- <div class="setup-banner-title">Admin Setup Required</div>
147
- <div class="setup-banner-body">No admin account configured. Open this link to create your first admin account:</div>
148
- <div class="setup-banner-url">${inviteUrl}</div>
149
- <a href="${inviteUrl}" class="setup-banner-btn" target="_blank" rel="noopener noreferrer">Open Setup Page →</a>
150
- </div>` : "";
 
 
 
151
 
152
- return `<!DOCTYPE html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  <html lang="en">
154
  <head>
155
- <meta charset="UTF-8">
156
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
157
- <title>HuggingClip Dashboard</title>
158
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
159
- <style>
160
- :root {
161
- --bg: #0f172a;
162
- --card-bg: rgba(30, 41, 59, 0.7);
163
- --accent: linear-gradient(135deg, #667eea, #764ba2);
164
- --text: #f8fafc;
165
- --text-dim: #94a3b8;
166
- --success: #10b981;
167
- --error: #ef4444;
168
- --warning: #f59e0b;
169
- }
170
- * { box-sizing: border-box; margin: 0; padding: 0; }
171
- body {
172
- font-family: 'Outfit', sans-serif;
173
- background-color: var(--bg);
174
- color: var(--text);
175
- display: flex;
176
- justify-content: center;
177
- align-items: flex-start;
178
- min-height: 100vh;
179
- padding: 24px 0;
180
- background-image:
181
- radial-gradient(at 0% 0%, rgba(102, 126, 234, 0.15) 0px, transparent 50%),
182
- radial-gradient(at 100% 0%, rgba(118, 75, 162, 0.15) 0px, transparent 50%);
183
- }
184
- .dashboard {
185
- width: 90%;
186
- max-width: 600px;
187
- background: var(--card-bg);
188
- backdrop-filter: blur(12px);
189
- border: 1px solid rgba(255,255,255,0.1);
190
- border-radius: 24px;
191
- padding: 40px;
192
- box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
193
- animation: fadeIn 0.8s ease-out;
194
- margin: 24px 0;
195
- }
196
- @keyframes fadeIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
197
- header { text-align: center; margin-bottom: 40px; }
198
- h1 {
199
- font-size: 2.5rem;
200
- margin-bottom: 8px;
201
- background: var(--accent);
202
- -webkit-background-clip: text;
203
- -webkit-text-fill-color: transparent;
204
- font-weight: 600;
205
- }
206
- .subtitle { color: var(--text-dim); font-size: 0.9rem; letter-spacing: 1px; text-transform: uppercase; }
207
- .stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 20px; }
208
- .stat-card {
209
- background: rgba(255,255,255,0.03);
210
- border: 1px solid rgba(255,255,255,0.05);
211
- padding: 20px;
212
- border-radius: 16px;
213
- transition: transform 0.3s ease, border-color 0.3s ease;
214
- }
215
- .stat-card:hover { transform: translateY(-3px); border-color: rgba(102,126,234,0.3); }
216
- .stat-label { color: var(--text-dim); font-size: 0.75rem; text-transform: uppercase; margin-bottom: 8px; display: block; }
217
- .stat-value { font-size: 1.1rem; font-weight: 600; }
218
- .stat-btn {
219
- grid-column: span 2;
220
- background: var(--accent);
221
- color: #fff;
222
- padding: 16px;
223
- border-radius: 16px;
224
- text-align: center;
225
- text-decoration: none;
226
- font-weight: 600;
227
- display: block;
228
- transition: transform 0.3s ease, box-shadow 0.3s ease;
229
- box-shadow: 0 10px 20px -5px rgba(102,126,234,0.4);
230
- }
231
- .stat-btn:hover { transform: scale(1.02); box-shadow: 0 15px 30px -5px rgba(102,126,234,0.6); }
232
- .status-badge {
233
- display: inline-flex;
234
- align-items: center;
235
- gap: 6px;
236
- padding: 4px 12px;
237
- border-radius: 20px;
238
- font-size: 0.8rem;
239
- font-weight: 600;
240
- }
241
- .status-online { background: rgba(16,185,129,0.1); color: var(--success); }
242
- .status-offline { background: rgba(239,68,68,0.1); color: var(--error); }
243
- .status-syncing { background: rgba(59,130,246,0.1); color: #3b82f6; }
244
- .status-error { background: rgba(239,68,68,0.1); color: var(--error); }
245
- .pulse {
246
- width: 8px; height: 8px; border-radius: 50%;
247
- background: currentColor;
248
- box-shadow: 0 0 0 0 rgba(16,185,129,0.7);
249
- animation: pulse 2s infinite;
250
- }
251
- @keyframes pulse {
252
- 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16,185,129,0.7); }
253
- 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(16,185,129,0); }
254
- 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16,185,129,0); }
255
- }
256
- .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
257
- .card-header .stat-label { margin-bottom: 0; }
258
- .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; }
259
- #sync-msg { color: var(--text); display: block; margin-top: 4px; }
260
- .helper-card { width: 100%; margin-top: 20px; }
261
- .helper-copy { color: var(--text-dim); font-size: 0.92rem; line-height: 1.6; margin-top: 10px; }
262
- .helper-copy strong { color: var(--text); }
263
- .helper-row { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
264
- .helper-input {
265
- flex: 1; min-width: 240px;
266
- background: rgba(255,255,255,0.04);
267
- border: 1px solid rgba(255,255,255,0.08);
268
- color: var(--text); border-radius: 12px;
269
- padding: 14px 16px; font: inherit;
270
- }
271
- .helper-input::placeholder { color: var(--text-dim); }
272
- .helper-button {
273
- background: var(--accent); color: #fff; border: 0;
274
- border-radius: 12px; padding: 14px 18px;
275
- font: inherit; font-weight: 600; cursor: pointer; min-width: 180px;
276
- }
277
- .helper-button:disabled { opacity: 0.6; cursor: wait; }
278
- .hidden { display: none !important; }
279
- .helper-note { margin-top: 10px; font-size: 0.82rem; color: var(--text-dim); }
280
- .helper-result { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 0.9rem; display: none; }
281
- .helper-result.ok { display: block; background: rgba(16,185,129,0.1); color: var(--success); }
282
- .helper-result.error { display: block; background: rgba(239,68,68,0.1); color: var(--error); }
283
- .helper-shell { margin-top: 12px; }
284
- .helper-shell.hidden { display: none; }
285
- .helper-summary {
286
- margin-top: 14px; padding: 12px 14px; border-radius: 12px;
287
- background: rgba(255,255,255,0.03); color: var(--text-dim);
288
- font-size: 0.9rem; line-height: 1.5;
289
- display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
290
- }
291
- .helper-summary strong { color: var(--text); }
292
- .helper-summary code { background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; font-size: 0.85em; color: var(--text); }
293
- .helper-summary.success { background: rgba(16,185,129,0.08); }
294
- .helper-summary.error { background: rgba(239,68,68,0.08); }
295
- .helper-toggle {
296
- margin-top: 14px; display: inline-flex; align-items: center; justify-content: center;
297
- background: rgba(255,255,255,0.04); color: var(--text);
298
- border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;
299
- padding: 12px 16px; font: inherit; font-weight: 600; cursor: pointer;
300
- }
301
- .setup-banner {
302
- background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3);
303
- border-radius: 16px; padding: 20px; margin-bottom: 20px;
304
- }
305
- .setup-banner-title { font-weight: 600; color: var(--warning); margin-bottom: 8px; }
306
- .setup-banner-body { color: var(--text-dim); font-size: 0.9rem; margin-bottom: 10px; }
307
- .setup-banner-url {
308
- font-family: monospace; font-size: 0.8rem; word-break: break-all;
309
- background: rgba(255,255,255,0.04); border-radius: 8px;
310
- padding: 8px 12px; margin-bottom: 12px; color: var(--text);
311
- }
312
- .setup-banner-btn {
313
- display: inline-block; background: var(--warning); color: #000;
314
- font-weight: 700; padding: 8px 20px; border-radius: 8px;
315
- text-decoration: none; font-size: 0.9rem;
316
- }
317
- .links-row { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
318
- .link-btn {
319
- flex: 1; min-width: 120px; text-align: center; padding: 10px 16px;
320
- border-radius: 12px; text-decoration: none; font-size: 0.9rem; font-weight: 600;
321
- transition: opacity 0.2s;
322
- }
323
- .link-btn:hover { opacity: 0.8; }
324
- .link-primary { background: var(--accent); color: #fff; }
325
- .link-secondary { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid rgba(255,255,255,0.08); }
326
- .footer { text-align: center; color: var(--text-dim); font-size: 0.8rem; margin-top: 20px; }
327
- @media (max-width: 700px) {
328
- body { padding: 16px 0; }
329
- .dashboard { width: calc(100% - 24px); padding: 24px; border-radius: 18px; margin: 12px 0; }
330
- header { margin-bottom: 28px; }
331
- h1 { font-size: 2rem; }
332
- .stats-grid { grid-template-columns: 1fr; gap: 14px; margin-bottom: 16px; }
333
- .stat-btn { grid-column: span 1; }
334
- .helper-row { flex-direction: column; }
335
- .helper-input, .helper-button { width: 100%; min-width: 0; }
336
- }
337
- </style>
338
  </head>
339
  <body>
340
- <div class="dashboard">
341
- <header>
342
- <h1>📎 HuggingClip</h1>
343
- <p class="subtitle">Paperclip on HF Spaces</p>
344
- </header>
345
-
346
- ${setupBannerHtml}
347
-
348
- <div class="stats-grid">
349
- <div class="stat-card">
350
- <div class="card-header">
351
- <span class="stat-label">Paperclip</span>
352
- <span id="paperclip-badge">${paperclipBadge}</span>
353
- </div>
354
- <div style="margin-top: 8px; font-size: 0.82rem; color: var(--text-dim);">
355
- Port <strong style="color:var(--text)">3100</strong> · <a href="/app/" style="color:#818cf8;text-decoration:none;" target="_blank">Open UI →</a>
356
- </div>
357
- </div>
358
- <div class="stat-card">
359
- <span class="stat-label">Uptime</span>
360
- <span class="stat-value" id="uptime">${formatUptime(Math.floor((Date.now() - startTime) / 1000))}</span>
361
- </div>
362
- <div class="stat-card">
363
- <div class="card-header">
364
- <span class="stat-label">Backup</span>
365
- <span id="sync-badge">${syncBadge}</span>
366
- </div>
367
- <div style="margin-top: 8px; font-size: 0.82rem; color: var(--text-dim);">
368
- Last sync: <span id="last-sync">${lastSync}</span>
369
- </div>
370
- </div>
371
- <div class="stat-card">
372
- <span class="stat-label">Database</span>
373
- <span class="stat-value" id="db-status">${syncStatus.db_status === "connected" ? "PostgreSQL ✓" : syncStatus.db_status === "error" ? "Error" : "PostgreSQL"}</span>
374
- </div>
375
- <a href="/app/" id="open-ui-btn" class="stat-btn" target="_blank" rel="noopener noreferrer">Open Paperclip UI</a>
376
- </div>
377
-
378
- <div class="stat-card" style="width: 100%; margin-bottom: 20px;">
379
- <div class="card-header">
380
- <span class="stat-label">Backup Sync</span>
381
- <div id="sync-badge-detail">${syncBadge}</div>
382
- </div>
383
- <div class="sync-info">
384
- Last activity: <span id="sync-time-detail">${lastSync}</span>
385
- <span id="sync-msg">${syncError ? "Error: " + syncError : syncStatus.last_sync_time ? "Sync successful" : hasBackup ? "Waiting for first sync..." : "HF_TOKEN not set — backups disabled"}</span>
386
- </div>
387
- </div>
388
-
389
- <div class="stat-card helper-card">
390
- <span class="stat-label">Keep Space Awake</span>
391
- ${keepAwakeHtml}
392
- </div>
393
-
394
- <div class="footer">Live updates every 30s</div>
395
- </div>
396
-
397
- <script>
398
- function getCurrentSearch() { return window.location.search || ''; }
399
-
400
- function renderSyncBadge(status, lastSyncTime, lastError) {
401
- if (!${hasBackup}) return '<div class="status-badge status-offline">Disabled</div>';
402
- if (lastError) return '<div class="status-badge status-error">Error</div>';
403
- if (lastSyncTime) return '<div class="status-badge status-online"><div class="pulse"></div>Enabled</div>';
404
- return '<div class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Pending</div>';
405
- }
406
-
407
- async function updateStatus() {
408
- try {
409
- const res = await fetch('/status' + getCurrentSearch());
410
- const data = await res.json();
411
-
412
- document.getElementById('uptime').textContent = data.uptime;
413
-
414
- const pbadge = data.paperclipRunning
415
- ? '<div class="status-badge status-online"><div class="pulse"></div>Running</div>'
416
- : '<div class="status-badge status-offline">Unreachable</div>';
417
- document.getElementById('paperclip-badge').innerHTML = pbadge;
418
-
419
- const badge = renderSyncBadge(data.sync.db_status, data.sync.last_sync_time, data.sync.last_error);
420
- document.getElementById('sync-badge').innerHTML = badge;
421
- document.getElementById('sync-badge-detail').innerHTML = badge;
422
-
423
- const lastSync = data.sync.last_sync_time
424
- ? new Date(data.sync.last_sync_time).toLocaleString()
425
- : 'Never';
426
- document.getElementById('last-sync').textContent = lastSync;
427
- document.getElementById('sync-time-detail').textContent = lastSync;
428
-
429
- const syncMsg = data.sync.last_error
430
- ? 'Error: ' + data.sync.last_error
431
- : data.sync.last_sync_time
432
- ? 'Sync successful'
433
- : ${hasBackup} ? 'Waiting for first sync...' : 'HF_TOKEN not set — backups disabled';
434
- document.getElementById('sync-msg').textContent = syncMsg;
435
-
436
- const dbEl = document.getElementById('db-status');
437
- dbEl.textContent = data.sync.db_status === 'connected' ? 'PostgreSQL ✓'
438
- : data.sync.db_status === 'error' ? 'Error' : 'PostgreSQL';
439
- } catch (e) {
440
- console.error('Status update failed:', e);
441
- }
442
- }
443
-
444
- updateStatus();
445
- setInterval(updateStatus, 30000);
446
- </script>
447
  </body>
448
  </html>`;
449
  }
450
 
451
- // ============================================================================
452
- // HTTP Proxy helpers
453
- // ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
- function buildProxyHeaders(headers) {
456
- const clientIp = (function() {
457
- const f = headers["x-forwarded-for"];
458
- if (typeof f === "string") return f.split(",")[0].trim();
459
- if (Array.isArray(f) && f.length > 0) return String(f[0]).split(",")[0].trim();
460
- return "";
461
- })();
462
- return {
463
- ...headers,
464
- host: `${PAPERCLIP_HOST}:${PAPERCLIP_PORT}`,
465
- "x-forwarded-for": clientIp,
466
- "x-forwarded-host": headers.host || "",
467
- "x-forwarded-proto": headers["x-forwarded-proto"] || "https",
 
 
 
 
 
 
 
468
  };
469
- }
470
 
471
- function proxyHttp(req, res, overridePath) {
472
- const targetPath = overridePath !== undefined ? overridePath : req.url;
473
- let upstreamStarted = false;
474
  const proxyReq = http.request(
475
- { hostname: PAPERCLIP_HOST, port: PAPERCLIP_PORT, method: req.method, path: targetPath, headers: buildProxyHeaders(req.headers) },
 
 
 
 
 
 
476
  (proxyRes) => {
477
- upstreamStarted = true;
478
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
479
  proxyRes.pipe(res);
 
480
  },
481
  );
482
- proxyReq.on("error", (error) => {
483
- if (res.headersSent || upstreamStarted) { res.destroy(); return; }
484
- res.writeHead(502, { "Content-Type": "application/json" });
485
- res.end(JSON.stringify({ status: "error", message: "Paperclip unavailable", detail: error.message }));
486
- });
487
- res.on("close", () => proxyReq.destroy());
488
- req.pipe(proxyReq);
489
- }
490
 
491
- function proxyUpgrade(req, socket, head, overridePath) {
492
- const targetPath = overridePath !== undefined ? overridePath : req.url;
493
- const proxySocket = net.connect(PAPERCLIP_PORT, PAPERCLIP_HOST);
494
- proxySocket.on("connect", () => {
495
- const clientIp = (function() {
496
- const f = req.headers["x-forwarded-for"];
497
- if (typeof f === "string") return f.split(",")[0].trim();
498
- return req.socket.remoteAddress || "";
499
- })();
500
- const lines = [
501
- `${req.method} ${targetPath} HTTP/${req.httpVersion}`,
502
- ...req.rawHeaders.reduce((acc, val, i) => {
503
- if (i % 2 === 0) { acc.push(i); } else { acc[acc.length - 1] = `${req.rawHeaders[acc[acc.length - 1]]}: ${val}`; }
504
- return acc;
505
- }, []).filter((h) => {
506
- const lower = (typeof h === "string" ? h : "").toLowerCase();
507
- return !lower.startsWith("host:") && !lower.startsWith("x-forwarded-");
508
- }),
509
- `Host: ${PAPERCLIP_HOST}:${PAPERCLIP_PORT}`,
510
- `X-Forwarded-For: ${clientIp}`,
511
- `X-Forwarded-Host: ${req.headers.host || ""}`,
512
- `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`,
513
- "", "",
514
- ];
515
- proxySocket.write(lines.join("\r\n"));
516
- if (head && head.length > 0) proxySocket.write(head);
517
- socket.pipe(proxySocket).pipe(socket);
518
- });
519
- proxySocket.on("error", () => {
520
- if (socket.writable) socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
521
- socket.destroy();
522
  });
523
- socket.on("error", () => proxySocket.destroy());
524
- }
525
-
526
- // ============================================================================
527
- // HTTP Server
528
- // ============================================================================
529
-
530
- const server = http.createServer((req, res) => {
531
- const parsedUrl = parseRequestUrl(req.url || "/");
532
- const pathname = parsedUrl.pathname;
533
- const uptime = Math.floor((Date.now() - startTime) / 1000);
534
-
535
- // ── Health endpoint ────────────────────────────────────────────────────────
536
- if (pathname === "/health") {
537
- res.writeHead(200, { "Content-Type": "application/json" });
538
- res.end(JSON.stringify({
539
- status: "ok",
540
- uptime,
541
- uptimeHuman: formatUptime(uptime),
542
- timestamp: new Date().toISOString(),
543
- sync: readSyncStatus(),
544
- }));
545
- return;
546
- }
547
-
548
- // ── Status endpoint (JSON, polled by dashboard) ───────────────────────────
549
- if (pathname === "/status") {
550
- void (async () => {
551
- const paperclipStatus = await checkPaperclipHealth();
552
- res.writeHead(200, { "Content-Type": "application/json" });
553
- res.end(JSON.stringify({
554
- uptime: formatUptime(uptime),
555
- paperclipRunning: paperclipStatus.status === "running",
556
- sync: readSyncStatus(),
557
- inviteUrl: readInviteUrl(),
558
- }));
559
- })();
560
- return;
561
- }
562
-
563
- // ── Dashboard (root) ───────────────────────────────────────────────────────
564
- if (pathname === "/" || pathname === "") {
565
- void (async () => {
566
- const paperclipStatus = await checkPaperclipHealth();
567
- const initialData = {
568
- paperclipRunning: paperclipStatus.status === "running",
569
- sync: readSyncStatus(),
570
- inviteUrl: readInviteUrl(),
571
- };
572
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
573
- res.end(renderDashboard(initialData));
574
- })();
575
- return;
576
- }
577
 
578
- // ── /invite/* → redirect to /app/invite/* (SPA uses basename="/app") ────────
579
- if (pathname.startsWith("/invite/") || pathname === "/invite") {
580
- const rest = pathname.slice("/invite".length) || "/";
581
- const query = parsedUrl.search || "";
582
- res.writeHead(302, { Location: "/app/invite" + rest + query });
583
- res.end();
584
- return;
585
- }
586
-
587
- // ── /app/* → strip prefix, proxy to Paperclip ─────────────────────────────
588
- // SPA built with basename="/app"; React Router strips /app client-side.
589
- if (pathname === "/app" || pathname.startsWith("/app/")) {
590
- const stripped = pathname.slice("/app".length) || "/";
591
- const query = parsedUrl.search || "";
592
- proxyHttp(req, res, stripped + query);
593
- return;
594
- }
595
-
596
- // ── Everything else → proxy directly ──────────────────────────────────────
597
- proxyHttp(req, res);
598
  });
599
 
600
  server.on("upgrade", (req, socket, head) => {
601
- const pathname = parseRequestUrl(req.url || "/").pathname;
602
- if (isLocalRoute(pathname)) { socket.destroy(); return; }
603
- if (pathname === "/app" || pathname.startsWith("/app/")) {
604
- const stripped = pathname.slice("/app".length) || "/";
605
- proxyUpgrade(req, socket, head, stripped + (parseRequestUrl(req.url).search || ""));
606
- return;
607
- }
608
- proxyUpgrade(req, socket, head);
 
 
 
 
609
  });
610
 
611
- server.listen(PORT, "0.0.0.0", () => {
612
- console.log(`✓ Health server listening on port ${PORT}`);
613
- console.log(`✓ Dashboard: http://localhost:${PORT}/`);
614
- console.log(` API proxy: http://localhost:${PORT}/api/*`);
615
- console.log(`✓ App proxy: http://localhost:${PORT}/ (root → Paperclip)`);
616
- });
 
3
  const fs = require("fs");
4
  const net = require("net");
5
 
6
+ const PORT = 7861;
7
+ const APP_PORT = 3100;
8
+ const APP_HOST = "127.0.0.1";
9
  const startTime = Date.now();
10
+ const INVITE_URL_FILE = "/tmp/invite-url.txt";
11
+ const SYNC_STATUS_FILE = "/tmp/sync-status.json";
12
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingclip-cloudflare-keepalive-status.json";
 
 
 
 
 
 
 
13
 
14
  function parseRequestUrl(url) {
15
  try {
 
19
  }
20
  }
21
 
22
+ function getSyncStatus() {
 
 
 
 
 
 
 
 
23
  try {
24
+ if (fs.existsSync(SYNC_STATUS_FILE)) {
25
+ return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
26
  }
27
  } catch {}
28
+ if (process.env.HF_TOKEN) {
29
+ return {
30
+ status: "configured",
31
+ message: `Backup is enabled. Waiting for sync window (${process.env.SYNC_INTERVAL || 3600}s).`,
32
+ };
33
+ }
34
+ return { status: "disabled", message: "HF_TOKEN not set" };
35
  }
36
 
37
+ function getKeepaliveStatus() {
 
 
 
 
38
  try {
39
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
40
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
41
  }
42
  } catch {}
43
+ return null;
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
+ function getInviteUrl() {
47
  try {
48
+ if (fs.existsSync(INVITE_URL_FILE)) {
49
+ return fs.readFileSync(INVITE_URL_FILE, "utf8").trim();
50
  }
51
  } catch {}
52
  return null;
53
  }
54
 
55
+ function probeAppHealth(timeoutMs = 1500) {
56
  return new Promise((resolve) => {
57
+ const request = http.get(
58
+ {
59
+ hostname: APP_HOST,
60
+ port: APP_PORT,
61
+ path: "/api/health",
62
+ timeout: timeoutMs,
63
+ },
64
+ (response) => {
65
+ response.resume();
66
+ resolve(response.statusCode >= 200 && response.statusCode < 400);
67
+ },
68
+ );
69
+ request.on("timeout", () => {
70
+ request.destroy();
71
+ resolve(false);
72
  });
73
+ request.on("error", () => resolve(false));
74
  });
75
  }
76
 
77
+ function formatUptime(ms) {
78
+ const total = Math.floor(ms / 1000);
79
+ const days = Math.floor(total / 86400);
80
+ const hours = Math.floor((total % 86400) / 3600);
81
+ const minutes = Math.floor((total % 3600) / 60);
82
+ if (days) return `${days}d ${hours}h ${minutes}m`;
83
+ if (hours) return `${hours}h ${minutes}m`;
84
+ return `${minutes}m`;
85
  }
86
 
87
+ function escapeHtml(value) {
88
+ return String(value)
89
+ .replace(/&/g, "&amp;")
90
+ .replace(/</g, "&lt;")
91
+ .replace(/>/g, "&gt;")
92
+ .replace(/"/g, "&quot;");
93
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
+ function toneBadge(label, tone = "neutral") {
96
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
97
+ }
98
 
99
+ function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
100
+ return `<article class="tile ${tone}">
101
+ <div class="tile-head">
102
+ <span class="tile-title">${escapeHtml(title)}</span>
103
+ <span class="tile-dot"></span>
104
+ </div>
105
+ <div class="tile-value">${value}</div>
106
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
107
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
108
+ </article>`;
109
+ }
110
 
111
+ function renderDashboard(data) {
112
+ const syncStatus = String(data.sync?.status || "unknown");
113
+ const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
114
+ ? "ok"
115
+ : syncStatus === "disabled"
116
+ ? "warn"
117
+ : "neutral";
118
+ const backupDetail = data.sync?.message ? escapeHtml(data.sync.message) : "No status yet";
119
+
120
+ const keepaliveConfigured = data.keepalive?.configured === true;
121
+ const keepaliveStatus = String(
122
+ data.keepalive?.status ||
123
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
124
+ );
125
+ const keepAliveTone = keepaliveConfigured
126
+ ? "ok"
127
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
128
+ ? "warn"
129
+ : "neutral";
130
+ const keepAliveDetail = keepaliveConfigured
131
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
132
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
133
+ ? "Worker pending or failed"
134
+ : "Not configured";
135
+
136
+ const inviteUrl = getInviteUrl();
137
+
138
+ const tiles = [
139
+ renderTile({
140
+ title: "Paperclip Core",
141
+ value: toneBadge(data.appReady ? "Online" : "Booting", data.appReady ? "ok" : "warn"),
142
+ detail: `Backend Port ${APP_PORT}`,
143
+ tone: data.appReady ? "ok" : "warn",
144
+ }),
145
+ renderTile({
146
+ title: "Database",
147
+ value: toneBadge("PostgreSQL", "ok"),
148
+ detail: "Embedded cluster active",
149
+ tone: "ok",
150
+ }),
151
+ renderTile({
152
+ title: "Runtime",
153
+ value: escapeHtml(data.uptimeHuman),
154
+ detail: `Exposed on port ${PORT}`,
155
+ tone: "neutral",
156
+ }),
157
+ renderTile({
158
+ title: "Backup",
159
+ value: toneBadge(syncStatus.toUpperCase(), syncTone),
160
+ detail: backupDetail,
161
+ tone: syncTone,
162
+ }),
163
+ renderTile({
164
+ title: "Keep Awake",
165
+ value: toneBadge(keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone),
166
+ detail: keepAliveDetail,
167
+ tone: keepAliveTone,
168
+ }),
169
+ ].join("");
170
+
171
+ return `<!doctype html>
172
  <html lang="en">
173
  <head>
174
+ <meta charset="utf-8" />
175
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
176
+ <title>HuggingClip</title>
177
+ <style>
178
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#3b82f6; --accent2:#8b5cf6; }
179
+ * { box-sizing:border-box; }
180
+ 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; }
181
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
182
+ header { text-align:center; margin-bottom:22px; }
183
+ h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
184
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
185
+ .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:linear-gradient(135deg, var(--accent), var(--accent2)); color:#ffffff; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; transition: opacity 0.15s ease; }
186
+ .hero-action:hover { opacity: 0.9; }
187
+ .invite-banner { background:rgba(245,197,66,.1); border:1px solid rgba(245,197,66,.2); border-radius:8px; padding:12px 16px; margin-bottom:20px; display:flex; flex-direction:column; gap:6px; }
188
+ .invite-banner span { color:var(--warn); font-weight:850; font-size:.75rem; text-transform:uppercase; }
189
+ .invite-banner code { font-size:1rem; padding:8px; margin-top:4px; display:block; overflow-wrap:anywhere; }
190
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
191
+ .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; }
192
+ .tile.ok { border-color:rgba(34,197,94,.22); }
193
+ .tile.warn { border-color:rgba(245,197,66,.24); }
194
+ .tile.off { border-color:rgba(251,113,133,.28); }
195
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
196
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
197
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
198
+ .tile.ok .tile-dot { background:var(--good); }
199
+ .tile.warn .tile-dot { background:var(--warn); }
200
+ .tile.off .tile-dot { background:var(--bad); }
201
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
202
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
203
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
204
+
205
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
206
+ .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; }
207
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
208
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
209
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
210
+ .badge.neutral { color:var(--soft); }
211
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
212
+ footer .live { color:var(--good); }
213
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
214
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </head>
216
  <body>
217
+ <main>
218
+ <header>
219
+ <h1>🧬 HuggingClip</h1>
220
+ <div class="subtitle">Paperclip Orchestrator Dashboard</div>
221
+ </header>
222
+ ${inviteUrl ? `
223
+ <div class="invite-banner">
224
+ <span>Admin Setup Required</span>
225
+ <code>${escapeHtml(inviteUrl)}</code>
226
+ </div>` : ""}
227
+ <a class="hero-action" href="/app/" target="_blank" rel="noopener noreferrer">Open Paperclip UI -></a>
228
+ <section class="overview">
229
+ ${tiles}
230
+ </section>
231
+ <footer><span class="live">Live</span> status - Health endpoint: <code>/health</code></footer>
232
+ </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  </body>
234
  </html>`;
235
  }
236
 
237
+ const server = http.createServer(async (req, res) => {
238
+ const url = parseRequestUrl(req.url);
239
+ const pathname = url.pathname;
240
+
241
+ if (pathname === "/health") {
242
+ const appReady = await probeAppHealth();
243
+ res.writeHead(appReady ? 200 : 503, { "Content-Type": "application/json" });
244
+ return res.end(
245
+ JSON.stringify({
246
+ status: appReady ? "ok" : "booting",
247
+ uptime: formatUptime(Date.now() - startTime),
248
+ sync: getSyncStatus(),
249
+ keepalive: getKeepaliveStatus(),
250
+ }),
251
+ );
252
+ }
253
 
254
+ if (pathname === "/" || pathname === "/dashboard") {
255
+ const appReady = await probeAppHealth();
256
+ res.writeHead(200, { "Content-Type": "text/html" });
257
+ return res.end(
258
+ renderDashboard({
259
+ uptimeHuman: formatUptime(Date.now() - startTime),
260
+ appReady,
261
+ sync: getSyncStatus(),
262
+ keepalive: getKeepaliveStatus(),
263
+ }),
264
+ );
265
+ }
266
+
267
+ // Proxy logic to Paperclip (port 3100)
268
+ const proxyHeaders = {
269
+ ...req.headers,
270
+ host: `${APP_HOST}:${APP_PORT}`,
271
+ "x-forwarded-for": req.socket.remoteAddress,
272
+ "x-forwarded-host": req.headers.host,
273
+ "x-forwarded-proto": "https",
274
  };
 
275
 
 
 
 
276
  const proxyReq = http.request(
277
+ {
278
+ hostname: APP_HOST,
279
+ port: APP_PORT,
280
+ path: pathname + url.search,
281
+ method: req.method,
282
+ headers: proxyHeaders,
283
+ },
284
  (proxyRes) => {
285
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
 
286
  proxyRes.pipe(res);
287
+ proxyRes.on("error", () => res.end());
288
  },
289
  );
 
 
 
 
 
 
 
 
290
 
291
+ req.on("error", () => proxyReq.destroy());
292
+ res.on("error", () => proxyReq.destroy());
293
+ proxyReq.on("error", () => {
294
+ if (!res.headersSent) {
295
+ res.writeHead(503, { "Content-Type": "application/json" });
296
+ res.end(JSON.stringify({ status: "starting", message: "Paperclip is booting..." }));
297
+ } else {
298
+ res.end();
299
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
+ req.pipe(proxyReq);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  });
304
 
305
  server.on("upgrade", (req, socket, head) => {
306
+ const url = parseRequestUrl(req.url);
307
+ const proxyPath = url.pathname;
308
+ const proxySocket = net.connect(APP_PORT, APP_HOST, () => {
309
+ proxySocket.write(`${req.method} ${proxyPath}${url.search} HTTP/${req.httpVersion}\r\n`);
310
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
311
+ proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
312
+ }
313
+ proxySocket.write("\r\n");
314
+ if (head && head.length) proxySocket.write(head);
315
+ proxySocket.pipe(socket).pipe(proxySocket);
316
+ });
317
+ proxySocket.on("error", () => socket.destroy());
318
  });
319
 
320
+ server.timeout = 0;
321
+ server.keepAliveTimeout = 65000;
322
+ server.listen(PORT, "0.0.0.0", () =>
323
+ console.log(`🧬 HuggingClip Dashboard on ${PORT} -> Paperclip on ${APP_PORT}`),
324
+ );
 
setup-uptimerobot.sh DELETED
@@ -1,91 +0,0 @@
1
- #!/bin/bash
2
- set -euo pipefail
3
-
4
- # Create or update a UptimeRobot monitor for this Hugging Face Space.
5
- #
6
- # Requirements:
7
- # - UPTIMEROBOT_API_KEY: Main API key from UptimeRobot
8
- # - SPACE_HOST or first CLI arg: your HF Space host, e.g. "user-space.hf.space"
9
- #
10
- # Optional:
11
- # - UPTIMEROBOT_MONITOR_NAME: friendly name for the monitor
12
- # - UPTIMEROBOT_ALERT_CONTACTS: dash-separated alert contact IDs, e.g. "123456-789012"
13
- # - UPTIMEROBOT_INTERVAL: monitoring interval in seconds (default: 300 = 5 min; min: 30)
14
-
15
- API_URL="https://api.uptimerobot.com/v2"
16
- API_KEY="${UPTIMEROBOT_API_KEY:-}"
17
- SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
18
- STATUS_FILE="/tmp/huggingclip-uptimerobot-status.json"
19
-
20
- if [ -z "$API_KEY" ]; then
21
- echo "Missing UPTIMEROBOT_API_KEY."
22
- echo "Use the Main API key from UptimeRobot -> Integrations."
23
- echo "Do not use the Read-only API key or a Monitor-specific API key."
24
- exit 1
25
- fi
26
-
27
- if [ -z "$SPACE_HOST_INPUT" ]; then
28
- echo "Missing Space host."
29
- echo "Usage: UPTIMEROBOT_API_KEY=... ./setup-uptimerobot.sh your-space.hf.space"
30
- exit 1
31
- fi
32
-
33
- SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
34
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
35
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
36
-
37
- MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
38
- MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingClip ${SPACE_HOST_CLEAN}}"
39
- INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
40
-
41
- echo "Checking existing UptimeRobot monitors for ${MONITOR_URL}..."
42
- MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
43
- -d "api_key=${API_KEY}" \
44
- -d "format=json" \
45
- -d "logs=0" \
46
- -d "response_times=0" \
47
- -d "response_times_limit=1")
48
-
49
- MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
50
- (.monitors // []) | map(select(.url == $url)) | first | .id // empty
51
- ')
52
-
53
- if [ -n "$MONITOR_ID" ]; then
54
- printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
55
- "$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
56
- echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
57
- exit 0
58
- fi
59
-
60
- echo "Creating new UptimeRobot monitor for ${MONITOR_URL}..."
61
-
62
- CURL_ARGS=(
63
- -sS
64
- -X POST "${API_URL}/newMonitor"
65
- -d "api_key=${API_KEY}"
66
- -d "format=json"
67
- -d "type=1"
68
- -d "friendly_name=${MONITOR_NAME}"
69
- -d "url=${MONITOR_URL}"
70
- -d "interval=${INTERVAL}"
71
- )
72
-
73
- if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
74
- CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
75
- fi
76
-
77
- CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
78
- CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
79
-
80
- if [ "$CREATE_STATUS" != "ok" ]; then
81
- printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
82
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
83
- echo "Failed to create monitor."
84
- printf '%s\n' "$CREATE_RESPONSE"
85
- exit 1
86
- fi
87
-
88
- NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
89
- printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
90
- "${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
91
- echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
start.sh CHANGED
@@ -209,9 +209,9 @@ fi
209
  node /app/health-server.js &
210
  HEALTH_PID=$!
211
 
212
- if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
213
- echo "Setting up UptimeRobot monitor..."
214
- bash /app/setup-uptimerobot.sh "${SPACE_HOST}" || true
215
  fi
216
 
217
  sleep 2
 
209
  node /app/health-server.js &
210
  HEALTH_PID=$!
211
 
212
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
213
+ echo "Setting up Cloudflare KeepAlive monitor..."
214
+ python3 /app/cloudflare-keepalive-setup.py || true
215
  fi
216
 
217
  sleep 2