|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
|
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Crypto Monitor ULTIMATE - Unified Dashboard</title> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" |
|
|
rel="stylesheet"> |
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
|
|
<link rel="stylesheet" href="/static/css/connection-status.css"> |
|
|
<script src="/static/js/websocket-client.js"></script> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
:root { |
|
|
--bg-dark: #0a0e1a; |
|
|
--bg-card: #111827; |
|
|
--bg-card-hover: #1f2937; |
|
|
--text-primary: #f9fafb; |
|
|
--text-secondary: #9ca3af; |
|
|
--accent-blue: #3b82f6; |
|
|
--accent-green: #10b981; |
|
|
--accent-red: #ef4444; |
|
|
--accent-yellow: #f59e0b; |
|
|
--accent-purple: #8b5cf6; |
|
|
--accent-pink: #ec4899; |
|
|
--border: rgba(255, 255, 255, 0.1); |
|
|
--shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; |
|
|
background: var(--bg-dark); |
|
|
color: var(--text-primary); |
|
|
line-height: 1.6; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
body::before { |
|
|
content: ''; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: |
|
|
radial-gradient(circle at 20% 30%, rgba(59, 130, 246, 0.15) 0%, transparent 40%), |
|
|
radial-gradient(circle at 80% 70%, rgba(139, 92, 246, 0.15) 0%, transparent 40%), |
|
|
radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.1) 0%, transparent 30%); |
|
|
pointer-events: none; |
|
|
z-index: 0; |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1920px; |
|
|
margin: 0 auto; |
|
|
padding: 20px; |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
|
|
|
.header { |
|
|
background: linear-gradient(135deg, rgba(17, 24, 39, 0.8) 0%, rgba(31, 41, 55, 0.4) 100%); |
|
|
backdrop-filter: blur(20px); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 24px; |
|
|
padding: 30px; |
|
|
margin-bottom: 30px; |
|
|
box-shadow: var(--shadow); |
|
|
} |
|
|
|
|
|
.header-top { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
flex-wrap: wrap; |
|
|
gap: 20px; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.logo { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 15px; |
|
|
} |
|
|
|
|
|
.logo-icon { |
|
|
width: 60px; |
|
|
height: 60px; |
|
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899); |
|
|
border-radius: 16px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
font-size: 32px; |
|
|
box-shadow: 0 10px 40px rgba(59, 130, 246, 0.4); |
|
|
animation: pulse-glow 3s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse-glow { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
box-shadow: 0 10px 40px rgba(59, 130, 246, 0.4); |
|
|
} |
|
|
|
|
|
50% { |
|
|
box-shadow: 0 10px 60px rgba(139, 92, 246, 0.6); |
|
|
} |
|
|
} |
|
|
|
|
|
.logo-text h1 { |
|
|
font-size: 32px; |
|
|
font-weight: 900; |
|
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
letter-spacing: -1px; |
|
|
} |
|
|
|
|
|
.logo-text p { |
|
|
font-size: 14px; |
|
|
color: var(--text-secondary); |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.header-actions { |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
align-items: center; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.status-badge { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
padding: 12px 24px; |
|
|
background: rgba(16, 185, 129, 0.15); |
|
|
border: 1px solid rgba(16, 185, 129, 0.3); |
|
|
border-radius: 12px; |
|
|
font-size: 14px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.status-dot { |
|
|
width: 10px; |
|
|
height: 10px; |
|
|
background: var(--accent-green); |
|
|
border-radius: 50%; |
|
|
animation: pulse 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
opacity: 1; |
|
|
transform: scale(1); |
|
|
} |
|
|
|
|
|
50% { |
|
|
opacity: 0.5; |
|
|
transform: scale(1.2); |
|
|
} |
|
|
} |
|
|
|
|
|
.live-indicator { |
|
|
padding: 8px 16px; |
|
|
background: rgba(239, 68, 68, 0.15); |
|
|
border: 1px solid rgba(239, 68, 68, 0.3); |
|
|
border-radius: 8px; |
|
|
font-size: 12px; |
|
|
font-weight: 700; |
|
|
text-transform: uppercase; |
|
|
color: var(--accent-red); |
|
|
animation: blink 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes blink { |
|
|
|
|
|
0%, |
|
|
50%, |
|
|
100% { |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
25%, |
|
|
75% { |
|
|
opacity: 0.3; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.tabs { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
margin-bottom: 30px; |
|
|
flex-wrap: wrap; |
|
|
border-bottom: 2px solid var(--border); |
|
|
padding-bottom: 10px; |
|
|
} |
|
|
|
|
|
.tab { |
|
|
padding: 12px 24px; |
|
|
background: transparent; |
|
|
border: none; |
|
|
border-radius: 12px 12px 0 0; |
|
|
color: var(--text-secondary); |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s; |
|
|
font-size: 14px; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.tab:hover { |
|
|
color: var(--text-primary); |
|
|
background: rgba(255, 255, 255, 0.05); |
|
|
} |
|
|
|
|
|
.tab.active { |
|
|
color: var(--accent-blue); |
|
|
background: rgba(59, 130, 246, 0.1); |
|
|
} |
|
|
|
|
|
.tab.active::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
bottom: -12px; |
|
|
left: 0; |
|
|
right: 0; |
|
|
height: 3px; |
|
|
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple)); |
|
|
border-radius: 2px; |
|
|
} |
|
|
|
|
|
.tab-content { |
|
|
display: none; |
|
|
animation: fadeIn 0.3s; |
|
|
} |
|
|
|
|
|
.tab-content.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
@keyframes fadeIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(10px); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.stats-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); |
|
|
gap: 16px; |
|
|
margin-bottom: 24px; |
|
|
align-items: stretch; |
|
|
} |
|
|
|
|
|
@media (min-width: 1280px) { |
|
|
.stats-grid--market { |
|
|
grid-template-columns: repeat(5, minmax(180px, 1fr)); |
|
|
} |
|
|
} |
|
|
|
|
|
.stat-card { |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
backdrop-filter: blur(10px); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 12px; |
|
|
} |
|
|
|
|
|
.stat-card::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
height: 4px; |
|
|
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple), var(--accent-pink)); |
|
|
transform: scaleX(0); |
|
|
transition: transform 0.3s ease; |
|
|
} |
|
|
|
|
|
.stat-card:hover { |
|
|
transform: translateY(-8px) scale(1.02); |
|
|
border-color: rgba(59, 130, 246, 0.5); |
|
|
box-shadow: 0 20px 50px rgba(59, 130, 246, 0.3); |
|
|
} |
|
|
|
|
|
.stat-card:hover::before { |
|
|
transform: scaleX(1); |
|
|
} |
|
|
|
|
|
.stat-header { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.stat-icon { |
|
|
width: 44px; |
|
|
height: 44px; |
|
|
border-radius: 14px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2)); |
|
|
color: var(--accent-blue); |
|
|
} |
|
|
|
|
|
.stat-icon svg { |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
stroke: currentColor; |
|
|
stroke-width: 1.6; |
|
|
stroke-linecap: round; |
|
|
stroke-linejoin: round; |
|
|
fill: none; |
|
|
} |
|
|
|
|
|
.stat-value { |
|
|
font-size: 30px; |
|
|
font-weight: 900; |
|
|
margin-bottom: 0; |
|
|
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary)); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 13px; |
|
|
color: var(--text-secondary); |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 1px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.stat-change { |
|
|
font-size: 14px; |
|
|
font-weight: 700; |
|
|
margin-top: 12px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
} |
|
|
|
|
|
.stat-change.positive { |
|
|
color: var(--accent-green); |
|
|
} |
|
|
|
|
|
.stat-change.negative { |
|
|
color: var(--accent-red); |
|
|
} |
|
|
|
|
|
|
|
|
.market-section { |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
backdrop-filter: blur(10px); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 24px; |
|
|
padding: 30px; |
|
|
margin-bottom: 30px; |
|
|
box-shadow: var(--shadow); |
|
|
} |
|
|
|
|
|
.section-header { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
margin-bottom: 25px; |
|
|
flex-wrap: wrap; |
|
|
gap: 15px; |
|
|
} |
|
|
|
|
|
.section-title { |
|
|
font-size: 24px; |
|
|
font-weight: 800; |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
} |
|
|
|
|
|
.refresh-btn { |
|
|
padding: 10px 20px; |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
border: none; |
|
|
border-radius: 10px; |
|
|
color: white; |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.refresh-btn:hover { |
|
|
transform: scale(1.05); |
|
|
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.4); |
|
|
} |
|
|
|
|
|
table { |
|
|
width: 100%; |
|
|
border-collapse: collapse; |
|
|
} |
|
|
|
|
|
thead { |
|
|
background: rgba(59, 130, 246, 0.1); |
|
|
} |
|
|
|
|
|
th { |
|
|
padding: 16px; |
|
|
text-align: left; |
|
|
font-size: 12px; |
|
|
font-weight: 700; |
|
|
color: var(--text-secondary); |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 1px; |
|
|
} |
|
|
|
|
|
td { |
|
|
padding: 16px; |
|
|
border-top: 1px solid var(--border); |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
tr:hover { |
|
|
background: rgba(59, 130, 246, 0.05); |
|
|
} |
|
|
|
|
|
.crypto-name { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
} |
|
|
|
|
|
.crypto-img { |
|
|
width: 36px; |
|
|
height: 36px; |
|
|
border-radius: 10px; |
|
|
object-fit: cover; |
|
|
} |
|
|
|
|
|
.price { |
|
|
font-weight: 700; |
|
|
font-size: 15px; |
|
|
} |
|
|
|
|
|
.change { |
|
|
padding: 6px 12px; |
|
|
border-radius: 8px; |
|
|
font-weight: 700; |
|
|
font-size: 13px; |
|
|
} |
|
|
|
|
|
.change.positive { |
|
|
background: rgba(16, 185, 129, 0.15); |
|
|
color: var(--accent-green); |
|
|
} |
|
|
|
|
|
.change.negative { |
|
|
background: rgba(239, 68, 68, 0.15); |
|
|
color: var(--accent-red); |
|
|
} |
|
|
|
|
|
|
|
|
.chart-container { |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
backdrop-filter: blur(10px); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 24px; |
|
|
padding: 30px; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
canvas { |
|
|
max-height: 350px; |
|
|
} |
|
|
|
|
|
|
|
|
.loading { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
padding: 60px; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
border: 4px solid var(--border); |
|
|
border-top-color: var(--accent-blue); |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
to { |
|
|
transform: rotate(360deg); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-group { |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.form-label { |
|
|
display: block; |
|
|
margin-bottom: 8px; |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.form-input, |
|
|
.form-select, |
|
|
.form-textarea { |
|
|
width: 100%; |
|
|
padding: 12px; |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 8px; |
|
|
color: var(--text-primary); |
|
|
font-family: inherit; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.form-input:focus, |
|
|
.form-select:focus, |
|
|
.form-textarea:focus { |
|
|
outline: none; |
|
|
border-color: var(--accent-blue); |
|
|
} |
|
|
|
|
|
.form-textarea { |
|
|
resize: vertical; |
|
|
min-height: 100px; |
|
|
} |
|
|
|
|
|
|
|
|
.badge { |
|
|
display: inline-block; |
|
|
padding: 4px 12px; |
|
|
border-radius: 12px; |
|
|
font-size: 12px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.badge-success { |
|
|
background: rgba(16, 185, 129, 0.15); |
|
|
color: var(--accent-green); |
|
|
} |
|
|
|
|
|
.badge-warning { |
|
|
background: rgba(245, 158, 11, 0.15); |
|
|
color: var(--accent-yellow); |
|
|
} |
|
|
|
|
|
.badge-danger { |
|
|
background: rgba(239, 68, 68, 0.15); |
|
|
color: var(--accent-red); |
|
|
} |
|
|
|
|
|
.badge-info { |
|
|
background: rgba(59, 130, 246, 0.15); |
|
|
color: var(--accent-blue); |
|
|
} |
|
|
|
|
|
|
|
|
.alert { |
|
|
padding: 12px 16px; |
|
|
border-radius: 8px; |
|
|
margin-bottom: 16px; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.alert-success { |
|
|
background: rgba(16, 185, 129, 0.15); |
|
|
color: var(--accent-green); |
|
|
border-left: 4px solid var(--accent-green); |
|
|
} |
|
|
|
|
|
.alert-error { |
|
|
background: rgba(239, 68, 68, 0.15); |
|
|
color: var(--accent-red); |
|
|
border-left: 4px solid var(--accent-red); |
|
|
} |
|
|
|
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.stats-grid { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
|
|
|
.header-top { |
|
|
flex-direction: column; |
|
|
align-items: flex-start; |
|
|
} |
|
|
|
|
|
table { |
|
|
font-size: 12px; |
|
|
} |
|
|
|
|
|
th, |
|
|
td { |
|
|
padding: 10px; |
|
|
} |
|
|
|
|
|
.tabs { |
|
|
overflow-x: auto; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.modal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(0, 0, 0, 0.8); |
|
|
backdrop-filter: blur(5px); |
|
|
z-index: 1000; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
animation: fadeIn 0.3s; |
|
|
} |
|
|
|
|
|
.modal.active { |
|
|
display: flex; |
|
|
} |
|
|
|
|
|
.modal-content { |
|
|
background: var(--bg-card); |
|
|
border-radius: 20px; |
|
|
padding: 30px; |
|
|
max-width: 600px; |
|
|
width: 90%; |
|
|
max-height: 90vh; |
|
|
overflow-y: auto; |
|
|
border: 1px solid var(--border); |
|
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); |
|
|
animation: slideUp 0.3s; |
|
|
} |
|
|
|
|
|
@keyframes slideUp { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(30px); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.btn-icon { |
|
|
padding: 8px 12px; |
|
|
border-radius: 8px; |
|
|
border: 1px solid; |
|
|
cursor: pointer; |
|
|
font-size: 12px; |
|
|
font-weight: 600; |
|
|
transition: all 0.3s; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
} |
|
|
|
|
|
.btn-icon:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
|
|
} |
|
|
|
|
|
|
|
|
.pool-card-hover { |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
} |
|
|
|
|
|
.pool-card-hover:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 20px 40px rgba(59, 130, 246, 0.2); |
|
|
} |
|
|
|
|
|
|
|
|
.empty-state { |
|
|
text-align: center; |
|
|
padding: 60px 20px; |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
border-radius: 20px; |
|
|
border: 2px dashed var(--border); |
|
|
} |
|
|
|
|
|
.empty-state-icon { |
|
|
font-size: 64px; |
|
|
margin-bottom: 20px; |
|
|
opacity: 0.5; |
|
|
} |
|
|
|
|
|
|
|
|
.skeleton { |
|
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%); |
|
|
background-size: 200% 100%; |
|
|
animation: loading 1.5s infinite; |
|
|
} |
|
|
|
|
|
@keyframes loading { |
|
|
0% { |
|
|
background-position: 200% 0; |
|
|
} |
|
|
|
|
|
100% { |
|
|
background-position: -200% 0; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
::-webkit-scrollbar { |
|
|
width: 10px; |
|
|
height: 10px; |
|
|
} |
|
|
|
|
|
::-webkit-scrollbar-track { |
|
|
background: var(--bg-dark); |
|
|
} |
|
|
|
|
|
::-webkit-scrollbar-thumb { |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
border-radius: 5px; |
|
|
} |
|
|
|
|
|
::-webkit-scrollbar-thumb:hover { |
|
|
background: linear-gradient(135deg, var(--accent-purple), var(--accent-pink)); |
|
|
} |
|
|
|
|
|
|
|
|
.toast { |
|
|
position: fixed; |
|
|
bottom: 20px; |
|
|
right: 20px; |
|
|
background: var(--bg-card); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 12px; |
|
|
padding: 16px 20px; |
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); |
|
|
z-index: 2000; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
min-width: 300px; |
|
|
animation: slideInRight 0.3s; |
|
|
} |
|
|
|
|
|
@keyframes slideInRight { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateX(100%); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateX(0); |
|
|
} |
|
|
} |
|
|
|
|
|
.toast-success { |
|
|
border-left: 4px solid var(--accent-green); |
|
|
} |
|
|
|
|
|
.toast-error { |
|
|
border-left: 4px solid var(--accent-red); |
|
|
} |
|
|
|
|
|
.toast-info { |
|
|
border-left: 4px solid var(--accent-blue); |
|
|
} |
|
|
|
|
|
|
|
|
.online-users-card { |
|
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2)); |
|
|
backdrop-filter: blur(10px); |
|
|
border: 2px solid rgba(59, 130, 246, 0.3); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.online-users-card::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
height: 4px; |
|
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899); |
|
|
} |
|
|
|
|
|
.online-users-card:hover { |
|
|
border-color: rgba(59, 130, 246, 0.6); |
|
|
box-shadow: 0 20px 50px rgba(59, 130, 246, 0.4); |
|
|
} |
|
|
|
|
|
.pulse-ring { |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
right: 20px; |
|
|
transform: translateY(-50%); |
|
|
width: 60px; |
|
|
height: 60px; |
|
|
} |
|
|
|
|
|
.pulse-ring::before, |
|
|
.pulse-ring::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
border: 2px solid var(--accent-blue); |
|
|
border-radius: 50%; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
opacity: 0; |
|
|
animation: pulse-ring-animation 3s infinite; |
|
|
} |
|
|
|
|
|
.pulse-ring::after { |
|
|
animation-delay: 1.5s; |
|
|
} |
|
|
|
|
|
@keyframes pulse-ring-animation { |
|
|
0% { |
|
|
transform: scale(0.5); |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
100% { |
|
|
transform: scale(1.5); |
|
|
opacity: 0; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.ws-status-indicator { |
|
|
position: fixed; |
|
|
top: 20px; |
|
|
right: 20px; |
|
|
z-index: 10000; |
|
|
background: rgba(17, 24, 39, 0.95); |
|
|
backdrop-filter: blur(20px); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 16px; |
|
|
padding: 12px 20px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.ws-status-indicator:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.6); |
|
|
} |
|
|
|
|
|
.ws-status-indicator.connected { |
|
|
border-color: rgba(16, 185, 129, 0.5); |
|
|
} |
|
|
|
|
|
.ws-status-indicator.disconnected { |
|
|
border-color: rgba(239, 68, 68, 0.5); |
|
|
} |
|
|
|
|
|
|
|
|
.stat-value { |
|
|
animation: fadeInUp 0.5s ease; |
|
|
} |
|
|
|
|
|
@keyframes fadeInUp { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(20px); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
.shimmer { |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.shimmer::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
left: 0; |
|
|
background: linear-gradient(90deg, |
|
|
transparent, |
|
|
rgba(255, 255, 255, 0.1), |
|
|
transparent); |
|
|
animation: shimmer 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes shimmer { |
|
|
0% { |
|
|
transform: translateX(-100%); |
|
|
} |
|
|
|
|
|
100% { |
|
|
transform: translateX(100%); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.stat-icon { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.stat-icon::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
inset: -10px; |
|
|
background: inherit; |
|
|
filter: blur(20px); |
|
|
opacity: 0; |
|
|
transition: opacity 0.3s ease; |
|
|
z-index: -1; |
|
|
} |
|
|
|
|
|
.stat-card:hover .stat-icon::after { |
|
|
opacity: 0.6; |
|
|
} |
|
|
|
|
|
.count-updated { |
|
|
animation: pop-scale 0.4s ease; |
|
|
} |
|
|
|
|
|
@keyframes pop-scale { |
|
|
0% { |
|
|
transform: scale(1); |
|
|
} |
|
|
|
|
|
50% { |
|
|
transform: scale(1.08); |
|
|
} |
|
|
|
|
|
100% { |
|
|
transform: scale(1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.refresh-btn { |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.refresh-btn::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
left: 50%; |
|
|
width: 0; |
|
|
height: 0; |
|
|
border-radius: 50%; |
|
|
background: rgba(255, 255, 255, 0.3); |
|
|
transform: translate(-50%, -50%); |
|
|
transition: width 0.6s, height 0.6s; |
|
|
} |
|
|
|
|
|
.refresh-btn:active::before { |
|
|
width: 300px; |
|
|
height: 300px; |
|
|
} |
|
|
|
|
|
|
|
|
.live-indicator { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.live-indicator::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
left: -20px; |
|
|
top: 50%; |
|
|
transform: translateY(-50%); |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
background: var(--accent-red); |
|
|
border-radius: 50%; |
|
|
animation: live-pulse 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes live-pulse { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
box-shadow: 0 0 0 0 var(--accent-red); |
|
|
} |
|
|
|
|
|
50% { |
|
|
box-shadow: 0 0 0 8px transparent; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.glass-card { |
|
|
background: rgba(255, 255, 255, 0.05); |
|
|
backdrop-filter: blur(10px) saturate(180%); |
|
|
border: 1px solid rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
|
|
|
|
|
|
.animated-progress { |
|
|
position: relative; |
|
|
height: 4px; |
|
|
background: rgba(255, 255, 255, 0.1); |
|
|
border-radius: 2px; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.animated-progress::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
height: 100%; |
|
|
width: 30%; |
|
|
background: linear-gradient(90deg, |
|
|
transparent, |
|
|
var(--accent-blue), |
|
|
transparent); |
|
|
animation: progress-slide 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes progress-slide { |
|
|
0% { |
|
|
transform: translateX(-100%); |
|
|
} |
|
|
|
|
|
100% { |
|
|
transform: translateX(400%); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.ripple { |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.ripple::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
left: 50%; |
|
|
width: 0; |
|
|
height: 0; |
|
|
border-radius: 50%; |
|
|
background: rgba(255, 255, 255, 0.5); |
|
|
transform: translate(-50%, -50%); |
|
|
transition: width 0.6s, height 0.6s; |
|
|
} |
|
|
|
|
|
.ripple:active::after { |
|
|
width: 300px; |
|
|
height: 300px; |
|
|
} |
|
|
|
|
|
|
|
|
.stat-card, |
|
|
.market-section, |
|
|
.chart-container { |
|
|
animation: cardFadeIn 0.6s ease-out; |
|
|
animation-fill-mode: both; |
|
|
} |
|
|
|
|
|
.stat-card:nth-child(1) { animation-delay: 0.1s; } |
|
|
.stat-card:nth-child(2) { animation-delay: 0.2s; } |
|
|
.stat-card:nth-child(3) { animation-delay: 0.3s; } |
|
|
.stat-card:nth-child(4) { animation-delay: 0.4s; } |
|
|
.stat-card:nth-child(5) { animation-delay: 0.5s; } |
|
|
|
|
|
@keyframes cardFadeIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(30px) scale(0.95); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0) scale(1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.number-counter { |
|
|
display: inline-block; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.number-counter.updated { |
|
|
animation: numberPop 0.5s ease; |
|
|
} |
|
|
|
|
|
@keyframes numberPop { |
|
|
0%, 100% { transform: scale(1); } |
|
|
50% { transform: scale(1.15); color: var(--accent-blue); } |
|
|
} |
|
|
|
|
|
|
|
|
.skeleton-loader { |
|
|
background: linear-gradient( |
|
|
90deg, |
|
|
rgba(255, 255, 255, 0.05) 25%, |
|
|
rgba(255, 255, 255, 0.15) 50%, |
|
|
rgba(255, 255, 255, 0.05) 75% |
|
|
); |
|
|
background-size: 200% 100%; |
|
|
animation: skeleton-loading 1.5s ease-in-out infinite; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
@keyframes skeleton-loading { |
|
|
0% { background-position: 200% 0; } |
|
|
100% { background-position: -200% 0; } |
|
|
} |
|
|
|
|
|
.skeleton-text { |
|
|
height: 16px; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.skeleton-title { |
|
|
height: 24px; |
|
|
width: 60%; |
|
|
margin-bottom: 16px; |
|
|
} |
|
|
|
|
|
.skeleton-avatar { |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
border-radius: 50%; |
|
|
} |
|
|
|
|
|
|
|
|
table tbody tr { |
|
|
animation: rowSlideIn 0.4s ease-out; |
|
|
animation-fill-mode: both; |
|
|
} |
|
|
|
|
|
table tbody tr:nth-child(1) { animation-delay: 0.05s; } |
|
|
table tbody tr:nth-child(2) { animation-delay: 0.1s; } |
|
|
table tbody tr:nth-child(3) { animation-delay: 0.15s; } |
|
|
table tbody tr:nth-child(4) { animation-delay: 0.2s; } |
|
|
table tbody tr:nth-child(5) { animation-delay: 0.25s; } |
|
|
table tbody tr:nth-child(n+6) { animation-delay: 0.3s; } |
|
|
|
|
|
@keyframes rowSlideIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateX(-20px); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateX(0); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
tr { |
|
|
transition: all 0.2s ease; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
tr:hover { |
|
|
background: rgba(59, 130, 246, 0.1) !important; |
|
|
transform: translateX(5px); |
|
|
box-shadow: -5px 0 0 var(--accent-blue); |
|
|
} |
|
|
|
|
|
|
|
|
.search-container { |
|
|
position: relative; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.search-input { |
|
|
width: 100%; |
|
|
padding: 14px 20px 14px 50px; |
|
|
background: rgba(17, 24, 39, 0.8); |
|
|
border: 2px solid var(--border); |
|
|
border-radius: 12px; |
|
|
color: var(--text-primary); |
|
|
font-size: 14px; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.search-input:focus { |
|
|
outline: none; |
|
|
border-color: var(--accent-blue); |
|
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); |
|
|
background: rgba(17, 24, 39, 0.95); |
|
|
} |
|
|
|
|
|
.search-icon { |
|
|
position: absolute; |
|
|
left: 18px; |
|
|
top: 50%; |
|
|
transform: translateY(-50%); |
|
|
color: var(--text-secondary); |
|
|
font-size: 18px; |
|
|
pointer-events: none; |
|
|
} |
|
|
|
|
|
|
|
|
.filter-chips { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
flex-wrap: wrap; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.filter-chip { |
|
|
padding: 8px 16px; |
|
|
background: rgba(17, 24, 39, 0.6); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 20px; |
|
|
color: var(--text-secondary); |
|
|
font-size: 13px; |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.filter-chip:hover { |
|
|
border-color: var(--accent-blue); |
|
|
color: var(--accent-blue); |
|
|
transform: translateY(-2px); |
|
|
} |
|
|
|
|
|
.filter-chip.active { |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
border-color: transparent; |
|
|
color: white; |
|
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4); |
|
|
} |
|
|
|
|
|
|
|
|
.toast-container { |
|
|
position: fixed; |
|
|
top: 20px; |
|
|
right: 20px; |
|
|
z-index: 10000; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 12px; |
|
|
max-width: 400px; |
|
|
} |
|
|
|
|
|
.toast { |
|
|
position: relative; |
|
|
padding: 16px 20px; |
|
|
background: var(--bg-card); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
min-width: 300px; |
|
|
animation: toastSlideIn 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); |
|
|
backdrop-filter: blur(20px); |
|
|
} |
|
|
|
|
|
@keyframes toastSlideIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateX(400px) scale(0.8); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateX(0) scale(1); |
|
|
} |
|
|
} |
|
|
|
|
|
.toast-icon { |
|
|
font-size: 24px; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.toast-content { |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.toast-title { |
|
|
font-weight: 700; |
|
|
font-size: 14px; |
|
|
margin-bottom: 4px; |
|
|
} |
|
|
|
|
|
.toast-message { |
|
|
font-size: 13px; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.toast-close { |
|
|
background: none; |
|
|
border: none; |
|
|
color: var(--text-secondary); |
|
|
font-size: 20px; |
|
|
cursor: pointer; |
|
|
padding: 0; |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
border-radius: 6px; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.toast-close:hover { |
|
|
background: rgba(255, 255, 255, 0.1); |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
|
|
|
.progress-indicator { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 3px; |
|
|
background: rgba(255, 255, 255, 0.1); |
|
|
z-index: 10001; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.progress-bar { |
|
|
height: 100%; |
|
|
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple), var(--accent-pink)); |
|
|
width: 0%; |
|
|
transition: width 0.3s ease; |
|
|
animation: progress-shimmer 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes progress-shimmer { |
|
|
0% { background-position: -200% 0; } |
|
|
100% { background-position: 200% 0; } |
|
|
} |
|
|
|
|
|
|
|
|
.fab { |
|
|
position: fixed; |
|
|
bottom: 30px; |
|
|
right: 30px; |
|
|
width: 60px; |
|
|
height: 60px; |
|
|
border-radius: 50%; |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
border: none; |
|
|
color: white; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.4); |
|
|
transition: all 0.3s ease; |
|
|
z-index: 1000; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.fab:hover { |
|
|
transform: scale(1.1) rotate(90deg); |
|
|
box-shadow: 0 15px 40px rgba(59, 130, 246, 0.6); |
|
|
} |
|
|
|
|
|
.fab:active { |
|
|
transform: scale(0.95); |
|
|
} |
|
|
|
|
|
|
|
|
.feedback-overlay { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(0, 0, 0, 0.7); |
|
|
backdrop-filter: blur(5px); |
|
|
z-index: 10002; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
opacity: 0; |
|
|
pointer-events: none; |
|
|
transition: opacity 0.3s ease; |
|
|
} |
|
|
|
|
|
.feedback-overlay.show { |
|
|
opacity: 1; |
|
|
pointer-events: all; |
|
|
} |
|
|
|
|
|
.feedback-card { |
|
|
background: var(--bg-card); |
|
|
border-radius: 20px; |
|
|
padding: 40px; |
|
|
text-align: center; |
|
|
max-width: 400px; |
|
|
border: 2px solid var(--border); |
|
|
transform: scale(0.8); |
|
|
transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); |
|
|
} |
|
|
|
|
|
.feedback-overlay.show .feedback-card { |
|
|
transform: scale(1); |
|
|
} |
|
|
|
|
|
.feedback-icon { |
|
|
font-size: 64px; |
|
|
margin-bottom: 20px; |
|
|
animation: feedbackBounce 0.6s ease; |
|
|
} |
|
|
|
|
|
@keyframes feedbackBounce { |
|
|
0%, 100% { transform: scale(1); } |
|
|
50% { transform: scale(1.2); } |
|
|
} |
|
|
|
|
|
.feedback-title { |
|
|
font-size: 24px; |
|
|
font-weight: 800; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.feedback-message { |
|
|
color: var(--text-secondary); |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
|
|
|
.pulse-data { |
|
|
animation: pulseGlow 2s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulseGlow { |
|
|
0%, 100% { |
|
|
box-shadow: 0 0 5px rgba(59, 130, 246, 0.5); |
|
|
} |
|
|
50% { |
|
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8), 0 0 30px rgba(59, 130, 246, 0.4); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
html { |
|
|
scroll-behavior: smooth; |
|
|
} |
|
|
|
|
|
|
|
|
*:focus-visible { |
|
|
outline: 2px solid var(--accent-blue); |
|
|
outline-offset: 2px; |
|
|
border-radius: 4px; |
|
|
} |
|
|
|
|
|
|
|
|
.loading-overlay { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(10, 14, 26, 0.9); |
|
|
backdrop-filter: blur(10px); |
|
|
z-index: 10003; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 20px; |
|
|
opacity: 0; |
|
|
pointer-events: none; |
|
|
transition: opacity 0.3s ease; |
|
|
} |
|
|
|
|
|
.loading-overlay.show { |
|
|
opacity: 1; |
|
|
pointer-events: all; |
|
|
} |
|
|
|
|
|
.loading-spinner-large { |
|
|
width: 80px; |
|
|
height: 80px; |
|
|
border: 6px solid var(--border); |
|
|
border-top-color: var(--accent-blue); |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
|
|
|
.loading-text { |
|
|
font-size: 18px; |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
|
|
|
.tooltip { |
|
|
position: relative; |
|
|
cursor: help; |
|
|
} |
|
|
|
|
|
.tooltip::before { |
|
|
content: attr(data-tooltip); |
|
|
position: absolute; |
|
|
bottom: 100%; |
|
|
left: 50%; |
|
|
transform: translateX(-50%) translateY(-10px); |
|
|
padding: 8px 12px; |
|
|
background: var(--bg-card); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 8px; |
|
|
font-size: 12px; |
|
|
white-space: nowrap; |
|
|
opacity: 0; |
|
|
pointer-events: none; |
|
|
transition: all 0.3s ease; |
|
|
z-index: 1000; |
|
|
} |
|
|
|
|
|
.tooltip:hover::before { |
|
|
opacity: 1; |
|
|
transform: translateX(-50%) translateY(-5px); |
|
|
} |
|
|
|
|
|
|
|
|
.gradient-text { |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple), var(--accent-pink)); |
|
|
background-size: 200% 200%; |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
animation: gradientShift 3s ease infinite; |
|
|
} |
|
|
|
|
|
@keyframes gradientShift { |
|
|
0%, 100% { background-position: 0% 50%; } |
|
|
50% { background-position: 100% 50%; } |
|
|
} |
|
|
|
|
|
|
|
|
.badge-pulse { |
|
|
animation: badgePulse 2s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes badgePulse { |
|
|
0%, 100% { transform: scale(1); } |
|
|
50% { transform: scale(1.1); } |
|
|
} |
|
|
|
|
|
|
|
|
button, a, input, select, textarea { |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
} |
|
|
|
|
|
|
|
|
table { |
|
|
border-collapse: separate; |
|
|
border-spacing: 0; |
|
|
} |
|
|
|
|
|
thead th:first-child { |
|
|
border-top-left-radius: 12px; |
|
|
} |
|
|
|
|
|
thead th:last-child { |
|
|
border-top-right-radius: 12px; |
|
|
} |
|
|
|
|
|
tbody tr:last-child td:first-child { |
|
|
border-bottom-left-radius: 12px; |
|
|
} |
|
|
|
|
|
tbody tr:last-child td:last-child { |
|
|
border-bottom-right-radius: 12px; |
|
|
} |
|
|
|
|
|
|
|
|
.icon { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.icon-sm { |
|
|
width: 16px; |
|
|
height: 16px; |
|
|
} |
|
|
|
|
|
.icon-md { |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
} |
|
|
|
|
|
.icon-lg { |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
} |
|
|
|
|
|
.icon-xl { |
|
|
width: 48px; |
|
|
height: 48px; |
|
|
} |
|
|
|
|
|
.icon svg { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
stroke: currentColor; |
|
|
fill: none; |
|
|
stroke-width: 2; |
|
|
stroke-linecap: round; |
|
|
stroke-linejoin: round; |
|
|
} |
|
|
|
|
|
.icon-filled svg { |
|
|
fill: currentColor; |
|
|
stroke: none; |
|
|
} |
|
|
|
|
|
.icon-gradient { |
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
} |
|
|
|
|
|
|
|
|
.tab-icon { |
|
|
width: 18px; |
|
|
height: 18px; |
|
|
margin-right: 8px; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.tab-icon svg { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
} |
|
|
|
|
|
|
|
|
.status-icon { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.status-icon-success svg { |
|
|
color: var(--accent-green); |
|
|
} |
|
|
|
|
|
.status-icon-error svg { |
|
|
color: var(--accent-red); |
|
|
} |
|
|
|
|
|
.status-icon-warning svg { |
|
|
color: var(--accent-yellow); |
|
|
} |
|
|
|
|
|
.status-icon-info svg { |
|
|
color: var(--accent-blue); |
|
|
} |
|
|
</style> |
|
|
|
|
|
|
|
|
<svg style="display: none;" xmlns="http://www.w3.org/2000/svg"> |
|
|
|
|
|
<symbol id="icon-success" viewBox="0 0 24 24"> |
|
|
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M8 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-error" viewBox="0 0 24 24"> |
|
|
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-warning" viewBox="0 0 24 24"> |
|
|
<path d="M12 2L2 22h20L12 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-info" viewBox="0 0 24 24"> |
|
|
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M12 16v-4M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-market" viewBox="0 0 24 24"> |
|
|
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M7 12l4-4 4 4 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-monitor" viewBox="0 0 24 24"> |
|
|
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-advanced" viewBox="0 0 24 24"> |
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-settings" viewBox="0 0 24 24"> |
|
|
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-hf" viewBox="0 0 24 24"> |
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-pools" viewBox="0 0 24 24"> |
|
|
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-logs" viewBox="0 0 24 24"> |
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-resources" viewBox="0 0 24 24"> |
|
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-reports" viewBox="0 0 24 24"> |
|
|
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M18 17V9M12 17V5M6 17v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-search" viewBox="0 0 24 24"> |
|
|
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/> |
|
|
<path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-refresh" viewBox="0 0 24 24"> |
|
|
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a10 10 0 0 1 17.8-4.3M20.49 15a10 10 0 0 1-17.8 4.3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-trending-up" viewBox="0 0 24 24"> |
|
|
<path d="M23 6l-9.5 9.5-5-5L1 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M17 6h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-trending-down" viewBox="0 0 24 24"> |
|
|
<path d="M23 18l-9.5-9.5-5 5L1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M17 18h6v-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-volume" viewBox="0 0 24 24"> |
|
|
<path d="M11 5L6 9H2v6h4l5 4V5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-diamond" viewBox="0 0 24 24"> |
|
|
<path d="M6 3h12l4 6-10 12L2 9l4-6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M11 3l1 18M13 3l-1 18M6 3l5 18M18 3l-5 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-fire" viewBox="0 0 24 24"> |
|
|
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-link" viewBox="0 0 24 24"> |
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-export" viewBox="0 0 24 24"> |
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-delete" viewBox="0 0 24 24"> |
|
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-brain" viewBox="0 0 24 24"> |
|
|
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44L2 22v-4a2.5 2.5 0 0 1 2.5-2.5h5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44L22 22v-4a2.5 2.5 0 0 0-2.5-2.5h-5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-arrow-up" viewBox="0 0 24 24"> |
|
|
<path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
|
|
</symbol> |
|
|
|
|
|
|
|
|
<symbol id="icon-live" viewBox="0 0 24 24"> |
|
|
<circle cx="12" cy="12" r="10" fill="currentColor"/> |
|
|
</symbol> |
|
|
</svg> |
|
|
</head> |
|
|
|
|
|
<body> |
|
|
|
|
|
<div class="progress-indicator" id="progressIndicator"> |
|
|
<div class="progress-bar" id="progressBar"></div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="toast-container" id="toastContainer"></div> |
|
|
|
|
|
|
|
|
<div class="loading-overlay" id="loadingOverlay"> |
|
|
<div class="loading-spinner-large"></div> |
|
|
<div class="loading-text" id="loadingText">در حال بارگذاری...</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="feedback-overlay" id="feedbackOverlay"> |
|
|
<div class="feedback-card"> |
|
|
<div class="feedback-icon" id="feedbackIcon">✅</div> |
|
|
<div class="feedback-title" id="feedbackTitle">موفق!</div> |
|
|
<div class="feedback-message" id="feedbackMessage">عملیات با موفقیت انجام شد</div> |
|
|
<button class="refresh-btn ripple" onclick="hideFeedback()">بستن</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<button class="fab ripple" onclick="scrollToTop()" title="بازگشت به بالا"> |
|
|
↑ |
|
|
</button> |
|
|
|
|
|
|
|
|
<div id="ws-connection-status" class="ws-status-indicator disconnected"> |
|
|
<div id="ws-status-dot" class="status-dot status-dot-offline"></div> |
|
|
<span id="ws-status-text" class="ws-status-text">در حال اتصال...</span> |
|
|
<div id="online-users-badge" class="badge badge-info badge-pulse" style="margin-left: 10px;">0</div> |
|
|
</div> |
|
|
|
|
|
<div class="container"> |
|
|
|
|
|
<div class="header"> |
|
|
<div class="header-top"> |
|
|
<div class="logo"> |
|
|
<div class="logo-icon">₿</div> |
|
|
<div class="logo-text"> |
|
|
<h1>Crypto Monitor ULTIMATE</h1> |
|
|
<p>Unified Dashboard • Real-time data from 100+ free APIs</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="header-actions"> |
|
|
<div class="live-indicator"> |
|
|
<span class="status-icon"><svg><use href="#icon-live"></use></svg></span> |
|
|
LIVE |
|
|
</div> |
|
|
<div class="status-badge"> |
|
|
<div class="status-dot"></div> |
|
|
<span id="statusText">All Systems Operational</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="tabs"> |
|
|
<button class="tab active" onclick="switchTab('market')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-market"></use></svg></span> |
|
|
Market |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('monitor')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-monitor"></use></svg></span> |
|
|
API Monitor |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('advanced')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-advanced"></use></svg></span> |
|
|
Advanced |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('admin')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-settings"></use></svg></span> |
|
|
Admin |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('hf')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-hf"></use></svg></span> |
|
|
HuggingFace |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('pools')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-pools"></use></svg></span> |
|
|
Pools |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('logs')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-logs"></use></svg></span> |
|
|
Logs |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('resources')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-resources"></use></svg></span> |
|
|
Resources |
|
|
</button> |
|
|
<button class="tab" onclick="switchTab('reports')"> |
|
|
<span class="tab-icon"><svg><use href="#icon-reports"></use></svg></span> |
|
|
Reports |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-market" class="tab-content active"> |
|
|
|
|
|
<div class="stats-grid stats-grid--market"> |
|
|
<div class="stat-card online-users-card"> |
|
|
<div class="pulse-ring"></div> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<circle cx="9" cy="8" r="3" /> |
|
|
<circle cx="17" cy="9" r="2.5" /> |
|
|
<path d="M4 19v-1.5A4.5 4.5 0 018.5 13h1A4.5 4.5 0 0114 17.5V19" /> |
|
|
<path d="M17 19v-1a3 3 0 00-3-3h-1.5" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value shimmer" id="active-users-count">0</div> |
|
|
<div class="stat-label">کاربران آنلاین</div> |
|
|
<div class="stat-change positive"> |
|
|
<span>📊</span> |
|
|
<span>کل نشستها: <span id="total-sessions-count">0</span></span> |
|
|
</div> |
|
|
<div class="animated-progress"></div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<circle cx="12" cy="12" r="6" /> |
|
|
<path d="M12 8v8" /> |
|
|
<path d="M9.5 10.5h5" /> |
|
|
<path d="M9.5 13.5h5" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="totalMarketCap">$0.00T</div> |
|
|
<div class="stat-label">Total Market Cap</div> |
|
|
<div class="stat-change positive" id="mcapChange"> |
|
|
<span>↑</span> <span>0.0%</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<path d="M6 16V9" /> |
|
|
<path d="M12 16V5" /> |
|
|
<path d="M18 16v-7" /> |
|
|
<path d="M4 16h16" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="totalVolume">$0.00B</div> |
|
|
<div class="stat-label">24h Trading Volume</div> |
|
|
<div class="stat-change positive"> |
|
|
<span>↑</span> <span>Volume spike</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<circle cx="12" cy="12" r="6" /> |
|
|
<path d="M10 8h3a2 2 0 010 4h-3h3a2 2 0 010 4h-3" /> |
|
|
<path d="M11 6v2" /> |
|
|
<path d="M11 16v2" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="btcDominance">0.0%</div> |
|
|
<div class="stat-label">BTC Dominance</div> |
|
|
<div class="stat-change"> |
|
|
<span id="btcDomIcon">↑</span> <span id="btcDomChange">0.0%</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<path |
|
|
d="M5.5 15c0 3.6 3 5.5 6.5 5.5s6.5-1.9 6.5-5.5c0-2.6-1.7-4.4-3.4-6.4-.4-.5-.8-1-1.1-1.6-.3-.6-.6-1.3-.8-2-1.6 1.4-3.3 3.2-3.3 5.1-1.2-.8-2.4-2-2.4-3.6C7 9 5.5 11.4 5.5 13.5Z" /> |
|
|
<path d="M10.5 18.5c-1.1-.7-1.8-1.7-1.8-3 0-1.1.5-2 1.2-2.8" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="fearGreed">50</div> |
|
|
<div class="stat-label">Fear & Greed Index</div> |
|
|
<div class="stat-change" id="sentimentLabel"> |
|
|
<span>Neutral</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title gradient-text"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-diamond"></use></svg></span> |
|
|
Live Market Data |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadMarketData()" data-tooltip="بهروزرسانی دادههای بازار"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
Refresh |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="search-container"> |
|
|
<span class="search-icon icon"><svg><use href="#icon-search"></use></svg></span> |
|
|
<input type="text" class="search-input" id="marketSearch" placeholder="جستجوی ارز دیجیتال (مثال: Bitcoin, BTC, Ethereum)..." oninput="filterMarketTable()"> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="filter-chips"> |
|
|
<button class="filter-chip active" onclick="filterByCategory('all')">همه</button> |
|
|
<button class="filter-chip" onclick="filterByCategory('top10')">Top 10</button> |
|
|
<button class="filter-chip" onclick="filterByCategory('gainers')"> |
|
|
<span class="icon icon-sm status-icon-success"><svg><use href="#icon-trending-up"></use></svg></span> |
|
|
در حال رشد |
|
|
</button> |
|
|
<button class="filter-chip" onclick="filterByCategory('losers')"> |
|
|
<span class="icon icon-sm status-icon-error"><svg><use href="#icon-trending-down"></use></svg></span> |
|
|
در حال سقوط |
|
|
</button> |
|
|
<button class="filter-chip" onclick="filterByCategory('volume')"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-volume"></use></svg></span> |
|
|
حجم بالا |
|
|
</button> |
|
|
</div> |
|
|
<div style="overflow-x: auto;"> |
|
|
<table id="marketTable"> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>#</th> |
|
|
<th>Name</th> |
|
|
<th>Price</th> |
|
|
<th>24h Change</th> |
|
|
<th>Market Cap</th> |
|
|
<th>Volume 24h</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="marketTableBody"> |
|
|
<tr> |
|
|
<td colspan="6"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 30px; margin-bottom: 30px;"> |
|
|
<div class="chart-container"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">📈 Market Dominance</div> |
|
|
<canvas id="dominanceChart"></canvas> |
|
|
</div> |
|
|
|
|
|
<div class="chart-container"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">😱 Fear & Greed Index</div> |
|
|
<div style="text-align: center;"> |
|
|
<canvas id="gaugeChart"></canvas> |
|
|
<div style="font-size: 48px; font-weight: 900; margin: 20px 0 10px;" id="sentimentValue">50 |
|
|
</div> |
|
|
<div style="color: var(--text-secondary); font-size: 16px; font-weight: 600;" |
|
|
id="sentimentText">Neutral</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-fire"></use></svg></span> |
|
|
Trending Now |
|
|
</div> |
|
|
<div id="trendingGrid" |
|
|
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">🏦 Top DeFi Protocols</div> |
|
|
<div id="defiList"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-monitor" class="tab-content"> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<rect x="4" y="4" width="16" height="5" rx="2" /> |
|
|
<rect x="4" y="11" width="16" height="5" rx="2" /> |
|
|
<path d="M8 6h.01" /> |
|
|
<path d="M8 13h.01" /> |
|
|
<path d="M12 16v4" /> |
|
|
<path d="M10 20h4" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="totalAPIs">0</div> |
|
|
<div class="stat-label">Total APIs</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<circle cx="12" cy="12" r="7.5" /> |
|
|
<path d="M9.2 12.2l2 2 4-4" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="onlineAPIs" style="color: var(--accent-green);">0</div> |
|
|
<div class="stat-label">Online</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<circle cx="12" cy="12" r="7.5" /> |
|
|
<path d="M9 9l6 6" /> |
|
|
<path d="M15 9l-6 6" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="offlineAPIs" style="color: var(--accent-red);">0</div> |
|
|
<div class="stat-label">Offline</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<path d="M11 3L6 13h5l-1 8 8-14h-5l1-4z" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="avgResponse" style="font-size: 28px;">0ms</div> |
|
|
<div class="stat-label">Avg Response</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-monitor"></use></svg></span> |
|
|
API Providers Status |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadMonitorData()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
Refresh |
|
|
</button> |
|
|
</div> |
|
|
<div style="overflow-x: auto;"> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Provider</th> |
|
|
<th>Category</th> |
|
|
<th>Status</th> |
|
|
<th>Response Time</th> |
|
|
<th>Last Check</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="providersTable"> |
|
|
<tr> |
|
|
<td colspan="5" style="text-align: center;">Loading...</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-brain"></use></svg></span> |
|
|
HuggingFace Sentiment Analysis |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Enter crypto-related text (one per line):</label> |
|
|
<textarea class="form-textarea" id="sentimentText" rows="5" |
|
|
placeholder="BTC strong breakout ETH looks weak Market is bullish">BTC strong breakout |
|
|
ETH looks weak |
|
|
Market is bullish today</textarea> |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="runSentiment()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-brain"></use></svg></span> |
|
|
Analyze Sentiment |
|
|
</button> |
|
|
<div id="sentimentResult" |
|
|
style="margin-top: 20px; padding: 20px; background: rgba(17, 24, 39, 0.6); border-radius: 12px; text-align: center; font-size: 36px; font-weight: 900;"> |
|
|
—</div> |
|
|
<pre id="sentimentDetails" |
|
|
style="background: #1e293b; color: #e2e8f0; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; margin-top: 15px; max-height: 300px; overflow-y: auto;"></pre> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-advanced" class="tab-content"> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<rect x="4" y="4" width="16" height="5" rx="2" /> |
|
|
<rect x="4" y="11" width="16" height="5" rx="2" /> |
|
|
<path d="M8 6h.01" /> |
|
|
<path d="M8 13h.01" /> |
|
|
<path d="M12 16v4" /> |
|
|
<path d="M10 20h4" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="totalApis">0</div> |
|
|
<div class="stat-label">Total APIs</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<path d="M6 4v7m0 5v4" /> |
|
|
<circle cx="6" cy="11" r="2" /> |
|
|
<path d="M12 4v2m0 4v10" /> |
|
|
<circle cx="12" cy="8" r="2" /> |
|
|
<path d="M18 4v9m0 4v3" /> |
|
|
<circle cx="18" cy="15" r="2" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="activeTasks">0</div> |
|
|
<div class="stat-label">Active Tasks</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<ellipse cx="12" cy="6" rx="7" ry="3" /> |
|
|
<path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6" /> |
|
|
<path d="M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="cachedData">0</div> |
|
|
<div class="stat-label">Cached Data</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-header"> |
|
|
<div class="stat-icon"> |
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"> |
|
|
<path d="M8 3v4" /> |
|
|
<path d="M16 3v4" /> |
|
|
<path d="M6 11h12" /> |
|
|
<path d="M10 15h4v6" /> |
|
|
<path d="M8 7h8v4a6 6 0 01-12 0V7z" /> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-value" id="wsConnections">0</div> |
|
|
<div class="stat-label">WS Connections</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title">🔧 Advanced Actions</div> |
|
|
</div> |
|
|
<div style="display: flex; gap: 15px; flex-wrap: wrap;"> |
|
|
<button class="refresh-btn" onclick="exportJSON()">💾 Export JSON</button> |
|
|
<button class="refresh-btn" onclick="exportCSV()">📊 Export CSV</button> |
|
|
<button class="refresh-btn" onclick="createBackup()">🔄 Create Backup</button> |
|
|
<button class="refresh-btn" onclick="clearCache()">🗑️ Clear Cache</button> |
|
|
<button class="refresh-btn" onclick="forceUpdateAll()">🔃 Force Update All</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">📈 Recent Activity</div> |
|
|
<div id="activityLog" |
|
|
style="max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 12px;"> |
|
|
<div style="padding: 10px; border-left: 3px solid var(--accent-blue); margin-bottom: 8px;"> |
|
|
<span style="opacity: 0.6;">--:--:--</span> Waiting for updates... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">🔌 API Sources</div> |
|
|
<div id="apiList"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-admin" class="tab-content"> |
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">➕ Add New API Source</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">API Name</label> |
|
|
<input type="text" class="form-input" id="newApiName" placeholder="e.g., CoinGecko"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">API URL</label> |
|
|
<input type="text" class="form-input" id="newApiUrl" placeholder="https://api.example.com/endpoint"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Category</label> |
|
|
<select class="form-select" id="newApiCategory"> |
|
|
<option value="market_data">Market Data</option> |
|
|
<option value="blockchain_explorers">Blockchain Explorers</option> |
|
|
<option value="news">News & Social</option> |
|
|
<option value="sentiment">Sentiment</option> |
|
|
<option value="defi">DeFi</option> |
|
|
<option value="nft">NFT</option> |
|
|
</select> |
|
|
</div> |
|
|
<button class="refresh-btn" onclick="addNewAPI()">➕ Add API Source</button> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-logs"></use></svg></span> |
|
|
Current API Sources |
|
|
</div> |
|
|
<div id="apisList">Loading...</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-settings"></use></svg></span> |
|
|
Settings |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">API Check Interval (seconds)</label> |
|
|
<input type="number" class="form-input" id="checkInterval" value="30" min="10" max="300"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Dashboard Auto-Refresh (seconds)</label> |
|
|
<input type="number" class="form-input" id="dashboardRefresh" value="30" min="5" max="300"> |
|
|
</div> |
|
|
<button class="refresh-btn" onclick="saveSettings()">💾 Save Settings</button> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-reports"></use></svg></span> |
|
|
Statistics |
|
|
</div> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="statTotal">0</div> |
|
|
<div class="stat-label">Total API Sources</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="statOnline" style="color: var(--accent-green);">0</div> |
|
|
<div class="stat-label">Currently Online</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="statOffline" style="color: var(--accent-red);">0</div> |
|
|
<div class="stat-label">Currently Offline</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-hf" class="tab-content"> |
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md" style="margin-left: 8px;"><svg><use href="#icon-reports"></use></svg></span> |
|
|
Health Status |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadHFHealth()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
Refresh |
|
|
</button> |
|
|
</div> |
|
|
<pre id="healthOutput" |
|
|
style="background: #1e293b; color: #e2e8f0; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; max-height: 200px; overflow-y: auto;">Loading...</pre> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 30px;"> |
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">🤖 Models Registry</div> |
|
|
<button class="refresh-btn" onclick="loadModels()" style="margin-bottom: 15px;">Load Models</button> |
|
|
<div id="modelsList" |
|
|
style="max-height: 300px; overflow-y: auto; background: rgba(17, 24, 39, 0.6); padding: 15px; border-radius: 8px;"> |
|
|
<p style="color: var(--text-secondary);">Click "Load Models" to fetch...</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">📚 Datasets Registry</div> |
|
|
<button class="refresh-btn" onclick="loadDatasets()" style="margin-bottom: 15px;">Load |
|
|
Datasets</button> |
|
|
<div id="datasetsList" |
|
|
style="max-height: 300px; overflow-y: auto; background: rgba(17, 24, 39, 0.6); padding: 15px; border-radius: 8px;"> |
|
|
<p style="color: var(--text-secondary);">Click "Load Datasets" to fetch...</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">🔍 Search Registry</div> |
|
|
<div class="form-group"> |
|
|
<input type="text" class="form-input" id="searchQuery" |
|
|
placeholder="Search query (e.g., crypto, bitcoin, sentiment)" value="crypto"> |
|
|
</div> |
|
|
<div style="display: flex; gap: 10px; margin-bottom: 15px;"> |
|
|
<button class="refresh-btn" onclick="doSearch()">Search Models</button> |
|
|
<button class="refresh-btn" onclick="doSearchDatasets()">Search Datasets</button> |
|
|
</div> |
|
|
<div id="searchResults" |
|
|
style="max-height: 300px; overflow-y: auto; background: rgba(17, 24, 39, 0.6); padding: 15px; border-radius: 8px;"> |
|
|
<p style="color: var(--text-secondary);">Enter a query and click search...</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;"> |
|
|
<span class="icon icon-md"><svg><use href="#icon-brain"></use></svg></span> |
|
|
Sentiment Analysis |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Enter text samples (one per line):</label> |
|
|
<textarea class="form-textarea" id="sentimentTexts" rows="5" |
|
|
placeholder="BTC strong breakout ETH looks weak Market sentiment is bullish">BTC strong breakout |
|
|
ETH looks weak |
|
|
Crypto market is bullish today</textarea> |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="doSentiment()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-brain"></use></svg></span> |
|
|
Run Sentiment Analysis |
|
|
</button> |
|
|
<div id="voteDisplay" |
|
|
style="margin-top: 20px; padding: 20px; background: rgba(17, 24, 39, 0.6); border-radius: 12px; text-align: center; font-size: 36px; font-weight: 900;"> |
|
|
—</div> |
|
|
<pre id="sentimentOutput" |
|
|
style="background: #1e293b; color: #e2e8f0; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; margin-top: 15px; max-height: 300px; overflow-y: auto;">Results will appear here...</pre> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-logs" class="tab-content"> |
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md"><svg><use href="#icon-logs"></use></svg></span> |
|
|
Log Management |
|
|
</div> |
|
|
<div style="display: flex; gap: 10px;"> |
|
|
<button class="refresh-btn ripple" onclick="loadLogs()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
Refresh |
|
|
</button> |
|
|
<button class="refresh-btn ripple" onclick="exportLogsJSON()" |
|
|
style="background: rgba(16, 185, 129, 0.2); color: var(--accent-green);"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-export"></use></svg></span> |
|
|
Export JSON |
|
|
</button> |
|
|
<button class="refresh-btn ripple" onclick="exportLogsCSV()" |
|
|
style="background: rgba(16, 185, 129, 0.2); color: var(--accent-green);"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-reports"></use></svg></span> |
|
|
Export CSV |
|
|
</button> |
|
|
<button class="refresh-btn ripple" onclick="clearAllLogs()" |
|
|
style="background: rgba(239, 68, 68, 0.2); color: var(--accent-red);"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-delete"></use></svg></span> |
|
|
Clear |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px;"> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Level</label> |
|
|
<select class="form-select" id="logLevelFilter" onchange="loadLogs()"> |
|
|
<option value="">All Levels</option> |
|
|
<option value="debug">Debug</option> |
|
|
<option value="info">Info</option> |
|
|
<option value="warning">Warning</option> |
|
|
<option value="error">Error</option> |
|
|
<option value="critical">Critical</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Category</label> |
|
|
<select class="form-select" id="logCategoryFilter" onchange="loadLogs()"> |
|
|
<option value="">All Categories</option> |
|
|
<option value="provider">Provider</option> |
|
|
<option value="pool">Pool</option> |
|
|
<option value="api">API</option> |
|
|
<option value="system">System</option> |
|
|
<option value="health_check">Health Check</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Search</label> |
|
|
<input type="text" class="form-input" id="logSearch" placeholder="Search logs..." |
|
|
onkeyup="loadLogs()"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Limit</label> |
|
|
<input type="number" class="form-input" id="logLimit" value="100" min="10" max="1000" |
|
|
onchange="loadLogs()"> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="stats-grid" style="margin-bottom: 20px;"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="totalLogs">0</div> |
|
|
<div class="stat-label">Total Logs</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="errorLogs" style="color: var(--accent-red);">0</div> |
|
|
<div class="stat-label">Errors</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="infoLogs" style="color: var(--accent-blue);">0</div> |
|
|
<div class="stat-label">Info</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="warningLogs" style="color: var(--accent-yellow);">0</div> |
|
|
<div class="stat-label">Warnings</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style="overflow-x: auto;"> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Time</th> |
|
|
<th>Level</th> |
|
|
<th>Category</th> |
|
|
<th>Message</th> |
|
|
<th>Provider</th> |
|
|
<th>Response Time</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="logsTableBody"> |
|
|
<tr> |
|
|
<td colspan="6" style="text-align: center;">Loading logs...</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-resources" class="tab-content"> |
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title">📦 Resource Management</div> |
|
|
<div style="display: flex; gap: 10px;"> |
|
|
<button class="refresh-btn" onclick="loadResources()">🔄 Refresh</button> |
|
|
<button class="refresh-btn" onclick="exportResourcesJSON()" |
|
|
style="background: rgba(16, 185, 129, 0.2); color: var(--accent-green);">💾 Export |
|
|
JSON</button> |
|
|
<button class="refresh-btn" onclick="exportResourcesCSV()" |
|
|
style="background: rgba(16, 185, 129, 0.2); color: var(--accent-green);">📊 Export |
|
|
CSV</button> |
|
|
<button class="refresh-btn" onclick="backupResources()" |
|
|
style="background: rgba(59, 130, 246, 0.2); color: var(--accent-blue);">💾 Backup</button> |
|
|
<button class="refresh-btn" onclick="showImportModal()" |
|
|
style="background: rgba(139, 92, 246, 0.2); color: var(--accent-purple);">📥 Import</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="stats-grid" style="margin-bottom: 20px;"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="totalResources">0</div> |
|
|
<div class="stat-label">Total Resources</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="freeResources" style="color: var(--accent-green);">0</div> |
|
|
<div class="stat-label">Free APIs</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="paidResources" style="color: var(--accent-yellow);">0</div> |
|
|
<div class="stat-label">Paid APIs</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="authResources">0</div> |
|
|
<div class="stat-label">Requires Auth</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style="margin-bottom: 20px;"> |
|
|
<label class="form-label">Filter by Category</label> |
|
|
<select class="form-select" id="resourceCategoryFilter" onchange="loadResources()" |
|
|
style="max-width: 300px;"> |
|
|
<option value="">All Categories</option> |
|
|
<option value="market_data">Market Data</option> |
|
|
<option value="exchange">Exchange</option> |
|
|
<option value="blockchain_explorer">Block Explorer</option> |
|
|
<option value="rpc">RPC</option> |
|
|
<option value="defi">DeFi</option> |
|
|
<option value="news">News</option> |
|
|
<option value="sentiment">Sentiment</option> |
|
|
<option value="analytics">Analytics</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="resourcesGrid" |
|
|
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px;"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="importModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
|
|
<h2 style="font-size: 24px; font-weight: 800;">📥 Import Resources</h2> |
|
|
<button onclick="closeImportModal()" |
|
|
style="background: none; border: none; color: var(--text-secondary); font-size: 28px; cursor: pointer;">×</button> |
|
|
</div> |
|
|
<form id="importForm"> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">File Path</label> |
|
|
<input type="text" class="form-input" id="importFilePath" placeholder="path/to/file.json" |
|
|
required> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Import Mode</label> |
|
|
<select class="form-select" id="importMode"> |
|
|
<option value="true">Merge (Add to existing)</option> |
|
|
<option value="false">Replace (Overwrite all)</option> |
|
|
</select> |
|
|
</div> |
|
|
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;"> |
|
|
<button type="button" class="refresh-btn" onclick="closeImportModal()" |
|
|
style="background: rgba(239, 68, 68, 0.2); color: var(--accent-red);">Cancel</button> |
|
|
<button type="submit" class="refresh-btn">Import</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-reports" class="tab-content"> |
|
|
|
|
|
<div class="market-section" id="systemAlertsSection" style="display: none;"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md status-icon-warning"><svg><use href="#icon-warning"></use></svg></span> |
|
|
System Status & Alerts |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadSystemAlerts()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
Refresh |
|
|
</button> |
|
|
</div> |
|
|
<div id="systemAlertsContainer"></div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md"><svg><use href="#icon-search"></use></svg></span> |
|
|
System Diagnostics |
|
|
</div> |
|
|
<div style="display: flex; gap: 10px;"> |
|
|
<button class="refresh-btn ripple" onclick="runDiagnostics(false)" |
|
|
style="background: rgba(59, 130, 246, 0.2); color: var(--accent-blue);"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-search"></use></svg></span> |
|
|
بررسی |
|
|
</button> |
|
|
<button class="refresh-btn ripple" onclick="runDiagnostics(true)" |
|
|
style="background: rgba(16, 185, 129, 0.2); color: var(--accent-green);"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-settings"></use></svg></span> |
|
|
بررسی و تعمیر |
|
|
</button> |
|
|
<button class="refresh-btn ripple" onclick="loadReports()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
بهروزرسانی |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="diagnosticsResults" style="margin-top: 20px;"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md"><svg><use href="#icon-pools"></use></svg></span> |
|
|
Auto-Discovery Service Report |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadDiscoveryReport()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
بهروزرسانی |
|
|
</button> |
|
|
</div> |
|
|
<div id="discoveryReport"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title"> |
|
|
<span class="icon icon-md"><svg><use href="#icon-brain"></use></svg></span> |
|
|
HuggingFace Models Status Report |
|
|
</div> |
|
|
<button class="refresh-btn ripple" onclick="loadModelsReport()"> |
|
|
<span class="icon icon-sm"><svg><use href="#icon-refresh"></use></svg></span> |
|
|
بهروزرسانی |
|
|
</button> |
|
|
</div> |
|
|
<div id="modelsReport"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="tab-pools" class="tab-content"> |
|
|
<div class="market-section"> |
|
|
<div class="section-header"> |
|
|
<div class="section-title">🔄 Source Pool Management</div> |
|
|
<div style="display: flex; gap: 10px;"> |
|
|
<button class="refresh-btn" onclick="showCreatePoolModal()">➕ Create Pool</button> |
|
|
<button class="refresh-btn" onclick="loadPools()">🔄 Refresh</button> |
|
|
</div> |
|
|
</div> |
|
|
<div id="poolsContainer" |
|
|
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px;"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="market-section"> |
|
|
<div class="section-title" style="margin-bottom: 20px;">📜 Rotation History</div> |
|
|
<div id="rotationHistory" style="max-height: 400px; overflow-y: auto;"> |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="createPoolModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
|
|
<h2 style="font-size: 24px; font-weight: 800;">➕ Create New Pool</h2> |
|
|
<button onclick="closeCreatePoolModal()" |
|
|
style="background: none; border: none; color: var(--text-secondary); font-size: 28px; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">×</button> |
|
|
</div> |
|
|
<form id="createPoolForm"> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Pool Name</label> |
|
|
<input type="text" class="form-input" id="poolName" required |
|
|
placeholder="e.g., Market Data Pool"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Category</label> |
|
|
<select class="form-select" id="poolCategory" required> |
|
|
<option value="market_data">Market Data</option> |
|
|
<option value="blockchain_explorers">Blockchain Explorers</option> |
|
|
<option value="news">News & Social</option> |
|
|
<option value="sentiment">Sentiment</option> |
|
|
<option value="defi">DeFi</option> |
|
|
<option value="nft">NFT</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Rotation Strategy</label> |
|
|
<select class="form-select" id="rotationStrategy" required> |
|
|
<option value="round_robin">Round Robin</option> |
|
|
<option value="priority">Priority Based</option> |
|
|
<option value="weighted">Weighted</option> |
|
|
<option value="least_used">Least Used</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Description (optional)</label> |
|
|
<textarea class="form-textarea" id="poolDescription" rows="3" |
|
|
placeholder="Pool description..."></textarea> |
|
|
</div> |
|
|
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;"> |
|
|
<button type="button" class="refresh-btn" onclick="closeCreatePoolModal()" |
|
|
style="background: rgba(239, 68, 68, 0.2); color: var(--accent-red);">Cancel</button> |
|
|
<button type="submit" class="refresh-btn">Create Pool</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="addMemberModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
|
|
<h2 style="font-size: 24px; font-weight: 800;">➕ Add Provider to Pool</h2> |
|
|
<button onclick="closeAddMemberModal()" |
|
|
style="background: none; border: none; color: var(--text-secondary); font-size: 28px; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">×</button> |
|
|
</div> |
|
|
<form id="addMemberForm"> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Provider</label> |
|
|
<select class="form-select" id="memberProvider" required> |
|
|
<option value="">Select a provider...</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Priority (1-10, higher = better)</label> |
|
|
<input type="number" class="form-input" id="memberPriority" value="1" min="1" max="10"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="form-label">Weight (1-100, for weighted strategy)</label> |
|
|
<input type="number" class="form-input" id="memberWeight" value="1" min="1" max="100"> |
|
|
</div> |
|
|
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;"> |
|
|
<button type="button" class="refresh-btn" onclick="closeAddMemberModal()" |
|
|
style="background: rgba(239, 68, 68, 0.2); color: var(--accent-red);">Cancel</button> |
|
|
<button type="submit" class="refresh-btn">Add Member</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
|
|
|
let charts = {}; |
|
|
let wsConnection = null; |
|
|
let currentTab = 'market'; |
|
|
|
|
|
|
|
|
function switchTab(tabName) { |
|
|
currentTab = tabName; |
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); |
|
|
|
|
|
event.target.classList.add('active'); |
|
|
document.getElementById(`tab-${tabName}`).classList.add('active'); |
|
|
|
|
|
|
|
|
if (tabName === 'market') { |
|
|
loadMarketData(); |
|
|
} else if (tabName === 'monitor') { |
|
|
loadMonitorData(); |
|
|
} else if (tabName === 'advanced') { |
|
|
loadAdvancedData(); |
|
|
} else if (tabName === 'admin') { |
|
|
loadAdminData(); |
|
|
} else if (tabName === 'hf') { |
|
|
loadHFHealth(); |
|
|
} else if (tabName === 'pools') { |
|
|
loadPools(); |
|
|
} else if (tabName === 'logs') { |
|
|
loadLogs(); |
|
|
} else if (tabName === 'resources') { |
|
|
loadResources(); |
|
|
} else if (tabName === 'reports') { |
|
|
loadReports(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => { |
|
|
await loadMarketData(); |
|
|
initCharts(); |
|
|
connectWebSocket(); |
|
|
setInterval(() => { |
|
|
if (currentTab === 'market') loadMarketData(); |
|
|
else if (currentTab === 'monitor') loadMonitorData(); |
|
|
}, 60000); |
|
|
}); |
|
|
|
|
|
|
|
|
async function loadMarketData() { |
|
|
try { |
|
|
|
|
|
const marketTableBody = document.getElementById('marketTableBody'); |
|
|
if (marketTableBody) { |
|
|
marketTableBody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 40px;"><div class="loading"><div class="spinner"></div></div><div style="margin-top: 10px; color: var(--text-secondary);">در حال بارگذاری دادههای بازار...</div></td></tr>'; |
|
|
} |
|
|
|
|
|
showProgress(60); |
|
|
|
|
|
const [marketRes, statsRes, sentimentRes, trendingRes, defiRes] = await Promise.all([ |
|
|
fetch('/api/market'), |
|
|
fetch('/api/stats'), |
|
|
fetch('/api/sentiment'), |
|
|
fetch('/api/trending'), |
|
|
fetch('/api/defi') |
|
|
]); |
|
|
|
|
|
showProgress(80); |
|
|
|
|
|
|
|
|
if (!marketRes.ok) throw new Error(`خطا در دریافت دادههای بازار: ${marketRes.status}`); |
|
|
if (!statsRes.ok) throw new Error(`خطا در دریافت آمار: ${statsRes.status}`); |
|
|
if (!sentimentRes.ok) throw new Error(`خطا در دریافت احساسات: ${sentimentRes.status}`); |
|
|
if (!trendingRes.ok) throw new Error(`خطا در دریافت ترندها: ${trendingRes.status}`); |
|
|
if (!defiRes.ok) throw new Error(`خطا در دریافت DeFi: ${defiRes.status}`); |
|
|
|
|
|
const [market, stats, sentiment, trending, defi] = await Promise.all([ |
|
|
marketRes.json(), |
|
|
statsRes.json(), |
|
|
sentimentRes.json(), |
|
|
trendingRes.json(), |
|
|
defiRes.json() |
|
|
]); |
|
|
|
|
|
|
|
|
if (!market || !Array.isArray(market.cryptocurrencies)) { |
|
|
throw new Error('دادههای بازار نامعتبر است: cryptocurrencies array not found'); |
|
|
} |
|
|
if (!stats || typeof stats !== 'object' || stats === null) { |
|
|
console.error('Invalid stats:', stats); |
|
|
throw new Error('آمار نامعتبر است: stats object not found'); |
|
|
} |
|
|
|
|
|
if (!stats.market || typeof stats.market !== 'object' || stats.market === null || Array.isArray(stats.market)) { |
|
|
console.error('Invalid stats.market:', stats.market); |
|
|
console.error('Full stats object:', JSON.stringify(stats, null, 2)); |
|
|
throw new Error('آمار نامعتبر است: stats.market object not found'); |
|
|
} |
|
|
if (!sentiment || typeof sentiment !== 'object' || sentiment === null) { |
|
|
throw new Error('دادههای احساسات نامعتبر است: sentiment object not found'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!trending || !Array.isArray(trending.trending)) { |
|
|
throw new Error('دادههای ترند نامعتبر است: trending array not found'); |
|
|
} |
|
|
if (!defi || typeof defi !== 'object' || defi === null) { |
|
|
throw new Error('دادههای DeFi نامعتبر است: defi object not found'); |
|
|
} |
|
|
|
|
|
|
|
|
if (stats && stats.market && typeof stats.market === 'object' && !Array.isArray(stats.market)) { |
|
|
updateStats(stats, sentiment); |
|
|
} else { |
|
|
console.error('Failed final validation before updateStats:', { stats, sentiment }); |
|
|
throw new Error('دادههای stats.market نامعتبر است'); |
|
|
} |
|
|
updateMarketTable(market.cryptocurrencies); |
|
|
updateTrending(trending.trending); |
|
|
updateDeFi(defi); |
|
|
updateCharts(market, sentiment); |
|
|
} catch (error) { |
|
|
console.error('Error loading market data:', error); |
|
|
const marketTableBody = document.getElementById('marketTableBody'); |
|
|
if (marketTableBody) { |
|
|
marketTableBody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--accent-red);"> |
|
|
<div style="font-size: 24px; margin-bottom: 10px;">❌</div> |
|
|
<div style="font-weight: 600; margin-bottom: 5px;">خطا در بارگذاری دادهها</div> |
|
|
<div style="font-size: 14px; color: var(--text-secondary);">${error.message || 'خطای نامشخص'}</div> |
|
|
<button onclick="loadMarketData()" style="margin-top: 15px; padding: 10px 20px; background: var(--accent-blue); border: none; border-radius: 8px; color: white; cursor: pointer; font-weight: 600;">تلاش مجدد</button> |
|
|
</td></tr>`; |
|
|
} |
|
|
showToast('❌ خطا در بارگذاری دادههای بازار: ' + (error.message || 'خطای نامشخص'), 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateStats(stats, sentiment) { |
|
|
try { |
|
|
|
|
|
if (!stats || typeof stats !== 'object' || stats === null) { |
|
|
console.warn('updateStats: stats is undefined, null, or not an object', stats); |
|
|
return; |
|
|
} |
|
|
if (!stats.market || typeof stats.market !== 'object' || stats.market === null || Array.isArray(stats.market)) { |
|
|
console.warn('updateStats: stats.market is invalid', { stats, market: stats.market }); |
|
|
return; |
|
|
} |
|
|
if (!sentiment || typeof sentiment !== 'object' || sentiment === null) { |
|
|
console.warn('updateStats: sentiment is undefined, null, or not an object', sentiment); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const marketObj = stats.market; |
|
|
if (!marketObj || typeof marketObj !== 'object' || marketObj === null) { |
|
|
console.warn('updateStats: marketObj is invalid', marketObj); |
|
|
return; |
|
|
} |
|
|
|
|
|
const mcap = (typeof marketObj.total_market_cap !== 'undefined' && marketObj.total_market_cap !== null) ? marketObj.total_market_cap : 0; |
|
|
const totalMarketCapEl = document.getElementById('totalMarketCap'); |
|
|
if (totalMarketCapEl) { |
|
|
totalMarketCapEl.textContent = '$' + (mcap / 1e12).toFixed(2) + 'T'; |
|
|
} |
|
|
|
|
|
const volume = (typeof marketObj.total_volume !== 'undefined' && marketObj.total_volume !== null) ? marketObj.total_volume : 0; |
|
|
const totalVolumeEl = document.getElementById('totalVolume'); |
|
|
if (totalVolumeEl) { |
|
|
totalVolumeEl.textContent = '$' + (volume / 1e9).toFixed(2) + 'B'; |
|
|
} |
|
|
|
|
|
const btcDom = (typeof marketObj.btc_dominance !== 'undefined' && marketObj.btc_dominance !== null) ? marketObj.btc_dominance : 0; |
|
|
const btcDominanceEl = document.getElementById('btcDominance'); |
|
|
if (btcDominanceEl) { |
|
|
btcDominanceEl.textContent = btcDom.toFixed(1) + '%'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let fg = 50; |
|
|
let classification = 'Neutral'; |
|
|
|
|
|
if (sentiment.fear_greed_index && typeof sentiment.fear_greed_index === 'object') { |
|
|
|
|
|
fg = (typeof sentiment.fear_greed_index.value !== 'undefined') ? sentiment.fear_greed_index.value : 50; |
|
|
classification = sentiment.fear_greed_index.classification || 'Neutral'; |
|
|
} else if (typeof sentiment.fear_greed_value !== 'undefined') { |
|
|
|
|
|
fg = sentiment.fear_greed_value; |
|
|
classification = sentiment.classification || 'Neutral'; |
|
|
} else if (typeof sentiment.value !== 'undefined') { |
|
|
|
|
|
fg = sentiment.value; |
|
|
classification = sentiment.classification || 'Neutral'; |
|
|
} |
|
|
|
|
|
const fearGreedEl = document.getElementById('fearGreed'); |
|
|
if (fearGreedEl) { |
|
|
fearGreedEl.textContent = fg; |
|
|
} |
|
|
const sentimentLabelEl = document.getElementById('sentimentLabel'); |
|
|
if (sentimentLabelEl) { |
|
|
sentimentLabelEl.innerHTML = `<span>${classification}</span>`; |
|
|
|
|
|
if (fg < 25) { |
|
|
sentimentLabelEl.style.color = 'var(--accent-red)'; |
|
|
} else if (fg < 45) { |
|
|
sentimentLabelEl.style.color = 'var(--accent-yellow)'; |
|
|
} else if (fg < 55) { |
|
|
sentimentLabelEl.style.color = 'var(--text-secondary)'; |
|
|
} else if (fg < 75) { |
|
|
sentimentLabelEl.style.color = 'var(--accent-blue)'; |
|
|
} else { |
|
|
sentimentLabelEl.style.color = 'var(--accent-green)'; |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error updating stats:', error); |
|
|
console.error('Stats object:', stats); |
|
|
console.error('Sentiment object:', sentiment); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateMarketTable(cryptos) { |
|
|
try { |
|
|
if (!cryptos || !Array.isArray(cryptos) || cryptos.length === 0) { |
|
|
const tbody = document.getElementById('marketTableBody'); |
|
|
if (tbody) { |
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--text-secondary);">هیچ دادهای یافت نشد</td></tr>'; |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
const tbody = document.getElementById('marketTableBody'); |
|
|
if (!tbody) return; |
|
|
|
|
|
|
|
|
marketDataCache = cryptos; |
|
|
|
|
|
tbody.innerHTML = cryptos.map((crypto, index) => { |
|
|
const price = crypto.price || 0; |
|
|
const change24h = crypto.change_24h || 0; |
|
|
const marketCap = crypto.market_cap || 0; |
|
|
const volume24h = crypto.volume_24h || 0; |
|
|
const symbol = crypto.symbol || 'N/A'; |
|
|
const name = crypto.name || 'نامشخص'; |
|
|
const changeClass = change24h >= 0 ? 'positive' : 'negative'; |
|
|
const changeIcon = change24h >= 0 ? '📈' : '📉'; |
|
|
|
|
|
return ` |
|
|
<tr data-name="${name.toLowerCase()}" data-symbol="${symbol.toLowerCase()}" data-change="${change24h}" data-rank="${crypto.rank || index + 1}"> |
|
|
<td style="font-weight: 700; color: var(--text-secondary);">${crypto.rank || index + 1}</td> |
|
|
<td> |
|
|
<div class="crypto-name"> |
|
|
${crypto.image ? `<img src="${crypto.image}" class="crypto-img" alt="${symbol}" onerror="this.style.display='none'">` : |
|
|
`<div class="crypto-img" style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); display: flex; align-items: center; justify-content: center; font-weight: 700; color: white;">${symbol[0] || '?'}</div>`} |
|
|
<div> |
|
|
<div style="font-weight: 600;">${name}</div> |
|
|
<div class="crypto-symbol">${symbol}</div> |
|
|
</div> |
|
|
</div> |
|
|
</td> |
|
|
<td class="price number-counter">$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })}</td> |
|
|
<td><span class="change ${changeClass} pulse-data">${changeIcon} ${change24h >= 0 ? '+' : ''}${change24h.toFixed(2)}%</span></td> |
|
|
<td style="font-weight: 600;">$${(marketCap / 1e9).toFixed(2)}B</td> |
|
|
<td style="color: var(--text-secondary);">$${(volume24h / 1e9).toFixed(2)}B</td> |
|
|
</tr> |
|
|
`; |
|
|
}).join(''); |
|
|
} catch (error) { |
|
|
console.error('Error updating market table:', error); |
|
|
const tbody = document.getElementById('marketTableBody'); |
|
|
if (tbody) { |
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--accent-red);">خطا در نمایش دادهها</td></tr>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function updateTrending(trending) { |
|
|
try { |
|
|
const grid = document.getElementById('trendingGrid'); |
|
|
if (!grid) return; |
|
|
|
|
|
if (!trending || !Array.isArray(trending) || trending.length === 0) { |
|
|
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--text-secondary);">هیچ ترندی یافت نشد</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
grid.innerHTML = trending.map((coin, index) => { |
|
|
const name = coin.name || 'نامشخص'; |
|
|
const symbol = coin.symbol || 'N/A'; |
|
|
return ` |
|
|
<div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 12px; padding: 15px; display: flex; align-items: center; gap: 12px;"> |
|
|
<div style="font-size: 20px; font-weight: 900; color: var(--accent-yellow);">#${index + 1}</div> |
|
|
${coin.thumb ? `<img src="${coin.thumb}" style="width: 32px; height: 32px; border-radius: 8px;" onerror="this.style.display='none'">` : ''} |
|
|
<div> |
|
|
<div style="font-weight: 600;">${name}</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);">${symbol}</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
} catch (error) { |
|
|
console.error('Error updating trending:', error); |
|
|
const grid = document.getElementById('trendingGrid'); |
|
|
if (grid) { |
|
|
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--accent-red);">خطا در نمایش ترندها</div>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function updateDeFi(defi) { |
|
|
try { |
|
|
const list = document.getElementById('defiList'); |
|
|
if (!list) return; |
|
|
|
|
|
const protocols = defi && defi.protocols ? defi.protocols : []; |
|
|
const totalTvl = defi && defi.total_tvl ? defi.total_tvl : 0; |
|
|
|
|
|
list.innerHTML = ` |
|
|
<div class="stat-card" style="margin-bottom: 20px; text-align: center; background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));"> |
|
|
<div class="stat-value gradient-text" style="font-size: 42px; margin-bottom: 8px;">$${(totalTvl / 1e9).toFixed(2)}B</div> |
|
|
<div class="stat-label" style="font-size: 16px;">Total Value Locked</div> |
|
|
</div> |
|
|
<div style="display: grid; gap: 12px;"> |
|
|
${protocols.length > 0 ? protocols.map((p, i) => { |
|
|
const name = p.name || 'نامشخص'; |
|
|
const chain = p.chain || 'N/A'; |
|
|
const tvl = p.tvl || 0; |
|
|
const change24h = p.change_24h || 0; |
|
|
const changeClass = change24h >= 0 ? 'positive' : 'negative'; |
|
|
return ` |
|
|
<div class="stat-card" style="animation-delay: ${i * 0.05}s; cursor: pointer;" onclick="showToast('${name}: $${(tvl / 1e9).toFixed(2)}B TVL', 'info', 'DeFi Protocol')"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center;"> |
|
|
<div> |
|
|
<div style="font-weight: 700; font-size: 16px; margin-bottom: 4px;">${i + 1}. ${name}</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px;"> |
|
|
<span>🔗</span> <span>${chain}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div style="text-align: right;"> |
|
|
<div class="stat-value" style="font-size: 18px; margin-bottom: 4px;">$${(tvl / 1e9).toFixed(2)}B</div> |
|
|
<div class="stat-change ${changeClass}" style="font-size: 13px;"> |
|
|
${change24h >= 0 ? '📈' : '📉'} ${change24h >= 0 ? '+' : ''}${change24h.toFixed(2)}% |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join('') : '<div class="empty-state"><div class="empty-state-icon">📦</div><div>هیچ پروتکلی یافت نشد</div></div>'} |
|
|
</div> |
|
|
`; |
|
|
} catch (error) { |
|
|
console.error('Error updating DeFi:', error); |
|
|
const list = document.getElementById('defiList'); |
|
|
if (list) { |
|
|
list.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--accent-red);">خطا در نمایش دادههای DeFi</div>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function initCharts() { |
|
|
Chart.defaults.color = '#9ca3af'; |
|
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)'; |
|
|
|
|
|
charts.dominance = new Chart(document.getElementById('dominanceChart'), { |
|
|
type: 'doughnut', |
|
|
data: { |
|
|
labels: ['Bitcoin', 'Ethereum', 'Others'], |
|
|
datasets: [{ |
|
|
data: [45, 18, 37], |
|
|
backgroundColor: ['#f7931a', '#627eea', '#8b5cf6'], |
|
|
borderWidth: 0 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
plugins: { |
|
|
legend: { position: 'bottom', labels: { padding: 15, font: { size: 12, weight: 600 } } } |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
charts.gauge = new Chart(document.getElementById('gaugeChart'), { |
|
|
type: 'doughnut', |
|
|
data: { |
|
|
datasets: [{ |
|
|
data: [50, 50], |
|
|
backgroundColor: ['#3b82f6', 'rgba(255, 255, 255, 0.1)'], |
|
|
borderWidth: 0 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
rotation: -90, |
|
|
circumference: 180, |
|
|
cutout: '75%', |
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } } |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function updateCharts(market, sentiment) { |
|
|
const btcDom = market.global.btc_dominance; |
|
|
const ethDom = market.global.eth_dominance; |
|
|
charts.dominance.data.datasets[0].data = [btcDom, ethDom, 100 - btcDom - ethDom]; |
|
|
charts.dominance.update(); |
|
|
|
|
|
const fg = sentiment.fear_greed_index.value; |
|
|
charts.gauge.data.datasets[0].data = [fg, 100 - fg]; |
|
|
|
|
|
if (fg < 25) { |
|
|
charts.gauge.data.datasets[0].backgroundColor[0] = '#ef4444'; |
|
|
} else if (fg < 45) { |
|
|
charts.gauge.data.datasets[0].backgroundColor[0] = '#f59e0b'; |
|
|
} else if (fg < 55) { |
|
|
charts.gauge.data.datasets[0].backgroundColor[0] = '#6b7280'; |
|
|
} else if (fg < 75) { |
|
|
charts.gauge.data.datasets[0].backgroundColor[0] = '#3b82f6'; |
|
|
} else { |
|
|
charts.gauge.data.datasets[0].backgroundColor[0] = '#10b981'; |
|
|
} |
|
|
|
|
|
charts.gauge.update(); |
|
|
document.getElementById('sentimentValue').textContent = fg; |
|
|
document.getElementById('sentimentText').textContent = sentiment.fear_greed_index.classification; |
|
|
} |
|
|
|
|
|
|
|
|
let wsConnectAttempts = 0; |
|
|
const MAX_WS_CONNECT_ATTEMPTS = 10; |
|
|
let wsStatsInterval = null; |
|
|
|
|
|
function connectWebSocket() { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (window.wsClient && typeof window.wsClient.on === 'function' && typeof window.wsClient.requestStats === 'function') { |
|
|
console.log('✅ WebSocket Client آماده است'); |
|
|
wsConnectAttempts = 0; |
|
|
|
|
|
|
|
|
window.wsClient.on('stats_update', (message) => { |
|
|
console.log('📊 Stats update:', message.data); |
|
|
if (typeof updateOnlineStats === 'function') { |
|
|
updateOnlineStats(message.data); |
|
|
} |
|
|
}); |
|
|
|
|
|
window.wsClient.on('provider_stats', (message) => { |
|
|
console.log('📡 Provider stats:', message.data); |
|
|
if (currentTab === 'monitor' && typeof updateProviderStatsDisplay === 'function') { |
|
|
updateProviderStatsDisplay(message.data); |
|
|
} |
|
|
}); |
|
|
|
|
|
window.wsClient.on('market_update', (message) => { |
|
|
console.log('💰 Market update'); |
|
|
if (currentTab === 'market') { |
|
|
loadMarketData(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (window.wsClient && window.wsClient.isConnected) { |
|
|
window.wsClient.requestStats(); |
|
|
} |
|
|
}, 1000); |
|
|
|
|
|
|
|
|
if (!wsStatsInterval) { |
|
|
wsStatsInterval = setInterval(() => { |
|
|
if (window.wsClient && window.wsClient.isConnected) { |
|
|
window.wsClient.requestStats(); |
|
|
} |
|
|
}, 10000); |
|
|
} |
|
|
} else { |
|
|
wsConnectAttempts++; |
|
|
if (wsConnectAttempts < MAX_WS_CONNECT_ATTEMPTS) { |
|
|
|
|
|
if (wsConnectAttempts % 5 === 0 || wsConnectAttempts === 1) { |
|
|
console.log(`⏳ در انتظار WebSocket Client... (${wsConnectAttempts}/${MAX_WS_CONNECT_ATTEMPTS})`); |
|
|
} |
|
|
setTimeout(connectWebSocket, 1000); |
|
|
} else { |
|
|
console.warn('⚠️ WebSocket Client پس از ' + MAX_WS_CONNECT_ATTEMPTS + ' تلاش آماده نشد. ممکن است فایل websocket-client.js لود نشده باشد یا WebSocket پشتیبانی نشود.'); |
|
|
console.warn('⚠️ بررسی کنید که فایل /static/js/websocket-client.js به درستی لود شده باشد.'); |
|
|
|
|
|
setTimeout(() => { |
|
|
if (!window.wsClient) { |
|
|
console.warn('⚠️ WebSocket Client غیرفعال است. برخی ویژگیهای real-time ممکن است کار نکنند.'); |
|
|
console.warn('⚠️ برای فعال کردن WebSocket، صفحه را refresh کنید (Ctrl+F5 برای clear cache).'); |
|
|
} |
|
|
}, 5000); |
|
|
|
|
|
return; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function updateOnlineStats(data) { |
|
|
const activeEl = document.getElementById('active-users-count'); |
|
|
const totalEl = document.getElementById('total-sessions-count'); |
|
|
|
|
|
if (data.active_connections !== undefined && activeEl) { |
|
|
activeEl.textContent = data.active_connections; |
|
|
|
|
|
activeEl.classList.add('count-updated'); |
|
|
setTimeout(() => activeEl.classList.remove('count-updated'), 500); |
|
|
} |
|
|
|
|
|
if (data.total_sessions !== undefined && totalEl) { |
|
|
totalEl.textContent = data.total_sessions; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function updateProviderStatsDisplay(stats) { |
|
|
if (stats.summary) { |
|
|
const summary = stats.summary; |
|
|
if (document.getElementById('totalAPIs')) { |
|
|
document.getElementById('totalAPIs').textContent = summary.total_providers || 0; |
|
|
} |
|
|
if (document.getElementById('onlineAPIs')) { |
|
|
document.getElementById('onlineAPIs').textContent = summary.online || 0; |
|
|
} |
|
|
if (document.getElementById('offlineAPIs')) { |
|
|
document.getElementById('offlineAPIs').textContent = summary.offline || 0; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadMonitorData() { |
|
|
try { |
|
|
|
|
|
const tbody = document.getElementById('providersTable'); |
|
|
if (tbody) { |
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 40px;"><div class="loading"><div class="spinner"></div></div><div style="margin-top: 10px; color: var(--text-secondary);">در حال بارگذاری وضعیت APIها...</div></td></tr>'; |
|
|
} |
|
|
|
|
|
const [statusRes, providersRes] = await Promise.all([ |
|
|
fetch('/api/status'), |
|
|
fetch('/api/providers') |
|
|
]); |
|
|
|
|
|
|
|
|
if (!statusRes.ok) throw new Error(`خطا در دریافت وضعیت: ${statusRes.status}`); |
|
|
if (!providersRes.ok) throw new Error(`خطا در دریافت لیست APIها: ${providersRes.status}`); |
|
|
|
|
|
const [status, providers] = await Promise.all([ |
|
|
statusRes.json(), |
|
|
providersRes.json() |
|
|
]); |
|
|
|
|
|
|
|
|
if (!status || typeof status.total_providers === 'undefined') throw new Error('دادههای وضعیت نامعتبر است'); |
|
|
if (!providers || !Array.isArray(providers)) throw new Error('لیست APIها نامعتبر است'); |
|
|
|
|
|
if (document.getElementById('totalAPIs')) { |
|
|
document.getElementById('totalAPIs').textContent = status.total_providers || 0; |
|
|
} |
|
|
if (document.getElementById('onlineAPIs')) { |
|
|
document.getElementById('onlineAPIs').textContent = status.online || 0; |
|
|
} |
|
|
if (document.getElementById('offlineAPIs')) { |
|
|
document.getElementById('offlineAPIs').textContent = status.offline || 0; |
|
|
} |
|
|
if (document.getElementById('avgResponse')) { |
|
|
document.getElementById('avgResponse').textContent = (status.avg_response_time_ms || 0) + 'ms'; |
|
|
} |
|
|
|
|
|
if (tbody) { |
|
|
if (providers.length === 0) { |
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--text-secondary);">هیچ APIای یافت نشد</td></tr>'; |
|
|
} else { |
|
|
tbody.innerHTML = providers.map(p => { |
|
|
let statusClass = 'badge-success'; |
|
|
if (p.status === 'offline') statusClass = 'badge-danger'; |
|
|
else if (p.status === 'degraded') statusClass = 'badge-warning'; |
|
|
|
|
|
return ` |
|
|
<tr> |
|
|
<td><strong>${p.name || 'نامشخص'}</strong></td> |
|
|
<td><span class="badge badge-info">${p.category || 'نامشخص'}</span></td> |
|
|
<td><span class="badge ${statusClass}">${(p.status || 'unknown').toUpperCase()}</span></td> |
|
|
<td>${p.response_time_ms || p.avg_response_time_ms || 0}ms</td> |
|
|
<td style="color: var(--text-secondary); font-size: 13px;">${p.last_fetch ? new Date(p.last_fetch).toLocaleTimeString() : 'نامشخص'}</td> |
|
|
</tr> |
|
|
`; |
|
|
}).join(''); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading monitor data:', error); |
|
|
const tbody = document.getElementById('providersTable'); |
|
|
if (tbody) { |
|
|
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--accent-red);"> |
|
|
<div style="font-size: 24px; margin-bottom: 10px;">❌</div> |
|
|
<div style="font-weight: 600; margin-bottom: 5px;">خطا در بارگذاری دادهها</div> |
|
|
<div style="font-size: 14px; color: var(--text-secondary);">${error.message || 'خطای نامشخص'}</div> |
|
|
<button onclick="loadMonitorData()" style="margin-top: 15px; padding: 10px 20px; background: var(--accent-blue); border: none; border-radius: 8px; color: white; cursor: pointer; font-weight: 600;">تلاش مجدد</button> |
|
|
</td></tr>`; |
|
|
} |
|
|
showToast('❌ خطا در بارگذاری دادههای مانیتور: ' + (error.message || 'خطای نامشخص'), 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function runSentiment() { |
|
|
const text = document.getElementById('sentimentText').value; |
|
|
const texts = text.split('\n').filter(t => t.trim()); |
|
|
|
|
|
if (texts.length === 0) { |
|
|
showToast('Please enter at least one line of text', 'info'); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
document.getElementById('sentimentResult').textContent = '⏳ Analyzing...'; |
|
|
document.getElementById('sentimentDetails').textContent = ''; |
|
|
|
|
|
const res = await fetch('/api/hf/run-sentiment', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ texts }) |
|
|
}); |
|
|
|
|
|
const data = await res.json(); |
|
|
const vote = data.vote || 0; |
|
|
let emoji = '😐'; |
|
|
let color = 'var(--text-secondary)'; |
|
|
|
|
|
if (vote > 0.2) { |
|
|
emoji = '📈'; |
|
|
color = 'var(--accent-green)'; |
|
|
} else if (vote < -0.2) { |
|
|
emoji = '📉'; |
|
|
color = 'var(--accent-red)'; |
|
|
} |
|
|
|
|
|
document.getElementById('sentimentResult').innerHTML = `<span style="color: ${color};">${emoji} ${vote.toFixed(3)}</span>`; |
|
|
document.getElementById('sentimentDetails').textContent = JSON.stringify(data, null, 2); |
|
|
} catch (error) { |
|
|
document.getElementById('sentimentResult').innerHTML = '<span style="color: var(--accent-red);">❌ Error</span>'; |
|
|
document.getElementById('sentimentDetails').textContent = 'Error: ' + error.message; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadAdvancedData() { |
|
|
try { |
|
|
const response = await fetch('/api/v2/status'); |
|
|
const data = await response.json(); |
|
|
|
|
|
document.getElementById('totalApis').textContent = data.services.config_loader.apis_loaded; |
|
|
document.getElementById('activeTasks').textContent = data.services.scheduler.total_tasks; |
|
|
document.getElementById('cachedData').textContent = data.services.persistence.cached_apis; |
|
|
document.getElementById('wsConnections').textContent = data.services.websocket.total_connections; |
|
|
|
|
|
const apisResponse = await fetch('/api/v2/config/apis'); |
|
|
const apisData = await apisResponse.json(); |
|
|
displayAPIs(apisData.apis); |
|
|
} catch (error) { |
|
|
console.error('Error loading advanced data:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function displayAPIs(apis) { |
|
|
const listElement = document.getElementById('apiList'); |
|
|
listElement.innerHTML = ''; |
|
|
|
|
|
for (const [apiId, api] of Object.entries(apis)) { |
|
|
const item = document.createElement('div'); |
|
|
item.style.cssText = 'background: rgba(17, 24, 39, 0.6); padding: 15px; border-radius: 12px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;'; |
|
|
item.innerHTML = ` |
|
|
<div> |
|
|
<div style="font-weight: 600;">${api.name}</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);">${api.category}</div> |
|
|
</div> |
|
|
<button class="refresh-btn" onclick="forceUpdate('${apiId}')" style="padding: 6px 12px; font-size: 12px;">🔄 Update</button> |
|
|
`; |
|
|
listElement.appendChild(item); |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportJSON() { |
|
|
try { |
|
|
const response = await fetch('/api/v2/export/json', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ include_history: true }) |
|
|
}); |
|
|
const data = await response.json(); |
|
|
showToast('✅ JSON export created!', 'success'); |
|
|
addLog(`Exported to JSON: ${data.filepath}`); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportCSV() { |
|
|
try { |
|
|
const response = await fetch('/api/v2/export/csv', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ flatten: true }) |
|
|
}); |
|
|
const data = await response.json(); |
|
|
showToast('✅ CSV export created!', 'success'); |
|
|
addLog(`Exported to CSV: ${data.filepath}`); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function createBackup() { |
|
|
try { |
|
|
const response = await fetch('/api/v2/backup', { method: 'POST' }); |
|
|
const data = await response.json(); |
|
|
showToast('✅ Backup created!', 'success'); |
|
|
addLog(`Backup created: ${data.backup_file}`); |
|
|
} catch (error) { |
|
|
showToast('❌ Backup failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function clearCache() { |
|
|
if (!confirm('Clear all cached data?')) return; |
|
|
try { |
|
|
await fetch('/api/v2/cleanup/cache', { method: 'POST' }); |
|
|
showToast('✅ Cache cleared!', 'success'); |
|
|
addLog('Cache cleared'); |
|
|
} catch (error) { |
|
|
showToast('❌ Failed to clear cache', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function forceUpdateAll() { |
|
|
try { |
|
|
const response = await fetch('/api/v2/config/apis'); |
|
|
const data = await response.json(); |
|
|
for (const apiId of Object.keys(data.apis)) { |
|
|
await forceUpdate(apiId); |
|
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
} |
|
|
showToast('✅ All APIs updated!', 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Update failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function forceUpdate(apiId) { |
|
|
try { |
|
|
await fetch(`/api/v2/schedule/tasks/${apiId}/force-update`, { method: 'POST' }); |
|
|
addLog(`Forced update: ${apiId}`); |
|
|
} catch (error) { |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
function addLog(text) { |
|
|
const logContainer = document.getElementById('activityLog'); |
|
|
const time = new Date().toLocaleTimeString(); |
|
|
const entry = document.createElement('div'); |
|
|
entry.style.cssText = 'padding: 10px; border-left: 3px solid var(--accent-blue); margin-bottom: 8px;'; |
|
|
entry.innerHTML = `<span style="opacity: 0.6;">${time}</span> ${text}`; |
|
|
logContainer.insertBefore(entry, logContainer.firstChild); |
|
|
while (logContainer.children.length > 50) { |
|
|
logContainer.removeChild(logContainer.lastChild); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadAdminData() { |
|
|
try { |
|
|
const [status, providers] = await Promise.all([ |
|
|
fetch('/api/status').then(r => r.json()), |
|
|
fetch('/api/providers').then(r => r.json()) |
|
|
]); |
|
|
|
|
|
document.getElementById('statTotal').textContent = status.total_providers; |
|
|
document.getElementById('statOnline').textContent = status.online; |
|
|
document.getElementById('statOffline').textContent = status.offline; |
|
|
|
|
|
const list = document.getElementById('apisList'); |
|
|
list.innerHTML = providers.map(api => ` |
|
|
<div style="background: rgba(17, 24, 39, 0.6); padding: 15px; border-radius: 12px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;"> |
|
|
<div> |
|
|
<div style="font-weight: 600;">${api.name}</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);">${api.category}</div> |
|
|
</div> |
|
|
<span class="badge ${api.status === 'online' ? 'badge-success' : 'badge-danger'}">${api.status.toUpperCase()}</span> |
|
|
</div> |
|
|
`).join(''); |
|
|
} catch (error) { |
|
|
console.error('Error loading admin data:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function addNewAPI() { |
|
|
const name = document.getElementById('newApiName').value; |
|
|
const url = document.getElementById('newApiUrl').value; |
|
|
const category = document.getElementById('newApiCategory').value; |
|
|
|
|
|
if (!name || !url) { |
|
|
showToast('Please fill in API name and URL', 'info'); |
|
|
return; |
|
|
} |
|
|
|
|
|
showToast('✅ API added! Note: Restart server to activate.', 'success'); |
|
|
document.getElementById('newApiName').value = ''; |
|
|
document.getElementById('newApiUrl').value = ''; |
|
|
loadAdminData(); |
|
|
} |
|
|
|
|
|
function saveSettings() { |
|
|
const interval = document.getElementById('checkInterval').value; |
|
|
const refresh = document.getElementById('dashboardRefresh').value; |
|
|
localStorage.setItem('monitorSettings', JSON.stringify({ interval, refresh })); |
|
|
showToast('✅ Settings saved!', 'success'); |
|
|
} |
|
|
|
|
|
|
|
|
async function loadHFHealth() { |
|
|
try { |
|
|
const data = await fetch('/api/hf/health').then(r => r.json()); |
|
|
document.getElementById('healthOutput').textContent = JSON.stringify(data, null, 2); |
|
|
} catch (err) { |
|
|
document.getElementById('healthOutput').textContent = `Error: ${err.message}`; |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadModels() { |
|
|
try { |
|
|
document.getElementById('modelsList').innerHTML = '<p style="color: var(--text-secondary);">Loading...</p>'; |
|
|
|
|
|
document.getElementById('modelsList').innerHTML = '<p style="color: var(--text-secondary);">Models registry endpoint not implemented</p>'; |
|
|
} catch (err) { |
|
|
document.getElementById('modelsList').innerHTML = `<p style="color: var(--accent-red);">Error: ${err.message}</p>`; |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadDatasets() { |
|
|
try { |
|
|
document.getElementById('datasetsList').innerHTML = '<p style="color: var(--text-secondary);">Loading...</p>'; |
|
|
|
|
|
document.getElementById('datasetsList').innerHTML = '<p style="color: var(--text-secondary);">Datasets registry endpoint not implemented</p>'; |
|
|
} catch (err) { |
|
|
document.getElementById('datasetsList').innerHTML = `<p style="color: var(--accent-red);">Error: ${err.message}</p>`; |
|
|
} |
|
|
} |
|
|
|
|
|
async function doSearch() { |
|
|
const q = document.getElementById('searchQuery').value; |
|
|
document.getElementById('searchResults').innerHTML = `<p style="color: var(--text-secondary);">Searching for "${q}"...</p>`; |
|
|
|
|
|
document.getElementById('searchResults').innerHTML = '<p style="color: var(--text-secondary);">Search endpoint not implemented</p>'; |
|
|
} |
|
|
|
|
|
async function doSearchDatasets() { |
|
|
const q = document.getElementById('searchQuery').value; |
|
|
document.getElementById('searchResults').innerHTML = `<p style="color: var(--text-secondary);">Searching datasets for "${q}"...</p>`; |
|
|
|
|
|
document.getElementById('searchResults').innerHTML = '<p style="color: var(--text-secondary);">Search endpoint not implemented</p>'; |
|
|
} |
|
|
|
|
|
async function doSentiment() { |
|
|
const texts = document.getElementById('sentimentTexts').value.split('\n').filter(t => t.trim()); |
|
|
if (texts.length === 0) { |
|
|
showToast('Please enter at least one text sample', 'info'); |
|
|
return; |
|
|
} |
|
|
try { |
|
|
document.getElementById('voteDisplay').innerHTML = '<span>⏳ Analyzing...</span>'; |
|
|
document.getElementById('sentimentOutput').textContent = 'Running sentiment analysis...'; |
|
|
|
|
|
const data = await fetch('/api/hf/run-sentiment', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ texts }) |
|
|
}).then(r => r.json()); |
|
|
|
|
|
const vote = data.vote || 0; |
|
|
let voteClass = 'vote-neutral'; |
|
|
let voteEmoji = '😐'; |
|
|
if (vote > 0.2) { voteClass = 'vote-positive'; voteEmoji = '📈'; } |
|
|
else if (vote < -0.2) { voteClass = 'vote-negative'; voteEmoji = '📉'; } |
|
|
|
|
|
document.getElementById('voteDisplay').innerHTML = `<span style="color: ${voteClass === 'vote-positive' ? 'var(--accent-green)' : voteClass === 'vote-negative' ? 'var(--accent-red)' : 'var(--text-secondary)'};">${voteEmoji} ${vote.toFixed(3)}</span>`; |
|
|
document.getElementById('sentimentOutput').textContent = JSON.stringify(data, null, 2); |
|
|
} catch (err) { |
|
|
document.getElementById('voteDisplay').innerHTML = '<span style="color: var(--accent-red);">Error</span>'; |
|
|
document.getElementById('sentimentOutput').textContent = `Error: ${err.message}`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let currentPoolId = null; |
|
|
let allProvidersList = []; |
|
|
|
|
|
async function loadPools() { |
|
|
try { |
|
|
const [poolsRes, historyRes] = await Promise.all([ |
|
|
fetch('/api/pools').then(r => r.json()), |
|
|
fetch('/api/pools/history?limit=20').then(r => r.json()) |
|
|
]); |
|
|
|
|
|
const pools = poolsRes.pools || []; |
|
|
const container = document.getElementById('poolsContainer'); |
|
|
|
|
|
if (pools.length === 0) { |
|
|
container.innerHTML = ` |
|
|
<div style="grid-column: 1/-1; text-align: center; padding: 40px; background: rgba(17, 24, 39, 0.6); border-radius: 20px; border: 2px dashed var(--border);"> |
|
|
<div style="font-size: 48px; margin-bottom: 15px;">🔄</div> |
|
|
<div style="font-size: 18px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary);">No pools configured</div> |
|
|
<div style="color: var(--text-secondary); margin-bottom: 20px;">Create your first pool to get started with API source rotation</div> |
|
|
<button class="refresh-btn" onclick="showCreatePoolModal()">➕ Create Pool</button> |
|
|
</div> |
|
|
`; |
|
|
} else { |
|
|
container.innerHTML = pools.map(pool => createPoolCard(pool)).join(''); |
|
|
} |
|
|
|
|
|
|
|
|
const history = historyRes.history || []; |
|
|
const historyContainer = document.getElementById('rotationHistory'); |
|
|
if (history.length === 0) { |
|
|
historyContainer.innerHTML = '<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No rotation history yet</p>'; |
|
|
} else { |
|
|
historyContainer.innerHTML = history.map(h => ` |
|
|
<div style="padding: 15px; background: rgba(17, 24, 39, 0.6); border-radius: 12px; margin-bottom: 10px; border-left: 3px solid var(--accent-blue);"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;"> |
|
|
<div> |
|
|
<div style="font-weight: 600; margin-bottom: 5px;">${h.pool_name}</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);">Rotated to: <strong>${h.provider_name}</strong></div> |
|
|
</div> |
|
|
<div style="text-align: right;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);">${new Date(h.timestamp).toLocaleString()}</div> |
|
|
<span class="badge badge-info" style="margin-top: 5px; display: inline-block;">${h.reason}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`).join(''); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading pools:', error); |
|
|
document.getElementById('poolsContainer').innerHTML = ` |
|
|
<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--accent-red);"> |
|
|
<div>❌ Error loading pools: ${error.message}</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
} |
|
|
|
|
|
function createPoolCard(pool) { |
|
|
const currentProvider = pool.current_provider |
|
|
? `<div style="margin-bottom: 15px; padding: 12px; background: rgba(16, 185, 129, 0.1); border-radius: 10px; border: 1px solid rgba(16, 185, 129, 0.3);"> |
|
|
<div style="display: flex; align-items: center; gap: 8px;"> |
|
|
<span style="width: 10px; height: 10px; background: var(--accent-green); border-radius: 50%; display: inline-block;"></span> |
|
|
<span style="font-weight: 600;">Current: ${pool.current_provider.name}</span> |
|
|
</div> |
|
|
</div>` |
|
|
: '<div style="margin-bottom: 15px; padding: 12px; background: rgba(239, 68, 68, 0.1); border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.3); color: var(--text-secondary);">No active provider</div>'; |
|
|
|
|
|
const membersHTML = pool.members && pool.members.length > 0 |
|
|
? pool.members.map(member => { |
|
|
const successRate = member.success_rate || 0; |
|
|
const statusClass = successRate >= 90 ? 'badge-success' : successRate >= 70 ? 'badge-warning' : 'badge-danger'; |
|
|
const rateLimit = member.rate_limit || { usage: 0, limit: 100, percentage: 0 }; |
|
|
|
|
|
return ` |
|
|
<div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 10px; margin-bottom: 8px;"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> |
|
|
<div style="font-weight: 600;">${member.provider_name}</div> |
|
|
<span class="badge ${statusClass}">${successRate.toFixed(1)}%</span> |
|
|
</div> |
|
|
<div style="display: flex; gap: 15px; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;"> |
|
|
<span>Used: ${member.use_count || 0}</span> |
|
|
<span>Priority: ${member.priority || 1}</span> |
|
|
<span>Weight: ${member.weight || 1}</span> |
|
|
</div> |
|
|
<div> |
|
|
<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;"> |
|
|
Rate Limit: ${rateLimit.usage}/${rateLimit.limit} (${rateLimit.percentage}%) |
|
|
</div> |
|
|
<div style="height: 6px; background: rgba(255, 255, 255, 0.1); border-radius: 3px; overflow: hidden;"> |
|
|
<div style="height: 100%; background: ${rateLimit.percentage < 70 ? 'var(--accent-green)' : rateLimit.percentage < 90 ? 'var(--accent-yellow)' : 'var(--accent-red)'}; width: ${rateLimit.percentage}%; transition: width 0.3s;"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join('') |
|
|
: '<div style="color: var(--text-secondary); font-size: 14px; padding: 20px; text-align: center;">No members in pool</div>'; |
|
|
|
|
|
return ` |
|
|
<div class="pool-card-hover" style="background: rgba(17, 24, 39, 0.6); backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 20px; padding: 25px;"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px;"> |
|
|
<div> |
|
|
<div style="font-size: 20px; font-weight: 700; margin-bottom: 8px;">${pool.pool_name}</div> |
|
|
<span class="badge badge-info">${pool.category}</span> |
|
|
</div> |
|
|
<div style="display: flex; gap: 8px;"> |
|
|
<button onclick="addMemberToPool(${pool.pool_id})" style="padding: 8px 12px; background: rgba(59, 130, 246, 0.2); border: 1px solid var(--accent-blue); border-radius: 8px; color: var(--accent-blue); cursor: pointer; font-size: 12px; font-weight: 600;">➕</button> |
|
|
<button onclick="rotatePool(${pool.pool_id})" style="padding: 8px 12px; background: rgba(16, 185, 129, 0.2); border: 1px solid var(--accent-green); border-radius: 8px; color: var(--accent-green); cursor: pointer; font-size: 12px; font-weight: 600;">🔄</button> |
|
|
<button onclick="deletePool(${pool.pool_id}, '${pool.pool_name}')" style="padding: 8px 12px; background: rgba(239, 68, 68, 0.2); border: 1px solid var(--accent-red); border-radius: 8px; color: var(--accent-red); cursor: pointer; font-size: 12px; font-weight: 600;">🗑️</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
${currentProvider} |
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 20px 0;"> |
|
|
<div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 10px;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">Strategy</div> |
|
|
<div style="font-weight: 600; font-size: 14px;">${pool.rotation_strategy.replace('_', ' ')}</div> |
|
|
</div> |
|
|
<div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 10px;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">Rotations</div> |
|
|
<div style="font-weight: 600; font-size: 14px;">${pool.total_rotations || 0}</div> |
|
|
</div> |
|
|
<div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 10px;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">Members</div> |
|
|
<div style="font-weight: 600; font-size: 14px;">${pool.members ? pool.members.length : 0}</div> |
|
|
</div> |
|
|
<div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 10px;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">Status</div> |
|
|
<span class="badge ${pool.enabled ? 'badge-success' : 'badge-danger'}">${pool.enabled ? 'Enabled' : 'Disabled'}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="margin-top: 20px;"> |
|
|
<div style="font-weight: 600; margin-bottom: 12px; font-size: 14px;">Pool Members</div> |
|
|
<div style="max-height: 300px; overflow-y: auto;"> |
|
|
${membersHTML} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
async function loadProvidersForPool() { |
|
|
try { |
|
|
const providers = await fetch('/api/providers').then(r => r.json()); |
|
|
allProvidersList = providers; |
|
|
const select = document.getElementById('memberProvider'); |
|
|
select.innerHTML = '<option value="">Select a provider...</option>' + providers.map(p => { |
|
|
const providerId = p.name.toLowerCase().replace(/\s+/g, '_'); |
|
|
return `<option value="${providerId}">${p.name} (${p.category})</option>`; |
|
|
}).join(''); |
|
|
} catch (error) { |
|
|
console.error('Error loading providers:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function showCreatePoolModal() { |
|
|
document.getElementById('createPoolModal').classList.add('active'); |
|
|
} |
|
|
|
|
|
function closeCreatePoolModal() { |
|
|
document.getElementById('createPoolModal').classList.remove('active'); |
|
|
document.getElementById('createPoolForm').reset(); |
|
|
} |
|
|
|
|
|
function addMemberToPool(poolId) { |
|
|
currentPoolId = poolId; |
|
|
loadProvidersForPool(); |
|
|
document.getElementById('addMemberModal').classList.add('active'); |
|
|
} |
|
|
|
|
|
function closeAddMemberModal() { |
|
|
document.getElementById('addMemberModal').classList.remove('active'); |
|
|
document.getElementById('addMemberForm').reset(); |
|
|
currentPoolId = null; |
|
|
} |
|
|
|
|
|
document.getElementById('createPoolForm').addEventListener('submit', async (e) => { |
|
|
e.preventDefault(); |
|
|
const data = { |
|
|
name: document.getElementById('poolName').value, |
|
|
category: document.getElementById('poolCategory').value, |
|
|
rotation_strategy: document.getElementById('rotationStrategy').value, |
|
|
description: document.getElementById('poolDescription').value |
|
|
}; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/pools', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(data) |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
showToast('✅ Pool created successfully!', 'success'); |
|
|
closeCreatePoolModal(); |
|
|
loadPools(); |
|
|
} else { |
|
|
const error = await response.json(); |
|
|
showToast('❌ Error: ' + (error.detail || 'Failed to create pool'), 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('❌ Error: ' + error.message, 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('addMemberForm').addEventListener('submit', async (e) => { |
|
|
e.preventDefault(); |
|
|
const data = { |
|
|
provider_id: document.getElementById('memberProvider').value, |
|
|
priority: parseInt(document.getElementById('memberPriority').value), |
|
|
weight: parseInt(document.getElementById('memberWeight').value) |
|
|
}; |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/pools/${currentPoolId}/members`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(data) |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
showToast('✅ Member added successfully!', 'success'); |
|
|
closeAddMemberModal(); |
|
|
loadPools(); |
|
|
} else { |
|
|
const error = await response.json(); |
|
|
showToast('❌ Error: ' + (error.detail || 'Failed to add member'), 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('❌ Error: ' + error.message, 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
}); |
|
|
|
|
|
async function rotatePool(poolId) { |
|
|
try { |
|
|
const response = await fetch(`/api/pools/${poolId}/rotate`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ reason: 'manual' }) |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const result = await response.json(); |
|
|
showToast(`✅ Rotated to ${result.provider_name}`, 'success'); |
|
|
loadPools(); |
|
|
} else { |
|
|
const error = await response.json(); |
|
|
showToast('❌ Error: ' + (error.detail || 'Failed to rotate'), 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('❌ Error: ' + error.message, 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function deletePool(poolId, poolName) { |
|
|
if (!confirm(`Are you sure you want to delete pool "${poolName}"?`)) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/pools/${poolId}`, { |
|
|
method: 'DELETE' |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
showToast('✅ Pool deleted successfully!', 'success'); |
|
|
loadPools(); |
|
|
} else { |
|
|
const error = await response.json(); |
|
|
showToast('❌ Error: ' + (error.detail || 'Failed to delete pool'), 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
alert('❌ Error: ' + error.message); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function showToast(message, type = 'info', title = null) { |
|
|
const toastContainer = document.getElementById('toastContainer') || document.body; |
|
|
const toast = document.createElement('div'); |
|
|
toast.className = `toast toast-${type}`; |
|
|
|
|
|
const icons = { |
|
|
success: '✅', |
|
|
error: '❌', |
|
|
warning: '⚠️', |
|
|
info: 'ℹ️' |
|
|
}; |
|
|
|
|
|
const titles = { |
|
|
success: 'موفق!', |
|
|
error: 'خطا!', |
|
|
warning: 'هشدار!', |
|
|
info: 'اطلاعیه' |
|
|
}; |
|
|
|
|
|
toast.innerHTML = ` |
|
|
<div class="toast-icon">${icons[type] || icons.info}</div> |
|
|
<div class="toast-content"> |
|
|
<div class="toast-title">${title || titles[type] || titles.info}</div> |
|
|
<div class="toast-message">${message}</div> |
|
|
</div> |
|
|
<button class="toast-close" onclick="this.parentElement.remove()">×</button> |
|
|
`; |
|
|
|
|
|
toastContainer.appendChild(toast); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
toast.style.animation = 'toastSlideIn 0.3s reverse'; |
|
|
setTimeout(() => toast.remove(), 300); |
|
|
}, 5000); |
|
|
|
|
|
|
|
|
toast.addEventListener('click', (e) => { |
|
|
if (e.target.classList.contains('toast-close') || e.target === toast) { |
|
|
toast.style.animation = 'toastSlideIn 0.3s reverse'; |
|
|
setTimeout(() => toast.remove(), 300); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function showProgress(percent = 0) { |
|
|
const progressBar = document.getElementById('progressBar'); |
|
|
if (progressBar) { |
|
|
progressBar.style.width = percent + '%'; |
|
|
} |
|
|
} |
|
|
|
|
|
function hideProgress() { |
|
|
const progressBar = document.getElementById('progressBar'); |
|
|
if (progressBar) { |
|
|
progressBar.style.width = '0%'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function showLoading(message = 'در حال بارگذاری...') { |
|
|
const overlay = document.getElementById('loadingOverlay'); |
|
|
const text = document.getElementById('loadingText'); |
|
|
if (overlay) { |
|
|
overlay.classList.add('show'); |
|
|
} |
|
|
if (text) { |
|
|
text.textContent = message; |
|
|
} |
|
|
} |
|
|
|
|
|
function hideLoading() { |
|
|
const overlay = document.getElementById('loadingOverlay'); |
|
|
if (overlay) { |
|
|
overlay.classList.remove('show'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function showFeedback(type, title, message) { |
|
|
const overlay = document.getElementById('feedbackOverlay'); |
|
|
const icon = document.getElementById('feedbackIcon'); |
|
|
const titleEl = document.getElementById('feedbackTitle'); |
|
|
const messageEl = document.getElementById('feedbackMessage'); |
|
|
|
|
|
if (overlay && icon && titleEl && messageEl) { |
|
|
const icons = { |
|
|
success: '✅', |
|
|
error: '❌', |
|
|
warning: '⚠️' |
|
|
}; |
|
|
|
|
|
icon.textContent = icons[type] || icons.success; |
|
|
titleEl.textContent = title; |
|
|
messageEl.textContent = message; |
|
|
|
|
|
overlay.classList.add('show'); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
hideFeedback(); |
|
|
}, 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
function hideFeedback() { |
|
|
const overlay = document.getElementById('feedbackOverlay'); |
|
|
if (overlay) { |
|
|
overlay.classList.remove('show'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function scrollToTop() { |
|
|
window.scrollTo({ |
|
|
top: 0, |
|
|
behavior: 'smooth' |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
let lastScroll = 0; |
|
|
window.addEventListener('scroll', () => { |
|
|
const fab = document.querySelector('.fab'); |
|
|
if (fab) { |
|
|
const currentScroll = window.pageYOffset; |
|
|
if (currentScroll > 300) { |
|
|
fab.style.opacity = '1'; |
|
|
fab.style.pointerEvents = 'all'; |
|
|
} else { |
|
|
fab.style.opacity = '0'; |
|
|
fab.style.pointerEvents = 'none'; |
|
|
} |
|
|
lastScroll = currentScroll; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
let currentFilter = 'all'; |
|
|
let marketDataCache = []; |
|
|
|
|
|
function filterMarketTable() { |
|
|
const searchInput = document.getElementById('marketSearch'); |
|
|
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; |
|
|
const tbody = document.getElementById('marketTableBody'); |
|
|
|
|
|
if (!tbody) return; |
|
|
|
|
|
const rows = tbody.querySelectorAll('tr'); |
|
|
let visibleCount = 0; |
|
|
|
|
|
|
|
|
const existingNoResults = tbody.querySelector('tr[data-no-results]'); |
|
|
if (existingNoResults) { |
|
|
existingNoResults.remove(); |
|
|
} |
|
|
|
|
|
rows.forEach((row, index) => { |
|
|
if (row.querySelector('td[colspan]')) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const cells = row.querySelectorAll('td'); |
|
|
if (cells.length < 4) return; |
|
|
|
|
|
const name = cells[1]?.textContent?.toLowerCase() || ''; |
|
|
const symbol = cells[1]?.querySelector('.crypto-symbol')?.textContent?.toLowerCase() || ''; |
|
|
const changeText = cells[3]?.textContent || ''; |
|
|
const changeValue = parseFloat(changeText.replace(/[^0-9.-]/g, '')) || 0; |
|
|
|
|
|
let matchesSearch = !searchTerm || name.includes(searchTerm) || symbol.includes(searchTerm); |
|
|
let matchesFilter = true; |
|
|
|
|
|
if (currentFilter === 'top10') { |
|
|
matchesFilter = index < 10; |
|
|
} else if (currentFilter === 'gainers') { |
|
|
matchesFilter = changeValue > 0; |
|
|
} else if (currentFilter === 'losers') { |
|
|
matchesFilter = changeValue < 0; |
|
|
} else if (currentFilter === 'volume') { |
|
|
|
|
|
matchesFilter = true; |
|
|
} |
|
|
|
|
|
if (matchesSearch && matchesFilter) { |
|
|
row.style.display = ''; |
|
|
visibleCount++; |
|
|
row.style.animation = `rowSlideIn 0.3s ease-out`; |
|
|
row.style.animationDelay = `${index * 0.05}s`; |
|
|
} else { |
|
|
row.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (visibleCount === 0 && rows.length > 0 && !searchTerm && currentFilter === 'all') { |
|
|
|
|
|
return; |
|
|
} |
|
|
|
|
|
if (visibleCount === 0) { |
|
|
const noResultsRow = document.createElement('tr'); |
|
|
noResultsRow.setAttribute('data-no-results', 'true'); |
|
|
noResultsRow.innerHTML = `<td colspan="6" style="text-align: center; padding: 40px; color: var(--text-secondary);"> |
|
|
<div style="font-size: 48px; margin-bottom: 10px;">🔍</div> |
|
|
<div style="font-weight: 600; margin-bottom: 5px;">نتیجهای یافت نشد</div> |
|
|
<div style="font-size: 14px;">لطفاً عبارت جستجوی دیگری را امتحان کنید</div> |
|
|
</td>`; |
|
|
tbody.appendChild(noResultsRow); |
|
|
} |
|
|
} |
|
|
|
|
|
function filterByCategory(category) { |
|
|
currentFilter = category; |
|
|
|
|
|
|
|
|
document.querySelectorAll('.filter-chip').forEach(chip => { |
|
|
chip.classList.remove('active'); |
|
|
}); |
|
|
if (event && event.target) { |
|
|
event.target.classList.add('active'); |
|
|
} |
|
|
|
|
|
filterMarketTable(); |
|
|
} |
|
|
|
|
|
|
|
|
function animateNumber(element, from, to, duration = 1000) { |
|
|
if (!element) return; |
|
|
|
|
|
const start = performance.now(); |
|
|
const difference = to - from; |
|
|
|
|
|
function update(currentTime) { |
|
|
const elapsed = currentTime - start; |
|
|
const progress = Math.min(elapsed / duration, 1); |
|
|
|
|
|
|
|
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4); |
|
|
const current = from + (difference * easeOutQuart); |
|
|
|
|
|
element.textContent = typeof to === 'number' && to >= 1000 |
|
|
? current.toLocaleString('fa-IR', { maximumFractionDigits: 2 }) |
|
|
: current.toFixed(2); |
|
|
|
|
|
if (progress < 1) { |
|
|
requestAnimationFrame(update); |
|
|
} else { |
|
|
element.classList.add('updated'); |
|
|
setTimeout(() => element.classList.remove('updated'), 500); |
|
|
} |
|
|
} |
|
|
|
|
|
requestAnimationFrame(update); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => { |
|
|
if (e.target.classList.contains('modal')) { |
|
|
e.target.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
async function loadLogs() { |
|
|
try { |
|
|
const level = document.getElementById('logLevelFilter')?.value || ''; |
|
|
const category = document.getElementById('logCategoryFilter')?.value || ''; |
|
|
const search = document.getElementById('logSearch')?.value || ''; |
|
|
const limit = parseInt(document.getElementById('logLimit')?.value || '100'); |
|
|
|
|
|
let url = `/api/logs?limit=${limit}`; |
|
|
if (level) url += `&level=${level}`; |
|
|
if (category) url += `&category=${category}`; |
|
|
if (search) url += `&search=${search}`; |
|
|
|
|
|
const response = await fetch(url); |
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
const statsResponse = await fetch('/api/logs/stats'); |
|
|
const stats = await statsResponse.json(); |
|
|
|
|
|
if (document.getElementById('totalLogs')) { |
|
|
document.getElementById('totalLogs').textContent = stats.total || 0; |
|
|
document.getElementById('errorLogs').textContent = stats.errors || 0; |
|
|
document.getElementById('infoLogs').textContent = stats.by_level?.info || 0; |
|
|
document.getElementById('warningLogs').textContent = stats.by_level?.warning || 0; |
|
|
} |
|
|
|
|
|
|
|
|
const tbody = document.getElementById('logsTableBody'); |
|
|
if (data.logs && data.logs.length > 0) { |
|
|
tbody.innerHTML = data.logs.map(log => { |
|
|
const levelColor = { |
|
|
'error': 'var(--accent-red)', |
|
|
'critical': 'var(--accent-red)', |
|
|
'warning': 'var(--accent-yellow)', |
|
|
'info': 'var(--accent-blue)', |
|
|
'debug': 'var(--text-secondary)' |
|
|
}[log.level] || 'var(--text-secondary)'; |
|
|
|
|
|
return ` |
|
|
<tr> |
|
|
<td style="font-size: 12px; color: var(--text-secondary);">${new Date(log.timestamp).toLocaleString()}</td> |
|
|
<td><span style="color: ${levelColor}; font-weight: 700;">${log.level.toUpperCase()}</span></td> |
|
|
<td><span class="badge badge-info">${log.category}</span></td> |
|
|
<td>${log.message}</td> |
|
|
<td>${log.provider_id || '—'}</td> |
|
|
<td>${log.response_time ? log.response_time.toFixed(0) + 'ms' : '—'}</td> |
|
|
</tr> |
|
|
`; |
|
|
}).join(''); |
|
|
} else { |
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No logs found</td></tr>'; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading logs:', error); |
|
|
if (document.getElementById('logsTableBody')) { |
|
|
document.getElementById('logsTableBody').innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--accent-red);">Error loading logs</td></tr>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportLogsJSON() { |
|
|
try { |
|
|
const level = document.getElementById('logLevelFilter')?.value || ''; |
|
|
const category = document.getElementById('logCategoryFilter')?.value || ''; |
|
|
|
|
|
let url = '/api/logs/export/json?'; |
|
|
if (level) url += `level=${level}&`; |
|
|
if (category) url += `category=${category}&`; |
|
|
|
|
|
const response = await fetch(url); |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Logs exported to ${data.filepath}`, 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportLogsCSV() { |
|
|
try { |
|
|
const level = document.getElementById('logLevelFilter')?.value || ''; |
|
|
const category = document.getElementById('logCategoryFilter')?.value || ''; |
|
|
|
|
|
let url = '/api/logs/export/csv?'; |
|
|
if (level) url += `level=${level}&`; |
|
|
if (category) url += `category=${category}&`; |
|
|
|
|
|
const response = await fetch(url); |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Logs exported to ${data.filepath}`, 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function clearAllLogs() { |
|
|
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) { |
|
|
return; |
|
|
} |
|
|
try { |
|
|
const response = await fetch('/api/logs', { method: 'DELETE' }); |
|
|
if (response.ok) { |
|
|
showToast('✅ All logs cleared', 'success'); |
|
|
loadLogs(); |
|
|
} else { |
|
|
showToast('❌ Failed to clear logs', 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('❌ Error clearing logs', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function loadResources() { |
|
|
try { |
|
|
const category = document.getElementById('resourceCategoryFilter')?.value || ''; |
|
|
|
|
|
let url = '/api/resources'; |
|
|
if (category) { |
|
|
url = `/api/resources/category/${category}`; |
|
|
} |
|
|
|
|
|
const response = await fetch(url); |
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
const stats = category ? { count: data.count } : data.statistics; |
|
|
if (stats && document.getElementById('totalResources')) { |
|
|
document.getElementById('totalResources').textContent = stats.total_providers || stats.count || 0; |
|
|
document.getElementById('freeResources').textContent = stats.by_free?.free || 0; |
|
|
document.getElementById('paidResources').textContent = stats.by_free?.paid || 0; |
|
|
document.getElementById('authResources').textContent = stats.by_auth?.requires_auth || 0; |
|
|
} |
|
|
|
|
|
|
|
|
const grid = document.getElementById('resourcesGrid'); |
|
|
const providers = category ? data.providers : Object.values(data.providers || {}); |
|
|
|
|
|
if (providers && providers.length > 0) { |
|
|
grid.innerHTML = providers.map(provider => { |
|
|
const authBadge = provider.requires_auth |
|
|
? '<span class="badge badge-warning">Auth Required</span>' |
|
|
: '<span class="badge badge-success">No Auth</span>'; |
|
|
|
|
|
const freeBadge = provider.free !== false |
|
|
? '<span class="badge badge-success">Free</span>' |
|
|
: '<span class="badge badge-danger">Paid</span>'; |
|
|
|
|
|
return ` |
|
|
<div class="stat-card pool-card-hover"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 15px;"> |
|
|
<div> |
|
|
<div style="font-size: 18px; font-weight: 700; margin-bottom: 8px;">${provider.name}</div> |
|
|
<span class="badge badge-info">${provider.category}</span> |
|
|
</div> |
|
|
<div style="display: flex; gap: 5px; flex-direction: column;"> |
|
|
${authBadge} |
|
|
${freeBadge} |
|
|
</div> |
|
|
</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 10px; word-break: break-all;"> |
|
|
${provider.base_url} |
|
|
</div> |
|
|
<div style="display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary);"> |
|
|
<span>Priority: ${provider.priority || 5}</span> |
|
|
<span>Weight: ${provider.weight || 50}</span> |
|
|
</div> |
|
|
${provider.docs_url ? `<div style="margin-top: 10px;"><a href="${provider.docs_url}" target="_blank" style="color: var(--accent-blue); font-size: 12px;">📖 Docs</a></div>` : ''} |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
} else { |
|
|
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--text-secondary);">No resources found</div>'; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading resources:', error); |
|
|
if (document.getElementById('resourcesGrid')) { |
|
|
document.getElementById('resourcesGrid').innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--accent-red);">Error loading resources</div>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportResourcesJSON() { |
|
|
try { |
|
|
const response = await fetch('/api/resources/export/json'); |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Resources exported to ${data.filepath}`, 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function exportResourcesCSV() { |
|
|
try { |
|
|
const response = await fetch('/api/resources/export/csv'); |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Resources exported to ${data.filepath}`, 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Export failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function backupResources() { |
|
|
try { |
|
|
const response = await fetch('/api/resources/backup', { method: 'POST' }); |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Backup created: ${data.filepath}`, 'success'); |
|
|
} catch (error) { |
|
|
showToast('❌ Backup failed', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
} |
|
|
|
|
|
function showImportModal() { |
|
|
document.getElementById('importModal').classList.add('active'); |
|
|
} |
|
|
|
|
|
function closeImportModal() { |
|
|
document.getElementById('importModal').classList.remove('active'); |
|
|
const form = document.getElementById('importForm'); |
|
|
if (form) form.reset(); |
|
|
} |
|
|
|
|
|
|
|
|
const importForm = document.getElementById('importForm'); |
|
|
if (importForm) { |
|
|
importForm.addEventListener('submit', async (e) => { |
|
|
e.preventDefault(); |
|
|
const filePath = document.getElementById('importFilePath').value; |
|
|
const merge = document.getElementById('importMode').value === 'true'; |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/resources/import/json?file_path=${encodeURIComponent(filePath)}&merge=${merge}`, { |
|
|
method: 'POST' |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
showToast(`✅ Resources imported successfully (${data.merged ? 'merged' : 'replaced'})`, 'success'); |
|
|
closeImportModal(); |
|
|
loadResources(); |
|
|
} else { |
|
|
const error = await response.json(); |
|
|
showToast(`❌ Import failed: ${error.detail}`, 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('❌ Error importing resources', 'error'); |
|
|
console.error(error); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function loadReports() { |
|
|
await Promise.all([ |
|
|
loadDiscoveryReport(), |
|
|
loadModelsReport(), |
|
|
loadLastDiagnostics() |
|
|
]); |
|
|
} |
|
|
|
|
|
|
|
|
async function loadSystemAlerts() { |
|
|
try { |
|
|
const response = await fetch('/api/diagnostics/last'); |
|
|
const report = await response.json(); |
|
|
|
|
|
if (report.message || !report.issues || report.issues.length === 0) { |
|
|
document.getElementById('systemAlertsSection').style.display = 'none'; |
|
|
return; |
|
|
} |
|
|
|
|
|
displaySystemAlerts(report.issues); |
|
|
document.getElementById('systemAlertsSection').style.display = 'block'; |
|
|
} catch (error) { |
|
|
console.error('Error loading system alerts:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function displaySystemAlerts(issues) { |
|
|
const container = document.getElementById('systemAlertsContainer'); |
|
|
if (!container) return; |
|
|
|
|
|
const severityConfig = { |
|
|
'critical': { |
|
|
icon: 'icon-error', |
|
|
color: 'var(--accent-red)', |
|
|
bg: 'rgba(239, 68, 68, 0.1)', |
|
|
border: 'rgba(239, 68, 68, 0.3)' |
|
|
}, |
|
|
'warning': { |
|
|
icon: 'icon-warning', |
|
|
color: 'var(--accent-yellow)', |
|
|
bg: 'rgba(245, 158, 11, 0.1)', |
|
|
border: 'rgba(245, 158, 11, 0.3)' |
|
|
}, |
|
|
'info': { |
|
|
icon: 'icon-info', |
|
|
color: 'var(--accent-blue)', |
|
|
bg: 'rgba(59, 130, 246, 0.1)', |
|
|
border: 'rgba(59, 130, 246, 0.3)' |
|
|
} |
|
|
}; |
|
|
|
|
|
const solutions = { |
|
|
'HF_API_TOKEN': { |
|
|
title: 'تنظیم متغیر محیطی HF_API_TOKEN', |
|
|
steps: [ |
|
|
'1. یک توکن از HuggingFace دریافت کنید:', |
|
|
' - به https://huggingface.co/settings/tokens بروید', |
|
|
' - یک توکن جدید ایجاد کنید', |
|
|
'2. توکن را به متغیر محیطی اضافه کنید:', |
|
|
' Windows: set HF_API_TOKEN=your_token_here', |
|
|
' Linux/Mac: export HF_API_TOKEN=your_token_here', |
|
|
' یا در فایل .env: HF_API_TOKEN=your_token_here' |
|
|
] |
|
|
}, |
|
|
'resources.json': { |
|
|
title: 'ایجاد فایل resources.json', |
|
|
steps: [ |
|
|
'این فایل به صورت خودکار ساخته میشود.', |
|
|
'اگر نیاز به تنظیمات دستی دارید، میتوانید آن را ایجاد کنید:', |
|
|
'{', |
|
|
' "resources": []', |
|
|
'}' |
|
|
] |
|
|
}, |
|
|
'config.json': { |
|
|
title: 'ایجاد فایل config.json', |
|
|
steps: [ |
|
|
'این فایل به صورت خودکار ساخته میشود.', |
|
|
'اگر نیاز به تنظیمات دستی دارید، میتوانید آن را ایجاد کنید.' |
|
|
] |
|
|
}, |
|
|
'HuggingFace API': { |
|
|
title: 'رفع مشکل اتصال به HuggingFace', |
|
|
steps: [ |
|
|
'1. بررسی اتصال اینترنت', |
|
|
'2. بررسی فایروال و پروکسی', |
|
|
'3. بررسی DNS settings', |
|
|
'4. اگر از VPN استفاده میکنید، آن را غیرفعال کنید', |
|
|
'5. بررسی کنید که /static-proxy?url=https%3A%2F%2Fapi.huggingface.co قابل دسترسی باشد' |
|
|
] |
|
|
}, |
|
|
'Auto-Discovery': { |
|
|
title: 'فعالسازی Auto-Discovery Service', |
|
|
steps: [ |
|
|
'برای فعالسازی سرویس Auto-Discovery:', |
|
|
'1. متغیر محیطی را تنظیم کنید:', |
|
|
' export ENABLE_AUTO_DISCOVERY=true', |
|
|
'2. یا در فایل .env اضافه کنید:', |
|
|
' ENABLE_AUTO_DISCOVERY=true', |
|
|
'3. سرور را restart کنید' |
|
|
] |
|
|
} |
|
|
}; |
|
|
|
|
|
let html = ''; |
|
|
issues.forEach((issue, index) => { |
|
|
const config = severityConfig[issue.severity] || severityConfig['info']; |
|
|
const solutionKey = issue.title.includes('HF_API_TOKEN') ? 'HF_API_TOKEN' : |
|
|
issue.title.includes('resources.json') ? 'resources.json' : |
|
|
issue.title.includes('config.json') ? 'config.json' : |
|
|
issue.title.includes('HuggingFace') ? 'HuggingFace API' : |
|
|
issue.title.includes('Auto-Discovery') ? 'Auto-Discovery' : null; |
|
|
|
|
|
const solution = solutionKey ? solutions[solutionKey] : null; |
|
|
|
|
|
html += ` |
|
|
<div class="stat-card" style="margin-bottom: 15px; border-left: 4px solid ${config.border}; background: ${config.bg}; animation-delay: ${index * 0.1}s;"> |
|
|
<div style="display: flex; align-items: start; gap: 12px;"> |
|
|
<div class="icon icon-md status-icon-${issue.severity}" style="flex-shrink: 0; margin-top: 2px;"> |
|
|
<svg><use href="#${config.icon}"></use></svg> |
|
|
</div> |
|
|
<div style="flex: 1;"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;"> |
|
|
<div> |
|
|
<h3 style="font-size: 16px; font-weight: 700; margin-bottom: 4px; color: ${config.color};"> |
|
|
${issue.title} |
|
|
</h3> |
|
|
<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;"> |
|
|
${issue.description} |
|
|
</div> |
|
|
</div> |
|
|
<span class="badge badge-${issue.severity === 'critical' ? 'danger' : issue.severity === 'warning' ? 'warning' : 'info'}" style="flex-shrink: 0;"> |
|
|
${issue.category} |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
${solution ? ` |
|
|
<div style="margin-top: 12px; padding: 12px; background: rgba(17, 24, 39, 0.6); border-radius: 8px; border: 1px solid var(--border);"> |
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
|
|
<span class="icon icon-sm status-icon-info"><svg><use href="#icon-info"></use></svg></span> |
|
|
<strong style="font-size: 14px;">راهحل:</strong> |
|
|
</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); font-family: 'Courier New', monospace; line-height: 1.8;"> |
|
|
${solution.steps.map(step => `<div style="margin-bottom: 4px;">${step}</div>`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
` : ''} |
|
|
|
|
|
<div style="margin-top: 8px; font-size: 11px; color: var(--text-secondary);"> |
|
|
${new Date(issue.timestamp).toLocaleString('fa-IR')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}); |
|
|
|
|
|
container.innerHTML = html || '<div style="text-align: center; padding: 40px; color: var(--text-secondary);">هیچ هشداری یافت نشد</div>'; |
|
|
} |
|
|
|
|
|
async function runDiagnostics(autoFix = false) { |
|
|
const resultsDiv = document.getElementById('diagnosticsResults'); |
|
|
resultsDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/diagnostics/run?auto_fix=${autoFix}`, { |
|
|
method: 'POST' |
|
|
}); |
|
|
const report = await response.json(); |
|
|
|
|
|
displayDiagnosticsReport(report); |
|
|
showToast(`اشکالیابی انجام شد (${report.total_issues} مشکل یافت شد)`, 'success', 'Diagnostics'); |
|
|
|
|
|
|
|
|
if (report.issues && report.issues.length > 0) { |
|
|
displaySystemAlerts(report.issues); |
|
|
document.getElementById('systemAlertsSection').style.display = 'block'; |
|
|
} |
|
|
} catch (error) { |
|
|
resultsDiv.innerHTML = `<div class="alert alert-error"> |
|
|
<span class="icon icon-md status-icon-error"><svg><use href="#icon-error"></use></svg></span> |
|
|
خطا: ${error.message} |
|
|
</div>`; |
|
|
showToast('خطا در اجرای اشکالیابی', 'error', 'Error'); |
|
|
} |
|
|
} |
|
|
|
|
|
function displayDiagnosticsReport(report) { |
|
|
const resultsDiv = document.getElementById('diagnosticsResults'); |
|
|
|
|
|
const severityColors = { |
|
|
'critical': 'var(--accent-red)', |
|
|
'warning': 'var(--accent-yellow)', |
|
|
'info': 'var(--accent-blue)' |
|
|
}; |
|
|
|
|
|
const severityIcons = { |
|
|
'critical': '🔴', |
|
|
'warning': '⚠️', |
|
|
'info': 'ℹ️' |
|
|
}; |
|
|
|
|
|
let html = ` |
|
|
<div class="stats-grid" style="margin-bottom: 20px;"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: ${severityColors.critical};">${report.critical_issues}</div> |
|
|
<div class="stat-label">مشکلات بحرانی</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: ${severityColors.warning};">${report.warnings}</div> |
|
|
<div class="stat-label">هشدارها</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: ${severityColors.info};">${report.info_issues}</div> |
|
|
<div class="stat-label">اطلاعات</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value">${report.fixed_issues.length}</div> |
|
|
<div class="stat-label">تعمیر شده</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
if (report.fixed_issues && report.fixed_issues.length > 0) { |
|
|
html += ` |
|
|
<div style="margin-bottom: 20px; padding: 15px; background: rgba(16, 185, 129, 0.1); border-radius: 12px; border-left: 4px solid var(--accent-green);"> |
|
|
<h3 style="margin-bottom: 10px; color: var(--accent-green);">✅ مشکلات تعمیر شده</h3> |
|
|
${report.fixed_issues.map(issue => ` |
|
|
<div style="padding: 10px; margin-bottom: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 8px;"> |
|
|
<strong>${issue.title}</strong><br> |
|
|
<span style="font-size: 12px; color: var(--text-secondary);">${issue.description}</span> |
|
|
</div> |
|
|
`).join('')} |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
if (report.issues && report.issues.length > 0) { |
|
|
html += ` |
|
|
<div> |
|
|
<h3 style="margin-bottom: 15px;">📋 لیست مشکلات</h3> |
|
|
${report.issues.map(issue => ` |
|
|
<div style="padding: 15px; margin-bottom: 10px; background: rgba(17, 24, 39, 0.6); border-radius: 12px; border-left: 4px solid ${severityColors[issue.severity]};"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;"> |
|
|
<div> |
|
|
<span style="font-size: 20px; margin-left: 8px;">${severityIcons[issue.severity]}</span> |
|
|
<strong>${issue.title}</strong> |
|
|
</div> |
|
|
<span class="badge badge-${issue.severity === 'critical' ? 'danger' : issue.severity === 'warning' ? 'warning' : 'info'}">${issue.category}</span> |
|
|
</div> |
|
|
<div style="color: var(--text-secondary); margin-bottom: 8px; font-size: 14px;"> |
|
|
${issue.description} |
|
|
</div> |
|
|
${issue.fixable && issue.fix_action ? ` |
|
|
<div style="margin-top: 8px; padding: 8px; background: rgba(59, 130, 246, 0.1); border-radius: 6px; font-family: monospace; font-size: 12px;"> |
|
|
🔧 ${issue.fix_action} |
|
|
</div> |
|
|
` : ''} |
|
|
<div style="margin-top: 8px; font-size: 11px; color: var(--text-secondary);"> |
|
|
${new Date(issue.timestamp).toLocaleString('fa-IR')} |
|
|
</div> |
|
|
</div> |
|
|
`).join('')} |
|
|
</div> |
|
|
`; |
|
|
} else { |
|
|
html += ` |
|
|
<div style="text-align: center; padding: 40px; background: rgba(16, 185, 129, 0.1); border-radius: 12px;"> |
|
|
<div style="font-size: 48px; margin-bottom: 15px;">✅</div> |
|
|
<div style="font-size: 18px; font-weight: 600; color: var(--accent-green);">هیچ مشکلی یافت نشد!</div> |
|
|
<div style="color: var(--text-secondary); margin-top: 10px;">سیستم شما در وضعیت مطلوب است.</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
html += ` |
|
|
<div style="margin-top: 20px; padding: 15px; background: rgba(17, 24, 39, 0.6); border-radius: 12px;"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary);"> |
|
|
<strong>زمان اجرا:</strong> ${(report.duration_ms / 1000).toFixed(2)} ثانیه<br> |
|
|
<strong>تاریخ:</strong> ${new Date(report.timestamp).toLocaleString('fa-IR')} |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
resultsDiv.innerHTML = html; |
|
|
} |
|
|
|
|
|
async function loadLastDiagnostics() { |
|
|
try { |
|
|
const response = await fetch('/api/diagnostics/last'); |
|
|
const report = await response.json(); |
|
|
|
|
|
if (report.message) { |
|
|
return; |
|
|
} |
|
|
|
|
|
displayDiagnosticsReport(report); |
|
|
|
|
|
|
|
|
if (report.issues && report.issues.length > 0) { |
|
|
displaySystemAlerts(report.issues); |
|
|
document.getElementById('systemAlertsSection').style.display = 'block'; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading last diagnostics:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadDiscoveryReport() { |
|
|
const reportDiv = document.getElementById('discoveryReport'); |
|
|
reportDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/reports/discovery'); |
|
|
const report = await response.json(); |
|
|
|
|
|
const lastRun = report.last_run; |
|
|
const status = report.service_status; |
|
|
|
|
|
let html = ` |
|
|
<div class="stats-grid" style="margin-bottom: 20px;"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: ${report.enabled ? 'var(--accent-green)' : 'var(--accent-red)'};"> |
|
|
${report.enabled ? '✅' : '❌'} |
|
|
</div> |
|
|
<div class="stat-label">وضعیت سرویس</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value">${report.model || 'N/A'}</div> |
|
|
<div class="stat-label">مدل استفاده شده</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value">${Math.floor(report.interval_seconds / 3600)}h</div> |
|
|
<div class="stat-label">فاصله اجرا</div> |
|
|
</div> |
|
|
${report.next_run_estimate ? ` |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: var(--accent-purple);">⏰</div> |
|
|
<div class="stat-label">اجرای بعدی</div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 5px;"> |
|
|
${new Date(report.next_run_estimate).toLocaleString('fa-IR')} |
|
|
</div> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
`; |
|
|
|
|
|
if (lastRun) { |
|
|
html += ` |
|
|
<div style="background: rgba(17, 24, 39, 0.6); padding: 20px; border-radius: 12px;"> |
|
|
<h3 style="margin-bottom: 15px;">📊 آخرین اجرا</h3> |
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;"> |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">شروع</div> |
|
|
<div style="font-weight: 600;">${new Date(lastRun.started_at).toLocaleString('fa-IR')}</div> |
|
|
</div> |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">پایان</div> |
|
|
<div style="font-weight: 600;">${new Date(lastRun.finished_at).toLocaleString('fa-IR')}</div> |
|
|
</div> |
|
|
${report.next_run_estimate ? ` |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">⏰ اجرای بعدی</div> |
|
|
<div style="font-weight: 600; color: var(--accent-purple);">${new Date(report.next_run_estimate).toLocaleString('fa-IR')}</div> |
|
|
</div> |
|
|
` : ''} |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">کاندیداها</div> |
|
|
<div style="font-weight: 600; color: var(--accent-blue);">${lastRun.candidates_seen || 0}</div> |
|
|
</div> |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">پیشنهاد شده</div> |
|
|
<div style="font-weight: 600; color: var(--accent-yellow);">${lastRun.suggested || 0}</div> |
|
|
</div> |
|
|
<div> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">ذخیره شده</div> |
|
|
<div style="font-weight: 600; color: var(--accent-green);">${lastRun.persisted || 0}</div> |
|
|
</div> |
|
|
</div> |
|
|
${lastRun.persisted_ids && lastRun.persisted_ids.length > 0 ? ` |
|
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--border);"> |
|
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Providerهای اضافه شده:</div> |
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;"> |
|
|
${lastRun.persisted_ids.map(id => `<span class="badge badge-success">${id}</span>`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
`; |
|
|
} else { |
|
|
html += ` |
|
|
<div style="text-align: center; padding: 40px; color: var(--text-secondary);"> |
|
|
هنوز اجرایی انجام نشده است |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
reportDiv.innerHTML = html; |
|
|
} catch (error) { |
|
|
reportDiv.innerHTML = `<div class="alert alert-error">❌ خطا: ${error.message}</div>`; |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadModelsReport() { |
|
|
const reportDiv = document.getElementById('modelsReport'); |
|
|
reportDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/reports/models'); |
|
|
const report = await response.json(); |
|
|
|
|
|
if (report.error) { |
|
|
reportDiv.innerHTML = ` |
|
|
<div class="alert alert-warning"> |
|
|
⚠️ ${report.error} |
|
|
</div> |
|
|
`; |
|
|
return; |
|
|
} |
|
|
|
|
|
let html = ` |
|
|
<div class="stats-grid" style="margin-bottom: 20px;"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value">${report.total_models}</div> |
|
|
<div class="stat-label">کل مدلها</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: var(--accent-green);">${report.available}</div> |
|
|
<div class="stat-label">در دسترس</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" style="color: var(--accent-red);">${report.errors}</div> |
|
|
<div class="stat-label">خطا</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
if (report.models && report.models.length > 0) { |
|
|
html += ` |
|
|
<div style="display: grid; gap: 15px;"> |
|
|
${report.models.map(model => ` |
|
|
<div style="padding: 20px; background: rgba(17, 24, 39, 0.6); border-radius: 12px; border-left: 4px solid ${model.status === 'available' ? 'var(--accent-green)' : 'var(--accent-red)'};"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 10px;"> |
|
|
<div> |
|
|
<h3 style="margin-bottom: 5px;">${model.model_id}</h3> |
|
|
<span class="badge badge-${model.status === 'available' ? 'success' : 'danger'}">${model.status === 'available' ? '✅ در دسترس' : '❌ خطا'}</span> |
|
|
</div> |
|
|
</div> |
|
|
${model.status === 'available' ? ` |
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 15px;"> |
|
|
${model.downloads ? `<div><span style="font-size: 12px; color: var(--text-secondary);">دانلودها:</span> <strong>${model.downloads.toLocaleString()}</strong></div>` : ''} |
|
|
${model.likes ? `<div><span style="font-size: 12px; color: var(--text-secondary);">لایکها:</span> <strong>${model.likes}</strong></div>` : ''} |
|
|
${model.pipeline_tag ? `<div><span style="font-size: 12px; color: var(--text-secondary);">نوع:</span> <strong>${model.pipeline_tag}</strong></div>` : ''} |
|
|
</div> |
|
|
` : ` |
|
|
<div style="color: var(--accent-red); margin-top: 10px;"> |
|
|
${model.error || 'خطای نامشخص'} |
|
|
</div> |
|
|
`} |
|
|
</div> |
|
|
`).join('')} |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
reportDiv.innerHTML = html; |
|
|
} catch (error) { |
|
|
reportDiv.innerHTML = `<div class="alert alert-error">❌ خطا: ${error.message}</div>`; |
|
|
} |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
|
|
|
</html> |