jebin2 commited on
Commit
d72816f
·
1 Parent(s): d218e46

table viewer

Browse files
Files changed (3) hide show
  1. app.py +2 -1
  2. routers/schema.py +81 -0
  3. templates/index.html +218 -779
app.py CHANGED
@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.responses import JSONResponse
13
 
14
  from core.database import init_db, DB_FILENAME
15
- from routers import auth, blink, contact, credits, general, gemini, payments
16
  from services.drive_service import DriveService
17
 
18
  # Configure logging
@@ -92,6 +92,7 @@ app.include_router(gemini.router)
92
  app.include_router(credits.router)
93
  app.include_router(payments.router)
94
  app.include_router(contact.router)
 
95
 
96
 
97
  @app.exception_handler(Exception)
 
12
  from fastapi.responses import JSONResponse
13
 
14
  from core.database import init_db, DB_FILENAME
15
+ from routers import auth, blink, contact, credits, general, gemini, payments, schema
16
  from services.drive_service import DriveService
17
 
18
  # Configure logging
 
92
  app.include_router(credits.router)
93
  app.include_router(payments.router)
94
  app.include_router(contact.router)
95
+ app.include_router(schema.router)
96
 
97
 
98
  @app.exception_handler(Exception)
routers/schema.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import text, inspect
4
+ from typing import List, Dict, Any, Optional
5
+ import logging
6
+
7
+ from core.database import get_db, engine, Base
8
+ # Import all models to ensure they are registered with Base.metadata
9
+ from core.models import User, ClientUser, AuditLog, GeminiJob, PaymentTransaction, Contact, RateLimit, ApiKeyUsage
10
+
11
+ router = APIRouter(prefix="/api/schema", tags=["schema"])
12
+ logger = logging.getLogger(__name__)
13
+
14
+ @router.get("/tables")
15
+ async def get_tables():
16
+ """
17
+ Get a list of all tables in the database.
18
+ """
19
+ # We can inspect the metadata from the Base class since all models inherit from it
20
+ # and are imported above.
21
+ return sorted(list(Base.metadata.tables.keys()))
22
+
23
+ @router.get("/table/{table_name}")
24
+ async def get_table_data(
25
+ table_name: str,
26
+ page: int = Query(1, ge=1),
27
+ per_page: int = Query(50, ge=1, le=1000),
28
+ db: AsyncSession = Depends(get_db)
29
+ ):
30
+ """
31
+ Get data for a specific table with pagination.
32
+ """
33
+ if table_name not in Base.metadata.tables:
34
+ raise HTTPException(status_code=404, detail=f"Table {table_name} not found")
35
+
36
+ table = Base.metadata.tables[table_name]
37
+
38
+ # Get columns
39
+ columns = [c.name for c in table.columns]
40
+
41
+ # Calculate offset
42
+ offset = (page - 1) * per_page
43
+
44
+ # Construct query safely using SQLAlchemy Core
45
+ # We use text() for dynamic table names but validate against metadata first
46
+ try:
47
+ # Get total count
48
+ count_query = text(f"SELECT COUNT(*) FROM {table_name}")
49
+ result = await db.execute(count_query)
50
+ total = result.scalar()
51
+
52
+ # Get data
53
+ data_query = text(f"SELECT * FROM {table_name} LIMIT :limit OFFSET :offset")
54
+ result = await db.execute(data_query, {"limit": per_page, "offset": offset})
55
+
56
+ # Convert rows to dicts
57
+ # result.keys() gives column names, result.all() gives rows
58
+ # We need to serialize datetime objects and others to JSON-friendly format
59
+ rows = []
60
+ for row in result:
61
+ row_dict = {}
62
+ for idx, col in enumerate(result.keys()):
63
+ val = row[idx]
64
+ # Simple string conversion for non-JSON serializable types might be needed
65
+ # FastAPI/Pydantic handles datetime usually, but let's be safe if needed.
66
+ # For now, let's rely on FastAPI's default encoder.
67
+ row_dict[col] = val
68
+ rows.append(row_dict)
69
+
70
+ return {
71
+ "table": table_name,
72
+ "columns": columns,
73
+ "total": total,
74
+ "page": page,
75
+ "per_page": per_page,
76
+ "data": rows
77
+ }
78
+
79
+ except Exception as e:
80
+ logger.error(f"Error fetching data for table {table_name}: {e}")
81
+ raise HTTPException(status_code=500, detail=str(e))
templates/index.html CHANGED
@@ -1,879 +1,318 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
-
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>API Gateway - Data Viewer</title>
8
  <style>
9
- * {
10
- margin: 0;
11
- padding: 0;
12
- box-sizing: border-box;
 
 
 
13
  }
14
 
15
  body {
16
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
17
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
18
- min-height: 100vh;
19
- color: #e4e4e4;
20
- padding: 20px;
21
- }
22
-
23
- .container {
24
- max-width: 1600px;
25
- margin: 0 auto;
26
- }
27
-
28
- header {
29
- text-align: center;
30
- margin-bottom: 30px;
31
  }
32
 
33
- h1 {
34
- font-size: 2.5rem;
35
- background: linear-gradient(90deg, #00d4ff, #7b2cbf);
36
- -webkit-background-clip: text;
37
- -webkit-text-fill-color: transparent;
38
- background-clip: text;
39
- margin-bottom: 10px;
 
 
40
  }
41
 
42
- .subtitle {
43
- color: #888;
44
- font-size: 1rem;
 
45
  }
46
 
47
- /* Tabs */
48
- .tabs {
49
- display: flex;
50
- gap: 10px;
51
- margin-bottom: 20px;
52
- justify-content: center;
53
- flex-wrap: wrap;
54
  }
55
 
56
- .tab-btn {
57
- background: rgba(255, 255, 255, 0.05);
58
- border: 1px solid rgba(255, 255, 255, 0.1);
59
- color: #888;
60
- padding: 12px 24px;
61
- border-radius: 8px;
62
  cursor: pointer;
63
- font-weight: 600;
64
- transition: all 0.3s ease;
65
  }
66
 
67
- .tab-btn:hover {
68
- background: rgba(255, 255, 255, 0.1);
69
- color: #fff;
70
  }
71
 
72
- .tab-btn.active {
73
- background: linear-gradient(135deg, #00d4ff, #7b2cbf);
74
  color: white;
75
- border-color: transparent;
76
  }
77
 
78
- .stats {
 
 
79
  display: flex;
80
- gap: 20px;
81
- justify-content: center;
82
- margin-bottom: 30px;
83
- flex-wrap: wrap;
84
- }
85
-
86
- .stat-card {
87
- background: rgba(255, 255, 255, 0.05);
88
- backdrop-filter: blur(10px);
89
- border: 1px solid rgba(255, 255, 255, 0.1);
90
- border-radius: 12px;
91
- padding: 20px 40px;
92
- text-align: center;
93
- }
94
-
95
- .stat-value {
96
- font-size: 2rem;
97
- font-weight: bold;
98
- color: #00d4ff;
99
  }
100
 
101
- .stat-label {
102
- color: #888;
103
- font-size: 0.9rem;
104
- margin-top: 5px;
 
105
  }
106
 
107
- .table-container {
108
- background: rgba(255, 255, 255, 0.03);
109
- backdrop-filter: blur(10px);
110
- border: 1px solid rgba(255, 255, 255, 0.1);
111
- border-radius: 16px;
112
- overflow: hidden;
113
  }
114
 
115
- .table-wrapper {
116
- overflow-x: auto;
 
 
 
 
 
 
117
  }
118
 
119
  table {
120
  width: 100%;
121
  border-collapse: collapse;
122
- min-width: 800px;
123
- }
124
-
125
- th {
126
- background: rgba(0, 212, 255, 0.1);
127
- padding: 16px;
128
- text-align: left;
129
- font-weight: 600;
130
- color: #00d4ff;
131
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
132
- white-space: nowrap;
133
- }
134
-
135
- td {
136
- padding: 14px 16px;
137
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
138
- vertical-align: top;
139
- }
140
-
141
- tr:hover {
142
- background: rgba(255, 255, 255, 0.03);
143
- }
144
-
145
- .user-id {
146
- font-family: 'Courier New', monospace;
147
- color: #7b2cbf;
148
- font-weight: 500;
149
- }
150
-
151
- .json-data {
152
- font-family: 'Courier New', monospace;
153
- font-size: 0.85rem;
154
- background: rgba(0, 0, 0, 0.3);
155
- padding: 8px 12px;
156
- border-radius: 6px;
157
- max-width: 400px;
158
- overflow-x: auto;
159
- white-space: pre-wrap;
160
- word-break: break-all;
161
- }
162
-
163
- .truncate {
164
- max-width: 200px;
165
- overflow: hidden;
166
- text-overflow: ellipsis;
167
  white-space: nowrap;
168
  }
169
 
170
- .timestamp {
171
- color: #666;
172
- font-size: 0.85rem;
173
- white-space: nowrap;
174
  }
175
 
176
- .status-badge {
177
- padding: 4px 12px;
178
- border-radius: 20px;
179
- font-size: 0.8rem;
180
  font-weight: 600;
 
 
 
181
  }
182
 
183
- .status-success {
184
- background: rgba(34, 197, 94, 0.2);
185
- color: #22c55e;
186
- }
187
-
188
- .status-failed {
189
- background: rgba(239, 68, 68, 0.2);
190
- color: #ef4444;
191
- }
192
-
193
- .status-queued {
194
- background: rgba(234, 179, 8, 0.2);
195
- color: #eab308;
196
- }
197
-
198
- .status-processing {
199
- background: rgba(59, 130, 246, 0.2);
200
- color: #3b82f6;
201
- }
202
-
203
- .status-completed {
204
- background: rgba(34, 197, 94, 0.2);
205
- color: #22c55e;
206
- }
207
-
208
- .credits-badge {
209
- background: linear-gradient(135deg, #00d4ff, #7b2cbf);
210
- padding: 4px 12px;
211
- border-radius: 20px;
212
- font-weight: 600;
213
  }
214
 
215
- .pagination {
 
216
  display: flex;
217
- justify-content: center;
218
  align-items: center;
219
- gap: 15px;
220
- padding: 20px;
221
- background: rgba(0, 0, 0, 0.2);
222
- }
223
-
224
- .pagination button {
225
- background: linear-gradient(135deg, #00d4ff, #7b2cbf);
226
- border: none;
227
- color: white;
228
- padding: 10px 24px;
229
- border-radius: 8px;
 
230
  cursor: pointer;
231
- font-weight: 600;
232
- transition: all 0.3s ease;
233
  }
234
 
235
- .pagination button:hover:not(:disabled) {
236
- transform: translateY(-2px);
237
- box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
238
  }
239
 
240
- .pagination button:disabled {
241
  opacity: 0.5;
242
  cursor: not-allowed;
243
  }
244
 
245
- .page-info {
246
- color: #888;
247
- }
248
-
249
- .loading {
250
- text-align: center;
251
- padding: 60px;
252
- color: #888;
253
- }
254
-
255
- .spinner {
256
- width: 40px;
257
- height: 40px;
258
- border: 3px solid rgba(255, 255, 255, 0.1);
259
- border-top-color: #00d4ff;
260
- border-radius: 50%;
261
- animation: spin 1s linear infinite;
262
- margin: 0 auto 20px;
263
- }
264
-
265
- @keyframes spin {
266
- to {
267
- transform: rotate(360deg);
268
- }
269
- }
270
-
271
- .empty-state {
272
- text-align: center;
273
- padding: 60px;
274
- color: #666;
275
- }
276
-
277
- .empty-state svg {
278
- width: 80px;
279
- height: 80px;
280
- margin-bottom: 20px;
281
- opacity: 0.3;
282
  }
283
 
284
- .hidden {
285
- display: none;
 
 
 
 
 
 
286
  }
287
  </style>
288
  </head>
289
-
290
  <body>
291
- <div class="container">
292
- <header>
293
- <h1>🔗 API Gateway</h1>
294
- <p class="subtitle">Data Viewer</p>
295
- </header>
296
 
297
- <div class="tabs">
298
- <button class="tab-btn active" onclick="switchTab('audit')">📝 Audit Logs</button>
299
- <button class="tab-btn" onclick="switchTab('clients')">🔗 Client Users</button>
300
- <button class="tab-btn" onclick="switchTab('users')">👥 Users</button>
301
- <button class="tab-btn" onclick="switchTab('jobs')">⚡ Gemini Jobs</button>
302
- <button class="tab-btn" onclick="switchTab('payments')">💳 Payments</button>
303
- <button class="tab-btn" onclick="switchTab('contacts')">📧 Contacts</button>
304
- <button class="tab-btn" onclick="switchTab('keys')">🔑 API Keys</button>
305
- </div>
306
-
307
- <div class="stats" id="statsContainer">
308
- <div class="stat-card">
309
- <div class="stat-value" id="totalRecords">-</div>
310
- <div class="stat-label">Total Records</div>
311
- </div>
312
- <div class="stat-card" id="uniqueUsersCard">
313
- <div class="stat-value" id="uniqueUsers">-</div>
314
- <div class="stat-label">Unique Users</div>
315
- </div>
316
- </div>
317
-
318
- <!-- Audit Logs Table -->
319
- <div class="table-container" id="auditMainTable">
320
- <div class="table-wrapper">
321
- <table>
322
- <thead>
323
- <tr>
324
- <th>ID</th>
325
- <th>Log Type</th>
326
- <th>User ID</th>
327
- <th>Client User ID</th>
328
- <th>Action</th>
329
- <th>Details (JSON)</th>
330
- <th>IP Address</th>
331
- <th>User Agent</th>
332
- <th>Refer URL</th>
333
- <th>Status</th>
334
- <th>Error</th>
335
- <th>Timestamp</th>
336
- </tr>
337
- </thead>
338
- <tbody id="auditMainBody">
339
- <tr>
340
- <td colspan="12">
341
- <div class="loading">
342
- <div class="spinner"></div>Loading...
343
- </div>
344
- </td>
345
- </tr>
346
- </tbody>
347
- </table>
348
- </div>
349
- <div class="pagination">
350
- <button onclick="prevPage('audit')" id="auditPrev" disabled>← Previous</button>
351
- <span class="page-info">Page <span id="auditCurrentPage">1</span> of <span
352
- id="auditTotalPages">1</span></span>
353
- <button onclick="nextPage('audit')" id="auditNext">Next →</button>
354
- </div>
355
- </div>
356
-
357
- <!-- Users Table -->
358
- <div class="table-container hidden" id="usersTable">
359
- <div class="table-wrapper">
360
- <table>
361
- <thead>
362
- <tr>
363
- <th>ID</th>
364
- <th>User ID</th>
365
- <th>Email</th>
366
- <th>Google ID</th>
367
- <th>Name</th>
368
- <th>Profile Picture</th>
369
- <th>Token Version</th>
370
- <th>Credits</th>
371
- <th>Active</th>
372
- <th>Created At</th>
373
- <th>Updated At</th>
374
- <th>Last Used</th>
375
- </tr>
376
- </thead>
377
- <tbody id="usersBody">
378
- <tr>
379
- <td colspan="12">
380
- <div class="loading">
381
- <div class="spinner"></div>Loading...
382
- </div>
383
- </td>
384
- </tr>
385
- </tbody>
386
- </table>
387
- </div>
388
- <div class="pagination">
389
- <button onclick="prevPage('users')" id="usersPrev" disabled>← Previous</button>
390
- <span class="page-info">Page <span id="usersCurrentPage">1</span> of <span
391
- id="usersTotalPages">1</span></span>
392
- <button onclick="nextPage('users')" id="usersNext">Next →</button>
393
- </div>
394
- </div>
395
-
396
- <!-- Client Users Table -->
397
- <div class="table-container hidden" id="clientsTable">
398
- <div class="table-wrapper">
399
- <table>
400
- <thead>
401
- <tr>
402
- <th>ID</th>
403
- <th>User ID (Server)</th>
404
- <th>Client User ID</th>
405
- <th>IPv4 Address</th>
406
- <th>IPv6 Address</th>
407
- <th>Device Fingerprint</th>
408
- <th>Device Info (JSON)</th>
409
- <th>Created At</th>
410
- <th>Last Seen At</th>
411
- </tr>
412
- </thead>
413
- <tbody id="clientsBody">
414
- <tr>
415
- <td colspan="9">
416
- <div class="loading">
417
- <div class="spinner"></div>Loading...
418
- </div>
419
- </td>
420
- </tr>
421
- </tbody>
422
- </table>
423
- </div>
424
- <div class="pagination">
425
- <button onclick="prevPage('clients')" id="clientsPrev" disabled>← Previous</button>
426
- <span class="page-info">Page <span id="clientsCurrentPage">1</span> of <span
427
- id="clientsTotalPages">1</span></span>
428
- <button onclick="nextPage('clients')" id="clientsNext">Next →</button>
429
- </div>
430
- </div>
431
- <!-- Gemini Jobs Table -->
432
- <div class="table-container hidden" id="jobsTable">
433
- <div class="table-wrapper">
434
- <table>
435
- <thead>
436
- <tr>
437
- <th>ID</th>
438
- <th>Job ID</th>
439
- <th>User ID</th>
440
- <th>Type</th>
441
- <th>Status</th>
442
- <th>API Response</th>
443
- <th>Error</th>
444
- <th>Created At</th>
445
- <th>Completed At</th>
446
- </tr>
447
- </thead>
448
- <tbody id="jobsBody">
449
- <tr>
450
- <td colspan="8">
451
- <div class="loading">
452
- <div class="spinner"></div>Loading...
453
- </div>
454
- </td>
455
- </tr>
456
- </tbody>
457
- </table>
458
- </div>
459
- <div class="pagination">
460
- <button onclick="prevPage('jobs')" id="jobsPrev" disabled>← Previous</button>
461
- <span class="page-info">Page <span id="jobsCurrentPage">1</span> of <span
462
- id="jobsTotalPages">1</span></span>
463
- <button onclick="nextPage('jobs')" id="jobsNext">Next →</button>
464
- </div>
465
- </div>
466
 
467
- <!-- Payment Transactions Table -->
468
- <div class="table-container hidden" id="paymentsTable">
469
- <div class="table-wrapper">
470
- <table>
471
- <thead>
472
- <tr>
473
- <th>ID</th>
474
- <th>Transaction ID</th>
475
- <th>User ID</th>
476
- <th>Gateway</th>
477
- <th>Order ID</th>
478
- <th>Payment ID</th>
479
- <th>Package</th>
480
- <th>Credits</th>
481
- <th>Amount</th>
482
- <th>Status</th>
483
- <th>Verified By</th>
484
- <th>Error Reason</th>
485
- <th>Created At</th>
486
- <th>Paid At</th>
487
- </tr>
488
- </thead>
489
- <tbody id="paymentsBody">
490
- <tr>
491
- <td colspan="14">
492
- <div class="loading">
493
- <div class="spinner"></div>Loading...
494
- </div>
495
- </td>
496
- </tr>
497
- </tbody>
498
- </table>
499
- </div>
500
- <div class="pagination">
501
- <button onclick="prevPage('payments')" id="paymentsPrev" disabled>← Previous</button>
502
- <span class="page-info">Page <span id="paymentsCurrentPage">1</span> of <span
503
- id="paymentsTotalPages">1</span></span>
504
- <button onclick="nextPage('payments')" id="paymentsNext">Next →</button>
505
  </div>
506
- </div>
507
 
508
- <!-- API Keys Usage Table -->
509
- <div class="table-container hidden" id="keysTable">
510
- <div class="table-wrapper">
511
- <table>
512
- <thead>
513
- <tr>
514
- <th>Key Index</th>
515
- <th>Total Requests</th>
516
- <th>Success</th>
517
- <th>Failed</th>
518
- <th>Success Rate</th>
519
- <th>Last Error</th>
520
- <th>Last Used</th>
521
- </tr>
522
- </thead>
523
- <tbody id="keysBody">
524
- <tr>
525
- <td colspan="7">
526
- <div class="loading">
527
- <div class="spinner"></div>Loading...
528
- </div>
529
- </td>
530
- </tr>
531
- </tbody>
532
- </table>
533
- </div>
534
- <div class="pagination">
535
- <span class="page-info">Auto-refreshes every 10 seconds</span>
536
- </div>
537
  </div>
538
 
539
- <!-- Contacts Table -->
540
- <div class="table-container hidden" id="contactsTable">
541
- <div class="table-wrapper">
542
- <table>
543
- <thead>
544
- <tr>
545
- <th>ID</th>
546
- <th>User ID</th>
547
- <th>Email</th>
548
- <th>Subject</th>
549
- <th>Message</th>
550
- <th>IP Address</th>
551
- <th>Created At</th>
552
- </tr>
553
- </thead>
554
- <tbody id="contactsBody">
555
- <tr>
556
- <td colspan="7">
557
- <div class="loading">
558
- <div class="spinner"></div>Loading...
559
- </div>
560
- </td>
561
- </tr>
562
- </tbody>
563
- </table>
564
- </div>
565
- <div class="pagination">
566
- <button onclick="prevPage('contacts')" id="contactsPrev" disabled>← Previous</button>
567
- <span class="page-info">Page <span id="contactsCurrentPage">1</span> of <span
568
- id="contactsTotalPages">1</span></span>
569
- <button onclick="nextPage('contacts')" id="contactsNext">Next →</button>
570
- </div>
571
  </div>
572
  </div>
573
 
574
  <script>
575
- const PAGE_SIZE = 50;
576
- const state = {
577
- audit: { page: 1, total: 0 },
578
- clients: { page: 1, total: 0 },
579
- users: { page: 1, total: 0 },
580
- jobs: { page: 1, total: 0 },
581
- payments: { page: 1, total: 0, revenue: 0 },
582
- contacts: { page: 1, total: 0 }
583
- };
584
- let currentTab = 'audit';
585
-
586
- const endpoints = {
587
- audit: '/api/audit-logs',
588
- clients: '/api/client-users',
589
- users: '/api/users',
590
- jobs: '/api/gemini-jobs',
591
- payments: '/api/payment-transactions',
592
- contacts: '/api/contacts'
593
- };
594
-
595
- function switchTab(tab) {
596
- currentTab = tab;
597
-
598
- // Update tab buttons
599
- document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
600
- event.target.classList.add('active');
601
-
602
- // Show/hide tables
603
- document.getElementById('auditMainTable').classList.toggle('hidden', tab !== 'audit');
604
- document.getElementById('clientsTable').classList.toggle('hidden', tab !== 'clients');
605
- document.getElementById('usersTable').classList.toggle('hidden', tab !== 'users');
606
- document.getElementById('jobsTable').classList.toggle('hidden', tab !== 'jobs');
607
- document.getElementById('paymentsTable').classList.toggle('hidden', tab !== 'payments');
608
- document.getElementById('contactsTable').classList.toggle('hidden', tab !== 'contacts');
609
- document.getElementById('keysTable').classList.toggle('hidden', tab !== 'keys');
610
-
611
- // Show/hide unique users stat (only for audit and payments)
612
- document.getElementById('uniqueUsersCard').classList.toggle('hidden', tab !== 'audit' && tab !== 'payments');
613
-
614
- // Load data
615
- if (tab === 'keys') {
616
- loadKeyStats();
617
- } else {
618
- loadPage(tab, state[tab].page);
619
- }
620
- }
621
-
622
- async function fetchData(tab, page) {
623
  try {
624
- const response = await fetch(`${endpoints[tab]}?page=${page}&limit=${PAGE_SIZE}`);
625
- return await response.json();
 
626
  } catch (error) {
627
- console.error('Error fetching data:', error);
628
- return { items: [], total: 0 };
629
  }
630
  }
631
 
632
- function renderAuditTable(items) {
633
- const tbody = document.getElementById('auditMainBody');
634
- if (items.length === 0) {
635
- tbody.innerHTML = '<tr><td colspan="12"><div class="empty-state">No audit logs found</div></td></tr>';
636
- return;
637
- }
638
- tbody.innerHTML = items.map(item => `
639
- <tr>
640
- <td>${item.id}</td>
641
- <td><span class="status-badge" style="background: rgba(0, 212, 255, 0.2); color: #00d4ff;">${item.log_type}</span></td>
642
- <td class="user-id">${item.user_id || '-'}</td>
643
- <td class="user-id">${item.client_user_id || '-'}</td>
644
- <td>${item.action}</td>
645
- <td class="truncate" title="${item.details || ''}">${item.details || '-'}</td>
646
- <td>${item.ip_address || '-'}</td>
647
- <td class="truncate" title="${item.user_agent || ''}">${item.user_agent || '-'}</td>
648
- <td class="truncate" title="${item.refer_url || ''}">${item.refer_url || '-'}</td>
649
- <td><span class="status-badge status-${item.status}">${item.status}</span></td>
650
- <td class="truncate" title="${item.error_message || ''}">${item.error_message || '-'}</td>
651
- <td class="timestamp">${item.timestamp ? new Date(item.timestamp).toLocaleString() : '-'}</td>
652
- </tr>
653
  `).join('');
654
  }
655
 
656
- function renderClientsTable(items) {
657
- const tbody = document.getElementById('clientsBody');
658
- if (items.length === 0) {
659
- tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state">No client users found</div></td></tr>';
660
- return;
661
- }
662
- tbody.innerHTML = items.map(item => `
663
- <tr>
664
- <td>${item.id}</td>
665
- <td class="user-id">${item.user_id}</td>
666
- <td class="user-id">${item.client_user_id || '-'}</td>
667
- <td>${item.ipv4_address || '-'}</td>
668
- <td>${item.ipv6_address || '-'}</td>
669
- <td class="truncate" title="${item.device_fingerprint || ''}">${item.device_fingerprint || '-'}</td>
670
- <td class="truncate" title="${JSON.stringify(item.device_info, null, 2)}">${JSON.stringify(item.device_info, null, 2) || '-'}</td>
671
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
672
- <td class="timestamp">${item.last_seen_at ? new Date(item.last_seen_at).toLocaleString() : '-'}</td>
673
- </tr>
674
- `).join('');
675
- }
676
-
677
- function renderUsersTable(items) {
678
- const tbody = document.getElementById('usersBody');
679
- if (items.length === 0) {
680
- tbody.innerHTML = '<tr><td colspan="12"><div class="empty-state">No users found</div></td></tr>';
681
- return;
682
- }
683
- tbody.innerHTML = items.map(item => `
684
- <tr>
685
- <td>${item.id}</td>
686
- <td class="user-id">${item.user_id}</td>
687
- <td>${item.email}</td>
688
- <td class="truncate" title="${item.google_id || ''}">${item.google_id || '-'}</td>
689
- <td>${item.name || '-'}</td>
690
- <td class="truncate" title="${item.profile_picture || ''}"><img src="${item.profile_picture || ''}" width="40" height="40" style="border-radius: 50%;" onerror="this.style.display='none'" /></td>
691
- <td>${item.token_version || 1}</td>
692
- <td><span class="credits-badge">${item.credits}</span></td>
693
- <td><span class="status-badge ${item.is_active ? 'status-success' : 'status-failed'}">${item.is_active ? 'Active' : 'Inactive'}</span></td>
694
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
695
- <td class="timestamp">${item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}</td>
696
- <td class="timestamp">${item.last_used_at ? new Date(item.last_used_at).toLocaleString() : '-'}</td>
697
- </tr>
698
- `).join('');
699
- }
700
-
701
- function renderJobsTable(items) {
702
- const tbody = document.getElementById('jobsBody');
703
- if (items.length === 0) {
704
- tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state">No jobs found</div></td></tr>';
705
- return;
706
- }
707
- tbody.innerHTML = items.map(item => `
708
- <tr>
709
- <td>${item.id}</td>
710
- <td class="user-id">${item.job_id}</td>
711
- <td class="user-id">${item.user_id}</td>
712
- <td>${item.job_type}</td>
713
- <td><span class="status-badge status-${item.status}">${item.status}</span></td>
714
- <td class="truncate" title="${item.api_response || ''}">${item.api_response || '-'}</td>
715
- <td class="truncate" title="${item.error_message || ''}">${item.error_message || '-'}</td>
716
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
717
- <td class="timestamp">${item.completed_at ? new Date(item.completed_at).toLocaleString() : '-'}</td>
718
- </tr>
719
- `).join('');
720
- }
721
-
722
- function renderPaymentsTable(items) {
723
- const tbody = document.getElementById('paymentsBody');
724
- if (items.length === 0) {
725
- tbody.innerHTML = '<tr><td colspan="14"><div class="empty-state">No payment transactions found</div></td></tr>';
726
- return;
727
- }
728
-
729
- const getVerifiedByBadge = (value) => {
730
- if (!value) return '-';
731
- const colors = {
732
- 'client': 'background: rgba(59, 130, 246, 0.2); color: #3b82f6;',
733
- 'webhook': 'background: rgba(234, 179, 8, 0.2); color: #eab308;',
734
- 'both': 'background: rgba(34, 197, 94, 0.2); color: #22c55e;'
735
- };
736
- return `<span class="status-badge" style="${colors[value] || ''}">${value}</span>`;
737
- };
738
-
739
- tbody.innerHTML = items.map(item => `
740
- <tr>
741
- <td>${item.id}</td>
742
- <td class="user-id">${item.transaction_id}</td>
743
- <td class="user-id">${item.user_id}</td>
744
- <td><span class="status-badge" style="background: rgba(123, 44, 191, 0.2); color: #a855f7;">${item.gateway}</span></td>
745
- <td class="truncate" title="${item.gateway_order_id || ''}">${item.gateway_order_id || '-'}</td>
746
- <td class="truncate" title="${item.gateway_payment_id || ''}">${item.gateway_payment_id || '-'}</td>
747
- <td>${item.package_id}</td>
748
- <td><span class="credits-badge">${item.credits_amount}</span></td>
749
- <td>₹${item.amount_rupees}</td>
750
- <td><span class="status-badge status-${item.status === 'paid' ? 'success' : item.status === 'failed' ? 'failed' : 'queued'}">${item.status}</span></td>
751
- <td>${getVerifiedByBadge(item.verified_by)}</td>
752
- <td class="truncate" title="${item.error_message || ''}">${item.error_message || '-'}</td>
753
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
754
- <td class="timestamp">${item.paid_at ? new Date(item.paid_at).toLocaleString() : '-'}</td>
755
- </tr>
756
- `).join('');
757
- }
758
-
759
- const renderers = {
760
- audit: renderAuditTable,
761
- clients: renderClientsTable,
762
- users: renderUsersTable,
763
- jobs: renderJobsTable,
764
- payments: renderPaymentsTable,
765
- contacts: renderContactsTable
766
- };
767
-
768
- function updatePagination(tab) {
769
- const totalPages = Math.ceil(state[tab].total / PAGE_SIZE) || 1;
770
- document.getElementById(`${tab}CurrentPage`).textContent = state[tab].page;
771
- document.getElementById(`${tab}TotalPages`).textContent = totalPages;
772
- document.getElementById(`${tab}Prev`).disabled = state[tab].page <= 1;
773
- document.getElementById(`${tab}Next`).disabled = state[tab].page >= totalPages;
774
- }
775
-
776
- async function loadPage(tab, page) {
777
- state[tab].page = page;
778
- const data = await fetchData(tab, page);
779
- state[tab].total = data.total;
780
-
781
- document.getElementById('totalRecords').textContent = data.total.toLocaleString();
782
- if (data.unique_users !== undefined) {
783
- document.getElementById('uniqueUsers').textContent = data.unique_users.toLocaleString();
784
- }
785
-
786
- // Show revenue for payments tab
787
- if (tab === 'payments' && data.total_revenue_rupees !== undefined) {
788
- state.payments.revenue = data.total_revenue_rupees;
789
- document.getElementById('uniqueUsers').textContent = '₹' + data.total_revenue_rupees.toLocaleString();
790
- document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Total Revenue';
791
- } else if (tab === 'audit') {
792
- document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Unique Users';
793
- }
794
-
795
- renderers[tab](data.items);
796
- updatePagination(tab);
797
- }
798
-
799
- function prevPage(tab) {
800
- if (state[tab].page > 1) {
801
- loadPage(tab, state[tab].page - 1);
802
- }
803
- }
804
 
805
- function nextPage(tab) {
806
- const totalPages = Math.ceil(state[tab].total / PAGE_SIZE);
807
- if (state[tab].page < totalPages) {
808
- loadPage(tab, state[tab].page + 1);
809
- }
810
- }
811
-
812
- // Load and render API key stats
813
- async function loadKeyStats() {
814
  try {
815
- const response = await fetch('/api/key-stats');
816
  const data = await response.json();
817
- renderKeysTable(data.keys);
818
- document.getElementById('totalRecords').textContent = data.total_keys;
819
  } catch (error) {
820
- console.error('Error fetching key stats:', error);
 
821
  }
822
  }
823
 
824
- function renderKeysTable(items) {
825
- const tbody = document.getElementById('keysBody');
826
- if (!items || items.length === 0) {
827
- tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">No API keys configured</div></td></tr>';
828
  return;
829
  }
830
- tbody.innerHTML = items.map(item => {
831
- const successRate = item.total_requests > 0
832
- ? ((item.success_count / item.total_requests) * 100).toFixed(1)
833
- : '0.0';
834
- return `
835
- <tr>
836
- <td><span class="credits-badge">Key ${item.key_index}</span></td>
837
- <td>${item.total_requests.toLocaleString()}</td>
838
- <td><span class="status-badge status-success">${item.success_count}</span></td>
839
- <td><span class="status-badge status-failed">${item.failure_count}</span></td>
840
- <td>${successRate}%</td>
841
- <td class="truncate" title="${item.last_error || ''}">${item.last_error || '-'}</td>
842
- <td class="timestamp">${item.last_used_at ? new Date(item.last_used_at).toLocaleString() : 'Never'}</td>
843
- </tr>
844
- `;
845
- }).join('');
846
- }
847
 
848
- function renderContactsTable(items) {
849
- const tbody = document.getElementById('contactsBody');
850
- if (items.length === 0) {
851
- tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">No contact submissions found</div></td></tr>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  return;
853
  }
854
- tbody.innerHTML = items.map(item => `
855
- <tr>
856
- <td>${item.id}</td>
857
- <td class="user-id">${item.user_id}</td>
858
- <td>${item.email}</td>
859
- <td class="truncate" title="${item.subject || ''}">${item.subject || '-'}</td>
860
- <td class="truncate" title="${item.message}">${item.message}</td>
861
- <td>${item.ip_address || '-'}</td>
862
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
863
- </tr>
864
- `).join('');
865
  }
866
 
867
- // Auto-refresh key stats every 10 seconds when on keys tab
868
- setInterval(() => {
869
- if (currentTab === 'keys') {
870
- loadKeyStats();
871
- }
872
- }, 10000);
873
 
874
- // Initial load
875
- loadPage('audit', 1);
 
 
 
 
876
  </script>
877
  </body>
878
-
879
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Database Viewer</title>
7
  <style>
8
+ :root {
9
+ --primary-color: #6366f1;
10
+ --primary-hover: #4f46e5;
11
+ --bg-color: #f3f4f6;
12
+ --sidebar-bg: #ffffff;
13
+ --text-color: #1f2937;
14
+ --border-color: #e5e7eb;
15
  }
16
 
17
  body {
18
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19
+ margin: 0;
20
+ padding: 0;
21
+ background-color: var(--bg-color);
22
+ color: var(--text-color);
23
+ display: flex;
24
+ height: 100vh;
25
+ overflow: hidden;
 
 
 
 
 
 
 
26
  }
27
 
28
+ /* Sidebar */
29
+ #sidebar {
30
+ width: 250px;
31
+ background-color: var(--sidebar-bg);
32
+ border-right: 1px solid var(--border-color);
33
+ display: flex;
34
+ flex-direction: column;
35
+ padding: 1rem;
36
+ overflow-y: auto;
37
  }
38
 
39
+ #sidebar h2 {
40
+ font-size: 1.25rem;
41
+ margin-bottom: 1rem;
42
+ color: var(--primary-color);
43
  }
44
 
45
+ .table-list {
46
+ list-style: none;
47
+ padding: 0;
48
+ margin: 0;
 
 
 
49
  }
50
 
51
+ .table-item {
52
+ padding: 0.75rem 1rem;
53
+ margin-bottom: 0.5rem;
54
+ border-radius: 0.375rem;
 
 
55
  cursor: pointer;
56
+ transition: background-color 0.2s;
57
+ font-weight: 500;
58
  }
59
 
60
+ .table-item:hover {
61
+ background-color: #eff6ff;
62
+ color: var(--primary-color);
63
  }
64
 
65
+ .table-item.active {
66
+ background-color: var(--primary-color);
67
  color: white;
 
68
  }
69
 
70
+ /* Main Content */
71
+ #main-content {
72
+ flex: 1;
73
  display: flex;
74
+ flex-direction: column;
75
+ padding: 1.5rem;
76
+ overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
+ header {
80
+ display: flex;
81
+ justify-content: space-between;
82
+ align-items: center;
83
+ margin-bottom: 1rem;
84
  }
85
 
86
+ h1 {
87
+ font-size: 1.5rem;
88
+ margin: 0;
 
 
 
89
  }
90
 
91
+ /* Table Container */
92
+ #table-container {
93
+ flex: 1;
94
+ background: white;
95
+ border-radius: 0.5rem;
96
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
97
+ overflow: auto;
98
+ position: relative;
99
  }
100
 
101
  table {
102
  width: 100%;
103
  border-collapse: collapse;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  white-space: nowrap;
105
  }
106
 
107
+ th, td {
108
+ padding: 0.75rem 1rem;
109
+ text-align: left;
110
+ border-bottom: 1px solid var(--border-color);
111
  }
112
 
113
+ th {
114
+ background-color: #f9fafb;
 
 
115
  font-weight: 600;
116
+ position: sticky;
117
+ top: 0;
118
+ z-index: 10;
119
  }
120
 
121
+ tr:hover {
122
+ background-color: #f9fafb;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
+ /* Pagination */
126
+ #pagination {
127
  display: flex;
128
+ justify-content: space-between;
129
  align-items: center;
130
+ margin-top: 1rem;
131
+ padding: 0.5rem;
132
+ background: white;
133
+ border-radius: 0.5rem;
134
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
135
+ }
136
+
137
+ .btn {
138
+ padding: 0.5rem 1rem;
139
+ border: 1px solid var(--border-color);
140
+ background-color: white;
141
+ border-radius: 0.375rem;
142
  cursor: pointer;
143
+ transition: all 0.2s;
 
144
  }
145
 
146
+ .btn:hover:not(:disabled) {
147
+ background-color: #f3f4f6;
148
+ border-color: #d1d5db;
149
  }
150
 
151
+ .btn:disabled {
152
  opacity: 0.5;
153
  cursor: not-allowed;
154
  }
155
 
156
+ #page-info {
157
+ font-size: 0.875rem;
158
+ color: #6b7280;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
160
 
161
+ /* Loading & Empty States */
162
+ .loading, .empty {
163
+ display: flex;
164
+ justify-content: center;
165
+ align-items: center;
166
+ height: 100%;
167
+ color: #6b7280;
168
+ font-size: 1.125rem;
169
  }
170
  </style>
171
  </head>
 
172
  <body>
 
 
 
 
 
173
 
174
+ <div id="sidebar">
175
+ <h2>Tables</h2>
176
+ <ul id="table-list" class="table-list">
177
+ <!-- Tables will be injected here -->
178
+ </ul>
179
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
+ <div id="main-content">
182
+ <header>
183
+ <h1 id="current-table-name">Select a Table</h1>
184
+ <div id="pagination-controls" style="display: none;">
185
+ <!-- Pagination controls -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  </div>
187
+ </header>
188
 
189
+ <div id="table-container">
190
+ <div class="empty">Select a table from the sidebar to view data.</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  </div>
192
 
193
+ <div id="pagination" style="display: none;">
194
+ <button id="prev-btn" class="btn">Previous</button>
195
+ <span id="page-info">Page 1 of 1</span>
196
+ <button id="next-btn" class="btn">Next</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
  </div>
199
 
200
  <script>
201
+ const API_BASE = '/api/schema';
202
+ let currentTable = null;
203
+ let currentPage = 1;
204
+ let totalPages = 1;
205
+ const perPage = 50;
206
+
207
+ // DOM Elements
208
+ const tableListEl = document.getElementById('table-list');
209
+ const currentTableNameEl = document.getElementById('current-table-name');
210
+ const tableContainerEl = document.getElementById('table-container');
211
+ const paginationEl = document.getElementById('pagination');
212
+ const prevBtn = document.getElementById('prev-btn');
213
+ const nextBtn = document.getElementById('next-btn');
214
+ const pageInfoEl = document.getElementById('page-info');
215
+
216
+ // Initialize
217
+ async function init() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  try {
219
+ const response = await fetch(`${API_BASE}/tables`);
220
+ const tables = await response.json();
221
+ renderTableList(tables);
222
  } catch (error) {
223
+ console.error('Failed to fetch tables:', error);
224
+ tableContainerEl.innerHTML = '<div class="empty">Error loading tables.</div>';
225
  }
226
  }
227
 
228
+ function renderTableList(tables) {
229
+ tableListEl.innerHTML = tables.map(table => `
230
+ <li class="table-item" onclick="loadTable('${table}')">${table}</li>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  `).join('');
232
  }
233
 
234
+ async function loadTable(tableName, page = 1) {
235
+ currentTable = tableName;
236
+ currentPage = page;
237
+
238
+ // Update UI
239
+ document.querySelectorAll('.table-item').forEach(el => {
240
+ el.classList.toggle('active', el.textContent === tableName);
241
+ });
242
+ currentTableNameEl.textContent = tableName;
243
+ tableContainerEl.innerHTML = '<div class="loading">Loading...</div>';
244
+ paginationEl.style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
 
 
 
 
 
 
 
 
 
246
  try {
247
+ const response = await fetch(`${API_BASE}/table/${tableName}?page=${page}&per_page=${perPage}`);
248
  const data = await response.json();
249
+ renderTableData(data);
 
250
  } catch (error) {
251
+ console.error(`Failed to fetch data for ${tableName}:`, error);
252
+ tableContainerEl.innerHTML = `<div class="empty">Error loading data for ${tableName}.</div>`;
253
  }
254
  }
255
 
256
+ function renderTableData(data) {
257
+ if (!data.data || data.data.length === 0) {
258
+ tableContainerEl.innerHTML = '<div class="empty">No data found in this table.</div>';
259
+ paginationEl.style.display = 'none';
260
  return;
261
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ // Calculate pagination
264
+ totalPages = Math.ceil(data.total / data.per_page);
265
+ updatePaginationUI(data.total);
266
+
267
+ // Build Table
268
+ const columns = data.columns;
269
+ const rows = data.data;
270
+
271
+ let html = '<table><thead><tr>';
272
+ columns.forEach(col => {
273
+ html += `<th>${col}</th>`;
274
+ });
275
+ html += '</tr></thead><tbody>';
276
+
277
+ rows.forEach(row => {
278
+ html += '<tr>';
279
+ columns.forEach(col => {
280
+ let val = row[col];
281
+ if (val === null) val = '<span style="color: #9ca3af;">null</span>';
282
+ else if (typeof val === 'object') val = JSON.stringify(val);
283
+ html += `<td>${val}</td>`;
284
+ });
285
+ html += '</tr>';
286
+ });
287
+
288
+ html += '</tbody></table>';
289
+ tableContainerEl.innerHTML = html;
290
+ }
291
+
292
+ function updatePaginationUI(totalItems) {
293
+ if (totalItems === 0) {
294
+ paginationEl.style.display = 'none';
295
  return;
296
  }
297
+
298
+ paginationEl.style.display = 'flex';
299
+ pageInfoEl.textContent = `Page ${currentPage} of ${totalPages} (${totalItems} items)`;
300
+
301
+ prevBtn.disabled = currentPage <= 1;
302
+ nextBtn.disabled = currentPage >= totalPages;
 
 
 
 
 
303
  }
304
 
305
+ // Event Listeners
306
+ prevBtn.addEventListener('click', () => {
307
+ if (currentPage > 1) loadTable(currentTable, currentPage - 1);
308
+ });
 
 
309
 
310
+ nextBtn.addEventListener('click', () => {
311
+ if (currentPage < totalPages) loadTable(currentTable, currentPage + 1);
312
+ });
313
+
314
+ // Start
315
+ init();
316
  </script>
317
  </body>
 
318
  </html>