- Reset master to upstream/main (16,697 commits) - Overlay 2,271 local-only files (skills, tools, workspace, configs, apps) - Restore IDENTITY.md and USER.md templates - Build verified, gateway running, Discord working Co-Authored-By: Claude Opus 4.6 <[email protected]>
1546 lines
50 KiB
HTML
1546 lines
50 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta
|
|
name="viewport"
|
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
/>
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
<meta name="theme-color" content="#0a0e1a" />
|
|
<title>OpenClaw Dashboard</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0e1a;
|
|
--bg-card: rgba(255, 255, 255, 0.05);
|
|
--bg-card-hover: rgba(255, 255, 255, 0.08);
|
|
--border-color: rgba(255, 255, 255, 0.1);
|
|
--text-primary: #e0e4f0;
|
|
--text-secondary: #8b95a8;
|
|
--text-muted: #6b7280;
|
|
--accent-blue: #7aa2ff;
|
|
--accent-green: #22c55e;
|
|
--accent-orange: #f59e0b;
|
|
--accent-red: #ef4444;
|
|
--accent-purple: #8b5cf6;
|
|
--spacing-xs: 4px;
|
|
--spacing-sm: 8px;
|
|
--spacing-md: 16px;
|
|
--spacing-lg: 24px;
|
|
--spacing-xl: 32px;
|
|
--radius-sm: 8px;
|
|
--radius-md: 12px;
|
|
--radius-lg: 16px;
|
|
--shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
min-height: -webkit-fill-available;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
|
|
.app {
|
|
padding: var(--spacing-md);
|
|
padding-bottom: calc(var(--spacing-xl) + env(safe-area-inset-bottom));
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-lg);
|
|
padding-bottom: var(--spacing-md);
|
|
border-bottom: 1px solid var(--border-color);
|
|
flex-wrap: wrap;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.header-left h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.header-left .timestamp {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.btn {
|
|
background: rgba(59, 89, 152, 0.8);
|
|
border: none;
|
|
color: white;
|
|
padding: 10px 16px;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
min-height: 44px; /* Touch target */
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.btn:hover {
|
|
background: rgba(74, 106, 168, 0.9);
|
|
transform: translateY(-1px);
|
|
}
|
|
.btn:active {
|
|
transform: translateY(0);
|
|
}
|
|
.btn-success {
|
|
background: var(--accent-green);
|
|
color: #000;
|
|
}
|
|
.btn-success:hover {
|
|
background: #16a34a;
|
|
}
|
|
.btn-sm {
|
|
padding: 6px 12px;
|
|
min-height: 36px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* Alerts */
|
|
.alerts-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-sm);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.alert {
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.alert-danger {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
border: 1px solid var(--accent-red);
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.alert-warning {
|
|
background: rgba(245, 158, 11, 0.2);
|
|
border: 1px solid var(--accent-orange);
|
|
color: #fbbf24;
|
|
}
|
|
|
|
/* Grid System */
|
|
.grid {
|
|
display: grid;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.grid-2 {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.grid-3 {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
.grid-4 {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
.grid-auto {
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.grid-2,
|
|
.grid-3,
|
|
.grid-4 {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.grid-auto {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 769px) and (max-width: 1024px) {
|
|
.grid-3,
|
|
.grid-4 {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
/* Cards */
|
|
.card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--spacing-md);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.card:hover {
|
|
background: var(--bg-card-hover);
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.card-gradient {
|
|
background: linear-gradient(135deg, rgba(122, 162, 255, 0.1), rgba(139, 92, 246, 0.1));
|
|
border: 1px solid rgba(122, 162, 255, 0.3);
|
|
}
|
|
|
|
/* Stats */
|
|
.stat-big {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--accent-blue);
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-sm) 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.stat-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* Section */
|
|
.section {
|
|
margin-bottom: var(--spacing-xl);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 700;
|
|
margin-bottom: var(--spacing-md);
|
|
padding-bottom: var(--spacing-sm);
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
/* Rate Limit Cards */
|
|
.rate-card {
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius-sm);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.rate-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.rate-provider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.rate-percent {
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
transition:
|
|
width 0.3s,
|
|
background-color 0.3s;
|
|
}
|
|
|
|
.progress-green {
|
|
background: var(--accent-green);
|
|
}
|
|
.progress-orange {
|
|
background: var(--accent-orange);
|
|
}
|
|
.progress-red {
|
|
background: var(--accent-red);
|
|
}
|
|
|
|
.rate-info {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.rate-info strong {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Provider Cards */
|
|
.provider-card {
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.provider-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-md);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.provider-name {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.provider-models {
|
|
padding: var(--spacing-sm);
|
|
}
|
|
|
|
.model-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border-radius: var(--radius-sm);
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.model-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.model-name {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.model-price {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.model-aliases {
|
|
display: flex;
|
|
gap: var(--spacing-xs);
|
|
flex-wrap: wrap;
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.alias-chip {
|
|
background: rgba(122, 162, 255, 0.2);
|
|
color: var(--accent-blue);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 0.625rem;
|
|
font-family: monospace;
|
|
}
|
|
|
|
/* Badges */
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.badge-default {
|
|
background: var(--accent-green);
|
|
color: #000;
|
|
}
|
|
.badge-fallback {
|
|
background: var(--accent-orange);
|
|
color: #000;
|
|
}
|
|
.badge-cooldown {
|
|
background: var(--accent-red);
|
|
color: #fff;
|
|
}
|
|
.badge-free {
|
|
background: var(--accent-purple);
|
|
color: #fff;
|
|
}
|
|
.badge-ok {
|
|
background: var(--accent-green);
|
|
color: #000;
|
|
}
|
|
|
|
/* Tables */
|
|
.table-container {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.usage-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.usage-table th {
|
|
text-align: left;
|
|
padding: var(--spacing-sm);
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.usage-table td {
|
|
padding: var(--spacing-sm);
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.usage-table .cost {
|
|
color: var(--accent-orange);
|
|
}
|
|
|
|
/* Charts */
|
|
.chart-container {
|
|
position: relative;
|
|
height: 250px;
|
|
margin-top: var(--spacing-md);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.chart-container {
|
|
height: 200px;
|
|
}
|
|
}
|
|
|
|
/* View Toggle */
|
|
.view-toggle {
|
|
display: flex;
|
|
gap: var(--spacing-xs);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: var(--spacing-xs);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.view-toggle button {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
min-height: 36px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.view-toggle button.active {
|
|
background: rgba(59, 89, 152, 0.8);
|
|
color: white;
|
|
}
|
|
|
|
.view-toggle button:hover:not(.active) {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
/* Budget Config */
|
|
.budget-config {
|
|
display: flex;
|
|
gap: var(--spacing-md);
|
|
margin-top: var(--spacing-md);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.budget-input {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
.budget-input label {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.budget-input input {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
color: var(--text-primary);
|
|
font-size: 0.875rem;
|
|
width: 120px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.budget-input input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
/* Fallback Chain */
|
|
.fallback-chain {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.fallback-item {
|
|
background: rgba(245, 158, 11, 0.2);
|
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.fallback-arrow {
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/* Loading & Error States */
|
|
.loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--spacing-xl);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
|
border-top-color: var(--accent-blue);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.error {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
border: 1px solid var(--accent-red);
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius-sm);
|
|
color: #fca5a5;
|
|
}
|
|
|
|
/* Totals Grid */
|
|
.totals-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.totals-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.total-card {
|
|
text-align: center;
|
|
padding: var(--spacing-lg);
|
|
}
|
|
|
|
/* Reset Timer */
|
|
.reset-timer {
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Copilot Budget Bar */
|
|
.budget-bar-container {
|
|
margin: var(--spacing-md) 0;
|
|
}
|
|
|
|
.budget-bar {
|
|
height: 10px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.budget-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--accent-green), var(--accent-blue));
|
|
border-radius: 5px;
|
|
transition: width 0.3s;
|
|
}
|
|
|
|
/* Claude Code Stats */
|
|
.claude-stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: var(--spacing-md);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.claude-stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.claude-stat-card {
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius-sm);
|
|
text-align: center;
|
|
}
|
|
|
|
.claude-stat-card.purple {
|
|
background: rgba(139, 92, 246, 0.1);
|
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.claude-stat-card.blue {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
.claude-stat-label {
|
|
font-size: 0.625rem;
|
|
text-transform: uppercase;
|
|
margin-bottom: var(--spacing-xs);
|
|
}
|
|
|
|
.claude-stat-value {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.claude-stat-sub {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
/* Info Box */
|
|
.info-box {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.75rem;
|
|
margin-top: var(--spacing-md);
|
|
}
|
|
|
|
.info-box.purple {
|
|
background: rgba(139, 92, 246, 0.1);
|
|
border: 1px solid rgba(139, 92, 246, 0.2);
|
|
color: var(--accent-purple);
|
|
}
|
|
|
|
.info-box.green {
|
|
background: rgba(16, 185, 129, 0.1);
|
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
color: #10b981;
|
|
}
|
|
|
|
.info-box.orange {
|
|
background: rgba(245, 158, 11, 0.1);
|
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
|
color: var(--accent-orange);
|
|
}
|
|
|
|
.info-box code {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<h1>📊 Model Dashboard</h1>
|
|
<div class="timestamp" id="timestamp">Loading...</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn" onclick="forceRefresh()">⟳ Refresh</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="alerts-container" id="alerts-container"></div>
|
|
|
|
<div id="content">
|
|
<div class="loading">
|
|
<div class="loading-spinner"></div>
|
|
<div>Loading dashboard...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let chartInstances = {};
|
|
let currentChartView = "daily";
|
|
let timerInterval = null;
|
|
let rateRefreshInterval = null;
|
|
|
|
// Utility functions
|
|
function formatNumber(n) {
|
|
if (n >= 1000000) return (n / 1000000).toFixed(2) + "M";
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
|
return n.toString();
|
|
}
|
|
|
|
function formatCost(usd) {
|
|
if (usd === 0) return "Free";
|
|
if (usd < 0.01) return "<$0.01";
|
|
return "$" + usd.toFixed(2);
|
|
}
|
|
|
|
function formatPrice(pricing) {
|
|
if (!pricing || (pricing.input === 0 && pricing.output === 0)) return "Free tier";
|
|
return `$${pricing.input}/$${pricing.output} per 1M`;
|
|
}
|
|
|
|
function getProgressColor(percent) {
|
|
if (percent >= 100) return "progress-red";
|
|
if (percent >= 80) return "progress-orange";
|
|
return "progress-green";
|
|
}
|
|
|
|
// API calls
|
|
async function switchModel(model) {
|
|
if (!confirm(`Switch default model to ${model}?`)) return;
|
|
try {
|
|
const res = await fetch("/api/set-model", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ model }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
loadStatus(true);
|
|
} else {
|
|
alert("Error: " + (data.error || "Unknown error"));
|
|
}
|
|
} catch (err) {
|
|
alert("Error: " + err.message);
|
|
}
|
|
}
|
|
|
|
async function saveBudget() {
|
|
const dailyLimit = document.getElementById("daily-limit").value;
|
|
const monthlyLimit = document.getElementById("monthly-limit").value;
|
|
|
|
try {
|
|
const res = await fetch("/api/set-budget", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
dailyLimitUsd: parseFloat(dailyLimit),
|
|
monthlyLimitUsd: parseFloat(monthlyLimit),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
alert("Budget limits updated!");
|
|
loadStatus(true);
|
|
} else {
|
|
alert("Error: " + (data.error || "Unknown error"));
|
|
}
|
|
} catch (err) {
|
|
alert("Error: " + err.message);
|
|
}
|
|
}
|
|
|
|
function toggleChartView(view) {
|
|
currentChartView = view;
|
|
loadStatus();
|
|
}
|
|
|
|
// Main data loading
|
|
async function loadStatus(forceRefresh = false) {
|
|
try {
|
|
const refreshParam = forceRefresh ? "?refresh=1" : "";
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
|
|
const res = await fetch(`/api/status${refreshParam}`, { signal: controller.signal });
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
|
|
const data = await res.json();
|
|
renderStatus(data);
|
|
|
|
if (!timerInterval) {
|
|
updateResetTimers();
|
|
timerInterval = setInterval(updateResetTimers, 1000);
|
|
}
|
|
} catch (err) {
|
|
if (err.name === "AbortError") {
|
|
document.getElementById("content").innerHTML = `
|
|
<div class="error">Request timed out. The server may be processing large log files. Please wait and try refreshing.</div>
|
|
`;
|
|
} else {
|
|
document.getElementById("content").innerHTML =
|
|
`<div class="error">Error: ${err.message}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function forceRefresh() {
|
|
document.getElementById("timestamp").textContent = "Refreshing...";
|
|
loadStatus(true);
|
|
}
|
|
|
|
function updateResetTimers() {
|
|
const timers = document.querySelectorAll(".reset-timer");
|
|
timers.forEach((timer) => {
|
|
const resetTime = new Date(timer.dataset.reset);
|
|
const now = new Date();
|
|
const diff = resetTime - now;
|
|
|
|
if (diff <= 0) {
|
|
timer.textContent = "Resetting...";
|
|
return;
|
|
}
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
|
|
let text = "";
|
|
if (days > 0) text = `${days}d ${hours}h`;
|
|
else if (hours > 0) text = `${hours}h ${minutes}m`;
|
|
else if (minutes > 0) text = `${minutes}m ${seconds}s`;
|
|
else text = `${seconds}s`;
|
|
|
|
timer.textContent = text;
|
|
});
|
|
}
|
|
|
|
function snapshotsToDailyDeltas(sortedSnaps) {
|
|
const deltas = [];
|
|
for (let i = 0; i < sortedSnaps.length; i++) {
|
|
const s = sortedSnaps[i];
|
|
const p = i > 0 ? sortedSnaps[i - 1] : null;
|
|
if (!p) continue;
|
|
deltas.push({
|
|
date: s.date,
|
|
cost: Math.max(0, (s.totalCostUsd || 0) - (p.totalCostUsd || 0)),
|
|
tokens: Math.max(0, (s.totalTokens || 0) - (p.totalTokens || 0)),
|
|
requests: Math.max(0, (s.totalRequests || 0) - (p.totalRequests || 0)),
|
|
});
|
|
}
|
|
return deltas;
|
|
}
|
|
|
|
function prepareChartData(snapshots, view) {
|
|
const sorted = [...snapshots].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
|
const daily = snapshotsToDailyDeltas(sorted);
|
|
|
|
if (view === "weekly") {
|
|
const weeks = {};
|
|
daily.forEach((s) => {
|
|
const date = new Date(s.date);
|
|
const weekKey = getWeekKey(date);
|
|
if (!weeks[weekKey]) weeks[weekKey] = { cost: 0, tokens: 0, requests: 0 };
|
|
weeks[weekKey].cost += s.cost;
|
|
weeks[weekKey].tokens += s.tokens;
|
|
weeks[weekKey].requests += s.requests;
|
|
});
|
|
return {
|
|
labels: Object.keys(weeks),
|
|
costs: Object.values(weeks).map((w) => w.cost),
|
|
tokens: Object.values(weeks).map((w) => w.tokens),
|
|
};
|
|
}
|
|
|
|
return {
|
|
labels: daily.map((s) => s.date),
|
|
costs: daily.map((s) => s.cost),
|
|
tokens: daily.map((s) => s.tokens),
|
|
};
|
|
}
|
|
|
|
function getWeekKey(date) {
|
|
const year = date.getFullYear();
|
|
const week = getWeekNumber(date);
|
|
return `${year}-W${week.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function getWeekNumber(date) {
|
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
const dayNum = d.getUTCDay() || 7;
|
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
|
}
|
|
|
|
function renderStatus(data) {
|
|
const cacheStatus = data.cached
|
|
? ` (cached ${Math.round((data.cacheAge || 0) / 1000)}s ago)`
|
|
: " (fresh)";
|
|
document.getElementById("timestamp").textContent =
|
|
`Updated: ${new Date(data.timestamp).toLocaleString()}${cacheStatus}`;
|
|
|
|
// Render alerts
|
|
let alertsHtml = "";
|
|
if (data.budget?.alerts?.length) {
|
|
data.budget.alerts.forEach((alert) => {
|
|
alertsHtml += `<div class="alert alert-danger">⚠️ ${alert}</div>`;
|
|
});
|
|
}
|
|
if (data.rateLimitPredictions?.length) {
|
|
const warnings = data.rateLimitPredictions.filter((rl) => rl.warning);
|
|
warnings.forEach((rl) => {
|
|
alertsHtml += `<div class="alert alert-warning">
|
|
🔥 <strong>${rl.provider}</strong>: ${rl.warning} (${rl.currentRate} / ${rl.hourlyLimit} RPM)
|
|
</div>`;
|
|
});
|
|
}
|
|
document.getElementById("alerts-container").innerHTML = alertsHtml;
|
|
|
|
// Build alias map
|
|
const aliasMap = {};
|
|
for (const [alias, model] of Object.entries(data.aliases || {})) {
|
|
if (!aliasMap[model]) aliasMap[model] = [];
|
|
aliasMap[model].push(alias);
|
|
}
|
|
|
|
let html = "";
|
|
|
|
// ========== Totals Section ==========
|
|
html += `
|
|
<section class="section">
|
|
<div class="totals-grid">
|
|
<div class="card card-gradient total-card">
|
|
<div class="stat-big">${formatCost(data.totals?.costUsd || 0)}</div>
|
|
<div class="stat-label">Total Cost (All Time)</div>
|
|
</div>
|
|
<div class="card card-gradient total-card">
|
|
<div class="stat-big">${formatNumber(data.totals?.tokens || 0)}</div>
|
|
<div class="stat-label">Total Tokens</div>
|
|
</div>
|
|
<div class="card card-gradient total-card">
|
|
<div class="stat-big">${formatNumber(data.totals?.requests || 0)}</div>
|
|
<div class="stat-label">Total Requests</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
// ========== Rate Limits Section ==========
|
|
const providersWithRates = (data.providers || []).filter((p) => p.rateStatus?.rpmLimit);
|
|
if (providersWithRates.length > 0) {
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">⚡ Real-Time Rate Limits</h2>
|
|
<div class="grid grid-auto">
|
|
${providersWithRates
|
|
.map((p) => {
|
|
const pct = p.rateStatus.rpmPercent || 0;
|
|
const colorClass = getProgressColor(pct);
|
|
return `
|
|
<div class="card">
|
|
<div class="rate-card" style="border-left: 3px solid ${p.color};">
|
|
<div class="rate-header">
|
|
<div class="rate-provider">
|
|
<span>${p.icon}</span>
|
|
<span>${p.displayName}</span>
|
|
</div>
|
|
<div class="rate-percent" style="color: ${pct >= 80 ? "var(--accent-orange)" : pct >= 100 ? "var(--accent-red)" : "var(--accent-green)"}">
|
|
${pct}%
|
|
</div>
|
|
</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill ${colorClass}" style="width: ${Math.min(100, pct)}%"></div>
|
|
</div>
|
|
<div class="rate-info">
|
|
<strong>${p.rateStatus.rpm}</strong> / ${p.rateStatus.rpmLimit} RPM
|
|
${p.rateStatus.rpdLimit ? `• <strong>${p.rateStatus.rpd}</strong> / ${p.rateStatus.rpdLimit} RPD` : ""}
|
|
<br>
|
|
Resets: <span class="reset-timer" data-reset="${p.rateStatus.resetAt}">...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
<div class="info-box orange">
|
|
⚠️ Free tier limits: Google 10 RPM, NVIDIA 40 RPM. Exceeding limits causes 429 errors.
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
// ========== Current Model & Fallbacks ==========
|
|
// Build cheap model recommendations (free or lowest cost, not current default)
|
|
const allModels = Object.entries(data.pricing || {})
|
|
.map(([key, p]) => ({
|
|
key,
|
|
provider: key.split("/")[0],
|
|
name: key.split("/").slice(1).join("/"),
|
|
input: p.input,
|
|
output: p.output,
|
|
cost: p.input + p.output,
|
|
}))
|
|
.sort((a, b) => a.cost - b.cost);
|
|
const freeModels = allModels
|
|
.filter((m) => m.cost === 0 && m.key !== data.defaultModel)
|
|
.slice(0, 3);
|
|
const cheapPaidModels = allModels
|
|
.filter((m) => m.cost > 0 && m.key !== data.defaultModel)
|
|
.slice(0, 2);
|
|
const recommendations = [...freeModels, ...cheapPaidModels];
|
|
|
|
// Fallback chain with numbered steps
|
|
const fallbackChainHtml = data.fallbacks?.length
|
|
? data.fallbacks
|
|
.map(
|
|
(f, i) => `
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
|
<span style="width:22px;height:22px;border-radius:50%;background:rgba(122,162,255,0.15);border:1px solid rgba(122,162,255,0.3);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:var(--accent-blue);flex-shrink:0;">${i + 1}</span>
|
|
<span style="font-size:0.8rem;">${f}</span>
|
|
<button onclick="switchModel('${f}')" style="margin-left:auto;padding:2px 8px;font-size:11px;border-radius:4px;border:1px solid rgba(122,162,255,0.4);background:transparent;color:var(--accent-blue);cursor:pointer;">Use</button>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")
|
|
: '<span style="color:var(--text-muted);font-size:0.8rem;">No fallbacks configured</span>';
|
|
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">🤖 Model Control</h2>
|
|
<div class="grid grid-2" style="margin-bottom:16px;">
|
|
<div class="card">
|
|
<div class="card-title">Active Model</div>
|
|
<div style="font-size:1rem;font-weight:700;margin:10px 0;color:var(--accent-blue);">${data.defaultModel}</div>
|
|
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:12px;">Switch to a model from recommendations below or type a model key:</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<input id="model-input" type="text" placeholder="provider/model-name" value="${data.defaultModel}"
|
|
style="flex:1;padding:6px 10px;border-radius:6px;border:1px solid var(--border-color);background:rgba(255,255,255,0.05);color:var(--text-primary);font-size:0.8rem;" />
|
|
<button onclick="switchModel(document.getElementById('model-input').value)"
|
|
style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent-blue);color:#000;font-weight:600;font-size:0.8rem;cursor:pointer;">Switch</button>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Fallback Chain</div>
|
|
<div style="margin-top:10px;">${fallbackChainHtml}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">💡 Free & Cheap Model Recommendations</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;">
|
|
${recommendations
|
|
.map(
|
|
(m) => `
|
|
<div style="background:rgba(255,255,255,0.04);border:1px solid var(--border-color);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:150px;flex:1;">
|
|
<div style="font-size:0.7rem;font-weight:700;color:${m.cost === 0 ? "var(--accent-green)" : "var(--accent-blue)"};">${m.cost === 0 ? "🆓 FREE" : "💲 PAID"}</div>
|
|
<div style="font-size:0.8rem;font-weight:600;">${m.name}</div>
|
|
<div style="font-size:0.7rem;color:var(--text-muted);">${m.provider}</div>
|
|
<div style="font-size:0.7rem;color:var(--text-muted);">${m.cost === 0 ? "No cost" : "$" + m.input + " / $" + m.output + " per 1M"}</div>
|
|
<button onclick="switchModel('${m.key}')"
|
|
style="margin-top:6px;padding:4px 8px;border-radius:4px;border:1px solid ${m.cost === 0 ? "rgba(34,197,94,0.4)" : "rgba(122,162,255,0.4)"};background:transparent;color:${m.cost === 0 ? "var(--accent-green)" : "var(--accent-blue)"};font-size:11px;cursor:pointer;font-weight:600;">
|
|
Use This
|
|
</button>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
// ========== Budget Section ==========
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">💰 Budget & Limits</h2>
|
|
<div class="card">
|
|
<div class="grid grid-2">
|
|
<div>
|
|
<div class="stat-big">${formatCost(data.history?.daily?.costUsd || 0)}</div>
|
|
<div class="stat-label">Today's Cost</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">
|
|
${formatNumber(data.history?.daily?.tokens || 0)} tokens • ${formatNumber(data.history?.daily?.requests || 0)} requests
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
|
Limit: ${formatCost(data.budget?.dailyLimitUsd || 0)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="stat-big">${formatCost(data.history?.monthly?.costUsd || 0)}</div>
|
|
<div class="stat-label">This Month's Cost</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">
|
|
${formatNumber(data.history?.monthly?.tokens || 0)} tokens • ${formatNumber(data.history?.monthly?.requests || 0)} requests
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
|
Limit: ${formatCost(data.budget?.monthlyLimitUsd || 0)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="budget-config">
|
|
<div class="budget-input">
|
|
<label>Daily Limit (USD)</label>
|
|
<input type="number" id="daily-limit" value="${data.budget?.dailyLimitUsd || 10}" step="1" min="0" />
|
|
</div>
|
|
<div class="budget-input">
|
|
<label>Monthly Limit (USD)</label>
|
|
<input type="number" id="monthly-limit" value="${data.budget?.monthlyLimitUsd || 200}" step="10" min="0" />
|
|
</div>
|
|
<button class="btn btn-success btn-sm" onclick="saveBudget()" style="align-self: flex-end;">Save Limits</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
// ========== GitHub Copilot Section ==========
|
|
if (data.githubCopilot && !data.githubCopilot.error) {
|
|
const usedPercent = Math.min(100, (data.githubCopilot.totalNetCostUsd / 20) * 100);
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">🐙 GitHub Copilot Billing</h2>
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px;">
|
|
<div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">Period: ${data.githubCopilot.period}</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">User: ${data.githubCopilot.user}</div>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<div class="stat-big" style="color: var(--accent-green);">${formatCost(data.githubCopilot.totalNetCostUsd)}</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">of $20 budget (${usedPercent.toFixed(1)}%)</div>
|
|
</div>
|
|
</div>
|
|
<div class="budget-bar-container">
|
|
<div class="budget-bar">
|
|
<div class="budget-bar-fill" style="width: ${usedPercent}%;"></div>
|
|
</div>
|
|
</div>
|
|
<div class="table-container">
|
|
<table class="usage-table">
|
|
<thead>
|
|
<tr><th>Model</th><th>Requests</th><th>Gross</th><th>Discount</th><th>Net</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.githubCopilot.models
|
|
.map(
|
|
(m) => `
|
|
<tr>
|
|
<td>${m.model}</td>
|
|
<td>${formatNumber(m.requests)}</td>
|
|
<td>${formatCost(m.grossCostUsd)}</td>
|
|
<td style="color: var(--accent-green);">-${formatCost(m.discountUsd)}</td>
|
|
<td class="cost">${formatCost(m.netCostUsd)}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
// ========== Claude Code Section ==========
|
|
if (data.claudeCode && !data.claudeCode.error) {
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">🟠 Claude Code Stats (Local)</h2>
|
|
<div class="card">
|
|
<div class="claude-stats-grid">
|
|
<div class="claude-stat-card purple">
|
|
<div class="claude-stat-label" style="color: var(--accent-purple);">Today</div>
|
|
<div class="claude-stat-value" style="color: #a78bfa;">${formatNumber(data.claudeCode.today?.tokens || 0)}</div>
|
|
<div class="claude-stat-sub">tokens • ${data.claudeCode.today?.messages || 0} msgs</div>
|
|
</div>
|
|
<div class="claude-stat-card blue">
|
|
<div class="claude-stat-label" style="color: #3b82f6;">This Week</div>
|
|
<div class="claude-stat-value" style="color: #60a5fa;">${formatNumber(data.claudeCode.week?.tokens || 0)}</div>
|
|
<div class="claude-stat-sub">tokens • ${formatNumber(data.claudeCode.week?.messages || 0)} msgs</div>
|
|
</div>
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 12px;">
|
|
Total: ${data.claudeCode.totalSessions} sessions • ${formatNumber(data.claudeCode.totalMessages)} messages
|
|
<span style="margin-left: 8px; opacity: 0.7;">Updated: ${data.claudeCode.lastUpdated}</span>
|
|
</div>
|
|
${
|
|
data.claudeCode.modelUsage && data.claudeCode.modelUsage.length > 0
|
|
? `
|
|
<div class="table-container">
|
|
<table class="usage-table">
|
|
<thead>
|
|
<tr><th>Model</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cost</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.claudeCode.modelUsage
|
|
.map(
|
|
(m) => `
|
|
<tr>
|
|
<td>${m.model.replace("claude-", "").replace(/-\d+$/, "")}</td>
|
|
<td>${formatNumber(m.inputTokens)}</td>
|
|
<td>${formatNumber(m.outputTokens)}</td>
|
|
<td>${formatNumber(m.cacheReadTokens)}</td>
|
|
<td class="cost">${formatCost(m.costUSD || 0)}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
// ========== OpenAI Section ==========
|
|
if (data.openAi && !data.openAi.error && data.openAi.totalCostUsd > 0) {
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">🤖 OpenAI Billing</h2>
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">Period: ${data.openAi.period}</div>
|
|
<div>
|
|
<div class="stat-big" style="color: #f97316; font-size: 1.5rem;">${formatCost(data.openAi.totalCostUsd)}</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">This month</div>
|
|
</div>
|
|
</div>
|
|
${
|
|
data.openAi.dailyCosts?.length
|
|
? `
|
|
<div class="table-container">
|
|
<table class="usage-table">
|
|
<thead><tr><th>Date</th><th>Cost</th></tr></thead>
|
|
<tbody>
|
|
${data.openAi.dailyCosts
|
|
.map(
|
|
(d) => `
|
|
<tr><td>${d.date}</td><td class="cost">${formatCost(d.costUsd)}</td></tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
// ========== Charts Section ==========
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">📈 Usage Over Time</h2>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="view-toggle">
|
|
<button class="${currentChartView === "daily" ? "active" : ""}" onclick="toggleChartView('daily')">Daily</button>
|
|
<button class="${currentChartView === "weekly" ? "active" : ""}" onclick="toggleChartView('weekly')">Weekly</button>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container">
|
|
<canvas id="cost-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="margin-top: 16px;">
|
|
<div class="chart-container">
|
|
<canvas id="tokens-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
// ========== Providers Section ==========
|
|
html += `<section class="section"><h2 class="section-title">🔌 Providers & Models</h2>`;
|
|
|
|
for (const provider of data.providers || []) {
|
|
const cooldownInfo =
|
|
provider.inCooldown && provider.cooldownUntil
|
|
? `<span style="font-size: 0.75rem; color: #fca5a5; margin-left: 8px;">⏱ ${Math.ceil((provider.cooldownUntil - Date.now()) / 1000)}s</span>`
|
|
: "";
|
|
|
|
html += `
|
|
<div class="card provider-card" style="margin-bottom: 16px;">
|
|
<div class="provider-header" style="border-left: 4px solid ${provider.color};">
|
|
<div class="provider-name">
|
|
<span>${provider.icon}</span>
|
|
<span>${provider.displayName}</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<span class="badge ${provider.inCooldown ? "badge-cooldown" : "badge-ok"}">
|
|
${provider.inCooldown ? "COOLDOWN" : "OK"}
|
|
</span>
|
|
${cooldownInfo}
|
|
</div>
|
|
</div>
|
|
<div class="provider-models">
|
|
`;
|
|
|
|
for (const model of provider.models) {
|
|
const aliases = aliasMap[model.key] || [];
|
|
const isDefault = model.key === data.defaultModel;
|
|
const isFallback = data.fallbacks?.includes(model.key);
|
|
const isFree = model.pricing && model.pricing.input === 0 && model.pricing.output === 0;
|
|
|
|
html += `
|
|
<div class="model-item">
|
|
<div>
|
|
<div class="model-name">${model.name}</div>
|
|
<div class="model-price">${formatPrice(model.pricing)}</div>
|
|
${
|
|
aliases.length
|
|
? `
|
|
<div class="model-aliases">
|
|
${aliases.map((a) => `<span class="alias-chip">${a}</span>`).join("")}
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 6px;">
|
|
${isFree ? '<span class="badge badge-free">FREE</span>' : ""}
|
|
${isDefault ? '<span class="badge badge-default">DEFAULT</span>' : ""}
|
|
${isFallback ? '<span class="badge badge-fallback">FALLBACK</span>' : ""}
|
|
${!isDefault ? `<button class="btn btn-sm" onclick="switchModel('${model.key}')">Switch</button>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += `</div></div>`;
|
|
}
|
|
html += `</section>`;
|
|
|
|
// ========== Usage Tables ==========
|
|
if (data.usage?.length) {
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">📊 Token Usage & Costs (All Time)</h2>
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table class="usage-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Model</th>
|
|
<th>Requests</th>
|
|
<th>Input</th>
|
|
<th>Output</th>
|
|
<th>Total</th>
|
|
<th>Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.usage
|
|
.map(
|
|
(u) => `
|
|
<tr>
|
|
<td>${u.provider}/${u.model}</td>
|
|
<td>${formatNumber(u.requestCount)}</td>
|
|
<td>${formatNumber(u.inputTokens)}</td>
|
|
<td>${formatNumber(u.outputTokens)}</td>
|
|
<td>${formatNumber(u.totalTokens)}</td>
|
|
<td class="cost">${formatCost(u.estimatedCostUsd)}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
// ========== Rate Limit Errors ==========
|
|
if (data.rateLimitErrors?.total > 0) {
|
|
html += `
|
|
<section class="section">
|
|
<h2 class="section-title">🚨 Rate Limit Events Detected</h2>
|
|
<div class="card" style="border-color: rgba(239, 68, 68, 0.4);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<div style="font-size: 1.5rem; font-weight: 700; color: var(--accent-red);">${formatNumber(data.rateLimitErrors.total)}</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted);">rate limit events in logs</div>
|
|
</div>
|
|
<div style="text-align: right; font-size: 0.75rem; color: var(--text-muted);">
|
|
${Object.entries(data.rateLimitErrors.byProvider || {})
|
|
.map(([p, c]) => `<div>${p}: ${c}</div>`)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
document.getElementById("content").innerHTML = html;
|
|
|
|
// Render charts
|
|
if (data.history?.snapshots?.length) {
|
|
const chartData = prepareChartData(data.history.snapshots, currentChartView);
|
|
renderCharts(chartData);
|
|
}
|
|
}
|
|
|
|
function renderCharts(chartData) {
|
|
Object.values(chartInstances).forEach((chart) => chart.destroy());
|
|
chartInstances = {};
|
|
|
|
const chartOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: true, labels: { color: "#e0e4f0", font: { size: 11 } } },
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { color: "#8b95a8", font: { size: 10 }, maxRotation: 45 },
|
|
grid: { color: "rgba(255,255,255,0.05)" },
|
|
},
|
|
y: {
|
|
ticks: { color: "#8b95a8", font: { size: 10 } },
|
|
grid: { color: "rgba(255,255,255,0.05)" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const costCtx = document.getElementById("cost-chart")?.getContext("2d");
|
|
if (costCtx) {
|
|
chartInstances.costChart = new Chart(costCtx, {
|
|
type: "line",
|
|
data: {
|
|
labels: chartData.labels,
|
|
datasets: [
|
|
{
|
|
label: "Cost (USD)",
|
|
data: chartData.costs,
|
|
borderColor: "#f59e0b",
|
|
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
|
tension: 0.3,
|
|
fill: true,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
...chartOptions,
|
|
plugins: {
|
|
...chartOptions.plugins,
|
|
title: { display: true, text: "Cost Over Time", color: "#e0e4f0" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const tokensCtx = document.getElementById("tokens-chart")?.getContext("2d");
|
|
if (tokensCtx) {
|
|
chartInstances.tokensChart = new Chart(tokensCtx, {
|
|
type: "bar",
|
|
data: {
|
|
labels: chartData.labels,
|
|
datasets: [
|
|
{
|
|
label: "Tokens",
|
|
data: chartData.tokens,
|
|
backgroundColor: "rgba(122, 162, 255, 0.6)",
|
|
borderColor: "#7aa2ff",
|
|
borderWidth: 1,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
...chartOptions,
|
|
plugins: {
|
|
...chartOptions.plugins,
|
|
title: { display: true, text: "Token Usage Over Time", color: "#e0e4f0" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
loadStatus();
|
|
setInterval(loadStatus, 30000); // Refresh every 30s
|
|
</script>
|
|
</body>
|
|
</html>
|