Files
Clawd BotandClaude Opus 4.6 ca9b510922 chore: align with upstream openclaw/openclaw and overlay local additions
- 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]>
2026-03-03 07:40:46 +01:00

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 &amp; 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>