feat: add configurable user statistics

This commit is contained in:
chuan
2026-08-22 22:42:06 +08:00
parent a9727e9b96
commit 74b5994180
18 changed files with 210 additions and 33 deletions
+1
View File
@@ -1 +1,2 @@
patch/*.patch whitespace=-space-before-tab
*.sh text eol=lf
+1
View File
@@ -30,6 +30,7 @@ type ManagedKey struct {
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
AllModels bool `json:"all_models"`
ShowInStats bool `json:"show_in_stats"`
Models []string `json:"models"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+30
View File
@@ -197,6 +197,36 @@ func TestCreateKeyRejectsShortSecret(t *testing.T) {
}
}
func TestManagedKeyStatsVisibilityCanBeConfigured(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\n"))); err != nil {
t.Fatal(err)
}
response := managementCallBody(t, app, http.MethodPost, managementBase+routeKeys, []byte(`{"name":"hidden","secret":"hidden-000000","show_in_stats":false}`))
if response.StatusCode != http.StatusCreated {
t.Fatalf("create status=%d body=%s", response.StatusCode, response.Body)
}
var created managedaccess.ManagedKey
if err := json.Unmarshal(response.Body, &created); err != nil {
t.Fatal(err)
}
if created.ShowInStats {
t.Fatalf("created key unexpectedly visible in stats: %+v", created)
}
summary := managementCall(t, app, http.MethodGet, managementBase+routeUsageSummary)
if strings.Contains(string(summary.Body), `"key_alias":"hidden"`) {
t.Fatalf("hidden key remains in dashboard: %s", summary.Body)
}
response = managementCallBody(t, app, http.MethodPatch, managementBase+routeKeys, []byte(`{"id":"`+created.ID+`","show_in_stats":true}`))
if response.StatusCode != http.StatusOK {
t.Fatalf("update status=%d body=%s", response.StatusCode, response.Body)
}
summary = managementCall(t, app, http.MethodGet, managementBase+routeUsageSummary)
if !strings.Contains(string(summary.Body), `"key_alias":"hidden"`) {
t.Fatalf("visible key missing from dashboard: %s", summary.Body)
}
}
func TestManagedSecretValidation(t *testing.T) {
for secret, want := range map[string]bool{
"12345": false, "000000": true, "alice-000000": true, "with space": false, strings.Repeat("x", 257): false,
+9 -1
View File
@@ -24,6 +24,7 @@ type createManagedKeyRequest struct {
RouteMode string `json:"route_mode,omitempty"`
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
AllModels *bool `json:"all_models,omitempty"`
ShowInStats *bool `json:"show_in_stats,omitempty"`
Models *[]string `json:"models,omitempty"`
Billing *billingSettingsRequest `json:"billing,omitempty"`
}
@@ -35,6 +36,7 @@ type updateManagedKeyRequest struct {
RouteMode *string `json:"route_mode,omitempty"`
UpstreamAccountID *string `json:"upstream_account_id,omitempty"`
AllModels *bool `json:"all_models,omitempty"`
ShowInStats *bool `json:"show_in_stats,omitempty"`
Models *[]string `json:"models,omitempty"`
Billing *billingSettingsRequest `json:"billing,omitempty"`
}
@@ -133,7 +135,7 @@ func (a *App) createManagedKey(body []byte) ManagementResponse {
}
key := managedaccess.ManagedKey{
ID: keyID, Name: name, Secret: secret,
Status: managedaccess.StatusActive, RouteMode: managedaccess.RouteAuto, AllModels: true,
Status: managedaccess.StatusActive, RouteMode: managedaccess.RouteAuto, AllModels: true, ShowInStats: true,
}
if template, err := store.ManagedKeyByID(context.Background(), "key_default"); err == nil {
key.RouteMode = template.RouteMode
@@ -150,6 +152,9 @@ func (a *App) createManagedKey(body []byte) ManagementResponse {
if req.AllModels != nil {
key.AllModels = *req.AllModels
}
if req.ShowInStats != nil {
key.ShowInStats = *req.ShowInStats
}
if req.Models != nil {
key.Models = normalizeModels(*req.Models)
}
@@ -211,6 +216,9 @@ func (a *App) updateManagedKey(body []byte) ManagementResponse {
if req.AllModels != nil {
key.AllModels = *req.AllModels
}
if req.ShowInStats != nil {
key.ShowInStats = *req.ShowInStats
}
if req.Models != nil {
key.Models = normalizeModels(*req.Models)
}
+2 -2
View File
@@ -665,7 +665,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, `data-view="logs">日志`) {
t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode)
}
for _, feature := range []string{`<body class="read-only">`, `<base href="/v0/resource/plugins/billing/">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `data-view="stats">统计`, `data-view="users">用户`, `id="view-users" class="view admin-only"`, `id="quota-chart"`, `id="page-buttons" class="page-buttons admin-only"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel admin-only"`, `id="refresh" class="admin-only"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="user-management"`, `id="reset-all-billing"`, `id="key-rows" class="user-card-grid"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
for _, feature := range []string{`<body class="read-only">`, `<base href="/v0/resource/plugins/billing/">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `data-view="stats">统计`, `data-view="users">用户`, `id="view-users" class="view admin-only"`, `id="quota-chart"`, `id="quota-health"`, `id="page-buttons" class="page-buttons admin-only"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel admin-only"`, `id="refresh" class="admin-only"`, `id="editor-show-stats"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="user-management"`, `id="reset-all-billing"`, `id="key-rows" class="user-card-grid"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain HTML feature %q", feature)
}
@@ -694,7 +694,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
}
javascript.Write(response.Body)
}
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "adminPageSize = 100", "userPageSize = 50", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", `identity.title = "点击复制 Key"`, `return "自由选择"`, "剩余额度", "今日使用 / 剩余额度", "quotaUsage(item.cost, item.balance)", "resetTime(item)", `document.body.classList.toggle("read-only"`, "compactTokens(price.long_context.threshold_input_tokens)", "appendPriceRow(table, \"长上下文\", price.long_context, true)", `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "adminPageSize = 100", "userPageSize = 50", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", `identity.title = "点击复制 Key"`, `return "自由选择"`, "今日使用", "剩余额度", "quotaAmount(item.cost)", "quotaAmount(item.balance)", "resetTime(item)", "show_in_stats", "renderQuotaHealth", `document.body.classList.toggle("read-only"`, "compactTokens(price.long_context.threshold_input_tokens)", "appendPriceRow(table, \"长上下文\", price.long_context, true)", `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
if !strings.Contains(javascript.String(), feature) {
t.Fatalf("UI modules do not contain feature %q", feature)
}
+2 -1
View File
@@ -19,6 +19,7 @@ type readOnlyManagedKeyDTO struct {
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
AllModels bool `json:"all_models"`
ShowInStats bool `json:"show_in_stats"`
Models []string `json:"models"`
Billing billingStateDTO `json:"billing"`
}
@@ -99,7 +100,7 @@ func (a *App) readOnlyManagedKeys(includeArchived bool) ManagementResponse {
items = append(items, readOnlyManagedKeyDTO{
ID: key.ID, Name: key.Name, MaskedSecret: maskManagedSecret(key.Secret), Status: key.Status,
RouteMode: key.RouteMode, UpstreamAccountID: key.UpstreamAccountID,
AllModels: key.AllModels, Models: key.Models, Billing: billingStateResponse(state),
AllModels: key.AllModels, ShowInStats: key.ShowInStats, Models: key.Models, Billing: billingStateResponse(state),
})
}
return jsonManagementResponse(http.StatusOK, map[string]any{"keys": items})
+10 -10
View File
@@ -26,7 +26,7 @@ func (r *SQLiteUsageRepository) BootstrapManagedKey(ctx context.Context, name, s
key := managedaccess.ManagedKey{
ID: "key_default", Name: strings.TrimSpace(name), Secret: secret,
Status: managedaccess.StatusActive, RouteMode: managedaccess.RouteAuto,
AllModels: true, CreatedAt: now, UpdatedAt: now,
AllModels: true, ShowInStats: true, CreatedAt: now, UpdatedAt: now,
}
if err := r.CreateManagedKey(ctx, key); err != nil {
return managedaccess.ManagedKey{}, err
@@ -52,9 +52,9 @@ func (r *SQLiteUsageRepository) CreateManagedKey(ctx context.Context, key manage
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(ctx, `
INSERT INTO managed_keys (id, name, secret, status, route_mode, upstream_account_id, all_models, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, key.ID, key.Name, key.Secret, key.Status, key.RouteMode,
key.UpstreamAccountID, key.AllModels, formatTime(key.CreatedAt), formatTime(key.UpdatedAt)); err != nil {
INSERT INTO managed_keys (id, name, secret, status, route_mode, upstream_account_id, all_models, show_in_stats, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, key.ID, key.Name, key.Secret, key.Status, key.RouteMode,
key.UpstreamAccountID, key.AllModels, key.ShowInStats, formatTime(key.CreatedAt), formatTime(key.UpdatedAt)); err != nil {
return fmt.Errorf("创建 Key: %w", err)
}
if err := replaceManagedKeyModels(ctx, tx, key.ID, key.Models); err != nil {
@@ -105,9 +105,9 @@ func (r *SQLiteUsageRepository) UpdateManagedKey(ctx context.Context, key manage
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `
UPDATE managed_keys SET name=?, status=?, route_mode=?, upstream_account_id=?, all_models=?, updated_at=?
UPDATE managed_keys SET name=?, status=?, route_mode=?, upstream_account_id=?, all_models=?, show_in_stats=?, updated_at=?
WHERE id=? AND status <> 'archived'`, key.Name, key.Status, key.RouteMode, key.UpstreamAccountID,
key.AllModels, formatTime(key.UpdatedAt), key.ID)
key.AllModels, key.ShowInStats, formatTime(key.UpdatedAt), key.ID)
if err != nil {
return fmt.Errorf("更新 Key: %w", err)
}
@@ -210,9 +210,9 @@ func (r *SQLiteUsageRepository) scanManagedKey(ctx context.Context, where string
var key managedaccess.ManagedKey
var createdAt, updatedAt string
err := r.readDB.QueryRowContext(ctx, `
SELECT id, name, secret, status, route_mode, upstream_account_id, all_models, created_at, updated_at
SELECT id, name, secret, status, route_mode, upstream_account_id, all_models, show_in_stats, created_at, updated_at
FROM managed_keys `+where, args...).Scan(&key.ID, &key.Name, &key.Secret, &key.Status, &key.RouteMode,
&key.UpstreamAccountID, &key.AllModels, &createdAt, &updatedAt)
&key.UpstreamAccountID, &key.AllModels, &key.ShowInStats, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return managedaccess.ManagedKey{}, ErrManagedKeyNotFound
}
@@ -231,7 +231,7 @@ func (r *SQLiteUsageRepository) ListManagedKeys(ctx context.Context, includeArch
where = ""
}
rows, err := r.readDB.QueryContext(ctx, `
SELECT id, name, secret, status, route_mode, upstream_account_id, all_models, created_at, updated_at
SELECT id, name, secret, status, route_mode, upstream_account_id, all_models, show_in_stats, created_at, updated_at
FROM managed_keys `+where+` ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("查询 Key 列表: %w", err)
@@ -241,7 +241,7 @@ FROM managed_keys `+where+` ORDER BY name COLLATE NOCASE`)
var key managedaccess.ManagedKey
var createdAt, updatedAt string
if err := rows.Scan(&key.ID, &key.Name, &key.Secret, &key.Status, &key.RouteMode,
&key.UpstreamAccountID, &key.AllModels, &createdAt, &updatedAt); err != nil {
&key.UpstreamAccountID, &key.AllModels, &key.ShowInStats, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("读取 Key: %w", err)
}
key.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
+12
View File
@@ -45,6 +45,18 @@ func TestManagedKeysBootstrapLifecycleAndHistoricalUsage(t *testing.T) {
if err := store.ArchiveManagedKey(context.Background(), key.ID); err != nil {
t.Fatal(err)
}
dashboard, err := store.UsageDashboard(context.Background(), time.Now(), 7)
if err != nil {
t.Fatal(err)
}
if dashboard.Today.Requests != 1 {
t.Fatalf("archived key usage should remain in dashboard totals: %+v", dashboard)
}
for _, user := range dashboard.Users {
if user.KeyID == key.ID || user.KeyAlias == key.Name {
t.Fatalf("archived key should not remain in user dashboard: %+v", dashboard)
}
}
archived, err := store.ManagedKeyByID(context.Background(), key.ID)
if err != nil || archived.Status != managedaccess.StatusArchived {
t.Fatalf("archived key = %+v, err=%v", archived, err)
+1 -1
View File
@@ -21,7 +21,7 @@ WITH today AS (
WHERE d.requested_at>=?
GROUP BY d.managed_key_id
), dashboard_keys AS (
SELECT id, name FROM managed_keys
SELECT id, name FROM managed_keys WHERE status <> 'archived' AND show_in_stats=1
UNION ALL
SELECT t.managed_key_id, '未识别'
FROM today t LEFT JOIN managed_keys k ON k.id=t.managed_key_id
+2
View File
@@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS managed_keys (
route_mode TEXT NOT NULL CHECK(route_mode IN ('auto', 'strict')),
upstream_account_id TEXT NOT NULL DEFAULT '',
all_models INTEGER NOT NULL DEFAULT 1,
show_in_stats INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -292,6 +293,7 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
`ALTER TABLE model_prices ADD COLUMN source_fetched_at TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE billing_accounts ADD COLUMN lifetime_spent_micros INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE upstream_accounts ADD COLUMN current INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE managed_keys ADD COLUMN show_in_stats INTEGER NOT NULL DEFAULT 1`,
} {
if _, err := db.Exec(migration); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
_ = db.Close()
+62 -8
View File
@@ -92,6 +92,7 @@ function openKeyEditor(key = null) {
const upstream = document.querySelector("#editor-upstream");
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
document.querySelector("#editor-show-stats").checked = key?.show_in_stats ?? true;
const models = document.querySelector("#editor-models");
models.value = (key?.models || []).join(", ");
models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
@@ -110,7 +111,7 @@ function openKeyEditor(key = null) {
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
["editor-name", "editor-status", "editor-route", "editor-all-models", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
["editor-name", "editor-status", "editor-route", "editor-all-models", "editor-show-stats", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
syncEditorRoute();
openDrawer();
if (!creating) loadBillingLedger(key);
@@ -189,7 +190,8 @@ function userCardDetail(label, value, hint) {
}
function renderUserCharts() {
const byUser = new Map(managedKeys.map(key => [key.name, {
const visibleKeys = managedKeys.filter(key => key.show_in_stats !== false);
const byUser = new Map(visibleKeys.map(key => [key.name, {
name: key.name, requests: 0, tokens: 0, cost: 0,
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
resetPeriod: key.billing?.reset_period || null,
@@ -210,7 +212,7 @@ function renderUserCharts() {
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
const header = document.createElement("div");
header.className = "user-usage-row header";
["用户", "今日请求", "今日 Token", "今日使用 / 剩余额度", "最近使用", "重置时间"].forEach(label => {
["用户", "今日请求", "今日 Token", "今日使用", "剩余额度", "最近使用", "重置时间"].forEach(label => {
const cell = document.createElement("span");
cell.textContent = label;
header.append(cell);
@@ -218,7 +220,7 @@ function renderUserCharts() {
document.querySelector("#user-usage").replaceChildren(header, ...userRows.map(item => {
const row = document.createElement("div");
row.className = "user-usage-row";
[item.name, number(item.requests), number(item.tokens), quotaUsage(item.cost, item.balance), item.last ? item.last.toLocaleString() : "从未", resetTime(item)].forEach(value => {
[item.name, number(item.requests), number(item.tokens), quotaAmount(item.cost), quotaAmount(item.balance), item.last ? item.last.toLocaleString() : "从未", resetTime(item)].forEach(value => {
const cell = document.createElement("span");
cell.textContent = value;
row.append(cell);
@@ -227,12 +229,13 @@ function renderUserCharts() {
}));
const days = [...(usageDashboard.days || [])].reverse().map(item => ({ date: new Date(item.date + "T00:00:00"), cost: Number(item.cost_usd || 0) }));
renderQuotaMatrix(days, [...byUser.values()]);
renderStatsInsights(visibleKeys);
}
function quotaUsage(cost, balance) {
const used = Number(cost || 0);
const remaining = Number(balance);
return `${(Number.isFinite(used) ? used : 0).toFixed(2)} / ${balance === null || !Number.isFinite(remaining) ? "—" : remaining.toFixed(2)}`;
function quotaAmount(value) {
if (value === null) return "—";
const amount = Number(value);
return Number.isFinite(amount) ? amount.toFixed(2) : "—";
}
function resetTime(item) {
@@ -247,6 +250,55 @@ function resetTime(item) {
return `${parts.join("-")} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
}
function renderStatsInsights(visibleKeys) {
renderQuotaHealth(visibleKeys);
}
function renderQuotaHealth(keys) {
const items = keys.map(key => {
const billing = key.billing || {};
const quota = Number(billing.quota_usd || 0);
const balance = Number(billing.balance_usd || 0);
const ratio = quota > 0 ? balance / quota : null;
return { key, billing, quota, balance, ratio };
}).sort((left, right) => {
if (left.ratio === null) return right.ratio === null ? left.key.name.localeCompare(right.key.name) : 1;
if (right.ratio === null) return -1;
return left.ratio - right.ratio || left.key.name.localeCompare(right.key.name);
});
const node = document.querySelector("#quota-health");
if (!items.length) {
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无展示中的用户" }));
return;
}
node.replaceChildren(...items.map(item => {
const row = document.createElement("div");
row.className = "quota-health-row";
const heading = document.createElement("div");
heading.className = "quota-health-heading";
const identity = document.createElement("strong");
identity.textContent = item.key.name;
const percent = item.ratio === null ? null : Math.round(item.ratio * 100);
const detail = document.createElement("span");
const active = Number(item.billing.active_requests || 0);
const maximum = Number(item.billing.max_concurrency || 4);
detail.textContent = item.ratio === null
? `未配置周期额度 · 当前并发 ${active} / ${maximum}`
: `${money(item.balance)} / ${money(item.quota)} · 剩余 ${percent}% · 当前并发 ${active} / ${maximum}`;
detail.className = item.ratio !== null && item.ratio <= 0.2 ? "danger" : "";
heading.append(identity, detail);
const track = document.createElement("div");
track.className = "quota-health-track";
const bar = document.createElement("div");
bar.className = "quota-health-bar";
if (item.ratio !== null && item.ratio <= 0.2) bar.classList.add("danger");
bar.style.width = `${item.ratio === null ? 0 : Math.max(0, Math.min(100, percent))}%`;
track.append(bar);
row.append(heading, track);
return row;
}));
}
function renderQuotaMatrix(days, users) {
const dates = days.map(day => dateKey(day.date));
const rows = users.map(user => {
@@ -297,6 +349,7 @@ async function createManagedKey() {
route_mode: route,
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
all_models: allModels,
show_in_stats: document.querySelector("#editor-show-stats").checked,
models: modelRules(),
billing: billingPayload()
};
@@ -321,6 +374,7 @@ async function saveKeyEditor() {
route_mode: route,
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
all_models: document.querySelector("#editor-all-models").checked,
show_in_stats: document.querySelector("#editor-show-stats").checked,
models: modelRules(),
billing: billingPayload()
}) });
+14 -1
View File
@@ -9,10 +9,23 @@
.quota-cell.total { background: var(--bg-tertiary); color: var(--text); font-weight: 600; }
.quota-cell.usage { background: color-mix(in srgb, var(--primary-active) var(--quota-intensity), var(--bg-primary)); color: var(--text); }
.user-usage { display: grid; overflow-x: auto; }
.user-usage-row { display: grid; grid-template-columns: 120px repeat(5, minmax(150px, 1fr)); align-items: center; gap: 10px; min-width: 900px; min-height: 34px; border-bottom: 1px solid var(--border-soft); font-size: 12px; }
.user-usage-row { display: grid; grid-template-columns: 120px repeat(6, minmax(130px, 1fr)); align-items: center; gap: 10px; min-width: 980px; min-height: 34px; border-bottom: 1px solid var(--border-soft); font-size: 12px; }
.user-usage-row:last-child { border-bottom: 0; }
.user-usage-row.header { min-height: 30px; color: var(--muted); font-size: 12px; }
.user-usage-row span:not(:first-child) { text-align: right; }
.stats-insight-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 9px; }
.insight-card { min-height: 280px; }
.insight-card > header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 16px; }
.insight-card h2 { margin: 0; font-size: 13px; }
#quota-health { display: grid; gap: 11px; }
.quota-health-row { display: grid; gap: 6px; }
.quota-health-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.quota-health-heading strong { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.quota-health-heading span { min-width: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.4; text-align: right; }
.quota-health-heading span.danger { color: var(--danger); }
.quota-health-track { height: 6px; overflow: hidden; border-radius: 6px; background: var(--bg-primary); }
.quota-health-bar { height: 100%; border-radius: inherit; background: var(--success); }
.quota-health-bar.danger { background: var(--danger); }
.user-management { display: grid; gap: 14px; }
.user-management-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.user-management-header .status { min-height: 16px; margin: 0; }
+1 -1
View File
@@ -1,3 +1,3 @@
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .chart-grid, .user-card-grid, .read-only #view-pricing .price-list { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .chart-grid, .stats-insight-grid, .user-card-grid, .read-only #view-pricing .price-list { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .workspace-bar, body.demo-mode .workspace-bar { align-items: stretch; flex-wrap: wrap; padding: 0 12px; } .workspace-nav { order: 2; width: 100%; padding: 0; } .topbar-actions { order: 1; width: 100%; justify-content: flex-end; padding-top: 8px; } .demo-perspective button { padding: 6px 8px; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .user-management-header { align-items: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .model-price-card-header { align-items: flex-start; flex-direction: column; gap: 7px; } .model-price-table { grid-template-columns: minmax(62px, .9fr) repeat(4, minmax(54px, 1fr)); } .model-price-table > div { padding: 7px 6px; } .drawer { width: 100vw; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
+5 -1
View File
@@ -36,6 +36,9 @@
<article class="chart-card"><div id="user-usage" class="user-usage"></div></article>
<article class="chart-card"><div id="quota-chart" class="quota-matrix-wrap"></div></article>
</div>
<div class="stats-insight-grid">
<article class="chart-card insight-card"><header><h2>额度健康</h2></header><div id="quota-health"></div></article>
</div>
</section>
<section id="view-users" class="view admin-only">
@@ -102,7 +105,8 @@
<div class="drawer-body">
<div id="key-editor" class="drawer-form">
<div class="drawer-section"><h3>用户</h3><label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
<label class="field">完整 Key<input id="editor-secret" class="secret" minlength="6" maxlength="256" placeholder="留空自动生成"></label></div>
<label class="field">完整 Key<input id="editor-secret" class="secret" minlength="6" maxlength="256" placeholder="留空自动生成"></label>
<label class="inline-check"><input id="editor-show-stats" type="checkbox" checked>在统计页显示该用户</label></div>
<div id="managed-fields" class="drawer-form">
<div class="drawer-section"><h3>访问与路由</h3>
<div class="field-row"><label class="field">状态<select id="editor-status"><option value="active">启用</option><option value="disabled">禁用</option><option value="archived">已归档</option></select></label><label class="field">路由<select id="editor-route"><option value="auto">自由选择</option><option value="strict">指定账号</option></select></label></div>
+5 -4
View File
@@ -30,6 +30,7 @@ type fakeKey struct {
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id"`
AllModels bool `json:"all_models"`
ShowInStats bool `json:"show_in_stats"`
Models []string `json:"models"`
Billing map[string]any `json:"billing"`
}
@@ -140,10 +141,10 @@ func fakeKeys(now time.Time) []fakeKey {
monthlyReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
monthlyStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
return []fakeKey{
{ID: "key_default", Name: "默认用户", Secret: "demo-default-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "auto", AllModels: true, Billing: fakeBilling("50", "18.427631", "31.572369", "monthly", monthlyReset, 8, 1, monthlyStart)},
{ID: "key_alice", Name: "Alice", Secret: "demo-alice-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-primary", Models: []string{"deepseek-*", "gpt-5.6-sol"}, Billing: fakeBilling("20", "6.983214", "13.016786", "weekly", now.Add(4*24*time.Hour), 4, 0, now.Add(-3*24*time.Hour))},
{ID: "key_bob", Name: "Bob", Secret: "demo-bob-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-backup", Models: []string{"deepseek-v4-flash"}, Billing: fakeBilling("10", "9.764502", "0.235498", "none", time.Time{}, 2, 2, now.Add(-18*24*time.Hour))},
{ID: "key_carol", Name: "Carol", Secret: "demo-carol-000000", MaskedSecret: "de******0000", Status: "disabled", RouteMode: "auto", Models: []string{"gpt-5.6-sol"}, Billing: fakeBilling("15", "2.154800", "12.845200", "monthly", monthlyReset, 4, 0, monthlyStart)},
{ID: "key_default", Name: "默认用户", Secret: "demo-default-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "auto", AllModels: true, ShowInStats: true, Billing: fakeBilling("50", "18.427631", "31.572369", "monthly", monthlyReset, 8, 1, monthlyStart)},
{ID: "key_alice", Name: "Alice", Secret: "demo-alice-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-primary", ShowInStats: true, Models: []string{"deepseek-*", "gpt-5.6-sol"}, Billing: fakeBilling("20", "6.983214", "13.016786", "weekly", now.Add(4*24*time.Hour), 4, 0, now.Add(-3*24*time.Hour))},
{ID: "key_bob", Name: "Bob", Secret: "demo-bob-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-backup", ShowInStats: true, Models: []string{"deepseek-v4-flash"}, Billing: fakeBilling("10", "9.764502", "0.235498", "none", time.Time{}, 2, 2, now.Add(-18*24*time.Hour))},
{ID: "key_carol", Name: "Carol", Secret: "demo-carol-000000", MaskedSecret: "de******0000", Status: "disabled", RouteMode: "auto", ShowInStats: true, Models: []string{"gpt-5.6-sol"}, Billing: fakeBilling("15", "2.154800", "12.845200", "monthly", monthlyReset, 4, 0, monthlyStart)},
}
}
+12 -1
View File
@@ -106,6 +106,7 @@ func (input *fakeInput) createKey(request *http.Request) (fakeKey, error) {
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id"`
AllModels bool `json:"all_models"`
ShowInStats *bool `json:"show_in_stats"`
Models []string `json:"models"`
Billing map[string]any `json:"billing"`
}
@@ -118,7 +119,11 @@ func (input *fakeInput) createKey(request *http.Request) (fakeKey, error) {
if value.Secret == "" {
value.Secret = fmt.Sprintf("demo-generated-%06d", len(input.keys)+1)
}
key := fakeKey{ID: fmt.Sprintf("key_demo_%d", len(input.keys)+1), Name: value.Name, Secret: value.Secret, MaskedSecret: maskFakeSecret(value.Secret), Status: "active", RouteMode: value.RouteMode, UpstreamAccountID: value.UpstreamAccountID, AllModels: value.AllModels, Models: value.Models, Billing: normalizeFakeBilling(value.Billing, nil)}
showInStats := true
if value.ShowInStats != nil {
showInStats = *value.ShowInStats
}
key := fakeKey{ID: fmt.Sprintf("key_demo_%d", len(input.keys)+1), Name: value.Name, Secret: value.Secret, MaskedSecret: maskFakeSecret(value.Secret), Status: "active", RouteMode: value.RouteMode, UpstreamAccountID: value.UpstreamAccountID, AllModels: value.AllModels, ShowInStats: showInStats, Models: value.Models, Billing: normalizeFakeBilling(value.Billing, nil)}
input.keys = append(input.keys, key)
return key, nil
}
@@ -146,6 +151,9 @@ func (input *fakeInput) updateKey(request *http.Request) (fakeKey, error) {
if allModels, ok := value["all_models"].(bool); ok {
key.AllModels = allModels
}
if showInStats, ok := value["show_in_stats"].(bool); ok {
key.ShowInStats = showInStats
}
if models, ok := value["models"].([]any); ok {
key.Models = make([]string, 0, len(models))
for _, model := range models {
@@ -289,6 +297,9 @@ func (input *fakeInput) dashboard() map[string]any {
todayRecords := filterUsage(input.usage, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) })
users := make([]map[string]any, 0, len(input.keys))
for _, key := range input.keys {
if key.Status == "archived" || !key.ShowInStats {
continue
}
records := filterUsage(input.usage, func(record fakeUsage) bool { return record.ManagedKeyID == key.ID })
current := filterUsage(records, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) })
userDays := make([]map[string]any, 0, 7)
+38
View File
@@ -148,6 +148,44 @@ func TestFakeDashboardIncludesSevenDailyCosts(t *testing.T) {
}
}
func TestFakeDashboardHidesArchivedUsers(t *testing.T) {
input := NewFakeInput().(*fakeInput)
archivedID := input.keys[0].ID
input.keys[0].Status = "archived"
dashboard := input.dashboard()
users := dashboard["users"].([]map[string]any)
if len(users) != len(input.keys)-1 {
t.Fatalf("dashboard users = %d, want %d", len(users), len(input.keys)-1)
}
for _, user := range users {
if user["key_id"] == archivedID {
t.Fatalf("archived user %q remains in dashboard", archivedID)
}
}
if dashboard["today"].(map[string]any)["requests"].(int) == 0 {
t.Fatal("archived user usage should remain in overall totals")
}
}
func TestFakeDashboardHidesOptedOutUsers(t *testing.T) {
input := NewFakeInput().(*fakeInput)
hiddenID := input.keys[0].ID
input.keys[0].ShowInStats = false
dashboard := input.dashboard()
users := dashboard["users"].([]map[string]any)
if len(users) != len(input.keys)-1 {
t.Fatalf("dashboard users = %d, want %d", len(users), len(input.keys)-1)
}
for _, user := range users {
if user["key_id"] == hiddenID {
t.Fatalf("opted-out user %q remains in dashboard", hiddenID)
}
}
if dashboard["today"].(map[string]any)["requests"].(int) == 0 {
t.Fatal("opted-out user usage should remain in overall totals")
}
}
func TestDatabaseInputServesStandaloneBillingDatabase(t *testing.T) {
input, err := NewDatabaseInput(t.TempDir() + "/billing.db")
if err != nil {
+3 -2
View File
@@ -6,6 +6,7 @@ version="${1:-0.1.0}"
target="linux_amd64"
name="billing_${version}_${target}"
artifact="$root/bin/billing.so"
cpa_revision="$(git -C "$root/.externals/CLIProxyAPI" rev-parse HEAD)"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $version" >&2
@@ -30,12 +31,12 @@ printf '%s\n' \
"target=linux/amd64" \
"native_abi=1" \
"rpc_schema=4" \
"cliproxyapi_revision=85d2faddd17e6f4f8675a84ee28b131f702e8eaa+usage-context+usage-identity+request-lifecycle-cancel" \
"cliproxyapi_revision=${cpa_revision}+usage-context+usage-identity+request-lifecycle-cancel" \
>"$package_dir/VERSION.txt"
archive="$root/dist/$name.tar.gz"
tar -czf "$archive" -C "$stage" "$name"
sha256sum "$archive" >"$archive.sha256"
(cd "$root/dist" && sha256sum "$name.tar.gz" >"$name.tar.gz.sha256")
echo "Packaged: $archive"
echo "Checksum: $archive.sha256"