Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Market-Data Observatory</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0e17; | |
| --surface: #111827; | |
| --surface-hover: #1e293b; | |
| --border: #1e293b; | |
| --text: #e2e8f0; | |
| --text-dim: #94a3b8; | |
| --accent: #6366f1; | |
| --accent-glow: rgba(99, 102, 241, 0.25); | |
| --green: #22c55e; | |
| --green-dim: rgba(34, 197, 94, 0.15); | |
| --amber: #f59e0b; | |
| --amber-dim: rgba(245, 158, 11, 0.15); | |
| --red: #ef4444; | |
| --red-dim: rgba(239, 68, 68, 0.15); | |
| --radius: 12px; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* ββ Animated gradient background ββ */ | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| top: -50%; | |
| left: -50%; | |
| width: 200%; | |
| height: 200%; | |
| background: radial-gradient(ellipse at 30% 20%, rgba(99, 102, 241, 0.08) 0%, transparent 50%), | |
| radial-gradient(ellipse at 70% 80%, rgba(139, 92, 246, 0.06) 0%, transparent 50%); | |
| animation: drift 20s ease-in-out infinite alternate; | |
| z-index: -1; | |
| } | |
| @keyframes drift { | |
| 0% { transform: translate(0, 0) rotate(0deg); } | |
| 100% { transform: translate(-5%, -5%) rotate(3deg); } | |
| } | |
| .container { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 40px 24px 60px; | |
| } | |
| /* ββ Header ββ */ | |
| .header { | |
| text-align: center; | |
| margin-bottom: 48px; | |
| } | |
| .logo-icon { | |
| font-size: 48px; | |
| margin-bottom: 12px; | |
| filter: drop-shadow(0 0 20px var(--accent-glow)); | |
| } | |
| h1 { | |
| font-size: 2rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, #e2e8f0, #6366f1); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 8px; | |
| } | |
| .subtitle { | |
| color: var(--text-dim); | |
| font-size: 0.95rem; | |
| font-weight: 400; | |
| } | |
| /* ββ Status bar ββ */ | |
| .status-bar { | |
| display: flex; | |
| gap: 12px; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| margin-bottom: 40px; | |
| } | |
| .status-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| } | |
| .status-chip .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| .status-chip.ok { background: var(--green-dim); color: var(--green); } | |
| .status-chip.ok .dot { background: var(--green); } | |
| .status-chip.syncing { background: var(--amber-dim); color: var(--amber); } | |
| .status-chip.syncing .dot { background: var(--amber); } | |
| .status-chip.error { background: var(--red-dim); color: var(--red); } | |
| .status-chip.error .dot { background: var(--red); } | |
| .status-chip.pending { background: var(--amber-dim); color: var(--amber); } | |
| .status-chip.pending .dot { background: var(--amber); } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.4; } | |
| } | |
| /* ββ Card grid ββ */ | |
| .card-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 40px; | |
| } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| text-decoration: none; | |
| color: var(--text); | |
| transition: all 0.2s ease; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .card:hover { | |
| background: var(--surface-hover); | |
| border-color: var(--accent); | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3), 0 0 20px var(--accent-glow); | |
| } | |
| .card-icon { | |
| font-size: 28px; | |
| margin-bottom: 12px; | |
| } | |
| .card-title { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 6px; | |
| } | |
| .card-desc { | |
| font-size: 0.82rem; | |
| color: var(--text-dim); | |
| line-height: 1.5; | |
| } | |
| /* ββ Quick query section ββ */ | |
| .query-section { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| margin-bottom: 40px; | |
| } | |
| .query-section h2 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .query-input-row { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .query-input { | |
| flex: 1; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| color: var(--text); | |
| font-family: 'SF Mono', 'Fira Code', monospace; | |
| font-size: 0.85rem; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| .query-input:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px var(--accent-glow); | |
| } | |
| .query-btn { | |
| padding: 12px 24px; | |
| background: var(--accent); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| white-space: nowrap; | |
| } | |
| .query-btn:hover { | |
| background: #5558e6; | |
| box-shadow: 0 4px 15px var(--accent-glow); | |
| } | |
| .query-result { | |
| margin-top: 16px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 16px; | |
| font-family: 'SF Mono', 'Fira Code', monospace; | |
| font-size: 0.8rem; | |
| color: var(--text-dim); | |
| max-height: 300px; | |
| overflow: auto; | |
| white-space: pre-wrap; | |
| display: none; | |
| } | |
| .query-result.visible { display: block; } | |
| /* ββ API reference ββ */ | |
| .api-section { | |
| margin-bottom: 40px; | |
| } | |
| .api-section h2 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .api-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px 16px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| font-size: 0.85rem; | |
| transition: border-color 0.2s; | |
| } | |
| .api-row:hover { border-color: var(--accent); } | |
| .method { | |
| font-weight: 700; | |
| font-size: 0.7rem; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| background: var(--accent-glow); | |
| color: var(--accent); | |
| min-width: 40px; | |
| text-align: center; | |
| } | |
| .api-path { | |
| font-family: 'SF Mono', 'Fira Code', monospace; | |
| color: var(--text); | |
| font-weight: 500; | |
| } | |
| .api-desc { | |
| color: var(--text-dim); | |
| margin-left: auto; | |
| font-size: 0.8rem; | |
| } | |
| /* ββ Footer ββ */ | |
| .footer { | |
| text-align: center; | |
| color: var(--text-dim); | |
| font-size: 0.78rem; | |
| padding-top: 20px; | |
| border-top: 1px solid var(--border); | |
| } | |
| .footer a { | |
| color: var(--accent); | |
| text-decoration: none; | |
| } | |
| .footer a:hover { text-decoration: underline; } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 600px) { | |
| .container { padding: 24px 16px 40px; } | |
| h1 { font-size: 1.5rem; } | |
| .card-grid { grid-template-columns: 1fr; } | |
| .query-input-row { flex-direction: column; } | |
| .api-desc { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <!-- Header --> | |
| <div class="header"> | |
| <div class="logo-icon">π</div> | |
| <h1>Market-Data Observatory</h1> | |
| <p class="subtitle">ClickHouse-powered analytics engine on Hugging Face Spaces</p> | |
| </div> | |
| <!-- Live status --> | |
| <div class="status-bar" id="statusBar"> | |
| <span class="status-chip pending" id="chStatus"><span class="dot"></span>ClickHouse: checking...</span> | |
| <span class="status-chip pending" id="syncStatus"><span class="dot"></span>Data: checking...</span> | |
| </div> | |
| <!-- Navigation cards --> | |
| <div class="card-grid"> | |
| <a class="card" href="/play"> | |
| <div class="card-icon">β‘</div> | |
| <div class="card-title">Web SQL UI</div> | |
| <div class="card-desc">Interactive SQL editor with syntax highlighting and instant results</div> | |
| </a> | |
| <a class="card" href="/dashboard"> | |
| <div class="card-icon">π</div> | |
| <div class="card-title">Dashboard</div> | |
| <div class="card-desc">Visual dashboard for exploring tables and running saved queries</div> | |
| </a> | |
| <a class="card" href="/api/status"> | |
| <div class="card-icon">π©Ί</div> | |
| <div class="card-title">System Status</div> | |
| <div class="card-desc">Health check, sync state, repo status, and last refresh timestamps</div> | |
| </a> | |
| <a class="card" href="/api/refresh"> | |
| <div class="card-icon">π</div> | |
| <div class="card-title">Refresh Data</div> | |
| <div class="card-desc">Pull latest data from all repos and re-run SQL initialization scripts</div> | |
| </a> | |
| <a class="card" href="/api/sources"> | |
| <div class="card-icon">π</div> | |
| <div class="card-title">View Config</div> | |
| <div class="card-desc">Current sources.yaml configuration as JSON</div> | |
| </a> | |
| <a class="card" href="https://clickhouse.com/docs" target="_blank" rel="noopener"> | |
| <div class="card-icon">π</div> | |
| <div class="card-title">ClickHouse Docs</div> | |
| <div class="card-desc">Official ClickHouse documentation and SQL reference</div> | |
| </a> | |
| </div> | |
| <!-- Quick query --> | |
| <div class="query-section"> | |
| <h2>β‘ Quick Query</h2> | |
| <div class="query-input-row"> | |
| <input class="query-input" id="queryInput" type="text" placeholder="SELECT count() FROM ohlc" spellcheck="false"> | |
| <button class="query-btn" id="queryBtn" onclick="runQuery()">Run</button> | |
| </div> | |
| <pre class="query-result" id="queryResult"></pre> | |
| </div> | |
| <!-- API reference --> | |
| <div class="api-section"> | |
| <h2>π API Endpoints</h2> | |
| <div class="api-row"> | |
| <span class="method">GET</span> | |
| <span class="api-path">/?query=SELECT 1</span> | |
| <span class="api-desc">Raw SQL via query parameter</span> | |
| </div> | |
| <div class="api-row"> | |
| <span class="method">POST</span> | |
| <span class="api-path">/</span> | |
| <span class="api-desc">SQL in request body (for large queries)</span> | |
| </div> | |
| <div class="api-row"> | |
| <span class="method">GET</span> | |
| <span class="api-path">/api/refresh?target=all</span> | |
| <span class="api-desc">Sync repos β target: sql | data | all</span> | |
| </div> | |
| <div class="api-row"> | |
| <span class="method">GET</span> | |
| <span class="api-path">/api/status</span> | |
| <span class="api-desc">Health + sync state</span> | |
| </div> | |
| <div class="api-row"> | |
| <span class="method">GET</span> | |
| <span class="api-path">/play</span> | |
| <span class="api-desc">Built-in ClickHouse SQL playground</span> | |
| </div> | |
| </div> | |
| <!-- Footer --> | |
| <div class="footer"> | |
| Powered by <a href="https://clickhouse.com" target="_blank" rel="noopener">ClickHouse</a> Β· | |
| Hosted on <a href="https://huggingface.co/spaces" target="_blank" rel="noopener">Hugging Face Spaces</a> | |
| </div> | |
| </div> | |
| <script> | |
| // ββ Fetch live status ββ | |
| async function fetchStatus() { | |
| try { | |
| const res = await fetch('/api/status'); | |
| const data = await res.json(); | |
| const chEl = document.getElementById('chStatus'); | |
| const syncEl = document.getElementById('syncStatus'); | |
| // ClickHouse status | |
| const chOk = data.clickhouse === 'ok'; | |
| chEl.className = 'status-chip ' + (chOk ? 'ok' : 'error'); | |
| chEl.innerHTML = '<span class="dot"></span>ClickHouse: ' + (chOk ? 'online' : data.clickhouse); | |
| // Data sync status | |
| const sync = data.data_sync || {}; | |
| const sMap = { ready: 'ok', syncing: 'syncing', failed: 'error', pending: 'pending' }; | |
| const sClass = sMap[sync.status] || 'pending'; | |
| syncEl.className = 'status-chip ' + sClass; | |
| syncEl.innerHTML = '<span class="dot"></span>Data: ' + (sync.status || 'unknown'); | |
| } catch (e) { | |
| // Status endpoint not available yet | |
| } | |
| } | |
| fetchStatus(); | |
| setInterval(fetchStatus, 10000); | |
| // ββ Quick query ββ | |
| async function runQuery() { | |
| const input = document.getElementById('queryInput'); | |
| const result = document.getElementById('queryResult'); | |
| const btn = document.getElementById('queryBtn'); | |
| const sql = input.value.trim(); | |
| if (!sql) return; | |
| btn.textContent = '...'; | |
| btn.disabled = true; | |
| result.classList.add('visible'); | |
| result.textContent = 'Running...'; | |
| try { | |
| const res = await fetch('/?query=' + encodeURIComponent(sql + ' FORMAT PrettyCompact')); | |
| result.textContent = await res.text(); | |
| } catch (e) { | |
| result.textContent = 'Error: ' + e.message; | |
| } finally { | |
| btn.textContent = 'Run'; | |
| btn.disabled = false; | |
| } | |
| } | |
| // Enter key to run | |
| document.getElementById('queryInput').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') runQuery(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |