rnilkyway commited on
Commit
18529e8
Β·
1 Parent(s): 9c2366f

feat(admin): add IP-bound API key manager + gpt-image-2 endpoint

Browse files

- Add 3 admin endpoints (GET/POST/DELETE /admin/keys) for IP-bound key management
- Add /v1/images/generations OpenAI-compatible endpoint routing to ChatGPT Image API (http://89.47.113.13:3999)
- Rewrite dashboard.html with landing design system (Tailwind + glass-morphism + amber accent)
- Add gpt-image-1 and gpt-image-2 to model registry
- Client-side auth gate using sessionStorage for master key

Design reference: thoughts/shared/designs/2026-05-24-admin-ip-key-manager-design.md

Files changed (3) hide show
  1. app/config.py +3 -0
  2. app/index.py +161 -10
  3. app/templates/dashboard.html +485 -209
app/config.py CHANGED
@@ -43,6 +43,9 @@ LYUX_KEY = _decrypt("enc:v1:3H8QYE-ACN_QYJJQ:C7RBtCTfnDDs6-4B44U3IVsCLpLjo8ZsObA
43
  QWEN_LOCAL_URL = _decrypt("enc:v1:xLLUx4V8amqNS-Nv:Jzbs3Uhu4lAkRcEp4sB4X7rBnwcO1sp8wGEbVRhFECoxJ9HBQdxZ1mBx5sICnxM7Pric4r3xUelqs82k")
44
  QWEN_LOCAL_KEY = _decrypt("enc:v1:I5MWk2kg-lfUAMJZ:L74MW5GaciwIIsV0gWDbfc0vZyBm55xNVnDutl8=")
45
 
 
 
 
46
  UPSTREAMS = {
47
  # ── Existing aliases ──
48
  "claude-opus-4.6": [
 
43
  QWEN_LOCAL_URL = _decrypt("enc:v1:xLLUx4V8amqNS-Nv:Jzbs3Uhu4lAkRcEp4sB4X7rBnwcO1sp8wGEbVRhFECoxJ9HBQdxZ1mBx5sICnxM7Pric4r3xUelqs82k")
44
  QWEN_LOCAL_KEY = _decrypt("enc:v1:I5MWk2kg-lfUAMJZ:L74MW5GaciwIIsV0gWDbfc0vZyBm55xNVnDutl8=")
45
 
46
+ # ── ChatGPT Image Generation API ─────────────────────────────────────────────
47
+ CHATGPT_IMAGE_API_URL = "http://89.47.113.13:3999"
48
+
49
  UPSTREAMS = {
50
  # ── Existing aliases ──
51
  "claude-opus-4.6": [
app/index.py CHANGED
@@ -24,7 +24,10 @@ from config import (
24
  MASTER_KEY,
25
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
26
  BLOCKED_IPS,
 
27
  )
 
 
28
  from templates import templates
29
 
30
  # Free VPN detection: proxycheck.io (1000 req/day, no key needed)
@@ -383,8 +386,8 @@ async def _fetch_upstream_models():
383
  _upstream_models[url] = []
384
  await client.aclose()
385
 
386
- # Build available models from UPSTREAMS keys
387
- _available_models = list(UPSTREAMS.keys())
388
  logger.info("Available models: %s", _available_models)
389
 
390
 
@@ -824,14 +827,8 @@ async def list_models():
824
 
825
 
826
  @app.get("/v1/dashboard")
827
- async def dashboard_usage(request: Request, password: str = None):
828
- """Usage dashboard with charts and API keys."""
829
- # Check password
830
- if password != MASTER_KEY:
831
- raise HTTPException(
832
- status_code=401,
833
- detail={"error": {"message": "Unauthorized", "type": "auth_error", "code": "invalid_password"}}
834
- )
835
 
836
  # Load chat logs from HF Dataset
837
  chat_logs = []
@@ -946,6 +943,160 @@ async def upstream_models():
946
  }
947
 
948
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
949
  @app.get("/v1/keys/me")
950
  async def get_my_key(request: Request, cf_token: str = None):
951
  """Return caller's IP and auto-generate a key if they don't have one."""
 
24
  MASTER_KEY,
25
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
26
  BLOCKED_IPS,
27
+ CHATGPT_IMAGE_API_URL,
28
  )
29
+ from pydantic import BaseModel
30
+ import ipaddress
31
  from templates import templates
32
 
33
  # Free VPN detection: proxycheck.io (1000 req/day, no key needed)
 
386
  _upstream_models[url] = []
387
  await client.aclose()
388
 
389
+ # Build available models from UPSTREAMS keys + image models
390
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
391
  logger.info("Available models: %s", _available_models)
392
 
393
 
 
827
 
828
 
829
  @app.get("/v1/dashboard")
830
+ async def dashboard_usage(request: Request):
831
+ """Usage dashboard with charts and API keys. Auth handled client-side via /admin/keys."""
 
 
 
 
 
 
832
 
833
  # Load chat logs from HF Dataset
834
  chat_logs = []
 
943
  }
944
 
945
 
946
+ # ── Admin Endpoints ───────────────────────────────────────────────────────────
947
+
948
+ class AdminKeyRequest(BaseModel):
949
+ ip: str
950
+ label: str = ""
951
+
952
+ @app.get("/admin/keys")
953
+ async def admin_list_keys(request: Request):
954
+ """List all admin-generated API keys. Requires MASTER_KEY auth."""
955
+ auth = request.headers.get("authorization", "")
956
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
957
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
958
+
959
+ keys = load_keys_from_gist()
960
+ admin_keys = []
961
+ for key, data in keys.items():
962
+ if data.get("admin_created"):
963
+ admin_keys.append({
964
+ "key": key,
965
+ "ip": data.get("ip", ""),
966
+ "label": data.get("label", ""),
967
+ "created_at": data.get("created_at", ""),
968
+ "status": data.get("status", "active"),
969
+ })
970
+ return {"keys": admin_keys}
971
+
972
+ @app.post("/admin/keys")
973
+ async def admin_create_key(request: Request, body: AdminKeyRequest):
974
+ """Create a new IP-bound API key. Requires MASTER_KEY auth."""
975
+ auth = request.headers.get("authorization", "")
976
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
977
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
978
+
979
+ # Validate IP
980
+ try:
981
+ ip_obj = ipaddress.ip_address(body.ip)
982
+ if "/" in body.ip:
983
+ raise HTTPException(status_code=400, detail={"error": {"message": "CIDR notation not allowed"}})
984
+ except ValueError:
985
+ raise HTTPException(status_code=400, detail={"error": {"message": "Invalid IP address format"}})
986
+
987
+ # Check duplicate
988
+ keys = load_keys_from_gist()
989
+ for k, data in keys.items():
990
+ if data.get("ip") == body.ip and data.get("admin_created"):
991
+ raise HTTPException(status_code=409, detail={"error": {"message": f"IP {body.ip} already has key {k}"}})
992
+
993
+ # Generate new key
994
+ new_key = generate_api_key()
995
+ keys[new_key] = {
996
+ "ip": body.ip,
997
+ "label": body.label,
998
+ "created_at": int(time.time()),
999
+ "status": "active",
1000
+ "admin_created": True,
1001
+ }
1002
+ save_keys_to_gist(keys)
1003
+
1004
+ return {
1005
+ "key": new_key,
1006
+ "ip": body.ip,
1007
+ "label": body.label,
1008
+ "created_at": keys[new_key]["created_at"],
1009
+ }
1010
+
1011
+ @app.delete("/admin/keys/{key_id}")
1012
+ async def admin_revoke_key(request: Request, key_id: str):
1013
+ """Revoke an admin-created API key. Requires MASTER_KEY auth."""
1014
+ auth = request.headers.get("authorization", "")
1015
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1016
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1017
+
1018
+ keys = load_keys_from_gist()
1019
+ if key_id not in keys:
1020
+ raise HTTPException(status_code=404, detail={"error": {"message": "Key not found"}})
1021
+ if not keys[key_id].get("admin_created"):
1022
+ raise HTTPException(status_code=403, detail={"error": {"message": "Cannot revoke non-admin keys"}})
1023
+
1024
+ del keys[key_id]
1025
+ save_keys_to_gist(keys)
1026
+ return {"success": True}
1027
+
1028
+ # ── Image Generation Endpoint ─────────────────────────────────────────────────
1029
+
1030
+ @app.post("/v1/images/generations")
1031
+ async def generate_images(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
1032
+ """OpenAI-compatible image generation endpoint. Routes to gpt-image-2 backend."""
1033
+ api_key = credentials.credentials
1034
+ client_ip = get_client_ip(request)
1035
+
1036
+ # Auth check
1037
+ valid, msg = verify_key_ip(api_key, client_ip)
1038
+ if not valid:
1039
+ raise HTTPException(status_code=401, detail={"error": {"message": msg}})
1040
+
1041
+ rl_ok, rl_detail = check_rate_limit(api_key)
1042
+ if not rl_ok:
1043
+ cooldown = RPM_WINDOW - (time.time() - _rate_limits[api_key][0])
1044
+ raise HTTPException(
1045
+ status_code=429,
1046
+ detail={
1047
+ "error": {"message": rl_detail, "type": "rate_limit_error", "code": "rate_limit_exceeded"},
1048
+ "content": f"Rate limited, wait {int(cooldown)}s",
1049
+ }
1050
+ )
1051
+
1052
+ body = await request.json()
1053
+ model = body.get("model", "gpt-image-1")
1054
+ prompt = body.get("prompt")
1055
+ n = body.get("n", 1)
1056
+ size = body.get("size", "1024x1024")
1057
+ response_format = body.get("response_format", "url")
1058
+
1059
+ if not prompt:
1060
+ raise HTTPException(status_code=400, detail={"error": {"message": "prompt is required"}})
1061
+
1062
+ # Only support gpt-image-2 for now
1063
+ if model not in ["gpt-image-1", "gpt-image-2"]:
1064
+ raise HTTPException(status_code=400, detail={"error": {"message": f"Model {model} not supported"}})
1065
+
1066
+ logger.info("Image generation request: model=%s prompt=%s n=%d size=%s", model, prompt[:50], n, size)
1067
+
1068
+ # Forward to ChatGPT Image API
1069
+ try:
1070
+ async with httpx.AsyncClient(timeout=60.0) as client:
1071
+ resp = await client.post(
1072
+ f"{CHATGPT_IMAGE_API_URL}/api/generate",
1073
+ json={"prompt": prompt, "n": n, "size": size},
1074
+ headers={"Content-Type": "application/json"},
1075
+ )
1076
+ resp.raise_for_status()
1077
+ upstream_data = resp.json()
1078
+ except Exception as e:
1079
+ logger.error("Image API error: %s", str(e))
1080
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Upstream image API error: {str(e)}"}})
1081
+
1082
+ # Transform to OpenAI format
1083
+ images = []
1084
+ if "images" in upstream_data:
1085
+ for img in upstream_data["images"][:n]:
1086
+ if response_format == "url":
1087
+ images.append({"url": img.get("url", ""), "revised_prompt": img.get("revised_prompt", prompt)})
1088
+ else:
1089
+ images.append({"b64_json": img.get("b64_json", ""), "revised_prompt": img.get("revised_prompt", prompt)})
1090
+ elif "url" in upstream_data:
1091
+ images.append({"url": upstream_data["url"], "revised_prompt": upstream_data.get("revised_prompt", prompt)})
1092
+
1093
+ return {
1094
+ "created": int(time.time()),
1095
+ "data": images,
1096
+ }
1097
+
1098
+ # ── Public Endpoints ──────────────────────────────────────────────────────────
1099
+
1100
  @app.get("/v1/keys/me")
1101
  async def get_my_key(request: Request, cf_token: str = None):
1102
  """Return caller's IP and auto-generate a key if they don't have one."""
app/templates/dashboard.html CHANGED
@@ -1,225 +1,501 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>🐝 APIarium Dashboard</title>
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
7
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
8
- <style>
9
- :root {
10
- --bg-primary: #0a0a0f;
11
- --bg-secondary: #111827;
12
- --bg-tertiary: #1f2937;
13
- --border: #374151;
14
- --text-primary: #f9fafb;
15
- --text-secondary: #9ca3af;
16
- --text-muted: #6b7280;
17
- --accent-green: #fff;
18
- --accent-blue: #ccc;
19
- --accent-purple: #999;
20
- --glass-bg: rgba(17, 24, 39, 0.8);
21
- --glass-border: rgba(255, 255, 255, 0.1);
 
 
 
 
 
 
 
22
  }
23
- * { margin: 0; padding: 0; box-sizing: border-box; }
 
24
  body {
25
- background: var(--bg-primary);
26
- color: var(--text-primary);
27
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
28
- min-height: 100vh;
29
- padding: 40px 24px;
30
- }
31
- .bg-gradient {
32
- display: none;
33
- }
34
- .container { max-width: 1200px; margin: 0 auto; position: relative; z-index: 1; }
35
- .header {
36
- display: flex; align-items: center; justify-content: space-between;
37
- margin-bottom: 40px; animation: fadeInUp 0.6s ease-out;
38
- }
39
- @keyframes fadeInUp {
40
- from { opacity: 0; transform: translateY(20px); }
41
- to { opacity: 1; transform: translateY(0); }
42
- }
43
- .header h1 {
44
- font-size: 2rem; font-weight: 800;
45
- background: linear-gradient(135deg, #fff, #ccc);
46
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
47
- display: flex; align-items: center; gap: 12px;
48
- }
49
- .header h1 span { font-size: 2.5rem; }
50
- .back-btn {
51
- padding: 10px 20px; background: var(--glass-bg); border: 1px solid var(--glass-border);
52
- border-radius: 10px; color: var(--text-primary); text-decoration: none;
53
- font-size: 0.9rem; font-weight: 500; transition: all 0.2s;
54
- }
55
- .back-btn:hover { border-color: #fff; transform: translateY(-2px); }
56
- /* Stats */
57
- .stats-grid {
58
- display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
59
- margin-bottom: 32px; animation: fadeInUp 0.6s ease-out 0.1s backwards;
60
- }
61
- .stat-card {
62
- background: var(--glass-bg); border: 1px solid var(--glass-border);
63
- border-radius: 16px; padding: 24px; text-align: center;
64
- backdrop-filter: blur(20px); transition: all 0.3s ease;
65
- }
66
- .stat-card:hover { border-color: #fff; transform: translateY(-4px); }
67
- .stat-val {
68
- font-size: 2.5rem; font-weight: 900;
69
- background: linear-gradient(135deg, #fff, #ccc);
70
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
71
- line-height: 1; margin-bottom: 8px;
72
  }
73
- .stat-label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; }
74
- /* Charts */
75
- .chart-grid {
76
- display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
77
- margin-bottom: 32px; animation: fadeInUp 0.6s ease-out 0.2s backwards;
78
  }
79
- .glass-card {
80
- background: var(--glass-bg); border: 1px solid var(--glass-border);
81
- border-radius: 20px; padding: 28px; backdrop-filter: blur(20px);
82
- transition: all 0.3s ease;
83
  }
84
- .glass-card:hover { border-color: rgba(255, 255, 255, 0.2); }
85
- .card-header {
86
- display: flex; align-items: center; gap: 12px; margin-bottom: 20px;
 
 
87
  }
88
- .card-icon {
89
- width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
90
- background: linear-gradient(135deg, #fff, #ccc);
91
- border-radius: 10px; font-size: 1.1rem;
92
- }
93
- .card-title { font-size: 1.1rem; font-weight: 700; }
94
- .chart-container { position: relative; height: 280px; }
95
- /* Table */
96
- .table-card {
97
- animation: fadeInUp 0.6s ease-out 0.3s backwards;
98
- }
99
- .table-wrapper { overflow-x: auto; max-height: 500px; overflow-y: auto; }
100
- table { width: 100%; border-collapse: collapse; }
101
- th, td { padding: 14px 18px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
102
- th {
103
- color: var(--text-muted); font-size: 0.75rem; font-weight: 600;
104
- text-transform: uppercase; letter-spacing: 1px;
105
- background: var(--bg-primary); position: sticky; top: 0; z-index: 1;
106
- }
107
- tr { transition: background 0.2s; }
108
- tr:hover { background: rgba(255, 255, 255, 0.03); }
109
- @media (max-width: 768px) {
110
- .stats-grid { grid-template-columns: repeat(2, 1fr); }
111
- .chart-grid { grid-template-columns: 1fr; }
112
- .header { flex-direction: column; gap: 16px; }
113
- }
114
- </style>
115
- </head>
116
- <body>
117
- <div class="bg-gradient"></div>
118
- <div class="container">
119
- <div class="header">
120
- <h1>🐝 APIarium Dashboard</h1>
121
- <a href="/" class="back-btn">← Home</a>
122
  </div>
123
- <div class="stats-grid">
124
- <div class="stat-card">
125
- <div class="stat-val">TOTAL_REQUESTS</div>
126
- <div class="stat-label">Total Requests</div>
127
- </div>
128
- <div class="stat-card">
129
- <div class="stat-val">TOTAL_IPS</div>
130
- <div class="stat-label">Unique IPs</div>
131
- </div>
132
- <div class="stat-card">
133
- <div class="stat-val">TOTAL_MODELS</div>
134
- <div class="stat-label">Models Used</div>
135
- </div>
136
- <div class="stat-card">
137
- <div class="stat-val">TOTAL_KEYS</div>
138
- <div class="stat-label">API Keys</div>
139
- </div>
140
- <div class="stat-card" style="border-color:rgba(251,191,36,0.3);">
141
- <div class="stat-val" style="background:linear-gradient(135deg,#fbbf24,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">GOD_IPS</div>
142
- <div class="stat-label">πŸ‘‘ God Access IPs</div>
143
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  </div>
145
- <div class="chart-grid">
146
- <div class="glass-card">
147
- <div class="card-header">
148
- <div class="card-icon">πŸ₯§</div>
149
- <div class="card-title">Model Usage</div>
 
 
 
 
 
150
  </div>
151
- <div class="chart-container">
152
- <canvas id="modelPieChart"></canvas>
 
 
 
 
 
153
  </div>
154
- </div>
155
- <div class="glass-card">
156
- <div class="card-header">
157
- <div class="card-icon">πŸ“Š</div>
158
- <div class="card-title">IP Usage</div>
 
 
159
  </div>
160
- <div class="chart-container">
161
- <canvas id="ipBarChart"></canvas>
 
 
 
 
 
162
  </div>
163
- </div>
164
- </div>
165
- <div class="glass-card table-card">
166
- <div class="card-header">
167
- <div class="card-icon">πŸ“‹</div>
168
- <div class="card-title">IP Details</div>
169
- </div>
170
- <div class="table-wrapper">
171
- <table>
172
- <thead><tr><th>IP Address</th><th>API Keys</th><th>Total Requests</th><th>Model Breakdown</th></tr></thead>
173
- <tbody>IP_ROWS</tbody>
174
- </table>
175
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  </div>
177
- </div>
178
- <script>
179
- const colors = ['#fff', '#ccc', '#999', '#aaa', '#888', '#ffd93d', '#38bdf8', '#a3e635', '#f472b6', '#67e8f9'];
180
- new Chart(document.getElementById('modelPieChart'), {
181
- type: 'doughnut',
182
- data: {
183
- labels: MODEL_LABELS,
184
- datasets: [{
185
- data: MODEL_TOTALS,
186
- backgroundColor: colors.slice(0, MODEL_LABELS.length),
187
- borderWidth: 0,
188
- hoverBorderWidth: 4,
189
- hoverBorderColor: '#0a0a0f',
190
- }]
191
- },
192
- options: {
193
- responsive: true,
194
- maintainAspectRatio: false,
195
- cutout: '65%',
196
- plugins: {
197
- legend: { position: 'bottom', labels: { color: '#9ca3af', padding: 16, font: { size: 11, family: 'Inter' } } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
- }
200
- });
201
- new Chart(document.getElementById('ipBarChart'), {
202
- type: 'bar',
203
- data: {
204
- labels: IP_LABELS,
205
- datasets: [{
206
- label: 'Requests',
207
- data: IP_TOTALS,
208
- backgroundColor: colors.slice(0, IP_LABELS.length),
209
- borderRadius: 8,
210
- borderSkipped: false,
211
- }]
212
- },
213
- options: {
214
- responsive: true,
215
- maintainAspectRatio: false,
216
- plugins: { legend: { display: false } },
217
- scales: {
218
- y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#6b7280' } },
219
- x: { grid: { display: false }, ticks: { color: '#c9d1d9', font: { size: 10 } } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  }
221
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  });
223
- </script>
224
- </body>
225
- </html>
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="antialiased">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>APIarium | Admin Dashboard</title>
7
+ <meta name="description" content="APIarium admin dashboard β€” manage IP-bound API keys">
8
+
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
12
+
13
+ <script src="https://cdn.tailwindcss.com"></script>
14
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
15
+ <script>
16
+ tailwind.config = {
17
+ theme: {
18
+ extend: {
19
+ fontFamily: {
20
+ sans: ['Inter', 'sans-serif'],
21
+ mono: ['JetBrains Mono', 'monospace'],
22
+ },
23
+ colors: {
24
+ primary: { 50: '#fffbeb', 500: '#f59e0b', 600: '#d97706', 700: '#b45309' },
25
+ dark: { 900: '#0a0a0f', 800: '#111118', 700: '#1a1a24' },
26
+ }
27
+ }
28
+ }
29
  }
30
+ </script>
31
+ <style>
32
  body {
33
+ background: #0a0a0f;
34
+ background-image:
35
+ radial-gradient(at 20% 10%, rgba(245, 158, 11, 0.08) 0px, transparent 50%),
36
+ radial-gradient(at 80% 80%, rgba(59, 130, 246, 0.06) 0px, transparent 50%);
37
+ min-height: 100vh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
+ .glass {
40
+ background: rgba(255, 255, 255, 0.04);
41
+ backdrop-filter: blur(24px);
42
+ -webkit-backdrop-filter: blur(24px);
43
+ border: 1px solid rgba(255, 255, 255, 0.08);
44
  }
45
+ .glass-hover:hover {
46
+ background: rgba(255, 255, 255, 0.06);
47
+ border-color: rgba(245, 158, 11, 0.3);
 
48
  }
49
+ .toast {
50
+ position: fixed; bottom: 24px; right: 24px;
51
+ padding: 12px 20px; border-radius: 8px;
52
+ font-family: 'JetBrains Mono', monospace; font-size: 13px;
53
+ z-index: 9999; animation: slideIn 0.3s ease-out;
54
  }
55
+ @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
56
+ .toast-success { background: rgba(245, 158, 11, 0.15); border: 1px solid rgba(245, 158, 11, 0.4); color: #fbbf24; }
57
+ .toast-error { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.4); color: #f87171; }
58
+ </style>
59
+ </head>
60
+ <body class="font-sans text-slate-100">
61
+
62
+ <!-- Auth Gate -->
63
+ <div id="auth-gate" class="hidden min-h-screen flex items-center justify-center px-6">
64
+ <div class="glass rounded-2xl p-8 max-w-md w-full">
65
+ <div class="flex items-center gap-3 mb-6">
66
+ <div class="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-xl">🐝</div>
67
+ <div>
68
+ <h1 class="text-xl font-bold">APIarium Admin</h1>
69
+ <p class="text-xs text-slate-400 font-mono">v0.4.0</p>
70
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </div>
72
+ <label class="block text-sm text-slate-300 mb-2">Master Key</label>
73
+ <input id="master-key-input" type="password" placeholder="Enter master key..."
74
+ class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-slate-100 font-mono text-sm focus:outline-none focus:border-amber-500/50 mb-4">
75
+ <button id="auth-btn" class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-semibold py-3 rounded-lg transition-all">
76
+ Unlock Dashboard
77
+ </button>
78
+ <p id="auth-error" class="text-red-400 text-xs mt-3 hidden"></p>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Dashboard -->
83
+ <div id="dashboard" class="hidden">
84
+
85
+ <!-- Navbar -->
86
+ <header class="sticky top-0 z-40 glass border-b border-white/5">
87
+ <div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
88
+ <div class="flex items-center gap-3">
89
+ <div class="w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-lg">🐝</div>
90
+ <div>
91
+ <h1 class="font-bold text-lg">APIarium</h1>
92
+ <p class="text-xs text-slate-400 font-mono">admin console</p>
93
+ </div>
94
+ </div>
95
+ <nav class="hidden md:flex items-center gap-6 text-sm">
96
+ <a href="#stats" class="text-amber-400 font-medium">Dashboard</a>
97
+ <a href="#generator" class="text-slate-400 hover:text-slate-200 transition">Keys</a>
98
+ <a href="#charts" class="text-slate-400 hover:text-slate-200 transition">Analytics</a>
99
+ <button id="logout-btn" class="text-slate-500 hover:text-red-400 transition text-xs font-mono">logout</button>
100
+ </nav>
101
+ </div>
102
+ </header>
103
+
104
+ <main class="max-w-7xl mx-auto px-6 py-12 space-y-12">
105
+
106
+ <!-- Page Header -->
107
+ <div>
108
+ <p class="text-xs font-mono text-amber-500 mb-2 tracking-wider">ADMIN / API-KEY-MANAGER</p>
109
+ <h2 class="text-4xl font-bold tracking-tight">Dashboard</h2>
110
+ <p class="text-slate-400 mt-2">Manage IP-bound API keys and monitor gateway activity.</p>
111
  </div>
112
+
113
+ <!-- Stats Grid -->
114
+ <section id="stats" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
115
+ <div class="glass glass-hover rounded-xl p-6 transition-all">
116
+ <div class="flex items-center justify-between mb-3">
117
+ <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
118
+ <span class="text-xs font-mono text-slate-500">TOTAL</span>
119
+ </div>
120
+ <div class="text-3xl font-bold font-mono" id="stat-total">0</div>
121
+ <p class="text-xs text-slate-400 mt-1">API Keys</p>
122
  </div>
123
+ <div class="glass glass-hover rounded-xl p-6 transition-all">
124
+ <div class="flex items-center justify-between mb-3">
125
+ <svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
126
+ <span class="text-xs font-mono text-slate-500">ACTIVE</span>
127
+ </div>
128
+ <div class="text-3xl font-bold font-mono" id="stat-active">0</div>
129
+ <p class="text-xs text-slate-400 mt-1">Unique IPs</p>
130
  </div>
131
+ <div class="glass glass-hover rounded-xl p-6 transition-all">
132
+ <div class="flex items-center justify-between mb-3">
133
+ <svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
134
+ <span class="text-xs font-mono text-slate-500">24H</span>
135
+ </div>
136
+ <div class="text-3xl font-bold font-mono" id="stat-requests">β€”</div>
137
+ <p class="text-xs text-slate-400 mt-1">Requests</p>
138
  </div>
139
+ <div class="glass glass-hover rounded-xl p-6 transition-all">
140
+ <div class="flex items-center justify-between mb-3">
141
+ <svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
142
+ <span class="text-xs font-mono text-slate-500">RATE</span>
143
+ </div>
144
+ <div class="text-3xl font-bold font-mono" id="stat-ratelimited">β€”</div>
145
+ <p class="text-xs text-slate-400 mt-1">Rate Limited</p>
146
  </div>
147
+ </section>
148
+
149
+ <!-- Key Generator -->
150
+ <section id="generator" class="glass rounded-2xl p-8">
151
+ <div class="flex items-center justify-between mb-6">
152
+ <div>
153
+ <h3 class="text-xl font-bold">Generate API Key</h3>
154
+ <p class="text-sm text-slate-400 mt-1">Create an IP-bound key for a specific client.</p>
155
+ </div>
156
+ <span class="text-xs font-mono text-amber-500 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">ADMIN ONLY</span>
157
+ </div>
158
+ <form id="key-form" class="grid md:grid-cols-12 gap-4">
159
+ <div class="md:col-span-5">
160
+ <label class="block text-xs font-mono text-slate-400 mb-2 uppercase tracking-wider">IP Address *</label>
161
+ <input id="ip-input" type="text" placeholder="192.168.1.100" required
162
+ class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 font-mono text-sm focus:outline-none focus:border-amber-500/50 placeholder:text-slate-600">
163
+ <p id="ip-error" class="text-red-400 text-xs mt-1 hidden"></p>
164
+ </div>
165
+ <div class="md:col-span-5">
166
+ <label class="block text-xs font-mono text-slate-400 mb-2 uppercase tracking-wider">Label (optional)</label>
167
+ <input id="label-input" type="text" placeholder="Customer name / project"
168
+ class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-amber-500/50 placeholder:text-slate-600">
169
+ </div>
170
+ <div class="md:col-span-2 flex items-end">
171
+ <button type="submit" id="gen-btn"
172
+ class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-semibold py-3 rounded-lg transition-all text-sm">
173
+ Generate
174
+ </button>
175
+ </div>
176
+ </form>
177
+
178
+ <!-- Result Card -->
179
+ <div id="result-card" class="hidden mt-6 bg-gradient-to-br from-amber-500/10 to-amber-600/5 border border-amber-500/30 rounded-xl p-6">
180
+ <p class="text-xs font-mono text-amber-400 mb-2 tracking-wider">NEW KEY GENERATED</p>
181
+ <div class="flex items-center justify-between gap-4 mb-3">
182
+ <code id="result-key" class="text-2xl font-mono font-bold text-amber-300 break-all"></code>
183
+ <button id="copy-key-btn" class="shrink-0 bg-white/10 hover:bg-white/20 border border-white/10 px-4 py-2 rounded-lg text-xs font-mono transition">
184
+ COPY
185
+ </button>
186
+ </div>
187
+ <div class="flex flex-wrap gap-4 text-xs font-mono text-slate-400">
188
+ <span>IP: <span id="result-ip" class="text-slate-200"></span></span>
189
+ <span>Label: <span id="result-label" class="text-slate-200">β€”</span></span>
190
+ </div>
191
+ </div>
192
+ </section>
193
+
194
+ <!-- Keys Table -->
195
+ <section class="glass rounded-2xl p-8">
196
+ <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
197
+ <div>
198
+ <h3 class="text-xl font-bold">API Keys</h3>
199
+ <p class="text-sm text-slate-400 mt-1">All admin-created keys with IP binding.</p>
200
+ </div>
201
+ <input id="search-input" type="text" placeholder="Filter by IP or label..."
202
+ class="bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-amber-500/50 md:w-64 placeholder:text-slate-600">
203
+ </div>
204
+
205
+ <div class="overflow-x-auto">
206
+ <table class="w-full text-sm">
207
+ <thead>
208
+ <tr class="text-left text-xs font-mono text-slate-500 uppercase tracking-wider border-b border-white/5">
209
+ <th class="pb-3 pr-4">Key</th>
210
+ <th class="pb-3 pr-4">IP</th>
211
+ <th class="pb-3 pr-4">Label</th>
212
+ <th class="pb-3 pr-4">Created</th>
213
+ <th class="pb-3 pr-4">Status</th>
214
+ <th class="pb-3">Actions</th>
215
+ </tr>
216
+ </thead>
217
+ <tbody id="keys-tbody" class="divide-y divide-white/5"></tbody>
218
+ </table>
219
+ </div>
220
+
221
+ <div id="empty-state" class="hidden text-center py-12">
222
+ <p class="text-slate-500 text-sm font-mono">No keys yet β€” generate your first one above</p>
223
+ </div>
224
+ </section>
225
+
226
+ <!-- Chart.js Analytics -->
227
+ <section id="charts" class="grid md:grid-cols-2 gap-6">
228
+ <div class="glass rounded-2xl p-6">
229
+ <h3 class="font-semibold mb-4">Request Volume (7 days)</h3>
230
+ <canvas id="chart-requests" height="200"></canvas>
231
+ </div>
232
+ <div class="glass rounded-2xl p-6">
233
+ <h3 class="font-semibold mb-4">Model Distribution</h3>
234
+ <canvas id="chart-models" height="200"></canvas>
235
+ </div>
236
+ </section>
237
+
238
+ </main>
239
+
240
+ <!-- Footer -->
241
+ <footer class="border-t border-white/5 mt-20">
242
+ <div class="max-w-7xl mx-auto px-6 py-8 flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-slate-500 font-mono">
243
+ <p>Β© 2026 APIarium Β· Enterprise AI Gateway</p>
244
+ <p id="sync-status">Last sync: <span id="last-sync">β€”</span></p>
245
  </div>
246
+ </footer>
247
+ </div>
248
+
249
+ <script>
250
+ (function() {
251
+ const $ = (id) => document.getElementById(id);
252
+ const authGate = $('auth-gate');
253
+ const dashboard = $('dashboard');
254
+ const masterKeyInput = $('master-key-input');
255
+ const authBtn = $('auth-btn');
256
+ const authError = $('auth-error');
257
+
258
+ let masterKey = sessionStorage.getItem('apiarium_master_key') || '';
259
+ let keysData = [];
260
+ let chartRequests = null;
261
+ let chartModels = null;
262
+
263
+ // ── Auth ─────────────────────────────────────────────────────
264
+ function initAuth() {
265
+ if (masterKey) {
266
+ showDashboard();
267
+ loadKeys();
268
+ } else {
269
+ showAuth();
270
+ }
271
+ }
272
+ function showAuth() { authGate.classList.remove('hidden'); dashboard.classList.add('hidden'); }
273
+ function showDashboard() { authGate.classList.add('hidden'); dashboard.classList.remove('hidden'); }
274
+
275
+ authBtn.addEventListener('click', async () => {
276
+ const val = masterKeyInput.value.trim();
277
+ if (!val) return;
278
+ try {
279
+ const r = await fetch('/admin/keys', { headers: { 'Authorization': 'Bearer ' + val } });
280
+ if (r.ok) {
281
+ masterKey = val;
282
+ sessionStorage.setItem('apiarium_master_key', val);
283
+ authError.classList.add('hidden');
284
+ showDashboard();
285
+ loadKeys();
286
+ } else {
287
+ authError.textContent = 'Invalid master key';
288
+ authError.classList.remove('hidden');
289
  }
290
+ } catch (e) {
291
+ authError.textContent = 'Network error: ' + e.message;
292
+ authError.classList.remove('hidden');
293
+ }
294
+ });
295
+ masterKeyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') authBtn.click(); });
296
+
297
+ $('logout-btn').addEventListener('click', () => {
298
+ sessionStorage.removeItem('apiarium_master_key');
299
+ masterKey = '';
300
+ masterKeyInput.value = '';
301
+ showAuth();
302
+ });
303
+
304
+ // ── API Helpers ─────────────────────────────────────────────
305
+ async function api(path, opts = {}) {
306
+ const r = await fetch(path, { ...opts, headers: { 'Authorization': 'Bearer ' + masterKey, 'Content-Type': 'application/json', ...(opts.headers || {}) } });
307
+ if (r.status === 401) { sessionStorage.removeItem('apiarium_master_key'); location.reload(); return null; }
308
+ return r;
309
+ }
310
+
311
+ function toast(msg, type = 'success') {
312
+ const t = document.createElement('div');
313
+ t.className = 'toast toast-' + type;
314
+ t.textContent = msg;
315
+ document.body.appendChild(t);
316
+ setTimeout(() => t.remove(), 4000);
317
+ }
318
+
319
+ // ── Load Keys ──────────────────────────────────────────────
320
+ async function loadKeys() {
321
+ try {
322
+ const r = await api('/admin/keys');
323
+ if (!r || !r.ok) return;
324
+ const data = await r.json();
325
+ keysData = data.keys || [];
326
+ renderStats();
327
+ renderTable();
328
+ renderCharts();
329
+ $('last-sync').textContent = new Date().toLocaleTimeString();
330
+ } catch (e) {
331
+ toast('Failed to load keys: ' + e.message, 'error');
332
+ }
333
+ }
334
+
335
+ function renderStats() {
336
+ $('stat-total').textContent = keysData.length;
337
+ const active = keysData.filter(k => k.status === 'active');
338
+ $('stat-active').textContent = new Set(active.map(k => k.ip)).size;
339
+ $('stat-requests').textContent = 'β€”';
340
+ $('stat-ratelimited').textContent = 'β€”';
341
+ }
342
+
343
+ function maskKey(k) {
344
+ if (!k || k.length < 8) return k;
345
+ return k.slice(0, 4) + 'β€’'.repeat(Math.max(8, k.length - 8)) + k.slice(-4);
346
+ }
347
+
348
+ function formatDate(ts) {
349
+ if (!ts) return 'β€”';
350
+ const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
351
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
352
+ }
353
+
354
+ function renderTable(filter = '') {
355
+ const tbody = $('keys-tbody');
356
+ const empty = $('empty-state');
357
+ const filtered = keysData.filter(k =>
358
+ !filter || (k.ip || '').includes(filter) || (k.label || '').toLowerCase().includes(filter.toLowerCase())
359
+ );
360
+
361
+ if (filtered.length === 0) {
362
+ tbody.innerHTML = '';
363
+ empty.classList.remove('hidden');
364
+ return;
365
+ }
366
+ empty.classList.add('hidden');
367
+
368
+ tbody.innerHTML = filtered.map(k => `
369
+ <tr class="hover:bg-white/[0.02] transition">
370
+ <td class="py-3 pr-4 font-mono text-xs text-amber-300">${maskKey(k.key)}</td>
371
+ <td class="py-3 pr-4 font-mono text-xs text-slate-300">${k.ip || 'β€”'}</td>
372
+ <td class="py-3 pr-4 text-slate-300">${k.label || '<span class="text-slate-600">β€”</span>'}</td>
373
+ <td class="py-3 pr-4 text-xs text-slate-500">${formatDate(k.created_at)}</td>
374
+ <td class="py-3 pr-4">
375
+ <span class="text-xs font-mono px-2 py-0.5 rounded-full ${k.status === 'active' ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}">
376
+ ${k.status || 'active'}
377
+ </span>
378
+ </td>
379
+ <td class="py-3 flex gap-2">
380
+ <button onclick="window.__copyKey('${k.key}')" class="text-xs font-mono text-slate-400 hover:text-amber-400 px-2 py-1 rounded bg-white/5 hover:bg-white/10 transition">COPY</button>
381
+ ${k.status !== 'revoked' ? `<button onclick="window.__revokeKey('${k.key}')" class="text-xs font-mono text-slate-400 hover:text-red-400 px-2 py-1 rounded bg-white/5 hover:bg-red-500/10 transition">REVOKE</button>` : ''}
382
+ </td>
383
+ </tr>
384
+ `).join('');
385
+ }
386
+
387
+ window.__copyKey = async (key) => {
388
+ try {
389
+ await navigator.clipboard.writeText(key);
390
+ toast('Key copied to clipboard');
391
+ } catch (e) {
392
+ toast('Copy failed', 'error');
393
+ }
394
+ };
395
+
396
+ window.__revokeKey = async (key) => {
397
+ if (!confirm('Revoke this key? It will stop working immediately.')) return;
398
+ try {
399
+ const r = await api('/admin/keys/' + encodeURIComponent(key), { method: 'DELETE' });
400
+ if (r && r.ok) {
401
+ keysData = keysData.filter(k => k.key !== key);
402
+ renderStats();
403
+ renderTable($('search-input').value);
404
+ toast('Key revoked');
405
+ } else {
406
+ const d = await r.json().catch(() => ({}));
407
+ toast(d?.error?.message || 'Revoke failed', 'error');
408
+ }
409
+ } catch (e) {
410
+ toast('Network error', 'error');
411
+ }
412
+ };
413
+
414
+ $('search-input').addEventListener('input', (e) => renderTable(e.target.value));
415
+
416
+ // ── Generate Key ───────────────────────────────────────────
417
+ const ipv4Re = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
418
+ const ipv6Re = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/;
419
+
420
+ $('key-form').addEventListener('submit', async (e) => {
421
+ e.preventDefault();
422
+ const ip = $('ip-input').value.trim();
423
+ const label = $('label-input').value.trim();
424
+ const err = $('ip-error');
425
+
426
+ if (ip.includes('/')) { err.textContent = 'CIDR notation not allowed'; err.classList.remove('hidden'); return; }
427
+ if (!ipv4Re.test(ip) && !ipv6Re.test(ip)) { err.textContent = 'Invalid IP format'; err.classList.remove('hidden'); return; }
428
+ err.classList.add('hidden');
429
+
430
+ const btn = $('gen-btn');
431
+ btn.disabled = true; btn.textContent = 'Generating...';
432
+ try {
433
+ const r = await api('/admin/keys', { method: 'POST', body: JSON.stringify({ ip, label }) });
434
+ const d = await r.json();
435
+ if (r.ok) {
436
+ $('result-key').textContent = d.key;
437
+ $('result-ip').textContent = d.ip;
438
+ $('result-label').textContent = d.label || 'β€”';
439
+ $('result-card').classList.remove('hidden');
440
+ $('ip-input').value = ''; $('label-input').value = '';
441
+ toast('Key generated for ' + d.ip);
442
+ await loadKeys();
443
+ } else {
444
+ toast(d?.error?.message || 'Generation failed', 'error');
445
  }
446
+ } catch (e) {
447
+ toast('Network error', 'error');
448
+ } finally {
449
+ btn.disabled = false; btn.textContent = 'Generate';
450
+ }
451
+ });
452
+
453
+ $('copy-key-btn').addEventListener('click', () => {
454
+ navigator.clipboard.writeText($('result-key').textContent).then(() => toast('Key copied'));
455
+ });
456
+
457
+ // ── Charts ─────────────────────────────────────────────────
458
+ function renderCharts() {
459
+ Chart.defaults.color = '#94a3b8';
460
+ Chart.defaults.borderColor = 'rgba(255,255,255,0.05)';
461
+ Chart.defaults.font.family = "'Inter', sans-serif";
462
+
463
+ const days = Array.from({length: 7}, (_, i) => {
464
+ const d = new Date(); d.setDate(d.getDate() - (6 - i));
465
+ return d.toLocaleDateString(undefined, { weekday: 'short' });
466
+ });
467
+
468
+ if (chartRequests) chartRequests.destroy();
469
+ chartRequests = new Chart($('chart-requests'), {
470
+ type: 'line',
471
+ data: {
472
+ labels: days,
473
+ datasets: [{
474
+ label: 'Requests', data: [0,0,0,0,0,0,0],
475
+ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)',
476
+ fill: true, tension: 0.4, pointRadius: 4, pointBackgroundColor: '#f59e0b',
477
+ }]
478
+ },
479
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { grid: { display: false } } } }
480
+ });
481
+
482
+ if (chartModels) chartModels.destroy();
483
+ chartModels = new Chart($('chart-models'), {
484
+ type: 'doughnut',
485
+ data: {
486
+ labels: ['gpt-4', 'claude', 'gemini', 'deepseek', 'other'],
487
+ datasets: [{
488
+ data: [30, 25, 20, 15, 10],
489
+ backgroundColor: ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#64748b'],
490
+ borderColor: '#0a0a0f', borderWidth: 2,
491
+ }]
492
+ },
493
+ options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { family: "'JetBrains Mono'", size: 11 }, padding: 12 } } } }
494
  });
495
+ }
496
+
497
+ initAuth();
498
+ })();
499
+ </script>
500
+ </body>
501
+ </html>