Spaces:
Running
Running
| <html lang="ar" dir="rtl"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>🦆 Duck.ai Dashboard</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #080b14; | |
| --surface: #0f1420; | |
| --surface2: #161c2d; | |
| --border: #1e2640; | |
| --border2: #252d45; | |
| --text: #d4daf0; | |
| --muted: #5a6280; | |
| --blue: #4fc3f7; | |
| --green: #43e97b; | |
| --orange: #f9a825; | |
| --red: #ef5350; | |
| --purple: #b39ddb; | |
| --cyan: #26c6da; | |
| --glow-blue: 0 0 20px #4fc3f730; | |
| --glow-green:0 0 20px #43e97b30; | |
| --radius: 14px; | |
| } | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'Inter', 'Segoe UI', sans-serif; | |
| padding: 24px; | |
| min-height: 100vh; | |
| background-image: | |
| radial-gradient(ellipse 800px 400px at 20% 0%, #0d1f3c44 0%, transparent 70%), | |
| radial-gradient(ellipse 600px 300px at 80% 100%, #1a0d2e33 0%, transparent 70%); | |
| } | |
| /* ── Header ── */ | |
| .header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 28px; | |
| position: relative; | |
| } | |
| .header h1 { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, #4fc3f7, #43e97b); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| letter-spacing: -0.3px; | |
| } | |
| .header-sub { | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| margin-top: 4px; | |
| margin-bottom: 28px; | |
| } | |
| .live-dot { | |
| display: inline-block; | |
| width: 7px; height: 7px; | |
| background: var(--green); | |
| border-radius: 50%; | |
| margin-left: 6px; | |
| animation: pulse 2s infinite; | |
| vertical-align: middle; | |
| } | |
| @keyframes pulse { | |
| 0%,100% { opacity:1; box-shadow: 0 0 0 0 #43e97b60; } | |
| 50% { opacity:.7; box-shadow: 0 0 0 5px #43e97b00; } | |
| } | |
| /* ── Logout ── */ | |
| #logout-btn { | |
| position: fixed; top: 20px; left: 20px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| padding: 7px 14px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 0.78rem; | |
| z-index: 100; | |
| transition: all .2s; | |
| font-family: inherit; | |
| } | |
| #logout-btn:hover { color: var(--red); border-color: #ef535040; background: #ef535010; } | |
| /* ── KPI Grid ── */ | |
| .grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); | |
| gap: 14px; | |
| margin-bottom: 20px; | |
| } | |
| .card { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 18px 20px; | |
| border: 1px solid var(--border); | |
| position: relative; | |
| overflow: hidden; | |
| transition: border-color .3s, transform .2s; | |
| } | |
| .card:hover { border-color: var(--border2); transform: translateY(-1px); } | |
| .card::before { | |
| content: ''; | |
| position: absolute; top:0; left:0; right:0; | |
| height: 2px; | |
| background: var(--card-accent, linear-gradient(90deg, #4fc3f7, #43e97b)); | |
| opacity: .7; | |
| } | |
| .card.accent-green { --card-accent: linear-gradient(90deg,#43e97b,#26c6da); } | |
| .card.accent-red { --card-accent: linear-gradient(90deg,#ef5350,#f9a825); } | |
| .card.accent-orange { --card-accent: linear-gradient(90deg,#f9a825,#ff8f00); } | |
| .card.accent-purple { --card-accent: linear-gradient(90deg,#b39ddb,#7e57c2); } | |
| .card.accent-blue { --card-accent: linear-gradient(90deg,#4fc3f7,#0288d1); } | |
| .card.accent-cyan { --card-accent: linear-gradient(90deg,#26c6da,#43e97b); } | |
| .card .icon { | |
| font-size: 1.4rem; | |
| margin-bottom: 10px; | |
| display: block; | |
| } | |
| .card .label { | |
| font-size: 0.7rem; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| margin-bottom: 6px; | |
| font-weight: 500; | |
| } | |
| .card .value { | |
| font-size: 1.9rem; | |
| font-weight: 700; | |
| line-height: 1; | |
| } | |
| .card .value.green { color: var(--green); } | |
| .card .value.blue { color: var(--blue); } | |
| .card .value.orange { color: var(--orange); } | |
| .card .value.red { color: var(--red); } | |
| .card .value.purple { color: var(--purple); } | |
| .card .value.cyan { color: var(--cyan); } | |
| /* ── Section ── */ | |
| .section { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 20px 22px; | |
| margin-bottom: 18px; | |
| border: 1px solid var(--border); | |
| } | |
| .section-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 18px; | |
| } | |
| .section-header h2 { | |
| color: var(--text); | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| letter-spacing: 0.2px; | |
| } | |
| .section-header .sh-icon { | |
| font-size: 1rem; | |
| } | |
| /* ── Table ── */ | |
| table { width: 100%; border-collapse: collapse; } | |
| thead tr { border-bottom: 1px solid var(--border); } | |
| th { | |
| text-align: right; | |
| padding: 10px 12px; | |
| color: var(--muted); | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: .8px; | |
| } | |
| td { | |
| padding: 12px; | |
| border-bottom: 1px solid var(--border); | |
| font-size: 0.87rem; | |
| color: var(--text); | |
| } | |
| tbody tr:last-child td { border-bottom: none; } | |
| tbody tr:hover td { background: #ffffff05; } | |
| /* ── Badge ── */ | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| } | |
| .badge.busy { background: #ef535015; color: var(--red); border: 1px solid #ef535035; } | |
| .badge.free { background: #43e97b15; color: var(--green); border: 1px solid #43e97b35; } | |
| /* ── Progress Bar ── */ | |
| .bar-wrap { | |
| background: var(--surface2); | |
| border-radius: 20px; | |
| height: 8px; | |
| overflow: hidden; | |
| } | |
| .bar { | |
| height: 100%; | |
| border-radius: 20px; | |
| transition: width .6s cubic-bezier(.4,0,.2,1); | |
| background: linear-gradient(90deg, var(--blue), var(--cyan)); | |
| } | |
| .bar.warn { background: linear-gradient(90deg, var(--orange), var(--red)); } | |
| /* ── Charts ── */ | |
| .chart-wrap { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 18px; | |
| margin-bottom: 18px; | |
| } | |
| @media (max-width: 700px) { | |
| .chart-wrap { grid-template-columns: 1fr; } | |
| .grid { grid-template-columns: repeat(2, 1fr); } | |
| } | |
| canvas { | |
| background: transparent; | |
| border-radius: 8px; | |
| display: block; | |
| width: 100%; | |
| } | |
| /* ── Log ── */ | |
| .log-box { | |
| background: #040608; | |
| border-radius: 8px; | |
| padding: 14px 16px; | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.78rem; | |
| height: 200px; | |
| overflow-y: auto; | |
| color: var(--muted); | |
| border: 1px solid var(--border); | |
| line-height: 1.8; | |
| } | |
| .log-box::-webkit-scrollbar { width: 4px; } | |
| .log-box::-webkit-scrollbar-track { background: transparent; } | |
| .log-box::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; } | |
| .log-box .ok { color: var(--green); } | |
| .log-box .err { color: var(--red); } | |
| .log-box .info { color: var(--blue); } | |
| .log-box .warn { color: var(--orange); } | |
| /* ── Footer ── */ | |
| .footer { | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: 0.73rem; | |
| margin-top: 16px; | |
| padding: 10px 0; | |
| } | |
| /* ── Login Overlay ── */ | |
| #login-overlay { | |
| display: none; | |
| position: fixed; inset: 0; | |
| background: #080b14f0; | |
| backdrop-filter: blur(12px); | |
| z-index: 999; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #login-overlay.show { display: flex; } | |
| .login-box { | |
| background: var(--surface); | |
| border: 1px solid var(--border2); | |
| border-radius: 20px; | |
| padding: 44px 40px; | |
| width: 380px; | |
| text-align: center; | |
| box-shadow: 0 40px 80px #00000080, var(--glow-blue); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .login-box::before { | |
| content: ''; | |
| position: absolute; top:0; left:0; right:0; height:2px; | |
| background: linear-gradient(90deg, #4fc3f7, #43e97b); | |
| } | |
| .login-duck { font-size: 3rem; margin-bottom: 12px; display: block; } | |
| .login-box h2 { | |
| font-size: 1.25rem; font-weight: 700; | |
| background: linear-gradient(135deg, #4fc3f7, #43e97b); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| margin-bottom: 6px; | |
| } | |
| .login-box p { color: var(--muted); font-size: 0.83rem; margin-bottom: 28px; } | |
| .login-box input { | |
| width: 100%; padding: 13px 16px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; color: var(--text); | |
| font-size: 0.93rem; margin-bottom: 14px; | |
| outline: none; direction: ltr; | |
| transition: border-color .2s; | |
| font-family: inherit; | |
| } | |
| .login-box input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px #4fc3f715; } | |
| .login-btn { | |
| width: 100%; padding: 13px; | |
| background: linear-gradient(135deg, #4fc3f7, #43e97b); | |
| border: none; border-radius: 10px; | |
| color: #050810; font-weight: 700; | |
| font-size: 0.97rem; cursor: pointer; | |
| transition: opacity .2s, transform .1s; | |
| font-family: inherit; | |
| } | |
| .login-btn:hover { opacity: .92; } | |
| .login-btn:active { transform: scale(.98); } | |
| .login-err { color: var(--red); font-size: 0.8rem; margin-top: 12px; display: none; } | |
| /* ── Uptime badge ── */ | |
| .uptime-row { | |
| display: flex; gap: 10px; flex-wrap: wrap; | |
| margin-bottom: 18px; | |
| } | |
| .uptime-chip { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 5px 14px; | |
| font-size: 0.75rem; | |
| color: var(--muted); | |
| } | |
| .uptime-chip span { color: var(--text); font-weight: 600; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ═══════════ LOGIN ═══════════ --> | |
| <div id="login-overlay" class="show"> | |
| <div class="login-box"> | |
| <span class="login-duck">🦆</span> | |
| <h2>Duck.ai Dashboard</h2> | |
| <p>أدخل API Key للدخول إلى لوحة المراقبة</p> | |
| <input type="password" id="key-input" placeholder="sk-..." autocomplete="current-password" /> | |
| <button class="login-btn" onclick="doLogin()">دخول ←</button> | |
| <div class="login-err" id="login-err">❌ مفتاح خاطئ، حاول مجدداً</div> | |
| </div> | |
| </div> | |
| <!-- ═══════════ LOGOUT ═══════════ --> | |
| <button id="logout-btn" onclick="doLogout()">🔓 خروج</button> | |
| <!-- ═══════════ HEADER ═══════════ --> | |
| <div class="header"> | |
| <div> | |
| <h1>🦆 Duck.ai API <span style="font-size:.9rem;font-weight:400;opacity:.6">لوحة المراقبة</span></h1> | |
| </div> | |
| </div> | |
| <div class="header-sub"> | |
| <span class="live-dot"></span> | |
| مراقبة مباشرة — يتجدد كل <strong id="interval_val" style="color:var(--text)">5</strong> ثوان | |
| • آخر تحديث: <strong id="last_update" style="color:var(--text)">—</strong> | |
| </div> | |
| <!-- ═══════════ UPTIME CHIPS ═══════════ --> | |
| <div class="uptime-row" id="uptime_row" style="display:none"> | |
| <div class="uptime-chip">Pool Size: <span id="chip_pool">—</span></div> | |
| <div class="uptime-chip">Queue Timeout: <span id="chip_timeout">—</span>s</div> | |
| <div class="uptime-chip">طلبات مرفوضة: <span id="chip_rej">0</span></div> | |
| <div class="uptime-chip">RAM %: <span id="chip_ram">—</span></div> | |
| </div> | |
| <!-- ═══════════ KPI GRID ═══════════ --> | |
| <div class="grid"> | |
| <div class="card accent-blue"> | |
| <span class="icon">📊</span> | |
| <div class="label">إجمالي الطلبات</div> | |
| <div class="value blue" id="total_req">—</div> | |
| </div> | |
| <div class="card accent-green"> | |
| <span class="icon">📅</span> | |
| <div class="label">طلبات اليوم</div> | |
| <div class="value green" id="today_req">—</div> | |
| </div> | |
| <div class="card accent-red"> | |
| <span class="icon">🚫</span> | |
| <div class="label">مرفوضة (Queue Full)</div> | |
| <div class="value red" id="rejected">—</div> | |
| </div> | |
| <div class="card accent-green"> | |
| <span class="icon">✅</span> | |
| <div class="label">Workers حرة</div> | |
| <div class="value green" id="workers_free">—</div> | |
| </div> | |
| <div class="card accent-orange"> | |
| <span class="icon">⚙️</span> | |
| <div class="label">Workers مشغولة</div> | |
| <div class="value orange" id="workers_busy">—</div> | |
| </div> | |
| <div class="card accent-purple"> | |
| <span class="icon">💾</span> | |
| <div class="label">RAM المستخدم</div> | |
| <div class="value purple" id="ram_used">—</div> | |
| </div> | |
| <div class="card accent-cyan"> | |
| <span class="icon">🖥️</span> | |
| <div class="label">RAM الكلي</div> | |
| <div class="value cyan" id="ram_total">—</div> | |
| </div> | |
| <div class="card accent-orange"> | |
| <span class="icon">🔥</span> | |
| <div class="label">CPU %</div> | |
| <div class="value orange" id="cpu">—</div> | |
| </div> | |
| </div> | |
| <!-- ═══════════ WORKERS TABLE ═══════════ --> | |
| <div class="section"> | |
| <div class="section-header"> | |
| <span class="sh-icon">⚙️</span> | |
| <h2>حالة Workers</h2> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Worker</th> | |
| <th>الحالة</th> | |
| <th>إجمالي الطلبات</th> | |
| <th>حتى التجديد</th> | |
| <th>شريط التقدم</th> | |
| </tr> | |
| </thead> | |
| <tbody id="workers_table"></tbody> | |
| </table> | |
| </div> | |
| <!-- ═══════════ CHARTS ═══════════ --> | |
| <div class="chart-wrap"> | |
| <div class="section" style="margin:0"> | |
| <div class="section-header"> | |
| <span class="sh-icon">📈</span> | |
| <h2>الطلبات — آخر 60 دقيقة</h2> | |
| </div> | |
| <canvas id="reqChart"></canvas> | |
| </div> | |
| <div class="section" style="margin:0"> | |
| <div class="section-header"> | |
| <span class="sh-icon">💾</span> | |
| <h2>RAM % — آخر 60 دقيقة</h2> | |
| </div> | |
| <canvas id="ramChart"></canvas> | |
| </div> | |
| </div> | |
| <!-- ═══════════ LIVE LOG ═══════════ --> | |
| <div class="section"> | |
| <div class="section-header"> | |
| <span class="sh-icon">📋</span> | |
| <h2>السجل المباشر</h2> | |
| </div> | |
| <div class="log-box" id="log_box"></div> | |
| </div> | |
| <div class="footer"> | |
| 🦆 Duck.ai API Pool Server • | |
| يتجدد كل <span id="interval_val2">5</span> ثوان | |
| </div> | |
| <!-- ═══════════ SCRIPT (منطق محفوظ بالكامل) ═══════════ --> | |
| <script> | |
| // ==================================================================== | |
| // Auth | |
| // ==================================================================== | |
| const urlToken = new URLSearchParams(window.location.search).get('token'); | |
| if (urlToken) { | |
| localStorage.setItem('duck_token', urlToken); | |
| window.history.replaceState({}, '', window.location.pathname); | |
| } | |
| let TOKEN = localStorage.getItem('duck_token') || ''; | |
| function doLogin() { | |
| const val = document.getElementById('key-input').value.trim(); | |
| if (!val) return; | |
| TOKEN = val; | |
| localStorage.setItem('duck_token', TOKEN); | |
| document.getElementById('login-err').style.display = 'none'; | |
| fetchData(); | |
| } | |
| function doLogout() { | |
| localStorage.removeItem('duck_token'); | |
| TOKEN = ''; | |
| document.getElementById('login-overlay').classList.add('show'); | |
| document.getElementById('key-input').value = ''; | |
| } | |
| function showLogin(msg) { | |
| document.getElementById('login-overlay').classList.add('show'); | |
| if (msg) { | |
| const err = document.getElementById('login-err'); | |
| err.textContent = msg; | |
| err.style.display = 'block'; | |
| } | |
| } | |
| document.getElementById('key-input').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') doLogin(); | |
| }); | |
| if (TOKEN) { | |
| document.getElementById('login-overlay').classList.remove('show'); | |
| } | |
| // ==================================================================== | |
| // Data & Charts | |
| // ==================================================================== | |
| const REFRESH = 5000; | |
| let reqHistory = Array(60).fill(0); | |
| let ramHistory = Array(60).fill(0); | |
| let logs = []; | |
| let lastTotal = 0; | |
| const todayKey = () => new Date().toISOString().slice(0, 10); | |
| let todayCount = parseInt(localStorage.getItem('today_' + todayKey()) || '0'); | |
| let lastDay = localStorage.getItem('last_day') || todayKey(); | |
| async function fetchData() { | |
| if (!TOKEN) return; | |
| try { | |
| const r = await fetch('/health', { | |
| headers: { 'Authorization': 'Bearer ' + TOKEN } | |
| }); | |
| if (r.status === 401) { | |
| showLogin('❌ مفتاح خاطئ، حاول مجدداً'); | |
| TOKEN = ''; | |
| localStorage.removeItem('duck_token'); | |
| return; | |
| } | |
| const d = await r.json(); | |
| document.getElementById('login-overlay').classList.remove('show'); | |
| // ── Uptime chips ────────────────────────────────────── | |
| document.getElementById('uptime_row').style.display = 'flex'; | |
| document.getElementById('chip_pool').textContent = d.pool_size ?? '—'; | |
| document.getElementById('chip_timeout').textContent = d.queue_timeout_sec ?? '—'; | |
| document.getElementById('chip_rej').textContent = d.rejected_requests ?? 0; | |
| document.getElementById('chip_ram').textContent = d.ram?.percent ? d.ram.percent + '%' : '—'; | |
| // ── KPIs ────────────────────────────────────────────── | |
| document.getElementById('total_req').textContent = (d.total_requests ?? 0).toLocaleString(); | |
| document.getElementById('rejected').textContent = d.rejected_requests ?? 0; | |
| document.getElementById('workers_free').textContent = d.workers_free ?? '—'; | |
| document.getElementById('workers_busy').textContent = d.workers_busy ?? '—'; | |
| if (d.ram) { | |
| document.getElementById('ram_used').textContent = d.ram.used_gb + ' GB'; | |
| document.getElementById('ram_total').textContent = d.ram.total_gb + ' GB'; | |
| } | |
| if (d.cpu !== undefined) { | |
| const cpuEl = document.getElementById('cpu'); | |
| cpuEl.textContent = d.cpu + '%'; | |
| cpuEl.className = 'value ' + (d.cpu > 80 ? 'red' : d.cpu > 50 ? 'orange' : 'green'); | |
| } | |
| // ── Today counter ───────────────────────────────────── | |
| const diff = (d.total_requests ?? 0) - lastTotal; | |
| if (diff > 0) { | |
| if (lastDay !== todayKey()) { | |
| todayCount = 0; | |
| lastDay = todayKey(); | |
| localStorage.setItem('last_day', lastDay); | |
| } | |
| todayCount += diff; | |
| localStorage.setItem('today_' + todayKey(), todayCount); | |
| lastTotal = d.total_requests ?? 0; | |
| } | |
| document.getElementById('today_req').textContent = todayCount.toLocaleString(); | |
| // ── Workers table ───────────────────────────────────── | |
| const tbody = document.getElementById('workers_table'); | |
| tbody.innerHTML = ''; | |
| (d.workers || []).forEach(w => { | |
| const maxR = parseInt(d.pool_size) || 30; | |
| const used = maxR - w.requests_until_rotation; | |
| const pct = Math.round((used / maxR) * 100); | |
| const warn = pct > 80 ? 'warn' : ''; | |
| tbody.innerHTML += ` | |
| <tr> | |
| <td><strong style="color:var(--blue)">W${w.id}</strong></td> | |
| <td><span class="badge ${w.busy ? 'busy' : 'free'}">${w.busy ? '● مشغول' : '● حر'}</span></td> | |
| <td>${w.total_requests.toLocaleString()}</td> | |
| <td><span style="color:${w.requests_until_rotation < 5 ? 'var(--red)' : 'var(--text)'}">${w.requests_until_rotation}</span></td> | |
| <td style="min-width:140px"> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| <div class="bar-wrap" style="flex:1"> | |
| <div class="bar ${warn}" style="width:${pct}%"></div> | |
| </div> | |
| <span style="font-size:.72rem;color:var(--muted);width:30px;text-align:left">${pct}%</span> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }); | |
| // ── History ─────────────────────────────────────────── | |
| reqHistory.push(diff > 0 ? diff : 0); reqHistory.shift(); | |
| if (d.ram) { ramHistory.push(d.ram.percent); ramHistory.shift(); } | |
| drawChart('reqChart', reqHistory, '#4fc3f7', '#26c6da'); | |
| drawChart('ramChart', ramHistory, '#b39ddb', '#7e57c2', 100); | |
| // ── Log ─────────────────────────────────────────────── | |
| addLog( | |
| `total=${(d.total_requests??0).toLocaleString()} | free=${d.workers_free} | busy=${d.workers_busy} | RAM=${d.ram?.percent ?? '?'}% | CPU=${d.cpu ?? '?'}%`, | |
| 'ok' | |
| ); | |
| document.getElementById('last_update').textContent = | |
| new Date().toLocaleTimeString('ar-EG'); | |
| } catch (e) { | |
| addLog('فشل الاتصال: ' + e.message, 'err'); | |
| } | |
| } | |
| function addLog(msg, cls = 'info') { | |
| const now = new Date().toLocaleTimeString('ar-EG'); | |
| logs.unshift(`<span class="${cls}">[${now}]</span> <span style="color:#3a4060">›</span> ${msg}`); | |
| if (logs.length > 80) logs.pop(); | |
| document.getElementById('log_box').innerHTML = logs.join('<br>'); | |
| } | |
| function drawChart(id, data, color1, color2, maxVal = null) { | |
| const canvas = document.getElementById(id); | |
| const ctx = canvas.getContext('2d'); | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| canvas.width = canvas.parentElement.clientWidth - 44; | |
| canvas.height = 130; | |
| const W = canvas.width, H = canvas.height; | |
| const max = maxVal ?? Math.max(...data, 1); | |
| ctx.clearRect(0, 0, W, H); | |
| // grid | |
| ctx.strokeStyle = '#1e264030'; | |
| ctx.lineWidth = 1; | |
| [0.25, 0.5, 0.75].forEach(f => { | |
| ctx.beginPath(); ctx.moveTo(0, H * f); ctx.lineTo(W, H * f); ctx.stroke(); | |
| }); | |
| // gradient fill | |
| const grad = ctx.createLinearGradient(0, 0, W, 0); | |
| grad.addColorStop(0, color1); | |
| grad.addColorStop(1, color2 || color1); | |
| const areaGrad = ctx.createLinearGradient(0, 0, 0, H); | |
| areaGrad.addColorStop(0, color1 + '44'); | |
| areaGrad.addColorStop(1, color1 + '00'); | |
| const step = W / (data.length - 1); | |
| ctx.beginPath(); | |
| data.forEach((v, i) => { | |
| const x = i * step; | |
| const y = H - (v / max) * (H - 14) - 7; | |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
| }); | |
| // stroke | |
| ctx.strokeStyle = grad; | |
| ctx.lineWidth = 2.5; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineCap = 'round'; | |
| ctx.stroke(); | |
| // area | |
| ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); | |
| ctx.fillStyle = areaGrad; | |
| ctx.fill(); | |
| } | |
| // ==================================================================== | |
| // Start | |
| // ==================================================================== | |
| document.getElementById('interval_val').textContent = REFRESH / 1000; | |
| document.getElementById('interval_val2').textContent = REFRESH / 1000; | |
| if (TOKEN) fetchData(); | |
| setInterval(fetchData, REFRESH); | |
| </script> | |
| </body> | |
| </html> |