Spaces:
Running
Running
Update admin.py
Browse files
admin.py
CHANGED
|
@@ -31,6 +31,17 @@ def login_required(f):
|
|
| 31 |
return f(*args, **kwargs)
|
| 32 |
return decorated_function
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# Templates
|
| 35 |
LOGIN_TEMPLATE = """
|
| 36 |
<!DOCTYPE html>
|
|
@@ -304,6 +315,33 @@ INDEX_TEMPLATE = """
|
|
| 304 |
color: var(--text-main);
|
| 305 |
font-weight: 500;
|
| 306 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
</style>
|
| 308 |
</head>
|
| 309 |
<body>
|
|
@@ -493,18 +531,62 @@ INDEX_TEMPLATE = """
|
|
| 493 |
|
| 494 |
<div class="row mt-4 g-4">
|
| 495 |
<div class="col-md-8">
|
| 496 |
-
<div class="table-container">
|
| 497 |
-
<
|
| 498 |
-
|
| 499 |
-
<
|
| 500 |
-
|
|
|
|
|
|
|
| 501 |
</div>
|
| 502 |
</div>
|
| 503 |
</div>
|
| 504 |
<div class="col-md-4">
|
| 505 |
-
<div class="table-container">
|
| 506 |
-
<h5 class="fw-bold mb-4">
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
</div>
|
| 509 |
</div>
|
| 510 |
</div>
|
|
@@ -632,9 +714,17 @@ INDEX_TEMPLATE = """
|
|
| 632 |
<h2 class="fw-bold mb-0">Generation Logs</h2>
|
| 633 |
<p class="text-secondary small">Latest activity across all bots</p>
|
| 634 |
</div>
|
| 635 |
-
<div class="
|
| 636 |
-
<
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
</div>
|
| 639 |
</div>
|
| 640 |
|
|
@@ -821,6 +911,17 @@ INDEX_TEMPLATE = """
|
|
| 821 |
window.location.href = url.toString();
|
| 822 |
}
|
| 823 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 824 |
// Selection Logic
|
| 825 |
function updateSelectionState() {
|
| 826 |
const checks = document.querySelectorAll('.user-checkbox:checked');
|
|
@@ -869,11 +970,19 @@ INDEX_TEMPLATE = """
|
|
| 869 |
});
|
| 870 |
}
|
| 871 |
|
| 872 |
-
|
| 873 |
-
const
|
| 874 |
-
if (
|
| 875 |
-
|
| 876 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
type: 'doughnut',
|
| 878 |
data: {
|
| 879 |
labels: ['Images', 'Voices'],
|
|
@@ -888,11 +997,64 @@ INDEX_TEMPLATE = """
|
|
| 888 |
plugins: {
|
| 889 |
legend: {
|
| 890 |
position: 'bottom',
|
| 891 |
-
labels: { color: '#9ca3af', padding:
|
| 892 |
}
|
| 893 |
},
|
| 894 |
cutout: '70%',
|
| 895 |
-
responsive: true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 896 |
}
|
| 897 |
});
|
| 898 |
}
|
|
@@ -986,6 +1148,7 @@ INDEX_TEMPLATE = """
|
|
| 986 |
|
| 987 |
if (data.status === 'success') {
|
| 988 |
const u = data.user;
|
|
|
|
| 989 |
content.innerHTML = `
|
| 990 |
<div class="info-item">
|
| 991 |
<span class="info-label">Username</span>
|
|
@@ -995,6 +1158,10 @@ INDEX_TEMPLATE = """
|
|
| 995 |
<span class="info-label">Full Name</span>
|
| 996 |
<span class="info-value">${u.first_name || '-'} ${u.last_name || ''}</span>
|
| 997 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
<div class="info-item">
|
| 999 |
<span class="info-label">Chat ID</span>
|
| 1000 |
<span class="info-value font-monospace">${u.chat_id}</span>
|
|
@@ -1004,24 +1171,24 @@ INDEX_TEMPLATE = """
|
|
| 1004 |
<span class="info-value">${u.language_code || 'N/A'}</span>
|
| 1005 |
</div>
|
| 1006 |
<div class="info-item">
|
| 1007 |
-
<span class="info-label">Daily
|
| 1008 |
-
<span class="info-value">${
|
| 1009 |
</div>
|
| 1010 |
<div class="info-item">
|
| 1011 |
<span class="info-label">Total Generated</span>
|
| 1012 |
-
<span class="info-value">${
|
| 1013 |
</div>
|
| 1014 |
<div class="info-item">
|
| 1015 |
<span class="info-label">Images (Total / Daily)</span>
|
| 1016 |
-
<span class="info-value">${
|
| 1017 |
</div>
|
| 1018 |
<div class="info-item">
|
| 1019 |
<span class="info-label">Voices (Total / Daily)</span>
|
| 1020 |
-
<span class="info-value">${
|
| 1021 |
</div>
|
| 1022 |
<div class="info-item">
|
| 1023 |
<span class="info-label">Remaining Coins</span>
|
| 1024 |
-
<span class="info-value fw-bold text-accent">${
|
| 1025 |
</div>
|
| 1026 |
<div class="info-item">
|
| 1027 |
<span class="info-label">Account Tier</span>
|
|
@@ -1029,7 +1196,7 @@ INDEX_TEMPLATE = """
|
|
| 1029 |
</div>
|
| 1030 |
<div class="info-item col-12">
|
| 1031 |
<span class="info-label">Bots Joined</span>
|
| 1032 |
-
<span class="info-value">${u.bots_joined ? u.bots_joined.join(', ') : 'None'}</span>
|
| 1033 |
</div>
|
| 1034 |
<div class="info-item">
|
| 1035 |
<span class="info-label">Joined Date</span>
|
|
@@ -1084,11 +1251,60 @@ def logout():
|
|
| 1084 |
@login_required
|
| 1085 |
def api_user_details(chat_id):
|
| 1086 |
try:
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
except Exception as e:
|
|
|
|
|
|
|
| 1092 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1093 |
|
| 1094 |
@app.route('/api/admin/save-settings', methods=['POST'])
|
|
@@ -1151,34 +1367,105 @@ def index():
|
|
| 1151 |
users_res = user_query.range(u_start, u_end).execute()
|
| 1152 |
total_users_filtered = users_res.count or 0
|
| 1153 |
|
| 1154 |
-
#
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1159 |
|
| 1160 |
# 2. Fetch Voice Logs with Pagination
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
v_start = (v_page - 1) * per_page
|
| 1162 |
v_end = v_start + per_page - 1
|
| 1163 |
-
voices_logs_res =
|
| 1164 |
total_voices_count = voices_logs_res.count or 0
|
| 1165 |
|
| 1166 |
# 3. Fetch Image Logs with Pagination
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1167 |
i_start = (i_page - 1) * per_page
|
| 1168 |
i_end = i_start + per_page - 1
|
| 1169 |
try:
|
| 1170 |
-
image_logs_res =
|
| 1171 |
image_logs_data = image_logs_res.data
|
| 1172 |
total_images_logs_count = image_logs_res.count or 0
|
| 1173 |
-
except:
|
|
|
|
| 1174 |
image_logs_data = []
|
| 1175 |
total_images_logs_count = 0
|
| 1176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
stats = {
|
| 1178 |
"total_users": total_users_count,
|
| 1179 |
"total_images": total_images_gen,
|
| 1180 |
"total_voices": total_voices_count,
|
| 1181 |
-
"premium_users": premium_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
}
|
| 1183 |
|
| 1184 |
pages = {
|
|
@@ -1189,10 +1476,21 @@ def index():
|
|
| 1189 |
"i_current": i_page,
|
| 1190 |
"i_total": (total_images_logs_count + per_page - 1) // per_page,
|
| 1191 |
"search": u_search,
|
|
|
|
| 1192 |
"bot_filter": bot_filter,
|
| 1193 |
"per_page": per_page
|
| 1194 |
}
|
| 1195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
return render_template_string(
|
| 1197 |
INDEX_TEMPLATE,
|
| 1198 |
stats=stats,
|
|
@@ -1201,7 +1499,7 @@ def index():
|
|
| 1201 |
image_logs=image_logs_data,
|
| 1202 |
settings=settings,
|
| 1203 |
pages=pages,
|
| 1204 |
-
now=datetime.now().strftime("%
|
| 1205 |
)
|
| 1206 |
except Exception as e:
|
| 1207 |
import traceback
|
|
|
|
| 31 |
return f(*args, **kwargs)
|
| 32 |
return decorated_function
|
| 33 |
|
| 34 |
+
def to_wib(dt_str):
|
| 35 |
+
if not dt_str: return "-"
|
| 36 |
+
try:
|
| 37 |
+
# Supabase strings: '2026-03-03T08:24:15.123+00:00' or similar
|
| 38 |
+
t_part = dt_str.split('.')[0].replace('Z', '').replace('T', ' ')
|
| 39 |
+
dt = datetime.fromisoformat(t_part).replace(tzinfo=timezone.utc)
|
| 40 |
+
wib = dt + timedelta(hours=7)
|
| 41 |
+
return wib.strftime("%Y-%m-%d %H:%M:%S")
|
| 42 |
+
except:
|
| 43 |
+
return dt_str
|
| 44 |
+
|
| 45 |
# Templates
|
| 46 |
LOGIN_TEMPLATE = """
|
| 47 |
<!DOCTYPE html>
|
|
|
|
| 315 |
color: var(--text-main);
|
| 316 |
font-weight: 500;
|
| 317 |
}
|
| 318 |
+
|
| 319 |
+
/* Insight Cards */
|
| 320 |
+
.insight-row {
|
| 321 |
+
display: grid;
|
| 322 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 323 |
+
gap: 15px;
|
| 324 |
+
margin-top: 15px;
|
| 325 |
+
}
|
| 326 |
+
.insight-card {
|
| 327 |
+
background: rgba(255, 255, 255, 0.03);
|
| 328 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 329 |
+
border-radius: 12px;
|
| 330 |
+
padding: 15px;
|
| 331 |
+
text-align: center;
|
| 332 |
+
}
|
| 333 |
+
.insight-label {
|
| 334 |
+
font-size: 10px;
|
| 335 |
+
color: var(--text-muted);
|
| 336 |
+
text-transform: uppercase;
|
| 337 |
+
letter-spacing: 0.1em;
|
| 338 |
+
margin-bottom: 5px;
|
| 339 |
+
}
|
| 340 |
+
.insight-value {
|
| 341 |
+
font-size: 18px;
|
| 342 |
+
font-weight: 700;
|
| 343 |
+
color: var(--text-main);
|
| 344 |
+
}
|
| 345 |
</style>
|
| 346 |
</head>
|
| 347 |
<body>
|
|
|
|
| 531 |
|
| 532 |
<div class="row mt-4 g-4">
|
| 533 |
<div class="col-md-8">
|
| 534 |
+
<div class="table-container pt-4">
|
| 535 |
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
| 536 |
+
<h5 class="fw-bold mb-0">30-Day Activity Trend</h5>
|
| 537 |
+
<span class="badge bg-dark border border-secondary text-secondary shadow-sm">Traffic & Usage</span>
|
| 538 |
+
</div>
|
| 539 |
+
<div style="height: 350px;">
|
| 540 |
+
<canvas id="trendChart"></canvas>
|
| 541 |
</div>
|
| 542 |
</div>
|
| 543 |
</div>
|
| 544 |
<div class="col-md-4">
|
| 545 |
+
<div class="table-container pt-4">
|
| 546 |
+
<h5 class="fw-bold mb-4 text-center">Growth Insights</h5>
|
| 547 |
+
|
| 548 |
+
<div class="mb-4">
|
| 549 |
+
<div class="small fw-bold text-secondary mb-2 px-1 text-center">NEW REGISTRATIONS</div>
|
| 550 |
+
<div class="insight-row">
|
| 551 |
+
<div class="insight-card shadow-sm">
|
| 552 |
+
<div class="insight-label">Today</div>
|
| 553 |
+
<div class="insight-value">{{ stats.u_insights.today }}</div>
|
| 554 |
+
</div>
|
| 555 |
+
<div class="insight-card shadow-sm">
|
| 556 |
+
<div class="insight-label">Week</div>
|
| 557 |
+
<div class="insight-value">{{ stats.u_insights.week }}</div>
|
| 558 |
+
</div>
|
| 559 |
+
<div class="insight-card shadow-sm">
|
| 560 |
+
<div class="insight-label">Month</div>
|
| 561 |
+
<div class="insight-value">{{ stats.u_insights.month }}</div>
|
| 562 |
+
</div>
|
| 563 |
+
</div>
|
| 564 |
+
</div>
|
| 565 |
+
|
| 566 |
+
<div class="mb-4">
|
| 567 |
+
<div class="small fw-bold text-secondary mb-2 px-1 text-center">TOTAL GENERATIONS</div>
|
| 568 |
+
<div class="insight-row">
|
| 569 |
+
<div class="insight-card shadow-sm">
|
| 570 |
+
<div class="insight-label">Today</div>
|
| 571 |
+
<div class="insight-value text-accent">{{ stats.g_insights.today }}</div>
|
| 572 |
+
</div>
|
| 573 |
+
<div class="insight-card shadow-sm">
|
| 574 |
+
<div class="insight-label">Week</div>
|
| 575 |
+
<div class="insight-value text-accent">{{ stats.g_insights.week }}</div>
|
| 576 |
+
</div>
|
| 577 |
+
<div class="insight-card shadow-sm">
|
| 578 |
+
<div class="insight-label">Month</div>
|
| 579 |
+
<div class="insight-value text-accent">{{ stats.g_insights.month }}</div>
|
| 580 |
+
</div>
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
|
| 584 |
+
<div class="mt-5">
|
| 585 |
+
<h6 class="fw-bold mb-3 text-center text-muted small">ACTIVITY MIX</h6>
|
| 586 |
+
<div style="height: 200px;">
|
| 587 |
+
<canvas id="activityChart"></canvas>
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
</div>
|
| 591 |
</div>
|
| 592 |
</div>
|
|
|
|
| 714 |
<h2 class="fw-bold mb-0">Generation Logs</h2>
|
| 715 |
<p class="text-secondary small">Latest activity across all bots</p>
|
| 716 |
</div>
|
| 717 |
+
<div class="d-flex align-items-center gap-3">
|
| 718 |
+
<div class="nav nav-pills" id="gen-tabs">
|
| 719 |
+
<button class="nav-link active me-2" data-bs-toggle="pill" data-bs-target="#voice-logs">Voice Logs</button>
|
| 720 |
+
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#image-logs">Image Logs</button>
|
| 721 |
+
</div>
|
| 722 |
+
<div class="position-relative">
|
| 723 |
+
<input type="text" id="genSearchQuery" class="search-bar" placeholder="Search User ID..." value="{{ pages.gen_search or '' }}" style="width: 250px;">
|
| 724 |
+
<button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary" id="genSearchBtn">
|
| 725 |
+
<i class="bi bi-search"></i>
|
| 726 |
+
</button>
|
| 727 |
+
</div>
|
| 728 |
</div>
|
| 729 |
</div>
|
| 730 |
|
|
|
|
| 911 |
window.location.href = url.toString();
|
| 912 |
}
|
| 913 |
|
| 914 |
+
function doGenSearch() {
|
| 915 |
+
const query = document.getElementById('genSearchQuery').value;
|
| 916 |
+
const url = new URL(window.location.href);
|
| 917 |
+
if (query) url.searchParams.set('gen_search', query);
|
| 918 |
+
else url.searchParams.delete('gen_search');
|
| 919 |
+
url.searchParams.set('v_page', 1);
|
| 920 |
+
url.searchParams.set('i_page', 1);
|
| 921 |
+
url.hash = '#generations';
|
| 922 |
+
window.location.href = url.toString();
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
// Selection Logic
|
| 926 |
function updateSelectionState() {
|
| 927 |
const checks = document.querySelectorAll('.user-checkbox:checked');
|
|
|
|
| 970 |
});
|
| 971 |
}
|
| 972 |
|
| 973 |
+
const genSearchBtn = document.getElementById('genSearchBtn');
|
| 974 |
+
const genSearchInput = document.getElementById('genSearchQuery');
|
| 975 |
+
if (genSearchBtn) genSearchBtn.addEventListener('click', doGenSearch);
|
| 976 |
+
if (genSearchInput) {
|
| 977 |
+
genSearchInput.addEventListener('keypress', (e) => {
|
| 978 |
+
if (e.key === 'Enter') doGenSearch();
|
| 979 |
+
});
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
// Summary Chart Initialization
|
| 983 |
+
const summaryCtx = document.getElementById('activityChart');
|
| 984 |
+
if (summaryCtx) {
|
| 985 |
+
new Chart(summaryCtx.getContext('2d'), {
|
| 986 |
type: 'doughnut',
|
| 987 |
data: {
|
| 988 |
labels: ['Images', 'Voices'],
|
|
|
|
| 997 |
plugins: {
|
| 998 |
legend: {
|
| 999 |
position: 'bottom',
|
| 1000 |
+
labels: { color: '#9ca3af', boxWidth: 12, padding: 15 }
|
| 1001 |
}
|
| 1002 |
},
|
| 1003 |
cutout: '70%',
|
| 1004 |
+
responsive: true,
|
| 1005 |
+
maintainAspectRatio: false
|
| 1006 |
+
}
|
| 1007 |
+
});
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
// Trend Chart Initialization
|
| 1011 |
+
const trendCtx = document.getElementById('trendChart');
|
| 1012 |
+
if (trendCtx) {
|
| 1013 |
+
new Chart(trendCtx.getContext('2d'), {
|
| 1014 |
+
type: 'line',
|
| 1015 |
+
data: {
|
| 1016 |
+
labels: {{ stats.chart_data.labels | tojson }},
|
| 1017 |
+
datasets: [
|
| 1018 |
+
{
|
| 1019 |
+
label: 'New Users',
|
| 1020 |
+
data: {{ stats.chart_data.users | tojson }},
|
| 1021 |
+
borderColor: '#6366f1',
|
| 1022 |
+
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
| 1023 |
+
fill: true,
|
| 1024 |
+
tension: 0.4,
|
| 1025 |
+
borderWidth: 2,
|
| 1026 |
+
pointRadius: 0
|
| 1027 |
+
},
|
| 1028 |
+
{
|
| 1029 |
+
label: 'Generations',
|
| 1030 |
+
data: {{ stats.chart_data.gens | tojson }},
|
| 1031 |
+
borderColor: '#10b981',
|
| 1032 |
+
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
| 1033 |
+
fill: true,
|
| 1034 |
+
tension: 0.4,
|
| 1035 |
+
borderWidth: 2,
|
| 1036 |
+
pointRadius: 0
|
| 1037 |
+
}
|
| 1038 |
+
]
|
| 1039 |
+
},
|
| 1040 |
+
options: {
|
| 1041 |
+
responsive: true,
|
| 1042 |
+
maintainAspectRatio: false,
|
| 1043 |
+
plugins: {
|
| 1044 |
+
legend: {
|
| 1045 |
+
labels: { color: '#9ca3af', boxWidth: 12 }
|
| 1046 |
+
}
|
| 1047 |
+
},
|
| 1048 |
+
scales: {
|
| 1049 |
+
y: {
|
| 1050 |
+
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
| 1051 |
+
ticks: { color: '#9ca3af' }
|
| 1052 |
+
},
|
| 1053 |
+
x: {
|
| 1054 |
+
grid: { display: false },
|
| 1055 |
+
ticks: { color: '#9ca3af', maxRotation: 0 }
|
| 1056 |
+
}
|
| 1057 |
+
}
|
| 1058 |
}
|
| 1059 |
});
|
| 1060 |
}
|
|
|
|
| 1148 |
|
| 1149 |
if (data.status === 'success') {
|
| 1150 |
const u = data.user;
|
| 1151 |
+
const stats = u.computed_stats;
|
| 1152 |
content.innerHTML = `
|
| 1153 |
<div class="info-item">
|
| 1154 |
<span class="info-label">Username</span>
|
|
|
|
| 1158 |
<span class="info-label">Full Name</span>
|
| 1159 |
<span class="info-value">${u.first_name || '-'} ${u.last_name || ''}</span>
|
| 1160 |
</div>
|
| 1161 |
+
<div class="info-item">
|
| 1162 |
+
<span class="info-label">User ID (Internal)</span>
|
| 1163 |
+
<span class="info-value font-monospace text-warning">${u.id || 'N/A'}</span>
|
| 1164 |
+
</div>
|
| 1165 |
<div class="info-item">
|
| 1166 |
<span class="info-label">Chat ID</span>
|
| 1167 |
<span class="info-value font-monospace">${u.chat_id}</span>
|
|
|
|
| 1171 |
<span class="info-value">${u.language_code || 'N/A'}</span>
|
| 1172 |
</div>
|
| 1173 |
<div class="info-item">
|
| 1174 |
+
<span class="info-label">Daily Generated</span>
|
| 1175 |
+
<span class="info-value">${stats.image_daily + stats.voice_daily}</span>
|
| 1176 |
</div>
|
| 1177 |
<div class="info-item">
|
| 1178 |
<span class="info-label">Total Generated</span>
|
| 1179 |
+
<span class="info-value">${stats.image_total + stats.voice_total}</span>
|
| 1180 |
</div>
|
| 1181 |
<div class="info-item">
|
| 1182 |
<span class="info-label">Images (Total / Daily)</span>
|
| 1183 |
+
<span class="info-value">${stats.image_total} / ${stats.image_daily}</span>
|
| 1184 |
</div>
|
| 1185 |
<div class="info-item">
|
| 1186 |
<span class="info-label">Voices (Total / Daily)</span>
|
| 1187 |
+
<span class="info-value">${stats.voice_total} / ${stats.voice_daily}</span>
|
| 1188 |
</div>
|
| 1189 |
<div class="info-item">
|
| 1190 |
<span class="info-label">Remaining Coins</span>
|
| 1191 |
+
<span class="info-value fw-bold text-accent">${stats.remaining_coins}</span>
|
| 1192 |
</div>
|
| 1193 |
<div class="info-item">
|
| 1194 |
<span class="info-label">Account Tier</span>
|
|
|
|
| 1196 |
</div>
|
| 1197 |
<div class="info-item col-12">
|
| 1198 |
<span class="info-label">Bots Joined</span>
|
| 1199 |
+
<span class="info-value">${u.bots_joined ? (Array.isArray(u.bots_joined) ? u.bots_joined.join(', ') : u.bots_joined) : 'None'}</span>
|
| 1200 |
</div>
|
| 1201 |
<div class="info-item">
|
| 1202 |
<span class="info-label">Joined Date</span>
|
|
|
|
| 1251 |
@login_required
|
| 1252 |
def api_user_details(chat_id):
|
| 1253 |
try:
|
| 1254 |
+
import re
|
| 1255 |
+
# Detect UUID vs ChatID (BigInt)
|
| 1256 |
+
is_uuid = bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', chat_id.lower()))
|
| 1257 |
+
|
| 1258 |
+
if is_uuid:
|
| 1259 |
+
res = supabase.table("telegram_users").select("*").eq("id", chat_id).execute()
|
| 1260 |
+
else:
|
| 1261 |
+
try:
|
| 1262 |
+
res = supabase.table("telegram_users").select("*").eq("chat_id", int(chat_id)).execute()
|
| 1263 |
+
except:
|
| 1264 |
+
return jsonify({"status": "error", "message": "Invalid Chat ID format"}), 400
|
| 1265 |
+
|
| 1266 |
+
if not res.data:
|
| 1267 |
+
return jsonify({"status": "error", "message": "User not found"}), 404
|
| 1268 |
+
|
| 1269 |
+
u = res.data[0]
|
| 1270 |
+
user_uuid = u['id']
|
| 1271 |
+
u_chat_id = u['chat_id']
|
| 1272 |
+
|
| 1273 |
+
# Fetch Actual Counts from Logs for accuracy
|
| 1274 |
+
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
| 1275 |
+
|
| 1276 |
+
# Voice Counts
|
| 1277 |
+
voice_total_res = supabase.table("voice_generation_logs").select("id", count="exact").eq("user_id", user_uuid).execute()
|
| 1278 |
+
voice_daily_res = supabase.table("voice_generation_logs").select("id", count="exact").eq("user_id", user_uuid).gte("created_at", today_start).execute()
|
| 1279 |
+
|
| 1280 |
+
# Image Counts
|
| 1281 |
+
image_total_res = supabase.table("image_generation_logs").select("id", count="exact").eq("user_id", user_uuid).execute()
|
| 1282 |
+
image_daily_res = supabase.table("image_generation_logs").select("id", count="exact").eq("user_id", user_uuid).gte("created_at", today_start).execute()
|
| 1283 |
+
|
| 1284 |
+
# Calculate remaining coins (free level is 60 as per robot info)
|
| 1285 |
+
if u.get('tier') == 'paid':
|
| 1286 |
+
remaining_coins = u.get('token_balance', 0)
|
| 1287 |
+
else:
|
| 1288 |
+
# For free, use daily_images_generated counter (max 60)
|
| 1289 |
+
remaining_coins = max(0, 60 - u.get('daily_images_generated', 0))
|
| 1290 |
+
|
| 1291 |
+
# Convert times to WIB for frontend
|
| 1292 |
+
u['created_at'] = to_wib(u.get('created_at'))
|
| 1293 |
+
u['updated_at'] = to_wib(u.get('updated_at'))
|
| 1294 |
+
|
| 1295 |
+
# Merge custom stats into user object for frontend
|
| 1296 |
+
u['computed_stats'] = {
|
| 1297 |
+
"voice_total": voice_total_res.count or 0,
|
| 1298 |
+
"voice_daily": voice_daily_res.count or 0,
|
| 1299 |
+
"image_total": image_total_res.count or 0,
|
| 1300 |
+
"image_daily": image_daily_res.count or 0,
|
| 1301 |
+
"remaining_coins": remaining_coins
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
return jsonify({"status": "success", "user": u})
|
| 1305 |
except Exception as e:
|
| 1306 |
+
import traceback
|
| 1307 |
+
print(traceback.format_exc())
|
| 1308 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1309 |
|
| 1310 |
@app.route('/api/admin/save-settings', methods=['POST'])
|
|
|
|
| 1367 |
users_res = user_query.range(u_start, u_end).execute()
|
| 1368 |
total_users_filtered = users_res.count or 0
|
| 1369 |
|
| 1370 |
+
# Insights Calculation
|
| 1371 |
+
now_dt = datetime.now(timezone.utc)
|
| 1372 |
+
today_start = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 1373 |
+
week_start = today_start - timedelta(days=7)
|
| 1374 |
+
month_start = today_start - timedelta(days=30)
|
| 1375 |
+
|
| 1376 |
+
# Helper to fetch since date
|
| 1377 |
+
def get_count_since(table, date_field, since_dt):
|
| 1378 |
+
try:
|
| 1379 |
+
res = supabase.table(table).select(date_field).gte(date_field, since_dt.isoformat()).execute()
|
| 1380 |
+
return res.data or []
|
| 1381 |
+
except Exception as e:
|
| 1382 |
+
print(f"Error fetching {table}: {e}")
|
| 1383 |
+
return []
|
| 1384 |
+
|
| 1385 |
+
# Fetch data for insights & chart
|
| 1386 |
+
user_dates = get_count_since("telegram_users", "created_at", month_start)
|
| 1387 |
+
image_dates = get_count_since("image_generation_logs", "created_at", month_start)
|
| 1388 |
+
voice_dates = get_count_since("voice_generation_logs", "created_at", month_start)
|
| 1389 |
+
|
| 1390 |
+
# Process Insights
|
| 1391 |
+
def count_periods(data_list, date_key):
|
| 1392 |
+
periods = {"today": 0, "week": 0, "month": len(data_list)}
|
| 1393 |
+
for item in data_list:
|
| 1394 |
+
dt_str = item.get(date_key, "")[:19]
|
| 1395 |
+
try:
|
| 1396 |
+
dt = datetime.fromisoformat(dt_str).replace(tzinfo=timezone.utc)
|
| 1397 |
+
if dt >= today_start: periods["today"] += 1
|
| 1398 |
+
if dt >= week_start: periods["week"] += 1
|
| 1399 |
+
except: continue
|
| 1400 |
+
return periods
|
| 1401 |
+
|
| 1402 |
+
u_insights = count_periods(user_dates, "created_at")
|
| 1403 |
+
combined_gen = image_dates + voice_dates
|
| 1404 |
+
g_insights = count_periods(combined_gen, "created_at")
|
| 1405 |
+
|
| 1406 |
+
# Build 30-day Chart Data
|
| 1407 |
+
chart_labels = []
|
| 1408 |
+
chart_users = []
|
| 1409 |
+
chart_gens = []
|
| 1410 |
+
|
| 1411 |
+
for i in range(29, -1, -1):
|
| 1412 |
+
day = today_start - timedelta(days=i)
|
| 1413 |
+
day_str = day.strftime("%Y-%m-%d")
|
| 1414 |
+
chart_labels.append(day.strftime("%d %b"))
|
| 1415 |
+
|
| 1416 |
+
u_count = sum(1 for x in user_dates if x.get("created_at", "").startswith(day_str))
|
| 1417 |
+
g_count = sum(1 for x in combined_gen if x.get("created_at", "").startswith(day_str))
|
| 1418 |
+
|
| 1419 |
+
chart_users.append(u_count)
|
| 1420 |
+
chart_gens.append(g_count)
|
| 1421 |
+
|
| 1422 |
+
# Pagination for Generations
|
| 1423 |
+
gen_search = request.args.get('gen_search', '').strip()
|
| 1424 |
|
| 1425 |
# 2. Fetch Voice Logs with Pagination
|
| 1426 |
+
v_query = supabase.table("voice_generation_logs").select("*", count="exact").order("created_at", desc=True)
|
| 1427 |
+
if gen_search:
|
| 1428 |
+
v_query = v_query.eq("user_id", gen_search)
|
| 1429 |
+
|
| 1430 |
v_start = (v_page - 1) * per_page
|
| 1431 |
v_end = v_start + per_page - 1
|
| 1432 |
+
voices_logs_res = v_query.range(v_start, v_end).execute()
|
| 1433 |
total_voices_count = voices_logs_res.count or 0
|
| 1434 |
|
| 1435 |
# 3. Fetch Image Logs with Pagination
|
| 1436 |
+
i_query = supabase.table("image_generation_logs").select("*", count="exact").order("created_at", desc=True)
|
| 1437 |
+
if gen_search:
|
| 1438 |
+
i_query = i_query.eq("user_id", gen_search)
|
| 1439 |
+
|
| 1440 |
i_start = (i_page - 1) * per_page
|
| 1441 |
i_end = i_start + per_page - 1
|
| 1442 |
try:
|
| 1443 |
+
image_logs_res = i_query.range(i_start, i_end).execute()
|
| 1444 |
image_logs_data = image_logs_res.data
|
| 1445 |
total_images_logs_count = image_logs_res.count or 0
|
| 1446 |
+
except Exception as e:
|
| 1447 |
+
print(f"Image logs fetch error: {e}")
|
| 1448 |
image_logs_data = []
|
| 1449 |
total_images_logs_count = 0
|
| 1450 |
|
| 1451 |
+
# Global stats totals for dashboard cards
|
| 1452 |
+
stats_query = supabase.table("telegram_users").select("total_images_generated, tier").execute()
|
| 1453 |
+
total_images_gen = sum(u.get('total_images_generated', 0) for u in stats_query.data)
|
| 1454 |
+
premium_count = sum(1 for u in stats_query.data if u.get('tier') == 'paid')
|
| 1455 |
+
total_users_count = len(stats_query.data)
|
| 1456 |
+
|
| 1457 |
stats = {
|
| 1458 |
"total_users": total_users_count,
|
| 1459 |
"total_images": total_images_gen,
|
| 1460 |
"total_voices": total_voices_count,
|
| 1461 |
+
"premium_users": premium_count,
|
| 1462 |
+
"u_insights": u_insights,
|
| 1463 |
+
"g_insights": g_insights,
|
| 1464 |
+
"chart_data": {
|
| 1465 |
+
"labels": chart_labels,
|
| 1466 |
+
"users": chart_users,
|
| 1467 |
+
"gens": chart_gens
|
| 1468 |
+
}
|
| 1469 |
}
|
| 1470 |
|
| 1471 |
pages = {
|
|
|
|
| 1476 |
"i_current": i_page,
|
| 1477 |
"i_total": (total_images_logs_count + per_page - 1) // per_page,
|
| 1478 |
"search": u_search,
|
| 1479 |
+
"gen_search": gen_search,
|
| 1480 |
"bot_filter": bot_filter,
|
| 1481 |
"per_page": per_page
|
| 1482 |
}
|
| 1483 |
|
| 1484 |
+
for user in users_res.data:
|
| 1485 |
+
user['created_at'] = to_wib(user.get('created_at'))
|
| 1486 |
+
user['last_active'] = to_wib(user.get('last_active'))
|
| 1487 |
+
|
| 1488 |
+
for log in voices_logs_res.data:
|
| 1489 |
+
log['created_at'] = to_wib(log.get('created_at'))
|
| 1490 |
+
|
| 1491 |
+
for log in image_logs_data:
|
| 1492 |
+
log['created_at'] = to_wib(log.get('created_at'))
|
| 1493 |
+
|
| 1494 |
return render_template_string(
|
| 1495 |
INDEX_TEMPLATE,
|
| 1496 |
stats=stats,
|
|
|
|
| 1499 |
image_logs=image_logs_data,
|
| 1500 |
settings=settings,
|
| 1501 |
pages=pages,
|
| 1502 |
+
now=(datetime.now(timezone.utc) + timedelta(hours=7)).strftime("%H:%M:%S WIB")
|
| 1503 |
)
|
| 1504 |
except Exception as e:
|
| 1505 |
import traceback
|