IDKHowToCodeFr commited on
Commit
3b4cb78
·
1 Parent(s): c5dd7b8

feat(ui): add minimalist status dashboard at / and /status

Browse files
Files changed (5) hide show
  1. ARCHITECTURE.md +4 -2
  2. README.md +5 -0
  3. dashboard.py +438 -0
  4. main.py +13 -2
  5. tests/test_host.py +37 -0
ARCHITECTURE.md CHANGED
@@ -18,7 +18,9 @@ Internet Request
18
  ▼
19
  [ RequestLimitMiddleware ] (Body size cap + request timeout)
20
  │
21
- ├── /health ─────────► [ Host Health Monitor ]
 
 
22
  ├── /semantic/* ─────► [ Mounted Subproject: Semantic-Comment-Analyze ]
23
  └── /<prefix>/* ─────► [ Future Mounted Services ]
24
  ```
@@ -149,7 +151,7 @@ The host places `RequestLimitMiddleware` in front of all endpoints:
149
 
150
  ## Health and Degradation Model
151
 
152
- The root `/health` and `/` endpoints report the aggregate status of all registered projects:
153
 
154
  ```json
155
  {
 
18
  ▼
19
  [ RequestLimitMiddleware ] (Body size cap + request timeout)
20
  │
21
+ ├── / (browser) ─────► [ Statuspage Dashboard (HTML) ]
22
+ ├── /status ─────────► [ Statuspage Dashboard (HTML) ]
23
+ ├── /health ─────────► [ Host Health Monitor (JSON) ]
24
  ├── /semantic/* ─────► [ Mounted Subproject: Semantic-Comment-Analyze ]
25
  └── /<prefix>/* ─────► [ Future Mounted Services ]
26
  ```
 
151
 
152
  ## Health and Degradation Model
153
 
154
+ The `/health` endpoint (and `/` when requested by API clients) reports the aggregate status of all registered projects:
155
 
156
  ```json
157
  {
README.md CHANGED
@@ -66,6 +66,11 @@ Repository secrets for GitHub Actions:
66
 
67
  ## Endpoints
68
 
 
 
 
 
 
69
  ### `GET /health`
70
  Returns the status of the host and each registered project:
71
 
 
66
 
67
  ## Endpoints
68
 
69
+ ### `GET /` and `GET /status`
70
+ Visiting the root `/` in a browser or accessing `/status` renders a status dashboard displaying service readiness beacons, lifespan warmup latencies, active router mounts, and direct route links.
71
+
72
+ When accessed with `Accept: application/json` or `/?format=json`, the root endpoint returns the raw health JSON structure.
73
+
74
  ### `GET /health`
75
  Returns the status of the host and each registered project:
76
 
dashboard.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimalist Statuspage HTML renderer for HF Backend Gateway.
2
+
3
+ Renders system status, per-project readiness badges, boot warmup timings,
4
+ and endpoint links with clean, modern dark-mode styling.
5
+ """
6
+
7
+ import html
8
+ from typing import Any
9
+
10
+
11
+ def render_statuspage(health_data: dict[str, Any]) -> str:
12
+ status = health_data.get("status", "unknown")
13
+ loaded = health_data.get("loaded", {})
14
+ failed = health_data.get("failed", {})
15
+ warm_status = health_data.get("warm_status", {})
16
+ warmup_seconds = health_data.get("total_warmup_seconds", 0.0)
17
+
18
+ if status == "healthy":
19
+ beacon_color = "#10b981"
20
+ beacon_bg = "rgba(16, 185, 129, 0.12)"
21
+ beacon_border = "rgba(16, 185, 129, 0.3)"
22
+ status_text = "All Systems Operational"
23
+ status_icon = "●"
24
+ elif status == "degraded":
25
+ beacon_color = "#f59e0b"
26
+ beacon_bg = "rgba(245, 158, 11, 0.12)"
27
+ beacon_border = "rgba(245, 158, 11, 0.3)"
28
+ status_text = "System Degraded"
29
+ status_icon = "▲"
30
+ else:
31
+ beacon_color = "#ef4444"
32
+ beacon_bg = "rgba(239, 68, 68, 0.12)"
33
+ beacon_border = "rgba(239, 68, 68, 0.3)"
34
+ status_text = "System Disruption"
35
+ status_icon = "✕"
36
+
37
+ service_rows = []
38
+ # Loaded projects
39
+ for prefix, target in loaded.items():
40
+ w_stat = warm_status.get(prefix, "unknown")
41
+ if w_stat == "warmed":
42
+ warm_badge = '<span class="badge badge-success">Warmed</span>'
43
+ elif w_stat == "no warmup":
44
+ warm_badge = '<span class="badge badge-neutral">No Warmup</span>'
45
+ else:
46
+ warm_badge = f'<span class="badge badge-danger">{html.escape(w_stat)}</span>'
47
+
48
+ # Specific metadata tags based on service name
49
+ if "semantic" in prefix:
50
+ model_tags = (
51
+ '<span class="tag">all-MiniLM-L6-v2</span>'
52
+ '<span class="tag">twitter-roberta-sentiment</span>'
53
+ '<span class="tag">intent-classifier</span>'
54
+ )
55
+ inspect_link = f'<a href="{prefix}/health" class="link-btn">/semantic/health</a>'
56
+ else:
57
+ model_tags = '<span class="tag">Standard Router</span>'
58
+ inspect_link = ""
59
+
60
+ service_rows.append(f"""
61
+ <div class="service-card">
62
+ <div class="service-header">
63
+ <div class="service-title">
64
+ <span class="service-prefix">{html.escape(prefix)}</span>
65
+ <span class="service-target">{html.escape(target)}</span>
66
+ </div>
67
+ <div class="service-badges">
68
+ <span class="badge badge-success">Mounted</span>
69
+ {warm_badge}
70
+ </div>
71
+ </div>
72
+ <div class="service-meta">
73
+ <div class="tags-group">
74
+ {model_tags}
75
+ </div>
76
+ <div class="links-group">
77
+ {inspect_link}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ """)
82
+
83
+ # Failed projects
84
+ for prefix, err in failed.items():
85
+ service_rows.append(f"""
86
+ <div class="service-card service-failed">
87
+ <div class="service-header">
88
+ <div class="service-title">
89
+ <span class="service-prefix">{html.escape(prefix)}</span>
90
+ <span class="service-error">{html.escape(err)}</span>
91
+ </div>
92
+ <div class="service-badges">
93
+ <span class="badge badge-danger">Failed to Mount</span>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ """)
98
+
99
+ services_html = "\n".join(service_rows) if service_rows else '<div class="empty-state">No services registered</div>'
100
+
101
+ total_services = len(loaded) + len(failed)
102
+
103
+ return f"""<!DOCTYPE html>
104
+ <html lang="en">
105
+ <head>
106
+ <meta charset="utf-8">
107
+ <meta name="viewport" content="width=device-width, initial-scale=1">
108
+ <title>HF Backend Gateway Status</title>
109
+ <style>
110
+ :root {{
111
+ --bg: #0b0f17;
112
+ --card-bg: #121824;
113
+ --card-border: #1e2838;
114
+ --text-main: #f1f5f9;
115
+ --text-muted: #94a3b8;
116
+ --text-dim: #64748b;
117
+ --accent: #38bdf8;
118
+ --success: #10b981;
119
+ --warning: #f59e0b;
120
+ --danger: #ef4444;
121
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
122
+ }}
123
+ * {{
124
+ box-sizing: border-box;
125
+ margin: 0;
126
+ padding: 0;
127
+ }}
128
+ body {{
129
+ background: var(--bg);
130
+ color: var(--text-main);
131
+ font-family: var(--font-family);
132
+ line-height: 1.5;
133
+ padding: 2.5rem 1.25rem;
134
+ min-height: 100vh;
135
+ display: flex;
136
+ flex-direction: column;
137
+ align-items: center;
138
+ }}
139
+ .container {{
140
+ width: 100%;
141
+ max-width: 760px;
142
+ }}
143
+ header {{
144
+ display: flex;
145
+ justify-content: space-between;
146
+ align-items: center;
147
+ margin-bottom: 2rem;
148
+ padding-bottom: 1rem;
149
+ border-bottom: 1px solid var(--card-border);
150
+ }}
151
+ .brand {{
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 0.75rem;
155
+ }}
156
+ .brand-icon {{
157
+ font-size: 1.5rem;
158
+ }}
159
+ .brand-name {{
160
+ font-size: 1.25rem;
161
+ font-weight: 700;
162
+ letter-spacing: -0.02em;
163
+ }}
164
+ .brand-tag {{
165
+ font-size: 0.75rem;
166
+ background: rgba(56, 189, 248, 0.1);
167
+ color: var(--accent);
168
+ border: 1px solid rgba(56, 189, 248, 0.25);
169
+ padding: 0.15rem 0.5rem;
170
+ border-radius: 9999px;
171
+ font-weight: 500;
172
+ }}
173
+ .status-banner {{
174
+ background: {beacon_bg};
175
+ border: 1px solid {beacon_border};
176
+ border-radius: 12px;
177
+ padding: 1.25rem 1.5rem;
178
+ display: flex;
179
+ justify-content: space-between;
180
+ align-items: center;
181
+ margin-bottom: 1.75rem;
182
+ }}
183
+ .status-headline {{
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 0.75rem;
187
+ }}
188
+ .beacon {{
189
+ color: {beacon_color};
190
+ font-size: 1.1rem;
191
+ animation: pulse 2s infinite ease-in-out;
192
+ }}
193
+ @keyframes pulse {{
194
+ 0%, 100% {{ opacity: 1; transform: scale(1); }}
195
+ 50% {{ opacity: 0.6; transform: scale(1.15); }}
196
+ }}
197
+ .status-title {{
198
+ font-size: 1.15rem;
199
+ font-weight: 600;
200
+ color: var(--text-main);
201
+ }}
202
+ .status-updated {{
203
+ font-size: 0.8rem;
204
+ color: var(--text-muted);
205
+ }}
206
+ .stats-grid {{
207
+ display: grid;
208
+ grid-template-columns: repeat(3, 1fr);
209
+ gap: 1rem;
210
+ margin-bottom: 2rem;
211
+ }}
212
+ @media (max-width: 600px) {{
213
+ .stats-grid {{
214
+ grid-template-columns: 1fr;
215
+ }}
216
+ }}
217
+ .stat-card {{
218
+ background: var(--card-bg);
219
+ border: 1px solid var(--card-border);
220
+ border-radius: 10px;
221
+ padding: 1rem 1.25rem;
222
+ }}
223
+ .stat-label {{
224
+ font-size: 0.75rem;
225
+ text-transform: uppercase;
226
+ letter-spacing: 0.05em;
227
+ color: var(--text-dim);
228
+ margin-bottom: 0.35rem;
229
+ font-weight: 600;
230
+ }}
231
+ .stat-value {{
232
+ font-size: 1.35rem;
233
+ font-weight: 700;
234
+ color: var(--text-main);
235
+ font-feature-settings: "tnum";
236
+ }}
237
+ .stat-sub {{
238
+ font-size: 0.75rem;
239
+ color: var(--text-muted);
240
+ margin-top: 0.2rem;
241
+ }}
242
+ .section-title {{
243
+ font-size: 0.85rem;
244
+ text-transform: uppercase;
245
+ letter-spacing: 0.05em;
246
+ color: var(--text-dim);
247
+ font-weight: 700;
248
+ margin-bottom: 0.85rem;
249
+ }}
250
+ .services-list {{
251
+ display: flex;
252
+ flex-direction: column;
253
+ gap: 0.85rem;
254
+ margin-bottom: 2rem;
255
+ }}
256
+ .service-card {{
257
+ background: var(--card-bg);
258
+ border: 1px solid var(--card-border);
259
+ border-radius: 10px;
260
+ padding: 1.25rem 1.5rem;
261
+ transition: border-color 0.15s ease;
262
+ }}
263
+ .service-card:hover {{
264
+ border-color: rgba(56, 189, 248, 0.4);
265
+ }}
266
+ .service-failed {{
267
+ border-color: rgba(239, 68, 68, 0.4);
268
+ background: rgba(239, 68, 68, 0.04);
269
+ }}
270
+ .service-header {{
271
+ display: flex;
272
+ justify-content: space-between;
273
+ align-items: flex-start;
274
+ margin-bottom: 0.75rem;
275
+ flex-wrap: wrap;
276
+ gap: 0.5rem;
277
+ }}
278
+ .service-prefix {{
279
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
280
+ font-weight: 700;
281
+ font-size: 1.05rem;
282
+ color: var(--accent);
283
+ margin-right: 0.75rem;
284
+ }}
285
+ .service-target {{
286
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
287
+ font-size: 0.85rem;
288
+ color: var(--text-muted);
289
+ }}
290
+ .service-error {{
291
+ font-size: 0.85rem;
292
+ color: var(--danger);
293
+ display: block;
294
+ margin-top: 0.25rem;
295
+ }}
296
+ .service-badges {{
297
+ display: flex;
298
+ gap: 0.4rem;
299
+ }}
300
+ .badge {{
301
+ font-size: 0.7rem;
302
+ font-weight: 600;
303
+ padding: 0.2rem 0.55rem;
304
+ border-radius: 9999px;
305
+ text-transform: uppercase;
306
+ letter-spacing: 0.03em;
307
+ }}
308
+ .badge-success {{
309
+ background: rgba(16, 185, 129, 0.15);
310
+ color: var(--success);
311
+ border: 1px solid rgba(16, 185, 129, 0.3);
312
+ }}
313
+ .badge-neutral {{
314
+ background: rgba(100, 116, 139, 0.15);
315
+ color: var(--text-muted);
316
+ border: 1px solid rgba(100, 116, 139, 0.3);
317
+ }}
318
+ .badge-danger {{
319
+ background: rgba(239, 68, 68, 0.15);
320
+ color: var(--danger);
321
+ border: 1px solid rgba(239, 68, 68, 0.3);
322
+ }}
323
+ .service-meta {{
324
+ display: flex;
325
+ justify-content: space-between;
326
+ align-items: center;
327
+ padding-top: 0.5rem;
328
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
329
+ flex-wrap: wrap;
330
+ gap: 0.5rem;
331
+ }}
332
+ .tags-group {{
333
+ display: flex;
334
+ gap: 0.4rem;
335
+ flex-wrap: wrap;
336
+ }}
337
+ .tag {{
338
+ font-size: 0.7rem;
339
+ background: rgba(255, 255, 255, 0.05);
340
+ color: var(--text-muted);
341
+ padding: 0.15rem 0.45rem;
342
+ border-radius: 4px;
343
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
344
+ }}
345
+ .link-btn {{
346
+ font-size: 0.75rem;
347
+ color: var(--accent);
348
+ text-decoration: none;
349
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
350
+ }}
351
+ .link-btn:hover {{
352
+ text-decoration: underline;
353
+ }}
354
+ footer {{
355
+ display: flex;
356
+ justify-content: space-between;
357
+ align-items: center;
358
+ padding-top: 1.5rem;
359
+ border-top: 1px solid var(--card-border);
360
+ font-size: 0.8rem;
361
+ color: var(--text-dim);
362
+ flex-wrap: wrap;
363
+ gap: 0.75rem;
364
+ }}
365
+ .footer-links {{
366
+ display: flex;
367
+ gap: 1rem;
368
+ }}
369
+ .footer-links a {{
370
+ color: var(--text-muted);
371
+ text-decoration: none;
372
+ }}
373
+ .footer-links a:hover {{
374
+ color: var(--text-main);
375
+ }}
376
+ </style>
377
+ </head>
378
+ <body>
379
+ <div class="container">
380
+ <header>
381
+ <div class="brand">
382
+ <span class="brand-icon">⚡</span>
383
+ <span class="brand-name">HF Backend</span>
384
+ <span class="brand-tag">Gateway</span>
385
+ </div>
386
+ <div>
387
+ <a href="/health" class="link-btn" title="View machine-readable JSON">JSON Endpoint</a>
388
+ </div>
389
+ </header>
390
+
391
+ <div class="status-banner">
392
+ <div class="status-headline">
393
+ <span class="beacon">{status_icon}</span>
394
+ <div>
395
+ <div class="status-title">{status_text}</div>
396
+ <div class="status-updated">Pre-warmed memory resident routing</div>
397
+ </div>
398
+ </div>
399
+ <div class="status-updated">
400
+ <span>Docker Space</span>
401
+ </div>
402
+ </div>
403
+
404
+ <div class="stats-grid">
405
+ <div class="stat-card">
406
+ <div class="stat-label">Warmup Latency</div>
407
+ <div class="stat-value">{warmup_seconds:.2f}s</div>
408
+ <div class="stat-sub">Lifespan sequential pre-warming</div>
409
+ </div>
410
+ <div class="stat-card">
411
+ <div class="stat-label">Active Services</div>
412
+ <div class="stat-value">{len(loaded)} / {total_services}</div>
413
+ <div class="stat-sub">Isolated router mounts</div>
414
+ </div>
415
+ <div class="stat-card">
416
+ <div class="stat-label">First Inference</div>
417
+ <div class="stat-value">&lt; 100ms</div>
418
+ <div class="stat-sub">Zero cold-start delay</div>
419
+ </div>
420
+ </div>
421
+
422
+ <div class="section-title">Registered Services</div>
423
+ <div class="services-list">
424
+ {services_html}
425
+ </div>
426
+
427
+ <footer>
428
+ <span>Hugging Face Docker Space &bull; Port 7860</span>
429
+ <div class="footer-links">
430
+ <a href="/health">/health</a>
431
+ <a href="https://github.com/rachit-mangawa/hf-backend" target="_blank" rel="noopener">GitHub</a>
432
+ <a href="/?format=json">Raw JSON</a>
433
+ </div>
434
+ </footer>
435
+ </div>
436
+ </body>
437
+ </html>
438
+ """
main.py CHANGED
@@ -15,8 +15,10 @@ from typing import Callable
15
 
16
  from fastapi import FastAPI, HTTPException, Request, Response
17
  from fastapi.middleware.cors import CORSMiddleware
 
18
  from starlette.middleware.base import BaseHTTPMiddleware
19
 
 
20
  from registry import PROJECT_REGISTRY
21
 
22
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
@@ -183,6 +185,15 @@ def health():
183
  }
184
 
185
 
 
 
 
 
 
186
  @app.get("/")
187
- def root():
188
- return health()
 
 
 
 
 
15
 
16
  from fastapi import FastAPI, HTTPException, Request, Response
17
  from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.responses import HTMLResponse
19
  from starlette.middleware.base import BaseHTTPMiddleware
20
 
21
+ from dashboard import render_statuspage
22
  from registry import PROJECT_REGISTRY
23
 
24
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
 
185
  }
186
 
187
 
188
+ @app.get("/status", response_class=HTMLResponse)
189
+ def status_page():
190
+ return HTMLResponse(content=render_statuspage(health()))
191
+
192
+
193
  @app.get("/")
194
+ def root(request: Request, format: str | None = None):
195
+ accept = request.headers.get("accept", "")
196
+ wants_html = format == "html" or ("text/html" in accept and not accept.startswith("*/*"))
197
+ if format == "json" or not wants_html:
198
+ return health()
199
+ return HTMLResponse(content=render_statuspage(health()))
tests/test_host.py CHANGED
@@ -314,3 +314,40 @@ def test_real_registry():
314
  assert sem_data["models_loaded"]["embedding"] is True
315
  assert sem_data["models_loaded"]["classifier_head"] is True
316
  assert sem_data["models_loaded"]["sentiment"] is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  assert sem_data["models_loaded"]["embedding"] is True
315
  assert sem_data["models_loaded"]["classifier_head"] is True
316
  assert sem_data["models_loaded"]["sentiment"] is True
317
+
318
+
319
+ def test_status_page_and_content_negotiation():
320
+ if "main" in sys.modules:
321
+ del sys.modules["main"]
322
+
323
+ import main
324
+ with TestClient(main.app) as client:
325
+ # 1. Dedicated /status endpoint always returns HTML
326
+ res_status = client.get("/status")
327
+ assert res_status.status_code == 200
328
+ assert "text/html" in res_status.headers.get("content-type", "")
329
+ assert "HF Backend" in res_status.text
330
+ assert "Registered Services" in res_status.text
331
+
332
+ # 2. Browser request to root with Accept: text/html returns HTML
333
+ res_browser = client.get("/", headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})
334
+ assert res_browser.status_code == 200
335
+ assert "text/html" in res_browser.headers.get("content-type", "")
336
+ assert "HF Backend" in res_browser.text
337
+
338
+ # 3. Explicit format query param
339
+ res_fmt_html = client.get("/?format=html")
340
+ assert res_fmt_html.status_code == 200
341
+ assert "text/html" in res_fmt_html.headers.get("content-type", "")
342
+
343
+ res_fmt_json = client.get("/?format=json")
344
+ assert res_fmt_json.status_code == 200
345
+ assert "application/json" in res_fmt_json.headers.get("content-type", "")
346
+ assert res_fmt_json.json()["status"] in ["healthy", "degraded", "unhealthy"]
347
+
348
+ # 4. Machine/API request (e.g. curl or monitoring probe) gets JSON by default
349
+ res_api = client.get("/")
350
+ assert res_api.status_code == 200
351
+ assert "application/json" in res_api.headers.get("content-type", "")
352
+ assert res_api.json()["status"] in ["healthy", "degraded", "unhealthy"]
353
+