feat: add configurable user statistics
This commit is contained in:
@@ -1 +1,2 @@
|
||||
patch/*.patch whitespace=-space-before-tab
|
||||
*.sh text eol=lf
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}) });
|
||||
|
||||
@@ -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,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; } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user