jebin2 commited on
Commit
b49d66f
Β·
1 Parent(s): 5bfbdc4
core/models.py CHANGED
@@ -129,3 +129,22 @@ class GeminiJob(Base):
129
  def __repr__(self):
130
  return f"<GeminiJob(job_id={self.job_id}, type={self.job_type}, status={self.status}, priority={self.priority})>"
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def __repr__(self):
130
  return f"<GeminiJob(job_id={self.job_id}, type={self.job_type}, status={self.status}, priority={self.priority})>"
131
 
132
+
133
+ class ApiKeyUsage(Base):
134
+ """
135
+ Track API key usage for round-robin load balancing.
136
+ Only stores the key index, not the actual key for security.
137
+ """
138
+ __tablename__ = "api_key_usage"
139
+
140
+ id = Column(Integer, primary_key=True, autoincrement=True)
141
+ key_index = Column(Integer, unique=True, index=True, nullable=False) # Index of key in GEMINI_API_KEYS
142
+ total_requests = Column(Integer, default=0)
143
+ success_count = Column(Integer, default=0)
144
+ failure_count = Column(Integer, default=0)
145
+ last_error = Column(Text, nullable=True) # Stores the last error message for review
146
+ last_used_at = Column(DateTime(timezone=True), nullable=True)
147
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
148
+
149
+ def __repr__(self):
150
+ return f"<ApiKeyUsage(index={self.key_index}, total={self.total_requests}, success={self.success_count}, failed={self.failure_count})>"
routers/general.py CHANGED
@@ -1,7 +1,10 @@
1
- from fastapi import APIRouter
2
  from fastapi.responses import HTMLResponse
 
3
  import os
4
 
 
 
5
  router = APIRouter()
6
 
7
  @router.get("/health")
@@ -27,3 +30,19 @@ async def root():
27
  template_path = os.path.join(base_dir, "templates", "index.html")
28
  with open(template_path, "r") as f:
29
  return HTMLResponse(content=f.read())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
  from fastapi.responses import HTMLResponse
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
  import os
5
 
6
+ from core.database import get_db
7
+
8
  router = APIRouter()
9
 
10
  @router.get("/health")
 
30
  template_path = os.path.join(base_dir, "templates", "index.html")
31
  with open(template_path, "r") as f:
32
  return HTMLResponse(content=f.read())
33
+
34
+
35
+ @router.get("/api/key-stats")
36
+ async def get_api_key_stats(db: AsyncSession = Depends(get_db)):
37
+ """
38
+ Get API key usage statistics.
39
+ Returns usage stats by key index (not actual keys for security).
40
+ """
41
+ from services.api_key_manager import get_all_usage_stats, get_key_count
42
+
43
+ stats = await get_all_usage_stats(db)
44
+ return {
45
+ "total_keys": get_key_count(),
46
+ "keys": stats
47
+ }
48
+
services/api_key_manager.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Key Manager - Round-robin management for multiple Gemini API keys.
3
+
4
+ Selects the least-used key to avoid rate limiting.
5
+ Tracks usage per key index (not the actual key for security).
6
+ """
7
+ import os
8
+ import logging
9
+ from datetime import datetime
10
+ from typing import Optional, List, Tuple
11
+ from sqlalchemy import select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Cache for API keys (loaded once from env)
17
+ _api_keys: Optional[List[str]] = None
18
+
19
+
20
+ def get_api_keys() -> List[str]:
21
+ """Load API keys from GEMINI_API_KEYS environment variable."""
22
+ global _api_keys
23
+ if _api_keys is None:
24
+ keys_str = os.getenv("GEMINI_API_KEYS", "")
25
+ if not keys_str:
26
+ # Fallback to single key
27
+ single_key = os.getenv("GEMINI_API_KEY", "")
28
+ if single_key:
29
+ _api_keys = [single_key]
30
+ else:
31
+ _api_keys = [] # Return empty list if no keys configured
32
+ else:
33
+ _api_keys = [k.strip() for k in keys_str.split(",") if k.strip()]
34
+
35
+ if _api_keys:
36
+ logger.info(f"Loaded {len(_api_keys)} API key(s)")
37
+ else:
38
+ logger.warning("Unable to authenticate GEMINI.")
39
+
40
+ return _api_keys
41
+
42
+
43
+ def get_key_count() -> int:
44
+ """Get the number of available API keys."""
45
+ return len(get_api_keys())
46
+
47
+
48
+ async def get_least_used_key(db: AsyncSession) -> Tuple[int, str]:
49
+ """
50
+ Get the API key with least requests (round-robin style).
51
+
52
+ Returns:
53
+ Tuple of (key_index, api_key)
54
+
55
+ Raises:
56
+ ValueError: If no API keys are configured
57
+ """
58
+ from core.models import ApiKeyUsage
59
+
60
+ keys = get_api_keys()
61
+ if not keys:
62
+ raise ValueError("No API keys configured. Set GEMINI_API_KEYS or GEMINI_API_KEY in environment.")
63
+
64
+ # Get all usage stats
65
+ query = select(ApiKeyUsage).order_by(ApiKeyUsage.total_requests)
66
+ result = await db.execute(query)
67
+ usages = {u.key_index: u for u in result.scalars().all()}
68
+
69
+ # Find the key with least usage
70
+ min_requests = float('inf')
71
+ selected_index = 0
72
+
73
+ for i in range(len(keys)):
74
+ if i in usages:
75
+ if usages[i].total_requests < min_requests:
76
+ min_requests = usages[i].total_requests
77
+ selected_index = i
78
+ else:
79
+ # Key not in DB yet - create it and use it (0 requests)
80
+ new_usage = ApiKeyUsage(key_index=i, total_requests=0, success_count=0, failure_count=0)
81
+ db.add(new_usage)
82
+ await db.commit()
83
+ selected_index = i
84
+ break
85
+
86
+ logger.debug(f"Selected API key index {selected_index} (least used)")
87
+ return selected_index, keys[selected_index]
88
+
89
+
90
+ async def record_usage(db: AsyncSession, key_index: int, success: bool, error_message: Optional[str] = None):
91
+ """
92
+ Record API key usage after a request.
93
+
94
+ Args:
95
+ db: Database session
96
+ key_index: Index of the key used
97
+ success: Whether the request succeeded
98
+ error_message: Error message if request failed
99
+ """
100
+ from core.models import ApiKeyUsage
101
+
102
+ query = select(ApiKeyUsage).where(ApiKeyUsage.key_index == key_index)
103
+ result = await db.execute(query)
104
+ usage = result.scalar_one_or_none()
105
+
106
+ if not usage:
107
+ usage = ApiKeyUsage(key_index=key_index, total_requests=0, success_count=0, failure_count=0)
108
+ db.add(usage)
109
+
110
+ usage.total_requests += 1
111
+ if success:
112
+ usage.success_count += 1
113
+ else:
114
+ usage.failure_count += 1
115
+ if error_message:
116
+ usage.last_error = error_message[:1000] # Truncate to 1000 chars
117
+ usage.last_used_at = datetime.utcnow()
118
+
119
+ await db.commit()
120
+ logger.debug(f"Recorded {'success' if success else 'failure'} for key index {key_index}")
121
+
122
+
123
+ async def get_all_usage_stats(db: AsyncSession) -> List[dict]:
124
+ """
125
+ Get usage stats for all API keys.
126
+
127
+ Returns:
128
+ List of dicts with key_index, total_requests, success_count, failure_count
129
+ """
130
+ from core.models import ApiKeyUsage
131
+
132
+ keys = get_api_keys()
133
+ query = select(ApiKeyUsage).order_by(ApiKeyUsage.key_index)
134
+ result = await db.execute(query)
135
+ usages = {u.key_index: u for u in result.scalars().all()}
136
+
137
+ stats = []
138
+ for i in range(len(keys)):
139
+ if i in usages:
140
+ u = usages[i]
141
+ stats.append({
142
+ "key_index": i,
143
+ "total_requests": u.total_requests,
144
+ "success_count": u.success_count,
145
+ "failure_count": u.failure_count,
146
+ "last_error": u.last_error,
147
+ "last_used_at": u.last_used_at.isoformat() if u.last_used_at else None
148
+ })
149
+ else:
150
+ stats.append({
151
+ "key_index": i,
152
+ "total_requests": 0,
153
+ "success_count": 0,
154
+ "failure_count": 0,
155
+ "last_error": None,
156
+ "last_used_at": None
157
+ })
158
+
159
+ return stats
services/gemini_job_worker.py CHANGED
@@ -38,54 +38,72 @@ def get_priority_for_job_type(job_type: str) -> str:
38
 
39
 
40
  class GeminiJobProcessor(JobProcessor[GeminiJob]):
41
- """Processes Gemini AI jobs (text, image, video generation)."""
42
 
43
- def __init__(self, api_key: Optional[str] = None):
44
- """Initialize with optional API key (uses env var if not provided)."""
45
- self.api_key = api_key
 
 
46
 
47
- def _get_service(self) -> GeminiService:
48
- """Get a GeminiService instance."""
49
- return GeminiService(api_key=self.api_key)
 
50
 
51
  async def process(self, job: GeminiJob, session: AsyncSession) -> GeminiJob:
52
- """Start processing a new job."""
53
- service = self._get_service()
54
  input_data = job.input_data or {}
 
 
55
 
56
  try:
57
  if job.job_type == "video":
58
  job = await self._start_video(job, session, service, input_data)
 
59
  elif job.job_type == "image":
60
  job = await self._process_image(job, service, input_data)
 
61
  elif job.job_type == "text":
62
  job = await self._process_text(job, service, input_data)
 
63
  elif job.job_type == "analyze":
64
  job = await self._process_analyze(job, service, input_data)
 
65
  elif job.job_type == "animation_prompt":
66
  job = await self._process_animation_prompt(job, service, input_data)
 
67
  else:
68
  job.status = "failed"
69
  job.error_message = f"Unknown job type: {job.job_type}"
70
  job.completed_at = datetime.utcnow()
 
71
  except Exception as e:
72
  logger.error(f"Error processing job {job.job_id}: {e}")
73
  job.status = "failed"
74
  job.error_message = str(e)
75
  job.completed_at = datetime.utcnow()
 
 
 
 
 
76
 
77
  return job
78
 
79
  async def check_status(self, job: GeminiJob, session: AsyncSession) -> GeminiJob:
80
  """Check status of an in-progress job (video generation)."""
81
  if job.job_type != "video" or not job.third_party_id:
82
- # Non-video jobs or missing third_party_id - shouldn't happen
83
  job.status = "failed"
84
  job.error_message = "Invalid job state for status check"
85
  job.completed_at = datetime.utcnow()
86
  return job
87
 
88
- service = self._get_service()
 
 
 
89
 
90
  try:
91
  status_result = await service.check_video_status(job.third_party_id)
@@ -97,12 +115,15 @@ class GeminiJobProcessor(JobProcessor[GeminiJob]):
97
  filename = await service.download_video(video_url, job.job_id)
98
  job.status = "completed"
99
  job.output_data = {"filename": filename}
 
100
  else:
101
  job.status = "failed"
102
  job.error_message = "No video URL returned"
 
103
  else:
104
  job.status = "failed"
105
  job.error_message = status_result.get("error", "Unknown error")
 
106
 
107
  job.completed_at = datetime.utcnow()
108
  else:
@@ -111,7 +132,7 @@ class GeminiJobProcessor(JobProcessor[GeminiJob]):
111
  config = WorkerConfig.from_env()
112
  interval = get_interval_for_priority(job.priority, config)
113
  job.next_process_at = datetime.utcnow() + timedelta(seconds=interval)
114
- logger.debug(f"Job {job.job_id}: retry #{job.retry_count}, next check at {job.next_process_at}")
115
 
116
  except Exception as e:
117
  logger.error(f"Error checking video status for {job.job_id}: {e}")
@@ -119,6 +140,11 @@ class GeminiJobProcessor(JobProcessor[GeminiJob]):
119
  config = WorkerConfig.from_env()
120
  interval = get_interval_for_priority(job.priority, config)
121
  job.next_process_at = datetime.utcnow() + timedelta(seconds=interval)
 
 
 
 
 
122
 
123
  return job
124
 
 
38
 
39
 
40
  class GeminiJobProcessor(JobProcessor[GeminiJob]):
41
+ """Processes Gemini AI jobs (text, image, video generation) with round-robin API keys."""
42
 
43
+ async def _get_service_with_key(self, session: AsyncSession) -> tuple:
44
+ """Get a GeminiService with the least-used API key."""
45
+ from services.api_key_manager import get_least_used_key
46
+ key_index, api_key = await get_least_used_key(session)
47
+ return key_index, GeminiService(api_key=api_key)
48
 
49
+ async def _record_usage(self, session: AsyncSession, key_index: int, success: bool, error_message: Optional[str] = None):
50
+ """Record API key usage after request."""
51
+ from services.api_key_manager import record_usage
52
+ await record_usage(session, key_index, success, error_message)
53
 
54
  async def process(self, job: GeminiJob, session: AsyncSession) -> GeminiJob:
55
+ """Start processing a new job with round-robin API key."""
56
+ key_index, service = await self._get_service_with_key(session)
57
  input_data = job.input_data or {}
58
+ success = False
59
+ error_msg = None
60
 
61
  try:
62
  if job.job_type == "video":
63
  job = await self._start_video(job, session, service, input_data)
64
+ success = True
65
  elif job.job_type == "image":
66
  job = await self._process_image(job, service, input_data)
67
+ success = True
68
  elif job.job_type == "text":
69
  job = await self._process_text(job, service, input_data)
70
+ success = True
71
  elif job.job_type == "analyze":
72
  job = await self._process_analyze(job, service, input_data)
73
+ success = True
74
  elif job.job_type == "animation_prompt":
75
  job = await self._process_animation_prompt(job, service, input_data)
76
+ success = True
77
  else:
78
  job.status = "failed"
79
  job.error_message = f"Unknown job type: {job.job_type}"
80
  job.completed_at = datetime.utcnow()
81
+ error_msg = job.error_message
82
  except Exception as e:
83
  logger.error(f"Error processing job {job.job_id}: {e}")
84
  job.status = "failed"
85
  job.error_message = str(e)
86
  job.completed_at = datetime.utcnow()
87
+ success = False
88
+ error_msg = str(e)
89
+
90
+ # Record usage
91
+ await self._record_usage(session, key_index, success, error_msg)
92
 
93
  return job
94
 
95
  async def check_status(self, job: GeminiJob, session: AsyncSession) -> GeminiJob:
96
  """Check status of an in-progress job (video generation)."""
97
  if job.job_type != "video" or not job.third_party_id:
 
98
  job.status = "failed"
99
  job.error_message = "Invalid job state for status check"
100
  job.completed_at = datetime.utcnow()
101
  return job
102
 
103
+ # Use round-robin key for status check
104
+ key_index, service = await self._get_service_with_key(session)
105
+ success = False
106
+ error_msg = None
107
 
108
  try:
109
  status_result = await service.check_video_status(job.third_party_id)
 
115
  filename = await service.download_video(video_url, job.job_id)
116
  job.status = "completed"
117
  job.output_data = {"filename": filename}
118
+ success = True
119
  else:
120
  job.status = "failed"
121
  job.error_message = "No video URL returned"
122
+ error_msg = job.error_message
123
  else:
124
  job.status = "failed"
125
  job.error_message = status_result.get("error", "Unknown error")
126
+ error_msg = job.error_message
127
 
128
  job.completed_at = datetime.utcnow()
129
  else:
 
132
  config = WorkerConfig.from_env()
133
  interval = get_interval_for_priority(job.priority, config)
134
  job.next_process_at = datetime.utcnow() + timedelta(seconds=interval)
135
+ success = True # Status check succeeded even if video not ready
136
 
137
  except Exception as e:
138
  logger.error(f"Error checking video status for {job.job_id}: {e}")
 
140
  config = WorkerConfig.from_env()
141
  interval = get_interval_for_priority(job.priority, config)
142
  job.next_process_at = datetime.utcnow() + timedelta(seconds=interval)
143
+ success = False
144
+ error_msg = str(e)
145
+
146
+ # Record usage
147
+ await self._record_usage(session, key_index, success, error_msg)
148
 
149
  return job
150
 
services/gemini_service.py CHANGED
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
18
  MODELS = {
19
  "text_generation": "gemini-2.5-flash",
20
  "image_edit": "gemini-2.5-flash-image",
21
- "video_generation": "veo-3.1-fast-generate-preview"
22
  }
23
 
24
  # Type aliases
@@ -73,7 +73,7 @@ def get_gemini_api_key() -> str:
73
  """Get Gemini API key from environment."""
74
  api_key = os.getenv("GEMINI_API_KEY")
75
  if not api_key:
76
- raise ValueError("GEMINI_API_KEY environment variable not set")
77
  return api_key
78
 
79
 
 
18
  MODELS = {
19
  "text_generation": "gemini-2.5-flash",
20
  "image_edit": "gemini-2.5-flash-image",
21
+ "video_generation": "veo-3.1-generate-preview"
22
  }
23
 
24
  # Type aliases
 
73
  """Get Gemini API key from environment."""
74
  api_key = os.getenv("GEMINI_API_KEY")
75
  if not api_key:
76
+ raise ValueError("Server Authentication Error with GEMINI")
77
  return api_key
78
 
79
 
templates/index.html CHANGED
@@ -180,11 +180,30 @@
180
  font-weight: 600;
181
  }
182
 
183
- .status-success { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
184
- .status-failed { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
185
- .status-queued { background: rgba(234, 179, 8, 0.2); color: #eab308; }
186
- .status-processing { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
187
- .status-completed { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  .credits-badge {
190
  background: linear-gradient(135deg, #00d4ff, #7b2cbf);
@@ -280,6 +299,7 @@
280
  <button class="tab-btn" onclick="switchTab('users')">πŸ‘₯ Users</button>
281
  <button class="tab-btn" onclick="switchTab('audit')">πŸ“ Audit Logs</button>
282
  <button class="tab-btn" onclick="switchTab('jobs')">⚑ Gemini Jobs</button>
 
283
  </div>
284
 
285
  <div class="stats" id="statsContainer">
@@ -310,13 +330,20 @@
310
  </tr>
311
  </thead>
312
  <tbody id="blinkBody">
313
- <tr><td colspan="8"><div class="loading"><div class="spinner"></div>Loading...</div></td></tr>
 
 
 
 
 
 
314
  </tbody>
315
  </table>
316
  </div>
317
  <div class="pagination">
318
  <button onclick="prevPage('blink')" id="blinkPrev" disabled>← Previous</button>
319
- <span class="page-info">Page <span id="blinkCurrentPage">1</span> of <span id="blinkTotalPages">1</span></span>
 
320
  <button onclick="nextPage('blink')" id="blinkNext">Next β†’</button>
321
  </div>
322
  </div>
@@ -338,13 +365,20 @@
338
  </tr>
339
  </thead>
340
  <tbody id="usersBody">
341
- <tr><td colspan="8"><div class="loading"><div class="spinner"></div>Loading...</div></td></tr>
 
 
 
 
 
 
342
  </tbody>
343
  </table>
344
  </div>
345
  <div class="pagination">
346
  <button onclick="prevPage('users')" id="usersPrev" disabled>← Previous</button>
347
- <span class="page-info">Page <span id="usersCurrentPage">1</span> of <span id="usersTotalPages">1</span></span>
 
348
  <button onclick="nextPage('users')" id="usersNext">Next β†’</button>
349
  </div>
350
  </div>
@@ -365,13 +399,20 @@
365
  </tr>
366
  </thead>
367
  <tbody id="auditBody">
368
- <tr><td colspan="7"><div class="loading"><div class="spinner"></div>Loading...</div></td></tr>
 
 
 
 
 
 
369
  </tbody>
370
  </table>
371
  </div>
372
  <div class="pagination">
373
  <button onclick="prevPage('audit')" id="auditPrev" disabled>← Previous</button>
374
- <span class="page-info">Page <span id="auditCurrentPage">1</span> of <span id="auditTotalPages">1</span></span>
 
375
  <button onclick="nextPage('audit')" id="auditNext">Next β†’</button>
376
  </div>
377
  </div>
@@ -393,16 +434,54 @@
393
  </tr>
394
  </thead>
395
  <tbody id="jobsBody">
396
- <tr><td colspan="8"><div class="loading"><div class="spinner"></div>Loading...</div></td></tr>
 
 
 
 
 
 
397
  </tbody>
398
  </table>
399
  </div>
400
  <div class="pagination">
401
  <button onclick="prevPage('jobs')" id="jobsPrev" disabled>← Previous</button>
402
- <span class="page-info">Page <span id="jobsCurrentPage">1</span> of <span id="jobsTotalPages">1</span></span>
 
403
  <button onclick="nextPage('jobs')" id="jobsNext">Next β†’</button>
404
  </div>
405
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  </div>
407
 
408
  <script>
@@ -424,22 +503,27 @@
424
 
425
  function switchTab(tab) {
426
  currentTab = tab;
427
-
428
  // Update tab buttons
429
  document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
430
  event.target.classList.add('active');
431
-
432
  // Show/hide tables
433
  document.getElementById('blinkTable').classList.toggle('hidden', tab !== 'blink');
434
  document.getElementById('usersTable').classList.toggle('hidden', tab !== 'users');
435
  document.getElementById('auditTable').classList.toggle('hidden', tab !== 'audit');
436
  document.getElementById('jobsTable').classList.toggle('hidden', tab !== 'jobs');
437
-
 
438
  // Show/hide unique users stat (only for blink)
439
  document.getElementById('uniqueUsersCard').classList.toggle('hidden', tab !== 'blink');
440
-
441
- // Load data if not loaded
442
- loadPage(tab, state[tab].page);
 
 
 
 
443
  }
444
 
445
  async function fetchData(tab, page) {
@@ -573,6 +657,49 @@
573
  }
574
  }
575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  // Initial load
577
  loadPage('blink', 1);
578
  </script>
 
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);
 
299
  <button class="tab-btn" onclick="switchTab('users')">πŸ‘₯ Users</button>
300
  <button class="tab-btn" onclick="switchTab('audit')">πŸ“ Audit Logs</button>
301
  <button class="tab-btn" onclick="switchTab('jobs')">⚑ Gemini Jobs</button>
302
+ <button class="tab-btn" onclick="switchTab('keys')">πŸ”‘ API Keys</button>
303
  </div>
304
 
305
  <div class="stats" id="statsContainer">
 
330
  </tr>
331
  </thead>
332
  <tbody id="blinkBody">
333
+ <tr>
334
+ <td colspan="8">
335
+ <div class="loading">
336
+ <div class="spinner"></div>Loading...
337
+ </div>
338
+ </td>
339
+ </tr>
340
  </tbody>
341
  </table>
342
  </div>
343
  <div class="pagination">
344
  <button onclick="prevPage('blink')" id="blinkPrev" disabled>← Previous</button>
345
+ <span class="page-info">Page <span id="blinkCurrentPage">1</span> of <span
346
+ id="blinkTotalPages">1</span></span>
347
  <button onclick="nextPage('blink')" id="blinkNext">Next β†’</button>
348
  </div>
349
  </div>
 
365
  </tr>
366
  </thead>
367
  <tbody id="usersBody">
368
+ <tr>
369
+ <td colspan="8">
370
+ <div class="loading">
371
+ <div class="spinner"></div>Loading...
372
+ </div>
373
+ </td>
374
+ </tr>
375
  </tbody>
376
  </table>
377
  </div>
378
  <div class="pagination">
379
  <button onclick="prevPage('users')" id="usersPrev" disabled>← Previous</button>
380
+ <span class="page-info">Page <span id="usersCurrentPage">1</span> of <span
381
+ id="usersTotalPages">1</span></span>
382
  <button onclick="nextPage('users')" id="usersNext">Next β†’</button>
383
  </div>
384
  </div>
 
399
  </tr>
400
  </thead>
401
  <tbody id="auditBody">
402
+ <tr>
403
+ <td colspan="7">
404
+ <div class="loading">
405
+ <div class="spinner"></div>Loading...
406
+ </div>
407
+ </td>
408
+ </tr>
409
  </tbody>
410
  </table>
411
  </div>
412
  <div class="pagination">
413
  <button onclick="prevPage('audit')" id="auditPrev" disabled>← Previous</button>
414
+ <span class="page-info">Page <span id="auditCurrentPage">1</span> of <span
415
+ id="auditTotalPages">1</span></span>
416
  <button onclick="nextPage('audit')" id="auditNext">Next β†’</button>
417
  </div>
418
  </div>
 
434
  </tr>
435
  </thead>
436
  <tbody id="jobsBody">
437
+ <tr>
438
+ <td colspan="8">
439
+ <div class="loading">
440
+ <div class="spinner"></div>Loading...
441
+ </div>
442
+ </td>
443
+ </tr>
444
  </tbody>
445
  </table>
446
  </div>
447
  <div class="pagination">
448
  <button onclick="prevPage('jobs')" id="jobsPrev" disabled>← Previous</button>
449
+ <span class="page-info">Page <span id="jobsCurrentPage">1</span> of <span
450
+ id="jobsTotalPages">1</span></span>
451
  <button onclick="nextPage('jobs')" id="jobsNext">Next β†’</button>
452
  </div>
453
  </div>
454
+
455
+ <!-- API Keys Usage Table -->
456
+ <div class="table-container hidden" id="keysTable">
457
+ <div class="table-wrapper">
458
+ <table>
459
+ <thead>
460
+ <tr>
461
+ <th>Key Index</th>
462
+ <th>Total Requests</th>
463
+ <th>Success</th>
464
+ <th>Failed</th>
465
+ <th>Success Rate</th>
466
+ <th>Last Error</th>
467
+ <th>Last Used</th>
468
+ </tr>
469
+ </thead>
470
+ <tbody id="keysBody">
471
+ <tr>
472
+ <td colspan="7">
473
+ <div class="loading">
474
+ <div class="spinner"></div>Loading...
475
+ </div>
476
+ </td>
477
+ </tr>
478
+ </tbody>
479
+ </table>
480
+ </div>
481
+ <div class="pagination">
482
+ <span class="page-info">Auto-refreshes every 10 seconds</span>
483
+ </div>
484
+ </div>
485
  </div>
486
 
487
  <script>
 
503
 
504
  function switchTab(tab) {
505
  currentTab = tab;
506
+
507
  // Update tab buttons
508
  document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
509
  event.target.classList.add('active');
510
+
511
  // Show/hide tables
512
  document.getElementById('blinkTable').classList.toggle('hidden', tab !== 'blink');
513
  document.getElementById('usersTable').classList.toggle('hidden', tab !== 'users');
514
  document.getElementById('auditTable').classList.toggle('hidden', tab !== 'audit');
515
  document.getElementById('jobsTable').classList.toggle('hidden', tab !== 'jobs');
516
+ document.getElementById('keysTable').classList.toggle('hidden', tab !== 'keys');
517
+
518
  // Show/hide unique users stat (only for blink)
519
  document.getElementById('uniqueUsersCard').classList.toggle('hidden', tab !== 'blink');
520
+
521
+ // Load data
522
+ if (tab === 'keys') {
523
+ loadKeyStats();
524
+ } else {
525
+ loadPage(tab, state[tab].page);
526
+ }
527
  }
528
 
529
  async function fetchData(tab, page) {
 
657
  }
658
  }
659
 
660
+ // Load and render API key stats
661
+ async function loadKeyStats() {
662
+ try {
663
+ const response = await fetch('/api/key-stats');
664
+ const data = await response.json();
665
+ renderKeysTable(data.keys);
666
+ document.getElementById('totalRecords').textContent = data.total_keys;
667
+ } catch (error) {
668
+ console.error('Error fetching key stats:', error);
669
+ }
670
+ }
671
+
672
+ function renderKeysTable(items) {
673
+ const tbody = document.getElementById('keysBody');
674
+ if (!items || items.length === 0) {
675
+ tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">No API keys configured</div></td></tr>';
676
+ return;
677
+ }
678
+ tbody.innerHTML = items.map(item => {
679
+ const successRate = item.total_requests > 0
680
+ ? ((item.success_count / item.total_requests) * 100).toFixed(1)
681
+ : '0.0';
682
+ return `
683
+ <tr>
684
+ <td><span class="credits-badge">Key ${item.key_index}</span></td>
685
+ <td>${item.total_requests.toLocaleString()}</td>
686
+ <td><span class="status-badge status-success">${item.success_count}</span></td>
687
+ <td><span class="status-badge status-failed">${item.failure_count}</span></td>
688
+ <td>${successRate}%</td>
689
+ <td class="truncate" title="${item.last_error || ''}">${item.last_error || '-'}</td>
690
+ <td class="timestamp">${item.last_used_at ? new Date(item.last_used_at).toLocaleString() : 'Never'}</td>
691
+ </tr>
692
+ `;
693
+ }).join('');
694
+ }
695
+
696
+ // Auto-refresh key stats every 10 seconds when on keys tab
697
+ setInterval(() => {
698
+ if (currentTab === 'keys') {
699
+ loadKeyStats();
700
+ }
701
+ }, 10000);
702
+
703
  // Initial load
704
  loadPage('blink', 1);
705
  </script>