grok2api / app /template /admin.html
tejmar's picture
Initial commit
2c97e18
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console - Grok2API</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0
}
to {
transform: translateY(0);
opacity: 1
}
}
.animate-slide-up {
animation: slide-up .3s ease-out
}
.tab-btn {
transition: all .2s ease
}
.hover-card {
position: relative;
display: inline-block
}
.hover-card-trigger {
cursor: pointer
}
.hover-card-content {
position: absolute;
left: 50%;
transform: translateX(-50%);
background: hsl(0 0% 3.9%);
color: hsl(0 0% 98%);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
z-index: 9999;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity .2s ease, transform .2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, .25);
border: 1px solid hsl(0 0% 14.9%)
}
.hover-card:hover .hover-card-content {
opacity: 1;
visibility: visible
}
.hover-card-content.top {
bottom: 100%;
transform: translateX(-50%) translateY(-8px)
}
.hover-card-content.bottom {
top: 100%;
transform: translateX(-50%) translateY(8px)
}
.hover-card-content::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
z-index: 1000
}
.hover-card-content.top::after {
top: 100%;
border-top-color: hsl(0 0% 3.9%)
}
.hover-card-content.bottom::after {
bottom: 100%;
border-bottom-color: hsl(0 0% 3.9%)
}
.hover-card-trigger:hover+.hover-card-content {
opacity: 1;
visibility: visible
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
transition: color .2s, background-color .2s;
height: 2rem;
width: 2rem
}
.btn-icon:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 1px hsl(0 0% 3.9%)
}
.btn-icon:hover {
background-color: hsl(0 0% 96.1%);
color: hsl(0 0% 9%)
}
.sticky-right {
position: sticky;
right: 0;
background-color: hsl(0 0% 100%);
z-index: 10;
border-left: 1px solid hsl(0 0% 89%)
}
.cfg-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(0 0% 45.1%);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem
}
.cfg-input {
display: flex;
height: 2.25rem;
width: 100%;
border-radius: 0.375rem;
border: 1px solid hsl(0 0% 89%);
background-color: hsl(0 0% 100%);
padding: 0.5rem 0.75rem;
font-size: 0.875rem
}
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
border-radius: 9999px;
border: 1px solid hsl(0 0% 45.1%);
color: hsl(0 0% 45.1%);
cursor: help;
font-size: 10px;
line-height: 1
}
[title] {
position: relative
}
[title]:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: hsl(0 0% 11%);
color: hsl(0 0% 98%);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: pre-line;
max-width: 350px;
width: max-content;
word-wrap: break-word;
z-index: 1000;
pointer-events: none;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
animation: tooltipFadeIn 0.2s ease forwards
}
[title]:hover::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-4px);
border: 6px solid transparent;
border-top-color: hsl(0 0% 11%);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease
}
[title]:hover::after,
[title]:hover::before {
opacity: 1;
visibility: visible
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-4px)
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-8px)
}
}
</style>
<script>
tailwind.config = { theme: { extend: { colors: { border: "hsl(0 0% 89%)", input: "hsl(0 0% 89%)", ring: "hsl(0 0% 3.9%)", background: "hsl(0 0% 100%)", foreground: "hsl(0 0% 3.9%)", primary: { DEFAULT: "hsl(0 0% 9%)", foreground: "hsl(0 0% 98%)" }, secondary: { DEFAULT: "hsl(0 0% 96.1%)", foreground: "hsl(0 0% 9%)" }, muted: { DEFAULT: "hsl(0 0% 96.1%)", foreground: "hsl(0 0% 45.1%)" }, accent: { DEFAULT: "hsl(0 0% 96.1%)", foreground: "hsl(0 0% 9%)" }, destructive: { DEFAULT: "hsl(0 84.2% 60.2%)", foreground: "hsl(0 0% 98%)" } } } } }
</script>
</head>
<body class="h-full bg-background text-foreground antialiased">
<!-- Navbar -->
<header
class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
<div class="mr-4 flex items-baseline gap-3">
<span class="font-bold text-xl">Grok2API</span>
<span class="text-xs text-gray-400">by @Chenyme & @Tomiya233</span>
</div>
<div class="flex flex-1 items-center justify-end">
<div class="flex items-center gap-2">
<div class="hover-card">
<span id="storageMode"
class="hover-card-trigger inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground border">
<svg class="h-3 w-3 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="m21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="m3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
<span id="storageModeText">FILE</span>
</span>
<div id="storageModeTooltip" class="hover-card-content top">
Loading...
</div>
</div>
<nav class="flex items-center gap-1">
<button onclick="logout()"
class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Sign Out
</button>
</nav>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
<!-- Tabs -->
<div class="border-b border-border mb-6">
<nav class="flex space-x-8">
<button onclick="switchTab('tokens')" id="tabTokens"
class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token Management</button>
<button onclick="switchTab('statistics')" id="tabStatistics"
class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Request Stats</button>
<button onclick="switchTab('keys')" id="tabKeys"
class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Key Management</button>
<button onclick="switchTab('logs')" id="tabLogs"
class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Audit Logs</button>
<button onclick="switchTab('cache')" id="tabCache"
class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Cache Preview</button>
<button onclick="switchTab('settings')" id="tabSettings"
class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Settings</button>
</nav>
</div>
<!-- Token management panel -->
<div id="panelTokens">
<!-- Stat cards -->
<div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6">
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Total Tokens</p>
<h3 class="text-xl font-bold" id="statTotal">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Active Tokens</p>
<h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Unused Tokens</p>
<h3 class="text-xl font-bold text-gray-500" id="statUnused">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Limited Tokens</p>
<h3 class="text-xl font-bold text-orange-600" id="statLimited">-</h3>
<p class="text-xs text-muted-foreground mt-2">
Cooldown <span id="statCooldown">-</span> · Exhausted <span id="statExhausted">-</span>
</p>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Expired Tokens</p>
<h3 class="text-xl font-bold text-destructive" id="statExpired">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Chat Remaining</p>
<h3 class="text-xl font-bold" id="statChatRemaining">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Image Remaining</p>
<h3 class="text-xl font-bold text-blue-600" id="statImageRemaining">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Video Remaining</p>
<h3 class="text-xl font-bold text-purple-600" id="statVideoRemaining">Unavailable</h3>
</div>
</div>
<!-- Token list -->
<div class="rounded-lg border border-border bg-background">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
<div class="flex items-center gap-3 flex-1">
<div class="flex items-center gap-2">
<select id="filterType" onchange="filterTokens()"
class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">All Types</option>
<option value="sso">SSO</option>
<option value="ssoSuper">SuperSSO</option>
</select>
<select id="filterStatus" onchange="filterTokens()"
class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">All Statuses</option>
<option value="unused">Unused</option>
<option value="cooldown">Cooldown</option>
<option value="exhausted">Exhausted</option>
<option value="expired">Expired</option>
<option value="active">Active</option>
</select>
<select id="filterTag" onchange="filterTokens()"
class="h-8 px-1 text-sm rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]">
<option value="all">All Tags</option>
</select>
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="refreshTokens()" class="btn-icon" title="Refresh list">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</button>
<button onclick="refreshAllTokens()" class="btn-icon" title="Refresh token limits">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
<div id="batchActions" class="hidden items-center gap-2">
<button onclick="exportSelected()" class="btn-icon" title="Export selected">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button onclick="batchDelete()" class="btn-icon hover:bg-destructive/10 hover:text-destructive"
title="Batch delete">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
<button onclick="openAddModal()"
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3"
title="Add token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span class="text-sm font-medium">Add</span>
</button>
</div>
</div>
<!-- Table -->
<div class="relative w-full overflow-auto">
<table class="w-full text-sm table-fixed">
<thead>
<tr class="border-b border-border">
<th class="h-10 px-3 text-left align-middle font-medium w-12">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()"
class="h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring">
</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-72">Token</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">Type</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">Status</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">Standard</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">Premium</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">Limit Info</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-48">Recent Failure</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">Tags</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">Notes</th>
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-32">Created</th>
<th
class="h-10 px-3 text-center align-middle text-sm font-medium text-muted-foreground w-28 sticky-right">
Actions</th>
</tr>
</thead>
<tbody id="tokenTableBody" class="divide-y divide-border">
<!-- Dynamic content -->
</tbody>
</table>
</div>
<div id="emptyState" class="hidden flex flex-col items-center justify-center py-12">
<svg class="h-10 w-10 text-muted-foreground/50 mb-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6v6H9z" />
</svg>
<p class="text-sm text-muted-foreground">No data</p>
</div>
</div>
</div>
<!-- Key management panel -->
<div id="panelKeys" class="hidden">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-4 mb-6">
<h2 class="text-xl font-bold">Key Management</h2>
<div class="flex items-center gap-2">
<button onclick="loadKeys()" class="btn-icon" title="Refresh list">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
<button onclick="openBatchAddModal()"
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
<span class="text-sm font-medium">Batch create</span>
</button>
<button onclick="openAddKeyModal()"
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span class="text-sm font-medium">Add key</span>
</button>
</div>
</div>
<!-- Batch actions bar -->
<div id="batchActionBar"
class="hidden mb-4 p-3 rounded-lg bg-primary/5 border border-primary/20 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-primary"><span id="selectedCount">0</span> items selected</span>
</div>
<div class="flex items-center gap-2">
<button onclick="batchUpdateStatus(true)"
class="text-xs px-2 py-1 rounded bg-background border border-border hover:bg-accent transition-colors">Enable selected</button>
<button onclick="batchUpdateStatus(false)"
class="text-xs px-2 py-1 rounded bg-background border border-border hover:bg-accent transition-colors">Disable selected</button>
<button onclick="batchDeleteKeys()"
class="text-xs px-2 py-1 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors">Delete selected</button>
</div>
</div>
<!-- Key table -->
<div class="rounded-lg border border-border bg-background">
<div class="relative w-full overflow-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="h-10 px-4 text-left align-middle font-medium w-12">
<input type="checkbox" id="selectAllKeys" onchange="toggleSelectAllKeys(this)"
class="rounded border-border text-primary focus:ring-primary h-4 w-4">
</th>
<th class="h-10 px-4 text-left align-middle font-medium w-48">Note</th>
<th class="h-10 px-4 text-left align-middle font-medium w-64">Key</th>
<th class="h-10 px-4 text-left align-middle font-medium w-32">Created</th>
<th class="h-10 px-4 text-left align-middle font-medium w-24">Status</th>
<th class="h-10 px-4 text-right align-middle font-medium w-48">Actions</th>
</tr>
</thead>
<tbody id="keyTableBody" class="divide-y divide-border">
<!-- JS content -->
</tbody>
</table>
</div>
<div id="keyEmptyState" class="hidden flex flex-col items-center justify-center py-12">
<p class="text-sm text-muted-foreground">No API keys</p>
</div>
</div>
<div id="globalKeyAlert" class="mt-4 p-4 rounded-lg bg-orange-50 border border-orange-200 hidden">
<div class="flex items-start gap-3">
<svg class="h-5 w-5 text-orange-600 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<div>
<h4 class="text-sm font-bold text-orange-800">Global key present</h4>
<p class="text-sm text-orange-700 mt-1">
The system detected a legacy global `api_key` in `setting.toml`.
It is treated as the "default admin" key and cannot be edited or removed here.
If you want to use multi-key management only, clear `api_key` in the config file.
</p>
</div>
</div>
</div>
</div>
<!-- Audit logs panel -->
<div id="panelLogs" class="hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">Audit Logs</h2>
<div class="flex items-center gap-2">
<button onclick="loadLogs()" class="btn-icon" title="Refresh logs">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
<button onclick="clearLogs()"
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-destructive text-destructive-foreground hover:bg-destructive/90 h-8 px-3">
<span class="text-sm font-medium">Clear logs</span>
</button>
</div>
</div>
<div class="rounded-lg border border-border bg-background">
<div class="relative w-full overflow-auto" style="max-height: 800px;">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border sticky top-0 bg-background z-10">
<th class="h-10 px-4 text-left align-middle font-medium w-40">Time</th>
<th class="h-10 px-4 text-left align-middle font-medium w-32">Key Name</th>
<th class="h-10 px-4 text-left align-middle font-medium w-40">Model</th>
<th class="h-10 px-4 text-left align-middle font-medium w-32">IP</th>
<th class="h-10 px-4 text-left align-middle font-medium w-24">Duration</th>
<th class="h-10 px-4 text-left align-middle font-medium w-24">Status</th>
<th class="h-10 px-4 text-left align-middle font-medium">Details</th>
</tr>
</thead>
<tbody id="logTableBody" class="divide-y divide-border">
<!-- JS content -->
</tbody>
</table>
</div>
<div id="logEmptyState" class="hidden flex flex-col items-center justify-center py-12">
<p class="text-sm text-muted-foreground">No logs</p>
</div>
<!-- Pagination -->
<div id="logPagination" class="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/20">
<div class="text-xs text-muted-foreground">
Total <span id="logTotalCount">0</span> logs
</div>
<div class="flex items-center gap-2">
<button onclick="changeLogPage(-1)" id="logPrevBtn"
class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 w-8 border border-input bg-background hover:bg-accent disabled:opacity-50 transition-colors">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span class="text-xs font-medium px-2">Page <span id="logCurrentPage">1</span> / <span id="logMaxPage">1</span>
</span>
<button onclick="changeLogPage(1)" id="logNextBtn"
class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 w-8 border border-input bg-background hover:bg-accent disabled:opacity-50 transition-colors">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Cache preview panel -->
<div id="panelCache" class="hidden">
<div class="rounded-lg border border-border bg-background p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Cache Preview</h3>
<div class="flex items-center gap-2">
<div class="inline-flex rounded-md border border-input overflow-hidden">
<button id="cacheTabImage" onclick="switchCachePreviewType('image')"
class="px-3 h-8 text-xs font-medium bg-primary text-primary-foreground">Images</button>
<button id="cacheTabVideo" onclick="switchCachePreviewType('video')"
class="px-3 h-8 text-xs font-medium bg-muted text-muted-foreground">Videos</button>
</div>
<button onclick="refreshCachePreview()"
class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-3 transition-colors">Refresh</button>
</div>
</div>
<div id="cachePreviewMeta" class="text-xs text-muted-foreground mb-3">Image cache: 0 items</div>
<div id="cachePreviewGrid" class="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
<div class="col-span-full text-sm text-muted-foreground text-center py-6">No cached files</div>
</div>
<div class="mt-3 flex items-center justify-center">
<button id="cacheLoadMoreBtn" onclick="loadMoreCachePreview()"
class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-4 transition-colors">Load more</button>
</div>
</div>
</div>
<!-- Global settings panel -->
<div id="panelSettings" class="hidden">
<!-- Global config section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">Global Settings</h2>
<button onclick="saveGlobalSettings()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">Save settings</button>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<!-- System settings -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">System Settings</h3>
<div class="space-y-4">
<div>
<label class="cfg-label">Login Username<span class="help-icon" title="Username for the admin console">?</span></label>
<input id="cfgAdminUser" class="cfg-input" placeholder="admin">
</div>
<div>
<label class="cfg-label">Login Password<span class="help-icon" title="Password for the admin console. Leave blank to keep unchanged.">?</span></label>
<input id="cfgAdminPass" type="password" class="cfg-input" placeholder="Leave blank to keep unchanged">
</div>
<div>
<label class="cfg-label">Log Level<span class="help-icon"
title="Log verbosity. DEBUG: most verbose | INFO: general | WARNING: warnings | ERROR: errors only">?</span></label>
<select id="cfgLogLevel" class="cfg-input">
<option>DEBUG</option>
<option>INFO</option>
<option>WARNING</option>
<option>ERROR</option>
</select>
</div>
</div>
</div>
<!-- Media settings -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Media Settings</h3>
<div class="space-y-4">
<div>
<label class="cfg-label">
Image Mode
<span class="help-icon" title="Image return mode. URL: links with cache support | Base64: encoded, no cache">?</span>
</label>
<select id="cfgImageMode" class="cfg-input">
<option value="url">URL Link</option>
<option value="base64">Base64</option>
</select>
</div>
<div>
<label class="cfg-label">
Service Base URL
<span class="help-icon" title="Public base URL used to build image links (only needed for URL mode)">?</span>
</label>
<input id="cfgBaseUrl" class="cfg-input" placeholder="http://localhost:8000">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="cfg-label">
Image Cache (MB)
<span class="help-icon" title="Max image cache size (MB). Old cache will be cleared when exceeded.">?</span>
</label>
<input id="cfgImageCacheMaxSize" type="number" class="cfg-input" placeholder="500">
</div>
<div>
<label class="cfg-label">
Video Cache (MB)
<span class="help-icon" title="Max video cache size (MB). Old cache will be cleared when exceeded.">?</span>
</label>
<input id="cfgVideoCacheMaxSize" type="number" class="cfg-input" placeholder="1000">
</div>
</div>
</div>
</div>
<!-- Cache management -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Cache Management</h3>
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Image Cache</label>
<div class="flex gap-2">
<input id="imageCacheSize" readonly
class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm"
placeholder="0 MB">
<button onclick="clearImageCache()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" />
</svg>
</button>
</div>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Video Cache</label>
<div class="flex gap-2">
<input id="videoCacheSize" readonly
class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm"
placeholder="0 MB">
<button onclick="clearVideoCache()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" />
</svg>
</button>
</div>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">All Cache</label>
<div class="flex gap-2">
<input id="totalCacheSize" readonly
class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm font-medium"
placeholder="0 MB">
<button onclick="clearCache()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Grok config section -->
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">Grok Settings</h2>
<button onclick="saveGrokSettings()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">Save settings</button>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<!-- Basic settings -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Basic Settings</h3>
<div class="space-y-4">
<div>
<label class="cfg-label">
API Key
<span class="help-icon" title="Auth key for API requests to secure access">?</span>
</label>
<input id="cfgApiKey" class="cfg-input" placeholder="">
</div>
<div>
<label class="cfg-label">
X Statsig ID
<span class="help-icon" title="Statsig ID for experiments and analytics">?</span>
</label>
<div class="flex items-center gap-3">
<input id="cfgStatsigId"
class="flex h-9 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="">
<label class="inline-flex items-center gap-2 cursor-pointer" title="Generate a new x-statsig-id for every request">
<span class="text-xs text-muted-foreground whitespace-nowrap">Dynamic</span>
<div class="relative">
<input type="checkbox" id="cfgDynamicStatsig" class="sr-only peer">
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary">
</div>
</div>
</label>
</div>
</div>
<div>
<label class="cfg-label">
Filter Tags
<span class="help-icon" title="Response tags to filter, comma-separated. e.g. xaiartifact,xai:tool_usage_card">?</span>
</label>
<input id="cfgFilteredTags" class="cfg-input" placeholder="xaiartifact,xai:tool_usage_card">
</div>
<div>
<label class="cfg-label">
Show Thinking
<span class="help-icon" title="Show model thinking (<think> content). Off returns only the final result.">?</span>
</label>
<select id="cfgShowThinking" class="cfg-input">
<option value="true">On</option>
<option value="false">Off</option>
</select>
</div>
<div>
<label class="cfg-label">
Temporary Sessions
<span class="help-icon" title="When on, each chat starts a new session without history. Off continues previous chats.">?</span>
</label>
<select id="cfgTemporary" class="cfg-input">
<option value="false">Off</option>
<option value="true">On</option>
</select>
</div>
</div>
</div>
<!-- Proxy settings -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Proxy Settings</h3>
<div class="space-y-4">
<div>
<label class="cfg-label">
CF Clearance
<span class="help-icon"
title="Cloudflare cookie value for verification bypass. Enter the value after cf_clearance=.">?</span>
</label>
<input id="cfgCfClearance" class="cfg-input" placeholder="">
</div>
<div>
<label class="cfg-label">
Proxy URL (service)
<span class="help-icon"
title="Proxy for API requests and uploads. Supports http/https/socks5. Format: socks5://user:pass@host:port">?</span>
</label>
<input id="cfgProxyUrl" class="cfg-input" placeholder="socks5://username:password@127.0.0.1:7890">
</div>
<div>
<label class="cfg-label">
Proxy Pool URL (pool API)
<span class="help-icon"
title="Proxy pool API returning a single proxy URL. Leave blank to use the static proxy above.&#10;&#10;Return format: plain text, single-line proxy URL&#10;Example: socks5h://1.2.3.4:1080&#10;Protocols: http://, https://, socks5://, socks5h://&#10;API example: http://your-api.com/get">?</span>
</label>
<input id="cfgProxyPoolUrl" class="cfg-input" placeholder="http://your-proxy-api.com/get">
</div>
<div>
<label class="cfg-label">
Proxy Pool Interval (seconds)
<span class="help-icon" title="Proxy pool refresh interval in seconds. Recommend 300-600s (5-10 minutes).">?</span>
</label>
<input id="cfgProxyPoolInterval" type="number" class="cfg-input" placeholder="300">
</div>
<div>
<label class="cfg-label">
Cache Proxy URL (cache)
<span class="help-icon" title="Proxy for image/video cache downloads. If unset, uses service proxy. These endpoints usually have low IP risk; cheaper high-bandwidth nodes are fine.">?</span>
</label>
<input id="cfgCacheProxyUrl" class="cfg-input" placeholder="socks5://username:password@127.0.0.1:7890">
</div>
</div>
</div>
<!-- Timeout settings -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Timeout Settings</h3>
<div class="space-y-4">
<div>
<label class="cfg-label">
First Response Timeout (s)
<span class="help-icon" title="Max time to wait for the first response (seconds). Recommend 30-60s.">?</span>
</label>
<input id="cfgStreamFirstResponseTimeout" type="number" class="cfg-input" placeholder="30">
</div>
<div>
<label class="cfg-label">
Stream Gap Timeout (s)
<span class="help-icon" title="Max interval between stream chunks (seconds). Recommend 60-180s.">?</span>
</label>
<input id="cfgStreamChunkTimeout" type="number" class="cfg-input" placeholder="120">
</div>
<div>
<label class="cfg-label">
Total Generation Timeout (s)
<span class="help-icon" title="Max total generation time (seconds). Recommend 300-900s.">?</span>
</label>
<input id="cfgStreamTotalTimeout" type="number" class="cfg-input" placeholder="600">
</div>
<div>
<label class="cfg-label">
Retry Status Codes
<span class="help-icon"
title="Auto-retry with a new token for these HTTP status codes. Comma-separated. Default: 401,429&#10;Common: 401 (unauthorized), 429 (rate limit), 500 (server error), 502 (gateway), 503 (service unavailable)">?</span>
</label>
<input id="cfgRetryStatusCodes" class="cfg-input" placeholder="401,429">
</div>
</div>
</div>
</div>
</div>
<!-- Configuration notes -->
<div class="mt-8 rounded border border-blue-300 bg-blue-50 px-4 py-3">
<div class="text-xs text-gray-800 leading-relaxed">
<div class="text-base font-medium text-gray-900 mb-2.5">Notes</div>
<div class="space-y-1.5">
<div><span class="font-medium">X Statsig ID:</span> Anti-bot parameter. When "Dynamic Statsig ID" is on, it is generated automatically and the fixed value is ignored. When off, the fixed value above is used.
</div>
<div><span class="font-medium">Dynamic Statsig:</span> Generates a new x-statsig-id per request to increase variance. Recommended to keep on.</div>
<div><span class="font-medium">Service Base URL:</span> Used to build image/video links (e.g.
https://yourdomain.com). If you do not use video and images are Base64, this can be empty.</div>
<div><span class="font-medium">Proxy Settings:</span> Service proxy is used for Grok API access and uploads; cache proxy is for image/video cache downloads. If only service proxy is set, cache uses the same proxy; if both are set, each uses its own.</div>
<div><span class="font-medium">403 errors:</span> Usually blocked by CF. Try: 1) change server IP | 2) configure a proxy | 3) access grok.com on the server, pass CF check, and retrieve cf_clearance from dev tools.</div>
</div>
</div>
</div>
</div>
<!-- Statistics panel -->
<div id="panelStatistics" class="hidden">
<!-- Overview cards -->
<div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6">
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Total Requests</p>
<h3 class="text-xl font-bold" id="reqStatTotal">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Successful Requests</p>
<h3 class="text-xl font-bold text-green-600" id="reqStatSuccess">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Failed Requests</p>
<h3 class="text-xl font-bold text-destructive" id="reqStatFailed">-</h3>
</div>
<div class="rounded-lg border border-border bg-background p-4">
<p class="text-sm font-medium text-muted-foreground mb-2">Success Rate</p>
<h3 class="text-xl font-bold text-blue-600" id="reqStatRate">-</h3>
</div>
</div>
<!-- Chart containers -->
<div class="grid gap-6 lg:grid-cols-2 mb-6">
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">24-Hour Request Trend</h3>
<canvas id="hourlyChart" height="200"></canvas>
</div>
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">7-Day Request Stats</h3>
<canvas id="dailyChart" height="200"></canvas>
</div>
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-sm font-semibold mb-4">Model Usage Distribution</h3>
<canvas id="modelsChart" height="200"></canvas>
</div>
</div>
</div>
</main>
<!-- Edit info modal -->
<div id="editTagsModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">Edit Info</h3>
<button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Token</label>
<input id="editTokenInput" readonly
class="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-xs font-mono" placeholder="">
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Tags <span
class="text-muted-foreground">(comma-separated)</span></label>
<input id="editTagsInput" placeholder="e.g. prod, test"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring" />
<div id="suggestedTags" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Note</label>
<textarea id="editNoteInput" rows="3" placeholder="Add a note..."
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none"></textarea>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button onclick="closeEditModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5">
Cancel
</button>
<button onclick="submitEditInfo()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
Save
</button>
</div>
</div>
</div>
<!-- Add token modal -->
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">Add Token</h3>
<button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Token Type</label>
<select id="addTokenType"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
<option value="sso">SSO</option>
<option value="ssoSuper">SuperSSO</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Token List <span
class="text-muted-foreground">(one per line)</span></label>
<textarea id="addTokenList" rows="12" placeholder="Enter tokens, one per line"
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring font-mono resize-none"></textarea>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button onclick="closeAddModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5">
Cancel
</button>
<button onclick="submitAddTokens()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
Add
</button>
</div>
</div>
</div>
<!-- Add key modal -->
<div id="modalAddKey" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">Add API Key</h3>
<button onclick="closeAddKeyModal()" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Note</label>
<input type="text" id="newKeyName" placeholder="e.g. Used by app X"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button onclick="closeAddKeyModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input bg-background hover:bg-accent h-9 px-5">Cancel</button>
<button onclick="doAddKey()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">Confirm</button>
</div>
</div>
</div>
<!-- Batch create key modal -->
<div id="modalBatchAddKey" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">Batch Create API Keys</h3>
<button onclick="closeBatchAddModal()" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Name Prefix</label>
<input type="text" id="batchKeyPrefix" placeholder="e.g. test-user"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-muted-foreground">Count (1-50)</label>
<input type="number" id="batchKeyCount" value="10" min="1" max="50"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
<button onclick="closeBatchAddModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input bg-background hover:bg-accent h-9 px-5">Cancel</button>
<button onclick="doBatchAddKeys()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">Create</button>
</div>
</div>
</div>
<!-- Batch create results modal -->
<div id="modalBatchResult" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">Created Successfully</h3>
<button onclick="copyBatchResults()" class="text-sm text-primary hover:underline font-medium">Copy all</button>
</div>
<div class="p-5">
<div class="bg-muted p-4 rounded-md border border-border">
<textarea id="batchResultContent" readonly
class="w-full h-80 bg-transparent border-none focus:ring-0 text-xs font-mono resize-none"></textarea>
</div>
</div>
<div class="flex items-center justify-end p-5 border-t border-border bg-muted/30">
<button onclick="closeBatchResultModal()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">Close</button>
</div>
</div>
</div>
<!-- Cache preview modal -->
<div id="cacheViewerModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-5xl shadow-xl overflow-hidden">
<div class="flex items-center justify-between p-4 border-b border-border">
<div class="min-w-0">
<h3 id="cacheViewerTitle" class="text-sm font-semibold truncate">Cache Preview</h3>
<div id="cacheViewerMeta" class="text-xs text-muted-foreground"></div>
</div>
<button onclick="closeCacheViewer()"
class="inline-flex items-center justify-center rounded-md text-xs font-medium border border-input bg-background hover:bg-accent h-8 px-3 transition-colors">Close</button>
</div>
<div id="cacheViewerBody" class="bg-black flex items-center justify-center p-4"></div>
</div>
</div>
<script>
let allTokens = [], filteredTokens = [], selectedTokens = new Set(), allTagsList = [];
let allLogsData = [], currentLogPage = 1, logsPerPage = 20;
const selectedKeys = new Set();
const $ = (id) => document.getElementById(id);
const apiRequest = async (u, o = {}) => {
const t = localStorage.getItem('adminToken');
if (!t && !u.includes('/login')) return window.location.href = '/login';
if (t) o.headers = { ...o.headers, 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' };
const r = await fetch(u, o);
if (r.status === 401 && !u.includes('/login')) return localStorage.removeItem('adminToken'), window.location.href = '/login';
return r;
};
const showToast = (m, t = 'success') => {
const d = document.createElement('div');
d.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg text-white text-sm z-[9999] transition-all duration-300 transform translate-x-full ${t === 'success' ? 'bg-green-600' : 'bg-red-600'}`;
d.textContent = m;
document.body.appendChild(d);
requestAnimationFrame(() => d.classList.remove('translate-x-full'));
setTimeout(() => { d.classList.add('translate-x-full'); setTimeout(() => d.remove(), 300) }, 3000);
};
const logout = () => { localStorage.removeItem('adminToken'); window.location.href = '/login' };
const switchTab = (target) => {
['tokens', 'statistics', 'keys', 'logs', 'cache', 'settings'].forEach(name => {
const cap = name.charAt(0).toUpperCase() + name.slice(1);
const active = name === target;
const panel = $(`panel${cap}`);
const tab = $(`tab${cap}`);
if (panel) panel.classList.toggle('hidden', !active);
if (tab) {
tab.classList.toggle('border-primary', active);
tab.classList.toggle('text-primary', active);
tab.classList.toggle('border-transparent', !active);
tab.classList.toggle('text-muted-foreground', !active);
}
});
if (target === 'settings') loadSettings();
if (target === 'cache') loadCachePreview(true);
if (target === 'statistics') loadRequestStats();
if (target === 'keys') loadKeys();
if (target === 'logs') loadLogs();
};
// --- Key management ---
const formatDate = (ts) => {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString();
};
const loadKeys = async () => {
try {
const r = await apiRequest('/api/keys');
const d = await r.json();
if (d.success) {
const tbody = $('keyTableBody');
tbody.innerHTML = '';
// Reset selection
selectedKeys.clear();
$('selectAllKeys').checked = false;
updateBatchActionBar();
d.data.forEach(k => {
const tr = document.createElement('tr');
tr.className = 'border-b border-border hover:bg-muted/50 transition-colors';
tr.innerHTML = `
<td class="p-4 align-middle">
<input type="checkbox" data-key="${k.key}" onchange="toggleKeySelection(this)" class="key-checkbox rounded border-border text-primary focus:ring-primary h-4 w-4">
</td>
<td class="p-4 align-middle">
<span class="font-medium">${k.name || '-'}</span>
<button onclick="editKeyName('${k.key}', '${k.name}')" class="ml-2 text-xs text-muted-foreground hover:text-primary">
<svg class="h-3 w-3 inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
</button>
</td>
<td class="p-4 align-middle font-mono text-xs text-muted-foreground">${k.display_key}</td>
<td class="p-4 align-middle text-muted-foreground">${formatDate(k.created_at)}</td>
<td class="p-4 align-middle">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${k.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}">
${k.is_active ? 'Enabled' : 'Disabled'}
</span>
</td>
<td class="p-4 align-middle text-right text-xs">
<button onclick="copyToClipboard('${k.key}')" class="btn-icon text-muted-foreground hover:text-primary mr-2" title="Copy full key">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button onclick="toggleKeyStatus('${k.key}', ${!k.is_active})" class="btn-icon text-muted-foreground hover:text-primary mr-2" title="${k.is_active ? 'Disable' : 'Enable'}">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg>
</button>
<button onclick="deleteKey('${k.key}')" class="btn-icon text-muted-foreground hover:text-destructive" title="Delete">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</td>
`;
tbody.appendChild(tr);
});
}
} catch (e) { console.error(e); showToast('Failed to load keys', 'error') }
};
const openAddKeyModal = () => $('modalAddKey').classList.remove('hidden');
const closeAddKeyModal = () => { $('modalAddKey').classList.add('hidden'); $('newKeyName').value = '' };
const doAddKey = async () => {
const name = $('newKeyName').value.trim();
if (!name) return showToast('Please enter a note', 'error');
try {
const r = await apiRequest('/api/keys/add', { method: 'POST', body: JSON.stringify({ name }) });
const d = await r.json();
if (d.success) {
closeAddKeyModal();
showToast('Key created');
loadKeys();
// Show modal for single creation to make copying easy
$('batchResultContent').value = d.data.key;
$('modalBatchResult').classList.remove('hidden');
} else throw new Error(d.error);
} catch (e) { showToast('Create failed: ' + e.message, 'error') }
};
const openBatchAddModal = () => $('modalBatchAddKey').classList.remove('hidden');
const closeBatchAddModal = () => { $('modalBatchAddKey').classList.add('hidden'); $('batchKeyPrefix').value = ''; $('batchKeyCount').value = 10 };
const doBatchAddKeys = async () => {
const prefix = $('batchKeyPrefix').value.trim();
const count = parseInt($('batchKeyCount').value);
if (!prefix) return showToast('Please enter a name prefix', 'error');
if (isNaN(count) || count < 1 || count > 50) return showToast('Count must be between 1-50', 'error');
try {
const r = await apiRequest('/api/keys/batch-add', { method: 'POST', body: JSON.stringify({ name_prefix: prefix, count }) });
const d = await r.json();
if (d.success) {
closeBatchAddModal();
showToast(`Created ${d.data.length} keys`);
loadKeys();
// Show results
$('batchResultContent').value = d.data.map(k => k.key).join('\n');
$('modalBatchResult').classList.remove('hidden');
} else throw new Error(d.error);
} catch (e) { showToast('Batch create failed: ' + e.message, 'error') }
};
const closeBatchResultModal = () => $('modalBatchResult').classList.add('hidden');
const copyBatchResults = () => {
const content = $('batchResultContent');
content.select();
document.execCommand('copy');
showToast('Copied all keys to clipboard', 'success');
};
const toggleSelectAllKeys = (el) => {
const boxes = document.querySelectorAll('.key-checkbox');
boxes.forEach(cb => {
cb.checked = el.checked;
if (el.checked) selectedKeys.add(cb.dataset.key);
else selectedKeys.delete(cb.dataset.key);
});
updateBatchActionBar();
};
const toggleKeySelection = (el) => {
if (el.checked) selectedKeys.add(el.dataset.key);
else selectedKeys.delete(el.dataset.key);
$('selectAllKeys').checked = selectedKeys.size === document.querySelectorAll('.key-checkbox').length;
updateBatchActionBar();
};
const updateBatchActionBar = () => {
const bar = $('batchActionBar');
const count = $('selectedCount');
if (count) count.textContent = selectedKeys.size;
if (bar) bar.classList.toggle('hidden', selectedKeys.size === 0);
};
const batchDeleteKeys = async () => {
if (!selectedKeys.size || !confirm(`Delete the selected ${selectedKeys.size} keys? This cannot be undone.`)) return;
try {
const r = await apiRequest('/api/keys/batch-delete', { method: 'POST', body: JSON.stringify({ keys: Array.from(selectedKeys) }) });
const d = await r.json();
if (d.success) { showToast(d.message); loadKeys() }
} catch (e) { showToast('Batch delete failed', 'error') }
};
const batchUpdateStatus = async (isActive) => {
if (!selectedKeys.size) return;
try {
const r = await apiRequest('/api/keys/batch-status', { method: 'POST', body: JSON.stringify({ keys: Array.from(selectedKeys), is_active: isActive }) });
const d = await r.json();
if (d.success) { showToast(d.message); loadKeys() }
} catch (e) { showToast('Batch update failed', 'error') }
};
const deleteKey = async (key) => {
if (!confirm('Delete this key? This cannot be undone.')) return;
try {
const r = await apiRequest('/api/keys/delete', { method: 'POST', body: JSON.stringify({ key }) });
if ((await r.json()).success) { showToast('Deleted successfully'); loadKeys() }
} catch (e) { showToast('Delete failed', 'error') }
};
const toggleKeyStatus = async (key, isActive) => {
try {
await apiRequest('/api/keys/status', { method: 'POST', body: JSON.stringify({ key, is_active: isActive }) });
showToast('Status updated'); loadKeys();
} catch (e) { showToast('Update failed', 'error') }
};
const editKeyName = async (key, oldName) => {
const name = prompt("Edit note:", oldName);
if (name && name !== oldName) {
try {
await apiRequest('/api/keys/name', { method: 'POST', body: JSON.stringify({ key, name }) });
showToast('Note updated'); loadKeys();
} catch (e) { showToast('Update failed', 'error') }
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard')).catch(() => showToast('Copy failed', 'error'));
};
// --- Audit logs ---
const loadLogs = async () => {
try {
const r = await apiRequest('/api/logs?limit=1000');
const d = await r.json();
if (d.success) {
allLogsData = d.data;
currentLogPage = 1;
renderLogs();
}
} catch (e) { console.error('Failed to load logs:', e); showToast('Failed to load logs', 'error') }
};
const renderLogs = () => {
const tbody = $('logTableBody');
tbody.innerHTML = '';
const total = allLogsData.length;
$('logEmptyState').classList.toggle('hidden', total > 0);
$('logPagination').classList.toggle('hidden', total === 0);
const maxPage = Math.max(1, Math.ceil(total / logsPerPage));
if (currentLogPage > maxPage) currentLogPage = maxPage;
$('logTotalCount').textContent = total;
$('logCurrentPage').textContent = currentLogPage;
$('logMaxPage').textContent = maxPage;
$('logPrevBtn').disabled = currentLogPage <= 1;
$('logNextBtn').disabled = currentLogPage >= maxPage;
const start = (currentLogPage - 1) * logsPerPage;
const end = start + logsPerPage;
const pageData = allLogsData.slice(start, end);
pageData.forEach(l => {
const tr = document.createElement('tr');
tr.className = 'border-b border-border hover:bg-muted/50 transition-colors';
const statusClass = l.status === 200 ? 'text-green-600' : 'text-red-600';
tr.innerHTML = `
<td class="p-4 align-middle text-muted-foreground whitespace-nowrap">${l.time}</td>
<td class="p-4 align-middle font-medium">${l.key_name}</td>
<td class="p-4 align-middle text-xs">${l.model}</td>
<td class="p-4 align-middle text-xs text-muted-foreground">${l.ip}</td>
<td class="p-4 align-middle text-xs">${l.duration}s</td>
<td class="p-4 align-middle ${statusClass} font-bold">${l.status}</td>
<td class="p-4 align-middle">
${l.error ? `<span class="text-red-500 text-xs" title="${l.error.replace(/"/g, '&quot;')}">Error details</span>` : '-'}
</td>
`;
tbody.appendChild(tr);
});
};
const changeLogPage = (delta) => {
currentLogPage += delta;
renderLogs();
$('logTableBody').closest('.overflow-auto').scrollTop = 0;
};
const clearLogs = async () => {
if (!confirm('Clear all logs?')) return;
try {
await apiRequest('/api/logs/clear', { method: 'POST' });
showToast('Logs cleared'); loadLogs();
} catch (e) { showToast('Clear failed', 'error') }
};
// checkAuth removed (using new apiRequest)
// apiRequest removed (using new version)
const loadStats = async () => {
try {
const r = await apiRequest('/api/stats');
if (!r) return;
const d = await r.json();
if (d.success) {
const s = d.data || {};
const normal = s.normal || {};
const superStats = s.super || {};
const sum = (key) => (normal[key] || 0) + (superStats[key] || 0);
$('statTotal').textContent = s.total || 0;
$('statActive').textContent = sum('active');
$('statUnused').textContent = sum('unused');
$('statLimited').textContent = sum('limited');
$('statExpired').textContent = sum('expired');
$('statCooldown').textContent = sum('cooldown');
$('statExhausted').textContent = sum('exhausted');
}
} catch (e) { console.error('Failed to load stats:', e) }
};
const calcRemaining = () => { let n = 0, h = 0; allTokens.forEach(t => { if (t.remaining_queries > 0) n += t.remaining_queries; if (t.heavy_remaining_queries > 0) h += t.heavy_remaining_queries }); return { normal: n, heavy: h, total: n + h } };
const loadTokens = async () => {
try {
const r = await apiRequest('/api/tokens');
if (!r) return;
const d = await r.json();
if (d.success) {
allTokens = d.data.map(t => ({
...t,
tags: t.tags || [],
note: t.note || '',
cooldown_remaining: t.cooldown_remaining || 0,
cooldown_until: t.cooldown_until || null,
last_failure_time: t.last_failure_time || null,
last_failure_reason: t.last_failure_reason || '',
limit_reason: t.limit_reason || ''
}));
filteredTokens = allTokens;
selectedTokens.clear();
renderTokens();
updateRemaining();
await loadAllTags();
}
} catch (e) { console.error('Failed to load list:', e) }
};
const updateRemaining = () => { const r = calcRemaining(); const chatTotal = r.total; const imageTotal = Math.floor(chatTotal / 2); $('statChatRemaining').textContent = chatTotal === 0 ? '-' : chatTotal.toLocaleString(); $('statImageRemaining').textContent = imageTotal === 0 ? '-' : imageTotal.toLocaleString(); $('statVideoRemaining').textContent = 'Unavailable' };
const formatDuration = (seconds) => {
if (!seconds || seconds <= 0) return '-';
const sec = Math.floor(seconds % 60);
const min = Math.floor(seconds / 60) % 60;
const hour = Math.floor(seconds / 3600);
const parts = [];
if (hour) parts.push(`${hour}h`);
if (min) parts.push(`${min}m`);
if (!hour && !min) parts.push(`${sec}s`);
else if (sec) parts.push(`${sec}s`);
return parts.join('');
};
const formatDateTime = (ms) => {
if (!ms) return '-';
return new Date(ms).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' });
};
const renderTokens = () => {
const tb = $('tokenTableBody');
const es = $('emptyState');
const ss = {
'unused': 'bg-muted text-muted-foreground',
'cooldown': 'bg-orange-50 text-orange-700 border-orange-200',
'exhausted': 'bg-amber-50 text-amber-700 border-amber-200',
'limited': 'bg-orange-50 text-orange-700 border-orange-200',
'expired': 'bg-destructive/10 text-destructive border-destructive/20',
'active': 'bg-green-50 text-green-700 border-green-200'
};
const ts = { sso: 'bg-blue-50 text-blue-700 border-blue-200', ssoSuper: 'bg-purple-50 text-purple-700 border-purple-200' };
const tl = { sso: 'SSO', ssoSuper: 'SuperSSO' };
const limitLabels = { cooldown: '429 cooldown', exhausted: 'exhausted' };
if (!filteredTokens.length) { tb.innerHTML = ''; es.classList.remove('hidden'); $('selectAll').checked = false; return updateBatchActions() }
es.classList.add('hidden');
tb.innerHTML = filteredTokens.map(t => {
const tagsHtml = t.tags && t.tags.length ? t.tags.map(tag => `<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">${tag}</span>`).join(' ') : '<span class="text-xs text-muted-foreground">-</span>';
const noteHtml = t.note && t.note.length ? `<span class="text-xs text-gray-700" title="${t.note}">${t.note.length > 20 ? t.note.substring(0, 20) + '...' : t.note}</span>` : '<span class="text-xs text-muted-foreground">-</span>';
const limitReasonText = limitLabels[t.limit_reason] || '-';
const cooldownText = t.cooldown_remaining ? formatDuration(t.cooldown_remaining) : '-';
const cooldownTitle = t.cooldown_until ? formatDateTime(t.cooldown_until) : '';
const limitInfoHtml = `<div class="text-xs ${limitReasonText === '-' ? 'text-muted-foreground' : 'text-gray-700'}"><div>${limitReasonText}</div><div${cooldownTitle ? ` title="${cooldownTitle}"` : ''}>${cooldownText}</div></div>`;
const failureReason = t.last_failure_reason || '-';
const failureTitle = failureReason.replace(/"/g, '&quot;');
const failureDisplay = failureReason.length > 24 ? failureReason.substring(0, 24) + '...' : failureReason;
const failureTime = t.last_failure_time ? formatDateTime(t.last_failure_time) : '-';
const failureHtml = `<div class="text-xs ${failureReason === '-' ? 'text-muted-foreground' : 'text-gray-700'}" title="${failureTitle}">${failureDisplay}</div><div class="text-xs text-muted-foreground">${failureTime}</div>`;
return `<tr class="transition-colors"><td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token) ? 'checked' : ''} onchange="toggleToken('${t.token}')"></td><td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0, 30)}...</span><button onclick="copyToken('${t.token.replace(/'/g, "\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="Copy full token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td><td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status] || ss['active']}">${t.status}</span></td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries === -1 ? '-' : t.remaining_queries}</td><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries === -1 ? '-' : t.heavy_remaining_queries}</td><td class="py-2.5 px-3 align-middle w-40">${limitInfoHtml}</td><td class="py-2.5 px-3 align-middle w-48">${failureHtml}</td><td class="py-2.5 px-3 align-middle w-32"><div class="flex flex-wrap gap-1">${tagsHtml}</div></td><td class="py-2.5 px-3 align-middle w-40">${noteHtml}</td><td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time ? new Date(t.created_time).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' }) : '-'}</td><td class="py-2.5 px-3 align-middle text-right w-28 sticky-right"><div class="flex items-center justify-end gap-1"><button onclick="testToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-blue-50 hover:text-blue-700 h-7 w-7" title="Test token"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></button><button onclick="editToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-7 w-7" title="Edit info"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7" title="Delete"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button></div></td></tr>`;
}).join('');
updateBatchActions();
};
const toggleToken = t => selectedTokens[selectedTokens.has(t) ? 'delete' : 'add'](t) || updateBatchActions();
const toggleSelectAll = () => { const sa = $('selectAll'); sa.checked ? filteredTokens.forEach(t => selectedTokens.add(t.token)) : selectedTokens.clear(); renderTokens() };
const updateBatchActions = () => { const ba = $('batchActions'), sc = $('selectedCount'), c = selectedTokens.size; ba.classList[c > 0 ? 'add' : 'remove']('flex'); ba.classList[c > 0 ? 'remove' : 'add']('hidden'); c > 0 && (sc.textContent = `Selected ${c} items`); $('selectAll').checked = filteredTokens.length > 0 && c === filteredTokens.length };
const filterTokens = () => { const tf = $('filterType').value, sf = $('filterStatus').value, tagf = $('filterTag').value; filteredTokens = allTokens.filter(t => (tf === 'all' || t.token_type === tf) && (sf === 'all' || t.status === sf) && (tagf === 'all' || t.tags && t.tags.includes(tagf))); selectedTokens.clear(); renderTokens() };
const loadAllTags = async () => { try { const r = await apiRequest('/api/tokens/tags/all'); if (!r) return; const d = await r.json(); if (d.success) { allTagsList = d.data; const tagFilter = $('filterTag'); const currentValue = tagFilter.value; tagFilter.innerHTML = '<option value="all">All Tags</option>' + allTagsList.map(tag => `<option value="${tag}">${tag}</option>`).join(''); tagFilter.value = currentValue } } catch (e) { console.error('Failed to load tag list:', e) } };
const refreshTokens = async () => { await loadTokens(); await loadStats() };
const refreshAllTokens = async () => {
if (!confirm('Refresh remaining counts for all tokens? This may take a while...')) return;
try {
const r = await apiRequest('/api/tokens/refresh-all', { method: 'POST' });
if (!r) return;
const d = await r.json();
if (!d.success && d.data?.running) {
showToast('Refresh task already running, please try again later', 'error');
return;
}
if (!d.success) {
showToast(d.message || 'Failed to start refresh', 'error');
return;
}
showToast('Refresh task started...', 'info');
await new Promise(resolve => setTimeout(resolve, 300));
while (true) {
await new Promise(resolve => setTimeout(resolve, 500));
try {
const pr = await apiRequest('/api/tokens/refresh-progress');
if (!pr) break;
const pd = await pr.json();
if (pd.success && pd.data) {
const { current, total, success, failed, running } = pd.data;
if (!running) {
showToast(`Refresh complete: ${success} succeeded, ${failed} failed`, 'success');
break;
}
if (total > 0) {
const pct = Math.round((current / total) * 100);
showToast(`Refresh progress: ${current}/${total} (${pct}%) | success: ${success} failed: ${failed}`, 'info');
}
}
} catch (e) { break; }
}
await refreshTokens();
} catch (e) {
showToast('Refresh failed: ' + e.message, 'error');
}
};
const openAddModal = () => $('addModal').classList.remove('hidden');
const closeAddModal = () => { $('addModal').classList.add('hidden'); $('addTokenList').value = '' };
const deleteToken = async (t, tt) => { if (!confirm('Delete this token?')) return; try { const r = await apiRequest('/api/tokens/delete', { method: 'POST', body: JSON.stringify({ tokens: [t], token_type: tt }) }); if (!r) return; const d = await r.json(); d.success ? await refreshTokens() : showToast('Delete failed: ' + (d.error || 'Unknown error'), 'error') } catch (e) { showToast('Delete failed: ' + e.message, 'error') } };
const batchDelete = async () => { if (!selectedTokens.size || !confirm(`Delete the selected ${selectedTokens.size} tokens? This cannot be undone.`)) return; const tbt = { sso: [], ssoSuper: [] }; document.querySelectorAll('.token-checkbox:checked').forEach(cb => tbt[cb.dataset.type].push(cb.dataset.token)); try { const ps = [];['sso', 'ssoSuper'].forEach(k => tbt[k].length && ps.push(apiRequest('/api/tokens/delete', { method: 'POST', body: JSON.stringify({ tokens: tbt[k], token_type: k }) }))); await Promise.all(ps); await refreshTokens() } catch (e) { showToast('Batch delete failed: ' + e.message, 'error') } };
const submitAddTokens = async () => { const tt = $('addTokenType').value, tks = $('addTokenList').value.split('\n').map(t => t.trim()).filter(t => t); if (!tks.length) return showToast('Please enter at least one token', 'error'); try { const r = await apiRequest('/api/tokens/add', { method: 'POST', body: JSON.stringify({ tokens: tks, token_type: tt }) }); if (!r) return; const d = await r.json(); d.success ? (closeAddModal(), await refreshTokens()) : showToast('Add failed: ' + (d.error || 'Unknown error'), 'error') } catch (e) { showToast('Add failed: ' + e.message, 'error') } };
const copyToken = async (t, e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(t); showToast('Token copied to clipboard', 'success') } catch (err) { console.error('Copy failed:', err); showToast('Copy failed, please copy manually', 'error') } };
let currentEditToken = '', currentEditTokenType = '';
const editToken = (token, tokenType) => { currentEditToken = token; currentEditTokenType = tokenType; const tokenData = allTokens.find(t => t.token === token); const currentTags = tokenData?.tags || []; const currentNote = tokenData?.note || ''; $('editTokenInput').value = token.substring(0, 50) + '...'; $('editTagsInput').value = currentTags.join(', '); $('editNoteInput').value = currentNote; const suggestedContainer = $('suggestedTags'); suggestedContainer.innerHTML = ''; if (allTagsList.length > 0) { suggestedContainer.innerHTML = '<div class="text-xs text-muted-foreground mb-1">Suggested tags:</div>' + allTagsList.map(tag => `<button onclick="addTagToInput('${tag}')" class="inline-flex items-center rounded px-2 py-1 text-xs bg-muted hover:bg-accent transition-colors">${tag}</button>`).join('') } $('editTagsModal').classList.remove('hidden') };
const closeEditModal = () => { $('editTagsModal').classList.add('hidden'); currentEditToken = ''; currentEditTokenType = '' };
const addTagToInput = (tag) => { const input = $('editTagsInput'); const currentValue = input.value.trim(); const tags = currentValue ? currentValue.split(',').map(t => t.trim()) : []; if (!tags.includes(tag)) { tags.push(tag); input.value = tags.join(', ') } };
const submitEditInfo = async () => { if (!currentEditToken) return; const tagsInput = $('editTagsInput').value; const note = $('editNoteInput').value.trim(); const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t); const promises = []; promises.push(apiRequest('/api/tokens/tags', { method: 'POST', body: JSON.stringify({ token: currentEditToken, token_type: currentEditTokenType, tags }) })); promises.push(apiRequest('/api/tokens/note', { method: 'POST', body: JSON.stringify({ token: currentEditToken, token_type: currentEditTokenType, note }) })); try { const results = await Promise.all(promises); const allSuccess = results.every(r => r && r.ok); if (allSuccess) { closeEditModal(); await refreshTokens(); showToast('Info updated', 'success') } else { showToast('Some updates failed, please retry', 'error') } } catch (e) { showToast('Update failed: ' + e.message, 'error') } };
const testToken = async (token, tokenType) => { const btn = event.target.closest('button'); const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<svg class="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>'; try { const r = await apiRequest('/api/tokens/test', { method: 'POST', body: JSON.stringify({ token, token_type: tokenType }) }); if (!r) return; const d = await r.json(); if (d.success && d.data.valid) { showToast(`Token valid! Remaining: ${d.data.remaining_queries === -1 ? 'Unlimited' : d.data.remaining_queries}`, 'success'); await refreshTokens() } else { const errMsgs = { expired: 'Token expired (401)', blocked: 'Server blocked, try later or change IP', cooldown: 'Token in cooldown', exhausted: 'Token quota exhausted' }; if (d.data?.error_type === 'cooldown' && d.data?.cooldown_remaining) { showToast(`Token in cooldown, remaining ${formatDuration(d.data.cooldown_remaining)}`, 'error') } else { showToast(errMsgs[d.data?.error_type] || 'Token invalid or expired', 'error') } } } catch (e) { showToast('Test failed: ' + e.message, 'error') } finally { btn.disabled = false; btn.innerHTML = originalHtml } };
const exportSelected = () => { if (!selectedTokens.size) return showToast('Please select tokens to export', 'error'); const sd = allTokens.filter(t => selectedTokens.has(t.token)), limitLabels = { cooldown: '429 cooldown', exhausted: 'exhausted' }, csv = [['Token', 'Type', 'Status', 'Standard Remaining', 'Premium Remaining', 'Limit Reason', 'Cooldown Remaining', 'Recent Failure Reason', 'Recent Failure Time', 'Created'].join(','), ...sd.map(t => { const limitReason = limitLabels[t.limit_reason] || '-'; const cooldown = t.cooldown_remaining ? formatDuration(t.cooldown_remaining) : '-'; const failureReason = t.last_failure_reason || '-'; const failureTime = t.last_failure_time ? formatDateTime(t.last_failure_time) : '-'; return [`"${t.token}"`, t.token_type === 'sso' ? 'SSO' : 'SuperSSO', t.status, t.remaining_queries === -1 ? 'unused' : t.remaining_queries, t.heavy_remaining_queries === -1 ? 'unused' : t.heavy_remaining_queries, limitReason, cooldown, `"${failureReason.replace(/"/g, '""')}"`, `"${failureTime}"`, `"${t.created_time ? new Date(t.created_time).toLocaleString('en-US') : '-'}"`].join(',') })].join('\n'), l = document.createElement('a'); l.href = URL.createObjectURL(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })); l.download = `grok_tokens_${new Date().toISOString().slice(0, 10)}.csv`; l.style.display = 'none'; document.body.appendChild(l); l.click(); document.body.removeChild(l); URL.revokeObjectURL(l.href); showToast(`Exported ${selectedTokens.size} tokens`, 'success') }
// showToast, logout, switchTab are defined above
const updateCacheProxyReadonly = () => { const proxyUrl = $('cfgProxyUrl').value.trim(), cacheProxyInput = $('cfgCacheProxyUrl'); if (proxyUrl) { cacheProxyInput.readOnly = false; cacheProxyInput.classList.remove('bg-muted'); cacheProxyInput.placeholder = 'socks5://username:password@127.0.0.1:7890' } else { cacheProxyInput.readOnly = true; cacheProxyInput.classList.add('bg-muted'); cacheProxyInput.value = ''; cacheProxyInput.placeholder = 'Enabled after setting service proxy' } };
const updateStatsigIdState = () => { const dynamicToggle = $('cfgDynamicStatsig'), statsigInput = $('cfgStatsigId'); if (dynamicToggle.checked) { statsigInput.disabled = true; statsigInput.classList.add('bg-muted', 'text-muted-foreground'); statsigInput.placeholder = 'Dynamic generation enabled' } else { statsigInput.disabled = false; statsigInput.classList.remove('bg-muted', 'text-muted-foreground'); statsigInput.placeholder = '' } };
const loadRequestStats = async () => {
try {
const r = await apiRequest('/api/request-stats');
if (!r) return;
const d = await r.json();
if (d.success) {
const { hourly, daily, models, summary } = d.data;
$('reqStatTotal').textContent = summary.total;
$('reqStatSuccess').textContent = summary.success;
$('reqStatFailed').textContent = summary.failed;
$('reqStatRate').textContent = summary.success_rate + '%';
['hourlyChart', 'dailyChart', 'modelsChart'].forEach(id => {
const canvas = document.getElementById(id);
if (canvas.chart) canvas.chart.destroy();
});
const ctxHourly = document.getElementById('hourlyChart').getContext('2d');
document.getElementById('hourlyChart').chart = new Chart(ctxHourly, {
type: 'line',
data: {
labels: hourly.map(i => i.hour),
datasets: [
{ label: 'Success', data: hourly.map(i => i.success), borderColor: '#16a34a', tension: 0.3 },
{ label: 'Failed', data: hourly.map(i => i.failed), borderColor: '#dc2626', tension: 0.3 }
]
},
options: { responsive: true, interaction: { mode: 'index', intersect: false } }
});
const ctxDaily = document.getElementById('dailyChart').getContext('2d');
document.getElementById('dailyChart').chart = new Chart(ctxDaily, {
type: 'bar',
data: {
labels: daily.map(i => i.date),
datasets: [
{ label: 'Success', data: daily.map(i => i.success), backgroundColor: '#16a34a' },
{ label: 'Failed', data: daily.map(i => i.failed), backgroundColor: '#dc2626' }
]
},
options: { responsive: true, scales: { x: { stacked: true }, y: { stacked: true } } }
});
const ctxModels = document.getElementById('modelsChart').getContext('2d');
document.getElementById('modelsChart').chart = new Chart(ctxModels, {
type: 'doughnut',
data: {
labels: models.map(i => i.model),
datasets: [{
data: models.map(i => i.count),
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#6366f1', '#14b8a6']
}]
},
options: { responsive: true, plugins: { legend: { position: 'right' } } }
});
}
} catch (e) { console.error('Failed to load stats:', e); showToast('Failed to load stats', 'error') }
};
const loadSettings = async () => { try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (d.success) { const g = d.data.global, k = d.data.grok; const cfClearance = k.cf_clearance || ''; const cleanCfDisplay = cfClearance.startsWith('cf_clearance=') ? cfClearance.split('cf_clearance=')[1] : cfClearance; $('cfgAdminUser').value = g.admin_username || ''; $('cfgAdminPass').value = ''; $('cfgLogLevel').value = g.log_level || 'DEBUG'; $('cfgImageCacheMaxSize').value = g.image_cache_max_size_mb || 500; $('cfgVideoCacheMaxSize').value = g.video_cache_max_size_mb || 1000; $('cfgImageMode').value = g.image_mode || 'url'; $('cfgBaseUrl').value = g.base_url || ''; $('cfgApiKey').value = k.api_key || ''; $('cfgProxyUrl').value = k.proxy_url || ''; $('cfgProxyPoolUrl').value = k.proxy_pool_url || ''; $('cfgProxyPoolInterval').value = k.proxy_pool_interval || 300; $('cfgCacheProxyUrl').value = k.cache_proxy_url || ''; $('cfgCfClearance').value = cleanCfDisplay; $('cfgStatsigId').value = k.x_statsig_id || ''; $('cfgDynamicStatsig').checked = k.dynamic_statsig !== false; updateStatsigIdState(); $('cfgFilteredTags').value = k.filtered_tags || ''; $('cfgShowThinking').value = k.show_thinking !== false ? 'true' : 'false'; $('cfgTemporary').value = k.temporary !== false ? 'true' : 'false'; $('cfgStreamChunkTimeout').value = k.stream_chunk_timeout || 120; $('cfgStreamFirstResponseTimeout').value = k.stream_first_response_timeout || 30; $('cfgStreamTotalTimeout').value = k.stream_total_timeout || 600; $('cfgRetryStatusCodes').value = (k.retry_status_codes || [401, 429]).join(','); updateCacheProxyReadonly(); await loadCacheSize() } } catch (e) { console.error('Failed to load settings:', e); showToast('Failed to load settings', 'error') } };
const loadCacheSize = async () => { try { const r = await apiRequest('/api/cache/size'); if (!r) return; const d = await r.json(); if (d.success) { ['image', 'video', 'total'].forEach(t => $(`${t}CacheSize`).value = d.data[`${t}_size`] || '0 MB') } } catch (e) { console.error('Failed to load cache size:', e);['image', 'video', 'total'].forEach(t => $(`${t}CacheSize`).value = '0 MB') } };
const clearCacheByType = async (type, url, msg) => { if (!confirm(msg)) return; try { const r = await apiRequest(url, { method: 'POST' }); if (!r) return; const d = await r.json(); d.success ? (showToast(`${type} cache cleared, deleted ${d.data.deleted_count || 0} files`, 'success'), await loadCacheSize(), await loadCachePreview(true)) : showToast('Clear failed: ' + (d.error || 'Unknown error'), 'error') } catch (e) { showToast('Clear failed: ' + e.message, 'error') } };
const clearImageCache = () => clearCacheByType('Image', '/api/cache/clear/images', 'Clear image cache? This will delete all cached images.');
const clearVideoCache = () => clearCacheByType('Video', '/api/cache/clear/videos', 'Clear video cache? This will delete all cached videos.');
const clearCache = () => clearCacheByType('', '/api/cache/clear', 'Clear cache? This will delete all files under /data/temp.');
const cachePreviewState = { type: 'image', limit: 24, offset: 0, total: 0, items: [], loading: false };
const escapeHtml = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const updateCachePreviewMeta = () => { const meta = $('cachePreviewMeta'); if (!meta) return; const label = cachePreviewState.type === 'image' ? 'Image' : 'Video'; meta.textContent = `${label} cache: ${cachePreviewState.total || 0} items` };
const updateCachePreviewTabs = () => {
const imageTab = $('cacheTabImage'), videoTab = $('cacheTabVideo');
if (!imageTab || !videoTab) return;
const isImage = cachePreviewState.type === 'image';
imageTab.classList.toggle('bg-primary', isImage);
imageTab.classList.toggle('text-primary-foreground', isImage);
imageTab.classList.toggle('bg-muted', !isImage);
imageTab.classList.toggle('text-muted-foreground', !isImage);
videoTab.classList.toggle('bg-primary', !isImage);
videoTab.classList.toggle('text-primary-foreground', !isImage);
videoTab.classList.toggle('bg-muted', isImage);
videoTab.classList.toggle('text-muted-foreground', isImage);
};
const updateCachePreviewLoadMore = () => {
const btn = $('cacheLoadMoreBtn');
if (!btn) return;
const hasMore = cachePreviewState.offset < cachePreviewState.total;
const disabled = cachePreviewState.loading || !hasMore;
btn.disabled = disabled;
btn.textContent = cachePreviewState.loading ? 'Loading...' : hasMore ? 'Load more' : 'No more';
btn.classList.toggle('opacity-50', disabled);
};
const renderCachePreview = () => {
const grid = $('cachePreviewGrid');
if (!grid) return;
if (!cachePreviewState.items.length) {
const emptyText = cachePreviewState.loading ? 'Loading...' : 'No cached files';
grid.innerHTML = `<div class="col-span-full text-sm text-muted-foreground text-center py-6">${emptyText}</div>`;
updateCachePreviewMeta();
updateCachePreviewLoadMore();
return;
}
grid.innerHTML = cachePreviewState.items.map(item => {
const rawName = item.name || '';
const name = escapeHtml(rawName);
const time = item.mtime ? new Date(item.mtime).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) : '-';
const metaText = `${item.size || '-'} • ${time}`;
const meta = escapeHtml(metaText);
const url = escapeHtml(item.url || '');
const type = cachePreviewState.type;
const dataName = encodeURIComponent(rawName);
const dataMeta = encodeURIComponent(metaText);
const media = type === 'video'
? `<video src="${url}" class="w-full h-32 bg-black object-cover" preload="metadata" muted playsinline></video>`
: `<img src="${url}" alt="${name}" loading="lazy" class="w-full h-32 object-cover bg-muted">`;
return `<div class="cache-preview-item rounded-md border border-border bg-muted/20 overflow-hidden cursor-pointer" data-url="${url}" data-type="${type}" data-name="${dataName}" data-meta="${dataMeta}"><div>${media}</div><div class="p-2 text-xs"><div class="truncate" title="${name}">${name}</div><div class="text-muted-foreground">${meta}</div><button type="button" class="text-primary hover:underline" data-action="open">Preview</button></div></div>`;
}).join('');
bindCachePreviewClicks();
updateCachePreviewMeta();
updateCachePreviewLoadMore();
};
const bindCachePreviewClicks = () => {
const grid = $('cachePreviewGrid');
if (!grid || grid.dataset.bound === '1') return;
grid.dataset.bound = '1';
grid.addEventListener('click', (e) => {
const item = e.target.closest('.cache-preview-item');
if (!item) return;
const url = item.dataset.url || '';
const type = item.dataset.type || 'image';
const name = decodeURIComponent(item.dataset.name || '');
const meta = decodeURIComponent(item.dataset.meta || '');
openCacheViewer(url, type, name, meta);
});
};
const openCacheViewer = (url, type, name, meta) => {
const modal = $('cacheViewerModal');
const title = $('cacheViewerTitle');
const metaEl = $('cacheViewerMeta');
const body = $('cacheViewerBody');
if (!modal || !body) return;
title.textContent = name || 'Cache Preview';
metaEl.textContent = meta || '';
body.innerHTML = '';
if (type === 'video') {
const video = document.createElement('video');
video.src = url;
video.controls = true;
video.className = 'max-h-[70vh] w-auto max-w-full bg-black';
body.appendChild(video);
} else {
const img = document.createElement('img');
img.src = url;
img.alt = name || 'Cached image';
img.className = 'max-h-[70vh] w-auto max-w-full object-contain';
body.appendChild(img);
}
modal.classList.remove('hidden');
};
const closeCacheViewer = () => {
const modal = $('cacheViewerModal');
const body = $('cacheViewerBody');
if (body) body.innerHTML = '';
if (modal) modal.classList.add('hidden');
};
const initCacheViewer = () => {
const modal = $('cacheViewerModal');
if (!modal || modal.dataset.bound === '1') return;
modal.dataset.bound = '1';
modal.addEventListener('click', (e) => { if (e.target === modal) closeCacheViewer(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) closeCacheViewer();
});
};
const loadCachePreview = async (reset = false) => {
if (cachePreviewState.loading) return;
cachePreviewState.loading = true;
if (reset) {
cachePreviewState.offset = 0;
cachePreviewState.total = 0;
cachePreviewState.items = [];
}
updateCachePreviewTabs();
renderCachePreview();
try {
const url = `/api/cache/list?type=${cachePreviewState.type}&limit=${cachePreviewState.limit}&offset=${cachePreviewState.offset}`;
const r = await apiRequest(url);
if (!r) return;
const d = await r.json();
if (d.success && d.data) {
const items = d.data.items || [];
cachePreviewState.total = d.data.total || 0;
cachePreviewState.items = reset ? items : cachePreviewState.items.concat(items);
cachePreviewState.offset = (d.data.offset || 0) + items.length;
}
} catch (e) {
console.error('Failed to load cache preview:', e);
showToast('Failed to load cache preview', 'error');
} finally {
cachePreviewState.loading = false;
renderCachePreview();
}
};
const switchCachePreviewType = (type) => { if (!['image', 'video'].includes(type) || cachePreviewState.type === type) return; cachePreviewState.type = type; loadCachePreview(true) };
const refreshCachePreview = () => loadCachePreview(true);
const loadMoreCachePreview = () => loadCachePreview(false);
const saveGlobalSettings = async () => { const gc = { admin_username: $('cfgAdminUser').value, log_level: $('cfgLogLevel').value, image_cache_max_size_mb: parseInt($('cfgImageCacheMaxSize').value) || 500, video_cache_max_size_mb: parseInt($('cfgVideoCacheMaxSize').value) || 1000, image_mode: $('cfgImageMode').value, base_url: $('cfgBaseUrl').value }; if ($('cfgAdminPass').value) gc.admin_password = $('cfgAdminPass').value; try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (!d.success) return showToast('Failed to load settings', 'error'); const s = await apiRequest('/api/settings', { method: 'POST', body: JSON.stringify({ global_config: gc, grok_config: d.data.grok }) }); if (!s) return; const sd = await s.json(); sd.success ? (showToast('Global settings saved', 'success'), $('cfgAdminPass').value = '') : showToast('Save failed: ' + (sd.error || 'Unknown error'), 'error') } catch (e) { showToast('Save failed: ' + e.message, 'error') } };
const saveGrokSettings = async () => { const pu = $('cfgProxyUrl').value.trim(), cf = $('cfgCfClearance').value.trim(); const cleanCf = cf.startsWith('cf_clearance=') ? cf.split('cf_clearance=')[1] : cf; const retryCodesStr = $('cfgRetryStatusCodes').value.trim(); const retryCodes = retryCodesStr ? retryCodesStr.split(',').map(c => parseInt(c.trim())).filter(c => !isNaN(c)) : [401, 429]; const kc = { api_key: $('cfgApiKey').value, proxy_url: pu, proxy_pool_url: $('cfgProxyPoolUrl').value.trim(), proxy_pool_interval: parseInt($('cfgProxyPoolInterval').value) || 300, cache_proxy_url: pu ? $('cfgCacheProxyUrl').value : '', cf_clearance: cleanCf, x_statsig_id: $('cfgStatsigId').value, dynamic_statsig: $('cfgDynamicStatsig').checked, filtered_tags: $('cfgFilteredTags').value, show_thinking: $('cfgShowThinking').value === 'true', temporary: $('cfgTemporary').value === 'true', stream_chunk_timeout: parseInt($('cfgStreamChunkTimeout').value) || 120, stream_first_response_timeout: parseInt($('cfgStreamFirstResponseTimeout').value) || 30, stream_total_timeout: parseInt($('cfgStreamTotalTimeout').value) || 600, retry_status_codes: retryCodes }; try { const r = await apiRequest('/api/settings'); if (!r) return; const d = await r.json(); if (!d.success) return showToast('Failed to load settings', 'error'); const s = await apiRequest('/api/settings', { method: 'POST', body: JSON.stringify({ global_config: d.data.global, grok_config: kc }) }); if (!s) return; const sd = await s.json(); sd.success ? showToast('Grok settings saved', 'success') : showToast('Save failed: ' + (sd.error || 'Unknown error'), 'error') } catch (e) { showToast('Save failed: ' + e.message, 'error') } };
const updateHoverCardPosition = c => { const t = c.querySelector('.hover-card-trigger'), ct = c.querySelector('.hover-card-content'); if (!t || !ct) return; const { top, bottom } = t.getBoundingClientRect(), h = window.innerHeight; ct.classList.remove('top', 'bottom'); const { visibility: v, opacity: o } = getComputedStyle(ct); Object.assign(ct.style, { visibility: 'hidden', opacity: '1' }); const ch = ct.offsetHeight; Object.assign(ct.style, { visibility: v, opacity: o }); ct.classList.add(top > ch + 10 ? 'top' : h - bottom > ch + 10 ? 'bottom' : 'top') };
const loadStorageMode = async () => { const modeConfig = { MYSQL: { classes: ['bg-blue-50', 'text-blue-700', 'border-blue-200'], tooltip: 'Database mode - persistent storage, slower config changes but safer' }, REDIS: { classes: ['bg-purple-50', 'text-purple-700', 'border-purple-200'], tooltip: 'Redis cache mode - fast memory storage with persistence' }, FILE: { classes: ['bg-green-50', 'text-green-700', 'border-green-200'], tooltip: 'File storage mode - local files with fast read/write' } }; const applyMode = (mode) => { $('storageModeText').textContent = mode; const config = modeConfig[mode] || modeConfig.FILE; $('storageMode').classList.add(...config.classes); $('storageModeTooltip').textContent = config.tooltip; updateHoverCardPosition($('storageMode').closest('.hover-card')) }; try { const r = await apiRequest('/api/storage/mode'); if (!r) return; const d = await r.json(); d.success && applyMode(d.data.mode) } catch (e) { console.error('Failed to load storage mode:', e); applyMode('FILE') } };
window.addEventListener('DOMContentLoaded', () => { loadStorageMode(); refreshTokens(); initCacheViewer(); setInterval(() => { loadStats(); updateRemaining() }, 30000); window.addEventListener('resize', () => { const hoverCard = $('storageMode').closest('.hover-card'); hoverCard && updateHoverCardPosition(hoverCard) }); const hoverCard = $('storageMode').closest('.hover-card'), trigger = hoverCard?.querySelector('.hover-card-trigger'), content = hoverCard?.querySelector('.hover-card-content'); if (trigger && content) { trigger.addEventListener('mouseenter', () => { content.style.opacity = '1'; content.style.visibility = 'visible' }); trigger.addEventListener('mouseleave', () => { content.style.opacity = '0'; content.style.visibility = 'hidden' }) }; $('cfgProxyUrl').addEventListener('input', updateCacheProxyReadonly); $('cfgDynamicStatsig').addEventListener('change', updateStatsigIdState) });
</script>
</body>
</html>