update .gitignore

This commit is contained in:
2026-05-21 13:28:04 +07:00
parent 9b48f2285d
commit 3404224455
5 changed files with 1349 additions and 0 deletions
+1
View File
@@ -1,3 +1,4 @@
cli-proxy-api
config.yaml
usage-dashboard/config.json
usage.sqlite
+9
View File
@@ -0,0 +1,9 @@
{
"cliproxy_host": "127.0.0.1",
"cliproxy_port": 8317,
"management_key": "replace-with-your-management-key",
"poll_interval_seconds": 2,
"quota_refresh_seconds": 300,
"dashboard_host": "0.0.0.0",
"dashboard_port": 8320
}
+644
View File
@@ -0,0 +1,644 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CLIProxyAPI Usage Dashboard</title>
<style>
/* ── Theme tokens ─────────────────────────────────────────────────────── */
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--header: #ffffff;
--text: #17202a;
--muted: #667085;
--line: #d9dee7;
--input-bg:#ffffff;
--blue: #2563eb;
--green: #0f9f6e;
--red: #d92d20;
--amber: #b7791f;
--bar-bg: #edf1f7;
/* canvas text colours (read by JS) */
--c-label: #667085;
--c-value: #17202a;
--c-axis: #d9dee7;
--c-bar-h: #2563eb;
--c-bar-m: #0f9f6e;
}
[data-theme="dark"] {
--bg: #0f1117;
--panel: #1a1d27;
--header: #13161f;
--text: #e2e8f0;
--muted: #8896a5;
--line: #2d3348;
--input-bg:#1f2335;
--blue: #4d8ef7;
--green: #22c78e;
--red: #f06b6b;
--amber: #f0a848;
--bar-bg: #252a3a;
--c-label: #8896a5;
--c-value: #e2e8f0;
--c-axis: #2d3348;
--c-bar-h: #4d8ef7;
--c-bar-m: #22c78e;
}
/* ── Reset & base ─────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
transition: background .25s, color .25s;
}
/* ── Header ───────────────────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--line);
background: var(--header);
position: sticky;
top: 0;
z-index: 10;
transition: background .25s, border-color .25s;
}
h1 { font-size: 18px; margin: 0; }
/* ── Toolbar controls ─────────────────────────────────────────────────── */
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
button, select {
border: 1px solid var(--line);
background: var(--input-bg);
color: var(--text);
border-radius: 6px;
padding: 7px 11px;
font-size: 13px;
cursor: pointer;
transition: background .2s, border-color .2s, color .2s;
}
button:hover { opacity: .85; }
button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
/* language toggle */
#langToggle {
background: transparent;
color: var(--blue);
border-color: var(--blue);
font-weight: 600;
min-width: 44px;
}
/* dark-mode toggle */
#themeToggle {
background: transparent;
border-color: var(--line);
font-size: 16px;
padding: 6px 10px;
line-height: 1;
}
/* ── Layout ───────────────────────────────────────────────────────────── */
main { padding: 20px 24px 40px; max-width: 1440px; margin: 0 auto; }
.grid { display: grid; gap: 14px; }
.kpis { grid-template-columns: repeat(5, minmax(140px, 1fr)); }
.two { grid-template-columns: 1.2fr .8fr; margin-top: 14px; }
/* ── Panel ────────────────────────────────────────────────────────────── */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px;
min-width: 0;
transition: background .25s, border-color .25s;
}
.panel h2 { margin: 0 0 14px; font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
/* ── KPI cards ────────────────────────────────────────────────────────── */
.kpi .label { color: var(--muted); font-size: 12px; }
.kpi .value { font-size: 28px; font-weight: 700; margin-top: 6px; color: var(--text); }
.kpi .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* ── Tables ───────────────────────────────────────────────────────────── */
.scroll { overflow: auto; max-height: 400px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; position: sticky; top: 0; background: var(--panel); }
td.num, th.num { text-align: right; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--bar-bg); }
/* ── Status dot ───────────────────────────────────────────────────────── */
.status { display: inline-flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--green); flex-shrink: 0; }
.dot.bad { background: var(--red); }
/* ── Quota bar ────────────────────────────────────────────────────────── */
.bar { height: 8px; background: var(--bar-bg); border-radius: 999px; overflow: hidden; min-width: 80px; display: inline-block; vertical-align: middle; margin-right: 6px; }
.bar > span { display: block; height: 100%; background: var(--green); transition: width .3s; }
.bar > span.warn { background: var(--amber); }
.bar > span.bad { background: var(--red); }
.quota-cell { display: flex; align-items: center; gap: 6px; }
/* ── Canvas charts ────────────────────────────────────────────────────── */
canvas { width: 100% !important; height: 240px !important; display: block; }
/* ── Misc ─────────────────────────────────────────────────────────────── */
.muted { color: var(--muted); }
#updated { font-size: 12px; }
/* ── Footer ────────────────────────────────────────────────────────────── */
footer {
text-align: center;
padding: 24px;
color: var(--muted);
font-size: 13px;
border-top: 1px solid var(--line);
margin-top: 40px;
}
footer a {
color: var(--blue);
text-decoration: none;
font-weight: 500;
}
footer a:hover {
text-decoration: underline;
}
/* ── Header icon ───────────────────────────────────────────────────────── */
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon {
font-size: 24px;
}
/* ── Responsive ───────────────────────────────────────────────────────── */
@media (max-width: 960px) {
.kpis, .two { grid-template-columns: 1fr; }
header { align-items: flex-start; flex-direction: column; }
}
@media (max-width: 480px) {
.kpis { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<div class="header-title">
<span class="header-icon">📊</span>
<h1 data-i18n="title">CLIProxyAPI Usage Dashboard</h1>
</div>
<div class="toolbar">
<!-- Dark / Light toggle -->
<button id="themeToggle" title="Toggle dark/light mode">🌙</button>
<!-- Language toggle -->
<button id="langToggle" title="Switch language / Chuyển ngôn ngữ">VI</button>
<select id="range">
<option value="today" data-i18n="rangeToday">Today</option>
<option value="1h" data-i18n="range1h">Last 1 hour</option>
<option value="5h" data-i18n="range5h">Last 5 hours</option>
<option value="24h" data-i18n="range24h">Last 24 hours</option>
<option value="7d" data-i18n="range7d">Last 7 days</option>
</select>
<button id="quota" class="primary" data-i18n="btnQuota">Refresh Quota</button>
<button id="refresh" data-i18n="btnRefresh">Refresh</button>
<span id="updated" class="muted"></span>
</div>
</header>
<main>
<!-- KPI row -->
<section class="grid kpis">
<div class="panel kpi">
<div class="label" data-i18n="kpiRequests">Requests / Tasks</div>
<div class="value" id="kReq">0</div>
<div class="sub" id="kFail">Failed: 0</div>
</div>
<div class="panel kpi">
<div class="label" data-i18n="kpiTotal">Total Tokens</div>
<div class="value" id="kTok">0</div>
<div class="sub" data-i18n="kpiTotalSub">Input + Output + Reasoning</div>
</div>
<div class="panel kpi">
<div class="label" data-i18n="kpiInput">Input Tokens</div>
<div class="value" id="kIn">0</div>
<div class="sub" data-i18n="kpiInputSub">Cache hits counted separately</div>
</div>
<div class="panel kpi">
<div class="label" data-i18n="kpiOutput">Output Tokens</div>
<div class="value" id="kOut">0</div>
<div class="sub" data-i18n="kpiOutputSub">Model response</div>
</div>
<div class="panel kpi">
<div class="label" data-i18n="kpiReasoning">Reasoning Tokens</div>
<div class="value" id="kReason">0</div>
<div class="sub">reasoning</div>
</div>
</section>
<!-- Charts row -->
<section class="grid two">
<div class="panel">
<h2 data-i18n="chartHourly">Hourly Usage</h2>
<canvas id="hourChart"></canvas>
</div>
<div class="panel">
<h2 data-i18n="chartModel">Usage by Model</h2>
<canvas id="modelChart"></canvas>
</div>
</section>
<!-- Tables row -->
<section class="grid two">
<div class="panel">
<h2 data-i18n="tableAccUsage">Usage by Account</h2>
<div class="scroll">
<table>
<thead><tr>
<th data-i18n="colAccount">Account</th>
<th class="num" data-i18n="colRequests">Requests</th>
<th class="num" data-i18n="colTotalToken">Total Tokens</th>
<th class="num" data-i18n="colInput">Input</th>
<th class="num" data-i18n="colOutput">Output</th>
<th class="num" data-i18n="colReasoning">Reasoning</th>
<th class="num" data-i18n="colFailed">Failed</th>
</tr></thead>
<tbody id="accounts"></tbody>
</table>
</div>
</div>
<div class="panel">
<h2 data-i18n="tableQuota">Account Quota</h2>
<div class="scroll">
<table>
<thead><tr>
<th data-i18n="colAccount">Account</th>
<th data-i18n="colStatus">Status</th>
<th data-i18n="col5hRemain">5h Remaining</th>
<th data-i18n="col7dRemain">7d Remaining</th>
<th data-i18n="colResetTime">Reset Time</th>
</tr></thead>
<tbody id="quotas"></tbody>
</table>
</div>
</div>
</section>
<!-- Recent requests -->
<section class="panel" style="margin-top:14px">
<h2 data-i18n="tableRecent">Recent Requests / Tasks</h2>
<div class="scroll">
<table>
<thead><tr>
<th data-i18n="colTime">Time</th>
<th data-i18n="colAccount">Account</th>
<th data-i18n="colModel">Model</th>
<th class="num" data-i18n="colTotalToken">Total Tokens</th>
<th class="num" data-i18n="colInput">Input</th>
<th class="num" data-i18n="colOutput">Output</th>
<th class="num" data-i18n="colReasoning">Reasoning</th>
<th class="num" data-i18n="colLatency">Latency</th>
<th data-i18n="colStatus">Status</th>
</tr></thead>
<tbody id="requests"></tbody>
</table>
</div>
</section>
</main>
<footer>
Copyright 2026 - CLIProxyAPI Usage Dashboard - <a href="https://ttaisolutions.com" target="_blank" rel="noopener">TTAI Solutions Software</a>
</footer>
<script>
// ── i18n ───────────────────────────────────────────────────────────────────────
const STRINGS = {
en: {
title: "CLIProxyAPI Usage Dashboard",
rangeToday:"Today", range1h:"Last 1 hour", range5h:"Last 5 hours",
range24h:"Last 24 hours", range7d:"Last 7 days",
btnRefresh:"Refresh", btnQuota:"Refresh Quota",
kpiRequests:"Requests / Tasks", kpiTotal:"Total Tokens",
kpiTotalSub:"Input + Output + Reasoning", kpiInput:"Input Tokens",
kpiInputSub:"Cache hits counted separately", kpiOutput:"Output Tokens",
kpiOutputSub:"Model response", kpiReasoning:"Reasoning Tokens",
chartHourly:"Hourly Usage", chartModel:"Usage by Model",
tableAccUsage:"Usage by Account", tableQuota:"Account Quota",
tableRecent:"Recent Requests / Tasks",
colAccount:"Account", colRequests:"Requests", colTotalToken:"Total Tokens",
colInput:"Input", colOutput:"Output", colReasoning:"Reasoning",
colFailed:"Failed", colStatus:"Status", col5hRemain:"5h Remaining",
col7dRemain:"7d Remaining", colResetTime:"Reset Time", colTime:"Time",
colModel:"Model", colLatency:"Latency",
statusOk:"Active", statusLimited:"Limited",
statusSuccess:"Success", statusFailed:"Failed",
failedCount: n => `Failed: ${n}`,
updatedAt: t => `Updated at ${t}`,
},
vi: {
title: "Thống Kê Sử Dụng CLIProxyAPI",
rangeToday:"Hôm nay", range1h:"1 giờ qua", range5h:"5 giờ qua",
range24h:"24 giờ qua", range7d:"7 ngày qua",
btnRefresh:"Làm mới", btnQuota:"Làm mới hạn mức",
kpiRequests:"Yêu cầu / Tác vụ", kpiTotal:"Tổng Tokens",
kpiTotalSub:"Đầu vào + Đầu ra + Suy luận", kpiInput:"Tokens đầu vào",
kpiInputSub:"Cache hit tính riêng", kpiOutput:"Tokens đầu ra",
kpiOutputSub:"Phản hồi của mô hình", kpiReasoning:"Tokens suy luận",
chartHourly:"Tiêu thụ theo giờ", chartModel:"Tiêu thụ theo mô hình",
tableAccUsage:"Tiêu thụ theo tài khoản", tableQuota:"Hạn mức tài khoản",
tableRecent:"Các yêu cầu / tác vụ gần đây",
colAccount:"Tài khoản", colRequests:"Yêu cầu", colTotalToken:"Tổng Token",
colInput:"Đầu vào", colOutput:"Đầu ra", colReasoning:"Suy luận",
colFailed:"Thất bại", colStatus:"Trạng thái", col5hRemain:"Còn lại 5h",
col7dRemain:"Còn lại 7d", colResetTime:"Thời gian reset", colTime:"Thời gian",
colModel:"Mô hình", colLatency:"Độ trễ",
statusOk:"Hoạt động", statusLimited:"Bị giới hạn",
statusSuccess:"Thành công", statusFailed:"Thất bại",
failedCount: n => `Thất bại: ${n}`,
updatedAt: t => `Cập nhật lúc ${t}`,
}
};
// ── State ──────────────────────────────────────────────────────────────────────
let lang = localStorage.getItem('dashLang') || 'en';
let theme = localStorage.getItem('dashTheme') || 'light';
let lastSummary = null, lastQuota = null, lastReqs = null;
// ── Helpers ────────────────────────────────────────────────────────────────────
const nf = new Intl.NumberFormat();
const $ = id => document.getElementById(id);
const fmt = n => nf.format(n || 0);
const esc = s => String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
// ── Theme ──────────────────────────────────────────────────────────────────────
function applyTheme() {
document.documentElement.setAttribute('data-theme', theme);
$('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
// Redraw charts with updated CSS-var colours
if (lastSummary) {
drawBars($('hourChart'), lastSummary.hours, 'hour', 'total_tokens', cssVar('--c-bar-h'));
drawHorizontal($('modelChart'), lastSummary.models);
}
}
$('themeToggle').onclick = () => {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('dashTheme', theme);
applyTheme();
};
// ── Language ───────────────────────────────────────────────────────────────────
function applyLang() {
const S = STRINGS[lang];
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (S[key] && typeof S[key] === 'string') el.textContent = S[key];
});
document.documentElement.setAttribute('lang', lang);
document.title = S.title;
$('langToggle').textContent = lang === 'en' ? 'VI' : 'EN';
}
$('langToggle').onclick = () => {
lang = lang === 'en' ? 'vi' : 'en';
localStorage.setItem('dashLang', lang);
applyLang();
if (lastSummary && lastQuota && lastReqs) renderAll(lastSummary, lastQuota, lastReqs);
};
// ── Charts ─────────────────────────────────────────────────────────────────────
function drawBars(canvas, rows, labelKey, valueKey, color) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 240 * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const W = rect.width, H = 240;
ctx.clearRect(0, 0, W, H);
ctx.font = `12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
const pad = { l:48, r:16, t:12, b:44 };
const max = Math.max(1, ...rows.map(r => Number(r[valueKey] || 0)));
const n = Math.max(1, rows.length);
const slotW = (W - pad.l - pad.r) / n;
const bw = Math.max(8, slotW * 0.55);
rows.forEach((r, i) => {
const x = pad.l + i * slotW + (slotW - bw) / 2;
const bh = (H - pad.t - pad.b) * Number(r[valueKey] || 0) / max;
const y = H - pad.b - bh;
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, y, bw, bh, [4, 4, 0, 0]);
ctx.fill();
ctx.fillStyle = cssVar('--c-label');
ctx.textAlign = 'center';
ctx.fillText(String(r[labelKey] || '').slice(-5), x + bw / 2, H - 14);
if (bh > 20) {
ctx.fillStyle = cssVar('--c-value');
ctx.fillText(fmt(r[valueKey]), x + bw / 2, y - 6);
}
});
// axis line
ctx.strokeStyle = cssVar('--c-axis');
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, H - pad.b);
ctx.lineTo(W - pad.r, H - pad.b);
ctx.stroke();
}
function drawHorizontal(canvas, rows) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 240 * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const W = rect.width, H = 240;
ctx.clearRect(0, 0, W, H);
ctx.font = `12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
const labelW = 160, valW = 70, barAreaW = W - labelW - valW - 16;
const max = Math.max(1, ...rows.map(r => Number(r.total_tokens || 0)));
const rowH = 28;
rows.slice(0, 8).forEach((r, i) => {
const y = 8 + i * rowH;
const bw = barAreaW * Number(r.total_tokens || 0) / max;
const name = String(r.model || 'unknown');
// label
ctx.fillStyle = cssVar('--c-value');
ctx.textAlign = 'left';
ctx.fillText(name.length > 24 ? name.slice(0, 22) + '…' : name, 4, y + 18);
// bar
ctx.fillStyle = cssVar('--c-bar-m');
ctx.beginPath();
ctx.roundRect(labelW, y + 8, Math.max(0, bw), 12, 3);
ctx.fill();
// value
ctx.fillStyle = cssVar('--c-label');
ctx.fillText(fmt(r.total_tokens), labelW + Math.max(0, bw) + 6, y + 18);
});
}
// ── Quota bar HTML ─────────────────────────────────────────────────────────────
function quotaBar(v) {
const cls = v <= 10 ? 'bad' : v <= 30 ? 'warn' : '';
const pct = Math.max(0, Math.min(100, v));
return `<div class="quota-cell">
<div class="bar" style="width:80px"><span class="${cls}" style="width:${pct}%"></span></div>
<span>${v}%</span>
</div>`;
}
// ── Render ─────────────────────────────────────────────────────────────────────
function renderAll(summary, quota, reqs) {
const s = summary.summary;
const T = STRINGS[lang];
// KPIs
$('kReq').textContent = fmt(s.requests);
$('kFail').textContent = T.failedCount(fmt(s.failed));
$('kTok').textContent = fmt(s.total_tokens);
$('kIn').textContent = fmt(s.input_tokens);
$('kOut').textContent = fmt(s.output_tokens);
$('kReason').textContent = fmt(s.reasoning_tokens);
// Accounts table
$('accounts').innerHTML = summary.accounts.map(a => `
<tr>
<td>${esc(a.account)}</td>
<td class="num">${fmt(a.requests)}</td>
<td class="num">${fmt(a.total_tokens)}</td>
<td class="num">${fmt(a.input_tokens)}</td>
<td class="num">${fmt(a.output_tokens)}</td>
<td class="num">${fmt(a.reasoning_tokens)}</td>
<td class="num">${fmt(a.failed)}</td>
</tr>`).join('');
// Quota table
$('quotas').innerHTML = quota.quotas.map(q => `
<tr>
<td>${esc(q.email)}</td>
<td><span class="status"><span class="dot ${q.allowed ? '' : 'bad'}"></span>${q.allowed ? T.statusOk : T.statusLimited}</span></td>
<td>${quotaBar(q.primary_remaining_percent)}</td>
<td>${quotaBar(q.secondary_remaining_percent)}</td>
<td><div>${esc(q.primary_reset_at)}</div><div class="muted" style="font-size:11px">${esc(q.secondary_reset_at)}</div></td>
</tr>`).join('');
// Requests table
$('requests').innerHTML = reqs.requests.map(r => `
<tr>
<td>${esc(r.local_time)}</td>
<td>${esc(r.source || r.auth_index)}</td>
<td>${esc(r.model)}</td>
<td class="num">${fmt(r.total_tokens)}</td>
<td class="num">${fmt(r.input_tokens)}</td>
<td class="num">${fmt(r.output_tokens)}</td>
<td class="num">${fmt(r.reasoning_tokens)}</td>
<td class="num">${fmt(r.latency_ms)}ms</td>
<td>${r.failed ? `<span style="color:var(--red)">${T.statusFailed}</span>` : T.statusSuccess}</td>
</tr>`).join('');
// Charts
drawBars($('hourChart'), summary.hours, 'hour', 'total_tokens', cssVar('--c-bar-h'));
drawHorizontal($('modelChart'), summary.models);
$('updated').textContent = T.updatedAt(new Date().toLocaleTimeString());
}
// ── Load data ──────────────────────────────────────────────────────────────────
async function load(forceQuota = false) {
const range = $('range').value;
const [summary, quota, reqs] = await Promise.all([
getJSON('/api/summary?range=' + encodeURIComponent(range)),
getJSON('/api/quota' + (forceQuota ? '?force=1' : '')),
getJSON('/api/requests?limit=120'),
]);
lastSummary = summary;
lastQuota = quota;
lastReqs = reqs;
renderAll(summary, quota, reqs);
}
// ── Event listeners ────────────────────────────────────────────────────────
$('refresh').onclick = () => load(false);
// Quota button with 5-minute cooldown
let quotaCooldown = 0;
$('quota').onclick = () => {
const now = Date.now();
if (now < quotaCooldown) {
const remaining = Math.ceil((quotaCooldown - now) / 1000);
alert(`Please wait ${remaining} seconds before refreshing quota again.`);
return;
}
const btn = $('quota');
btn.disabled = true;
btn.textContent = 'Refreshing...';
load(true).finally(() => {
quotaCooldown = Date.now() + 300000; // 5 minutes
// Re-enable after 5 minutes
setTimeout(() => {
btn.disabled = false;
applyLang(); // Restore button text
}, 300000);
// Show countdown
const updateCountdown = () => {
const remaining = Math.ceil((quotaCooldown - Date.now()) / 1000);
if (remaining > 0) {
btn.textContent = `Wait ${remaining}s`;
setTimeout(updateCountdown, 1000);
}
};
updateCountdown();
});
};
$('range').onchange = () => load(false);
// Redraw charts on window resize
window.addEventListener('resize', () => {
if (lastSummary) {
drawBars($('hourChart'), lastSummary.hours, 'hour', 'total_tokens', cssVar('--c-bar-h'));
drawHorizontal($('modelChart'), lastSummary.models);
}
});
// ── Boot ───────────────────────────────────────────────────────────────────────
applyTheme();
applyLang();
load(false);
setInterval(() => load(false), 30000);
</script>
</body>
</html>
+695
View File
@@ -0,0 +1,695 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import glob
import hashlib
import json
import os
import socket
import sqlite3
import sys
import time
import threading
import urllib.error
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from zoneinfo import ZoneInfo
_quota_refresh_lock = threading.Lock()
# Path to the companion HTML file (same directory as this script)
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "usage-dashboard.html")
JSON_DIR = os.path.expanduser("~/.cli-proxy-api")
BASE_DIR = os.path.expanduser("~/cliproxyapi/usage-dashboard")
AUTH_DIR = os.path.expanduser("~/cliproxyapi")
DB_PATH = os.path.join(BASE_DIR, "usage.sqlite")
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
LOCAL_TZ = ZoneInfo("Asia/Ho_Chi_Minh")
DEFAULT_CONFIG = {
"cliproxy_host": "127.0.0.1",
"cliproxy_port": 8317,
"management_key": "123456",
"poll_interval_seconds": 2,
"quota_refresh_seconds": 300,
"dashboard_host": "0.0.0.0",
"dashboard_port": 8320,
}
def ensure_dirs():
os.makedirs(BASE_DIR, exist_ok=True)
def load_config():
ensure_dirs()
if not os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
os.chmod(CONFIG_PATH, 0o600)
with open(CONFIG_PATH) as f:
cfg = json.load(f)
merged = dict(DEFAULT_CONFIG)
merged.update(cfg)
merged["management_key"] = os.environ.get("CLIPROXY_MANAGEMENT_KEY", merged["management_key"])
return merged
def db_connect():
ensure_dirs()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
with db_connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
timestamp TEXT NOT NULL,
ts_epoch REAL NOT NULL,
local_date TEXT NOT NULL,
local_hour TEXT NOT NULL,
request_id TEXT,
auth_index TEXT,
source TEXT,
provider TEXT,
model TEXT,
endpoint TEXT,
auth_type TEXT,
api_key_hash TEXT,
failed INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
cached_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
raw_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts_epoch);
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_events(local_date);
CREATE INDEX IF NOT EXISTS idx_usage_source ON usage_events(source);
CREATE INDEX IF NOT EXISTS idx_usage_auth ON usage_events(auth_index);
CREATE TABLE IF NOT EXISTS quota_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
ts_epoch REAL NOT NULL,
email TEXT NOT NULL,
plan TEXT,
allowed INTEGER,
limit_reached INTEGER,
primary_used_percent INTEGER,
primary_remaining_percent INTEGER,
primary_reset_at TEXT,
secondary_used_percent INTEGER,
secondary_remaining_percent INTEGER,
secondary_reset_at TEXT,
credits_balance TEXT,
raw_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quota_email_ts ON quota_snapshots(email, ts_epoch);
"""
)
def parse_rfc3339(value):
if not value:
return dt.datetime.now(dt.timezone.utc)
text = value.replace("Z", "+00:00")
try:
parsed = dt.datetime.fromisoformat(text)
except ValueError:
return dt.datetime.now(dt.timezone.utc)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return parsed.astimezone(dt.timezone.utc)
def resp_command(*parts):
data = [f"*{len(parts)}\r\n".encode()]
for part in parts:
b = str(part).encode()
data.append(f"${len(b)}\r\n".encode())
data.append(b + b"\r\n")
return b"".join(data)
class RespClient:
def __init__(self, host, port, password, timeout=10):
if not password:
raise RuntimeError("management_key is required in config.json or CLIPROXY_MANAGEMENT_KEY")
self.sock = socket.create_connection((host, port), timeout=timeout)
self.file = self.sock.makefile("rb")
self.send("AUTH", password)
reply = self.read()
if not (isinstance(reply, str) and reply.upper() == "OK"):
raise RuntimeError(f"AUTH failed: {reply!r}")
def close(self):
try:
self.file.close()
finally:
self.sock.close()
def send(self, *parts):
self.sock.sendall(resp_command(*parts))
def read_line(self):
line = self.file.readline()
if not line:
raise EOFError("RESP connection closed")
return line.rstrip(b"\r\n")
def read(self):
line = self.read_line()
prefix = line[:1]
payload = line[1:]
if prefix == b"+":
return payload.decode()
if prefix == b"-":
raise RuntimeError(payload.decode())
if prefix == b":":
return int(payload)
if prefix == b"$":
length = int(payload)
if length == -1:
return None
data = self.file.read(length)
self.file.read(2)
return data.decode("utf-8", "replace")
if prefix == b"*":
count = int(payload)
if count == -1:
return None
return [self.read() for _ in range(count)]
raise RuntimeError(f"Unknown RESP prefix: {line!r}")
def rpop(self, count=100):
result = []
for _ in range(count):
self.send("RPOP", "queue")
item = self.read()
if item is None:
break
result.append(item)
return result
def event_key(payload, raw):
rid = payload.get("request_id")
if rid:
return rid
return hashlib.sha256(raw.encode()).hexdigest()
def insert_usage(raw_items):
inserted = 0
with db_connect() as conn:
for raw in raw_items:
try:
payload = json.loads(raw)
except json.JSONDecodeError as e:
print(f"insert_usage: JSON decode error: {e} — raw: {raw[:120]!r}", file=sys.stderr, flush=True)
continue
ts_utc = parse_rfc3339(payload.get("timestamp"))
ts_local = ts_utc.astimezone(LOCAL_TZ)
tokens = payload.get("tokens") or {}
api_key = payload.get("api_key") or ""
api_hash = hashlib.sha256(api_key.encode()).hexdigest()[:12] if api_key else ""
values = {
"event_key": event_key(payload, raw),
"timestamp": ts_utc.isoformat(),
"ts_epoch": ts_utc.timestamp(),
"local_date": ts_local.strftime("%Y-%m-%d"),
"local_hour": ts_local.strftime("%Y-%m-%d %H:00"),
"request_id": payload.get("request_id"),
"auth_index": payload.get("auth_index"),
"source": payload.get("source"),
"provider": payload.get("provider"),
"model": payload.get("model"),
"endpoint": payload.get("endpoint"),
"auth_type": payload.get("auth_type"),
"api_key_hash": api_hash,
"failed": 1 if payload.get("failed") else 0,
"latency_ms": int(payload.get("latency_ms") or 0),
"input_tokens": int(tokens.get("input_tokens") or 0),
"output_tokens": int(tokens.get("output_tokens") or 0),
"reasoning_tokens": int(tokens.get("reasoning_tokens") or 0),
"cached_tokens": int(tokens.get("cached_tokens") or 0),
"total_tokens": int(tokens.get("total_tokens") or 0),
"raw_json": raw,
}
try:
conn.execute(
"""
INSERT INTO usage_events (
event_key,timestamp,ts_epoch,local_date,local_hour,request_id,auth_index,source,
provider,model,endpoint,auth_type,api_key_hash,failed,latency_ms,input_tokens,
output_tokens,reasoning_tokens,cached_tokens,total_tokens,raw_json
) VALUES (
:event_key,:timestamp,:ts_epoch,:local_date,:local_hour,:request_id,:auth_index,:source,
:provider,:model,:endpoint,:auth_type,:api_key_hash,:failed,:latency_ms,:input_tokens,
:output_tokens,:reasoning_tokens,:cached_tokens,:total_tokens,:raw_json
)
""",
values,
)
inserted += 1
except sqlite3.IntegrityError:
pass # duplicate event_key, expected
except Exception as e:
print(f"insert_usage: unexpected error: {e} — payload keys: {list(payload.keys())}", file=sys.stderr, flush=True)
return inserted
def latest_quota_age():
with db_connect() as conn:
row = conn.execute("SELECT MAX(ts_epoch) AS ts FROM quota_snapshots").fetchone()
return None if row["ts"] is None else time.time() - row["ts"]
def auth_files():
return sorted(glob.glob(os.path.join(JSON_DIR, "codex-*.json")))
def refresh_quota(force=False):
# Dùng lock để tránh nhiều request đồng thời
acquired = _quota_refresh_lock.acquire(blocking=False)
if not acquired:
print("refresh_quota: already running, skipping duplicate call", flush=True)
return 0
try:
cfg = load_config()
age = latest_quota_age()
if not force and age is not None and age < cfg["quota_refresh_seconds"]:
return 0
files = auth_files()
# ✅ LOG 1: Số lượng auth files tìm thấy
print(f"refresh_quota: found {len(files)} auth files in {JSON_DIR}", flush=True)
now = dt.datetime.now(dt.timezone.utc)
inserted = 0
with db_connect() as conn:
for path in files:
try:
auth = json.load(open(path))
token = auth.get("access_token")
email = auth.get("email") or os.path.basename(path)
if not token:
# ✅ LOG 2: Skip no token
print(f"refresh_quota: skipping {email} (no access_token)", flush=True)
continue
# ✅ LOG 3: Đang fetch quota cho email nào
print(f"refresh_quota: fetching quota for {email}...", flush=True)
req = urllib.request.Request(
"https://chatgpt.com/backend-api/wham/usage",
headers={
"Authorization": "Bearer " + token,
"Accept": "application/json",
"User-Agent": "codex-cli",
},
)
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.load(resp)
rl = data.get("rate_limit") or {}
primary = rl.get("primary_window") or {}
secondary = rl.get("secondary_window") or {}
primary_used = int(primary.get("used_percent") or 0)
secondary_used = int(secondary.get("used_percent") or 0)
conn.execute(
"""
INSERT INTO quota_snapshots (
timestamp,ts_epoch,email,plan,allowed,limit_reached,
primary_used_percent,primary_remaining_percent,primary_reset_at,
secondary_used_percent,secondary_remaining_percent,secondary_reset_at,
credits_balance,raw_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
now.isoformat(),
now.timestamp(),
email,
data.get("plan_type"),
1 if rl.get("allowed") else 0,
1 if rl.get("limit_reached") else 0,
primary_used,
max(0, 100 - primary_used),
epoch_to_local(primary.get("reset_at")),
secondary_used,
max(0, 100 - secondary_used),
epoch_to_local(secondary.get("reset_at")),
str((data.get("credits") or {}).get("balance", "")),
json.dumps(data, ensure_ascii=False),
),
)
inserted += 1
# ✅ LOG 4: Lưu thành công, hiển thị plan type
print(f"refresh_quota: saved quota for {email} (plan: {data.get('plan_type')})", flush=True)
except (OSError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, KeyError) as exc:
# ✅ LOG 5: Lỗi khi fetch (đã có sẵn, thêm flush=True)
print(f"quota refresh failed for {path}: {exc}", file=sys.stderr, flush=True)
# ✅ LOG 6: Tổng kết số quota snapshots đã insert
print(f"refresh_quota: inserted {inserted} quota snapshots", flush=True)
return inserted
finally:
_quota_refresh_lock.release()
def epoch_to_local(value):
if not value:
return ""
return dt.datetime.fromtimestamp(int(value), LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S")
def debug_collect():
"""Test one poll cycle and print raw queue items — does NOT write to DB."""
cfg = load_config()
print(f"[debug] Config: {cfg}", flush=True)
print(f"[debug] Connecting to {cfg['cliproxy_host']}:{cfg['cliproxy_port']} ...", flush=True)
try:
client = RespClient(cfg["cliproxy_host"], cfg["cliproxy_port"], cfg["management_key"])
except Exception as e:
print(f"[debug] Connection FAILED: {e}", file=sys.stderr, flush=True)
return
print("[debug] AUTH OK", flush=True)
try:
raw_items = client.rpop(10)
print(f"[debug] rpop returned {len(raw_items)} item(s)", flush=True)
for i, raw in enumerate(raw_items):
print(f"[debug] item[{i}]: {raw[:300]}", flush=True)
try:
payload = json.loads(raw)
print(f"[debug] parsed OK — keys: {list(payload.keys())}", flush=True)
tokens = payload.get("tokens") or {}
print(f"[debug] tokens: {tokens}", flush=True)
print(f"[debug] model: {payload.get('model')}, source: {payload.get('source')}", flush=True)
except Exception as e:
print(f"[debug] parse error: {e}", file=sys.stderr, flush=True)
finally:
client.close()
if not raw_items:
print("[debug] Queue is EMPTY — no events to collect. Make sure CLIProxy is running and receiving requests.", flush=True)
def collect_forever():
init_db()
cfg = load_config()
last_quota = 0
print(f"collector: connecting to {cfg['cliproxy_host']}:{cfg['cliproxy_port']}", flush=True)
while True:
try:
client = RespClient(cfg["cliproxy_host"], cfg["cliproxy_port"], cfg["management_key"])
print("collector: connected and authenticated OK", flush=True)
try:
while True:
raw_items = client.rpop(100)
if raw_items:
print(f"collector: got {len(raw_items)} raw item(s) from queue", flush=True)
inserted = insert_usage(raw_items)
print(f"collector: inserted {inserted}/{len(raw_items)} events into DB", flush=True)
now = time.time()
# if now - last_quota >= cfg["quota_refresh_seconds"]:
# refresh_quota(force=True)
# last_quota = now
time.sleep(cfg["poll_interval_seconds"])
finally:
client.close()
print("collector: connection closed", flush=True)
except Exception as exc:
import traceback
print(f"collector error: {exc}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
print("collector: retrying in 5s...", file=sys.stderr, flush=True)
time.sleep(5)
def range_bounds(name):
now = dt.datetime.now(LOCAL_TZ)
if name == "5h":
start = now - dt.timedelta(hours=5)
elif name == "1h":
start = now - dt.timedelta(hours=1)
elif name == "24h":
start = now - dt.timedelta(hours=24)
elif name == "7d":
start = now - dt.timedelta(days=7)
else:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start.astimezone(dt.timezone.utc).timestamp(), now.astimezone(dt.timezone.utc).timestamp()
def query_summary(range_name):
start, end = range_bounds(range_name)
with db_connect() as conn:
total = conn.execute(
"""
SELECT COUNT(*) requests,
COALESCE(SUM(total_tokens),0) total_tokens,
COALESCE(SUM(input_tokens),0) input_tokens,
COALESCE(SUM(output_tokens),0) output_tokens,
COALESCE(SUM(reasoning_tokens),0) reasoning_tokens,
COALESCE(SUM(cached_tokens),0) cached_tokens,
COALESCE(SUM(failed),0) failed
FROM usage_events WHERE ts_epoch BETWEEN ? AND ?
""",
(start, end),
).fetchone()
accounts = conn.execute(
"""
SELECT COALESCE(source, auth_index, 'unknown') account,
COUNT(*) requests,
COALESCE(SUM(total_tokens),0) total_tokens,
COALESCE(SUM(input_tokens),0) input_tokens,
COALESCE(SUM(output_tokens),0) output_tokens,
COALESCE(SUM(reasoning_tokens),0) reasoning_tokens,
COALESCE(SUM(failed),0) failed
FROM usage_events WHERE ts_epoch BETWEEN ? AND ?
GROUP BY account ORDER BY total_tokens DESC
""",
(start, end),
).fetchall()
models = conn.execute(
"""
SELECT COALESCE(model, 'unknown') model,
COUNT(*) requests,
COALESCE(SUM(total_tokens),0) total_tokens,
COALESCE(SUM(failed),0) failed
FROM usage_events WHERE ts_epoch BETWEEN ? AND ?
GROUP BY model ORDER BY total_tokens DESC LIMIT 12
""",
(start, end),
).fetchall()
hours = conn.execute(
"""
SELECT local_hour hour,
COUNT(*) requests,
COALESCE(SUM(total_tokens),0) total_tokens,
COALESCE(SUM(failed),0) failed
FROM usage_events WHERE ts_epoch BETWEEN ? AND ?
GROUP BY local_hour ORDER BY local_hour
""",
(start, end),
).fetchall()
return {
"range": range_name,
"summary": dict(total),
"accounts": [dict(x) for x in accounts],
"models": [dict(x) for x in models],
"hours": [dict(x) for x in hours],
}
def latest_quotas(force=False):
if force:
refresh_quota(force=True)
with db_connect() as conn:
rows = conn.execute(
"""
SELECT q.* FROM quota_snapshots q
JOIN (
SELECT email, MAX(ts_epoch) ts FROM quota_snapshots GROUP BY email
) latest ON latest.email = q.email AND latest.ts = q.ts_epoch
ORDER BY email
"""
).fetchall()
return [dict(row) for row in rows]
def recent_requests(limit=100):
with db_connect() as conn:
rows = conn.execute(
"""
SELECT timestamp, source, auth_index, model, endpoint, failed, latency_ms,
input_tokens, output_tokens, reasoning_tokens, cached_tokens, total_tokens, request_id
FROM usage_events ORDER BY ts_epoch DESC LIMIT ?
""",
(limit,),
).fetchall()
result = []
for row in rows:
item = dict(row)
item["local_time"] = parse_rfc3339(item["timestamp"]).astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S")
result.append(item)
return result
def json_response(handler, payload, status=200):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
class DashboardHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
try:
if parsed.path == "/":
self.serve_html()
elif parsed.path == "/api/summary":
json_response(self, query_summary(qs.get("range", ["today"])[0]))
elif parsed.path == "/api/quota":
json_response(self, {"quotas": latest_quotas(force=qs.get("force", ["0"])[0] == "1")})
elif parsed.path == "/api/requests":
limit = min(500, int(qs.get("limit", ["100"])[0]))
json_response(self, {"requests": recent_requests(limit)})
elif parsed.path == "/api/health":
json_response(self, {"ok": True, "db": DB_PATH, "auth_files": len(auth_files())})
else:
json_response(self, {"error": "not found"}, 404)
except Exception as exc:
json_response(self, {"error": str(exc)}, 500)
def serve_html(self):
try:
with open(HTML_PATH, "rb") as f:
body = f.read()
except FileNotFoundError:
body = b"<h1>Dashboard HTML not found</h1><p>Expected: " + HTML_PATH.encode() + b"</p>"
self.send_response(500)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def serve():
init_db()
cfg = load_config()
server = ThreadingHTTPServer((cfg["dashboard_host"], int(cfg["dashboard_port"])), DashboardHandler)
print(f"dashboard listening on http://{cfg['dashboard_host']}:{cfg['dashboard_port']}", flush=True)
server.serve_forever()
def print_report(range_name):
init_db()
summary = query_summary(range_name)
print(json.dumps(summary, ensure_ascii=False, indent=2))
def start(open_browser=True):
"""Run init + collect (background thread) + serve in a single command."""
# 1. Init DB and config
init_db()
cfg = load_config()
host = cfg["dashboard_host"]
port = int(cfg["dashboard_port"])
# When binding to 0.0.0.0, show localhost for browser and also the LAN IP
if host in ("0.0.0.0", ""):
local_url = f"http://127.0.0.1:{port}"
try:
lan_ip = socket.gethostbyname(socket.gethostname())
except OSError:
lan_ip = None
lan_url = f"http://{lan_ip}:{port}" if lan_ip and lan_ip != "127.0.0.1" else None
else:
local_url = f"http://{host}:{port}"
lan_url = None
# 2. Start collector in a daemon thread (dies automatically when main process exits)
collector_thread = threading.Thread(target=collect_forever, daemon=True, name="collector")
collector_thread.start()
print("collector started (background thread)", flush=True)
# 3. Open browser after a short delay so the server is ready
if open_browser:
def _open():
time.sleep(1.5)
webbrowser.open(local_url)
threading.Thread(target=_open, daemon=True).start()
# 4. Start HTTP server (blocks — keeps the process alive)
server = ThreadingHTTPServer((host, port), DashboardHandler)
print(f"dashboard listening on {host}:{port}", flush=True)
print(f" local → {local_url}", flush=True)
if lan_url:
print(f" LAN → {lan_url}", flush=True)
print("Press Ctrl+C to stop.", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.", flush=True)
server.shutdown()
def main():
parser = argparse.ArgumentParser(description="CLIProxyAPI usage dashboard")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("init", help="Initialise database and config")
sub.add_parser("collect", help="Run collector loop (foreground)")
sub.add_parser("debug", help="Test one poll cycle, print raw queue items (no DB write)")
sub.add_parser("serve", help="Run HTTP server only (no collector)")
start_p = sub.add_parser("start", help="Init + collect + serve in one command (recommended)")
start_p.add_argument("--no-browser", action="store_true", help="Do not open browser automatically")
quota_p = sub.add_parser("quota", help="Show current quota snapshots")
quota_p.add_argument("--force", action="store_true")
report_p = sub.add_parser("report", help="Print usage summary as JSON")
report_p.add_argument("range", choices=["today", "1h", "5h", "24h", "7d"])
args = parser.parse_args()
if args.cmd == "init":
init_db()
load_config()
print(DB_PATH)
elif args.cmd == "collect":
collect_forever()
elif args.cmd == "debug":
debug_collect()
elif args.cmd == "serve":
serve()
elif args.cmd == "start":
start(open_browser=not args.no_browser)
elif args.cmd == "quota":
init_db()
print(json.dumps({"quotas": latest_quotas(force=args.force)}, ensure_ascii=False, indent=2))
elif args.cmd == "report":
print_report(args.range)
if __name__ == "__main__":
main()
Binary file not shown.