feat(webdemo): add database-backed usage dashboard
This commit is contained in:
+14
-3
@@ -12,15 +12,26 @@ import (
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8320", "HTTP listen address")
|
||||
input := flag.String("input", "", "data input; supported value: fake")
|
||||
inputName := flag.String("input", "", "demo input; supported value: fake")
|
||||
databasePath := flag.String("database", "", "optional SQLite data source for the fake demo")
|
||||
flag.Parse()
|
||||
|
||||
if *input != "fake" {
|
||||
if *inputName != "fake" {
|
||||
fmt.Fprintln(os.Stderr, "missing supported -input value: fake")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(webdemo.NewFakeInput())}
|
||||
var input webdemo.Input = webdemo.NewFakeInput()
|
||||
if *databasePath != "" {
|
||||
databaseInput, err := webdemo.NewDatabaseInput(*databasePath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
input = databaseInput
|
||||
defer databaseInput.Close()
|
||||
}
|
||||
|
||||
server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(input)}
|
||||
log.Printf("billing web demo: http://%s/ui", *listen)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ git submodule update --init --recursive
|
||||
|
||||
升级 submodule、构建宿主、本地替换、验收和远端发布使用 [`operations.md`](operations.md)。该文档同时记录已确认的上游测试失败及免复测条件。
|
||||
|
||||
管理台界面开发使用 `go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320`。该命令用独立 Fake 输入端启动正式页面资源,不依赖本地 CPA 和 billing 数据库。
|
||||
管理台界面开发使用 `go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320`。该命令用独立 Fake 输入端启动正式页面资源,不依赖本地 CPA 和 billing 数据库。需要查看真实数据分布时,增加 `-database <SQLite 副本路径>` 显式挂载本地副本;省略该参数时继续使用生成数据。
|
||||
|
||||
开发按依赖从少到多推进:先确定数据和规则,再实现存储与服务,最后接入外部协议和界面。核心逻辑不依赖框架类型,协议转换集中在边界层。涉及持久化时,先定义迁移和历史数据语义
|
||||
|
||||
|
||||
@@ -110,7 +110,15 @@ Demo 使用独立 Fake 输入端。页面资源与正式插件相同,Fake 数
|
||||
go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320
|
||||
```
|
||||
|
||||
启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。进程重启后恢复初始数据。
|
||||
启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。页面右上角提供“管理员示教”和“普通用户视角”,后者使用页面已有的只读权限状态。进程重启后恢复初始数据。
|
||||
|
||||
也可以显式挂载独立的 billing SQLite 副本:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/webdemo -input fake -database .runtime/demo/billing.db -listen 127.0.0.1:8320
|
||||
```
|
||||
|
||||
指定 `-database` 时直接使用副本中的用户、用量、账目和价格;省略时继续使用原来的生成数据。界面修改会写入该副本;保留压缩快照可用于恢复初始数据。
|
||||
|
||||
## 管理接口
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ type UserUsageSummary struct {
|
||||
KeyID string
|
||||
KeyAlias string
|
||||
Today UsageSummary
|
||||
Days []DailyUsageSummary
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
type DailyUsageSummary struct {
|
||||
Date time.Time
|
||||
TotalTokens int64
|
||||
CostMicros int64
|
||||
}
|
||||
|
||||
@@ -322,11 +322,15 @@ func (a *App) usageDashboardResponse() ManagementResponse {
|
||||
}
|
||||
users := make([]map[string]any, 0, len(dashboard.Users))
|
||||
for _, user := range dashboard.Users {
|
||||
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "last_used_at": user.LastUsedAt})
|
||||
userDays := make([]map[string]any, 0, len(user.Days))
|
||||
for _, day := range user.Days {
|
||||
userDays = append(userDays, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
|
||||
}
|
||||
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "days": userDays, "last_used_at": user.LastUsedAt})
|
||||
}
|
||||
days := make([]map[string]any, 0, len(dashboard.Days))
|
||||
for _, day := range dashboard.Days {
|
||||
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens})
|
||||
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"today": usageSummaryItem(dashboard.Today), "users": users, "days": days})
|
||||
}
|
||||
|
||||
@@ -562,14 +562,19 @@ func TestUsageManagementResponseContainsDisplayFields(t *testing.T) {
|
||||
func TestUsageResourceServesFeatureModules(t *testing.T) {
|
||||
pageResponse := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI)
|
||||
page := string(pageResponse.Body)
|
||||
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") {
|
||||
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">`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="surface admin-only"`, `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">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `id="quota-chart"`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="surface admin-only"`, `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)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{`<h2>Day</h2>`, `<h2>Week</h2>`} {
|
||||
if strings.Contains(page, removed) {
|
||||
t.Fatalf("UI still contains removed card title %q", removed)
|
||||
}
|
||||
}
|
||||
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
|
||||
t.Fatal("UI pagination controls are not above the usage table")
|
||||
}
|
||||
@@ -589,7 +594,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
|
||||
}
|
||||
javascript.Write(response.Body)
|
||||
}
|
||||
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", "maskedSecret(key.secret)", `return "自由选择"`, `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
|
||||
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", "maskedSecret(key.secret)", `return "自由选择"`, "剩余额度", `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
|
||||
if !strings.Contains(javascript.String(), feature) {
|
||||
t.Fatalf("UI modules do not contain feature %q", feature)
|
||||
}
|
||||
|
||||
@@ -141,14 +141,21 @@ func TestSQLiteUsageDashboardUsesAllIndexedHistory(t *testing.T) {
|
||||
defer store.Close()
|
||||
location, _ := time.LoadLocation("Asia/Shanghai")
|
||||
today := time.Date(2026, 8, 15, 12, 0, 0, 0, location)
|
||||
if err := store.Insert(context.Background(), collection.Record{ManagedKeyID: "key-a", RequestedAt: today.Add(-time.Hour), Model: "model", InputTokens: 10, OutputTokens: 2, TotalTokens: 12, CostMicros: nil}); err != nil {
|
||||
cost := int64(125_000)
|
||||
if err := store.Insert(context.Background(), collection.Record{RequestedAt: today.Add(-time.Hour), Model: "model", InputTokens: 10, OutputTokens: 2, TotalTokens: 12, CostMicros: &cost}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dashboard, err := store.UsageDashboard(context.Background(), today, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dashboard.Today.Requests != 1 || dashboard.Today.TotalTokens != 12 || len(dashboard.Users) != 1 || len(dashboard.Days) != 7 {
|
||||
if dashboard.Today.Requests != 1 || dashboard.Today.TotalTokens != 12 || len(dashboard.Users) != 1 || len(dashboard.Days) != 7 || len(dashboard.Users[0].Days) != 7 {
|
||||
t.Fatalf("unexpected dashboard: %+v", dashboard)
|
||||
}
|
||||
if dashboard.Days[6].CostMicros != cost {
|
||||
t.Fatalf("today cost = %d, want %d", dashboard.Days[6].CostMicros, cost)
|
||||
}
|
||||
if dashboard.Users[0].Days[6].CostMicros != cost {
|
||||
t.Fatalf("user today cost = %d, want %d", dashboard.Users[0].Days[6].CostMicros, cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,26 +73,62 @@ WHERE d.requested_at>=?`, formatTime(start)).Scan(&dashboard.Today.Requests, &da
|
||||
|
||||
dailyStart := start.AddDate(0, 0, -(days - 1))
|
||||
rows, err = r.readDB.QueryContext(ctx, `
|
||||
SELECT DATE(d.requested_at, '+8 hours'), COALESCE(SUM(u.total_tokens),0)
|
||||
SELECT DATE(d.requested_at, '+8 hours'), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0)
|
||||
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
WHERE d.requested_at>=? GROUP BY DATE(d.requested_at, '+8 hours') ORDER BY 1`, formatTime(dailyStart))
|
||||
if err != nil {
|
||||
return dashboard, fmt.Errorf("汇总每日用量: %w", err)
|
||||
}
|
||||
byDate := make(map[string]int64)
|
||||
costByDate := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var tokens int64
|
||||
if err := rows.Scan(&date, &tokens); err != nil {
|
||||
var tokens, costMicros int64
|
||||
if err := rows.Scan(&date, &tokens, &costMicros); err != nil {
|
||||
_ = rows.Close()
|
||||
return dashboard, fmt.Errorf("读取每日用量: %w", err)
|
||||
}
|
||||
byDate[date] = tokens
|
||||
costByDate[date] = costMicros
|
||||
}
|
||||
_ = rows.Close()
|
||||
for index := 0; index < days; index++ {
|
||||
date := dailyStart.AddDate(0, 0, index)
|
||||
dashboard.Days = append(dashboard.Days, collection.DailyUsageSummary{Date: date, TotalTokens: byDate[date.Format("2006-01-02")]})
|
||||
key := date.Format("2006-01-02")
|
||||
dashboard.Days = append(dashboard.Days, collection.DailyUsageSummary{Date: date, TotalTokens: byDate[key], CostMicros: costByDate[key]})
|
||||
}
|
||||
|
||||
rows, err = r.readDB.QueryContext(ctx, `
|
||||
SELECT d.managed_key_id, DATE(d.requested_at, '+8 hours'),
|
||||
COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0)
|
||||
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
WHERE d.requested_at>=?
|
||||
GROUP BY d.managed_key_id, DATE(d.requested_at, '+8 hours')`, formatTime(dailyStart))
|
||||
if err != nil {
|
||||
return dashboard, fmt.Errorf("汇总用户每日用量: %w", err)
|
||||
}
|
||||
userDays := make(map[string]map[string]collection.DailyUsageSummary)
|
||||
for rows.Next() {
|
||||
var keyID, date string
|
||||
var tokens, costMicros int64
|
||||
if err := rows.Scan(&keyID, &date, &tokens, &costMicros); err != nil {
|
||||
_ = rows.Close()
|
||||
return dashboard, fmt.Errorf("读取用户每日用量: %w", err)
|
||||
}
|
||||
if userDays[keyID] == nil {
|
||||
userDays[keyID] = make(map[string]collection.DailyUsageSummary)
|
||||
}
|
||||
userDays[keyID][date] = collection.DailyUsageSummary{TotalTokens: tokens, CostMicros: costMicros}
|
||||
}
|
||||
_ = rows.Close()
|
||||
for index := range dashboard.Users {
|
||||
for dayIndex := 0; dayIndex < days; dayIndex++ {
|
||||
date := dailyStart.AddDate(0, 0, dayIndex)
|
||||
key := date.Format("2006-01-02")
|
||||
item := userDays[dashboard.Users[index].KeyID][key]
|
||||
item.Date = date
|
||||
dashboard.Users[index].Days = append(dashboard.Users[index].Days, item)
|
||||
}
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
@@ -23,8 +23,11 @@ export const keyInput = document.querySelector("#key");
|
||||
const readOnlyBase = config.readOnlyBase || location.pathname;
|
||||
const accessListeners = new Set();
|
||||
let managementAuthorized = false;
|
||||
let managementAuthenticated = false;
|
||||
let demoPerspective = "admin";
|
||||
|
||||
export function initializeRuntime(reload) {
|
||||
document.body.classList.toggle("demo-mode", Boolean(config.demoPerspectives));
|
||||
keyInput.value = config.managementKey || storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
|
||||
keyInput.disabled = Boolean(config.lockManagementKey);
|
||||
document.querySelector(".auth-box").classList.toggle("hidden", Boolean(config.hideManagementKey));
|
||||
@@ -40,6 +43,7 @@ export function initializeRuntime(reload) {
|
||||
keyInput.blur();
|
||||
}
|
||||
});
|
||||
initializeDemoPerspective(reload);
|
||||
setAccessState(false);
|
||||
}
|
||||
|
||||
@@ -54,6 +58,36 @@ export function isManagementAuthorized() {
|
||||
}
|
||||
|
||||
export function setAccessState(authorized) {
|
||||
managementAuthenticated = authorized;
|
||||
applyAccessState();
|
||||
}
|
||||
|
||||
function initializeDemoPerspective(reload) {
|
||||
const switcher = document.querySelector("#demo-perspective");
|
||||
if (!config.demoPerspectives) return;
|
||||
switcher.classList.remove("hidden");
|
||||
switcher.querySelectorAll("[data-perspective]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.dataset.perspective === demoPerspective) return;
|
||||
demoPerspective = button.dataset.perspective;
|
||||
syncDemoPerspective(switcher);
|
||||
applyAccessState();
|
||||
reload();
|
||||
});
|
||||
});
|
||||
syncDemoPerspective(switcher);
|
||||
}
|
||||
|
||||
function syncDemoPerspective(switcher) {
|
||||
switcher.querySelectorAll("[data-perspective]").forEach(button => {
|
||||
const selected = button.dataset.perspective === demoPerspective;
|
||||
button.classList.toggle("active", selected);
|
||||
button.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
}
|
||||
|
||||
function applyAccessState() {
|
||||
const authorized = managementAuthenticated && (!config.demoPerspectives || demoPerspective === "admin");
|
||||
if (managementAuthorized === authorized) return;
|
||||
managementAuthorized = authorized;
|
||||
document.body.classList.toggle("read-only", !authorized);
|
||||
|
||||
@@ -3,10 +3,6 @@ export function number(value) {
|
||||
return Number.isFinite(value) ? value.toLocaleString() : "-";
|
||||
}
|
||||
|
||||
export function compactNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
|
||||
}
|
||||
|
||||
export function money(value) {
|
||||
const amount = Number(value || 0);
|
||||
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
|
||||
import { compactNumber, localDateTimeValue, money, number, option, result } from "../core/shared.js";
|
||||
import { localDateTimeValue, money, number, option, result } from "../core/shared.js";
|
||||
|
||||
const keyRowsNode = document.querySelector("#key-rows");
|
||||
const keyStatusNode = document.querySelector("#key-status");
|
||||
@@ -191,29 +191,30 @@ function renderManagedKeys() {
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
const today = usageDashboard.today || {};
|
||||
document.querySelector("#metric-requests").textContent = number(today.requests || 0);
|
||||
document.querySelector("#metric-tokens").textContent = compactNumber(today.total_tokens || 0);
|
||||
document.querySelector("#metric-cost").textContent = "$" + Number(today.cost_usd || 0).toFixed(4);
|
||||
renderUserCharts();
|
||||
}
|
||||
|
||||
function renderUserCharts() {
|
||||
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
|
||||
const byUser = new Map(managedKeys.map(key => [key.name, {
|
||||
name: key.name, requests: 0, tokens: 0, cost: 0,
|
||||
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
|
||||
days: [], last: null
|
||||
}]));
|
||||
(usageDashboard.users || []).forEach(user => {
|
||||
const name = user.key_alias || "未识别";
|
||||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
|
||||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, balance: null, days: [], last: null });
|
||||
const value = byUser.get(name);
|
||||
const today = user.today || {};
|
||||
value.requests = today.requests || 0;
|
||||
value.tokens = today.total_tokens || 0;
|
||||
value.cost = today.cost_usd || 0;
|
||||
value.days = user.days || [];
|
||||
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
|
||||
});
|
||||
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);
|
||||
@@ -221,33 +222,56 @@ 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), "$" + item.cost.toFixed(4), item.last ? item.last.toLocaleString() : "从未"].forEach(value => {
|
||||
[item.name, number(item.requests), number(item.tokens), money(item.cost), item.balance === null ? "—" : money(item.balance), item.last ? item.last.toLocaleString() : "从未"].forEach(value => {
|
||||
const cell = document.createElement("span");
|
||||
cell.textContent = value;
|
||||
row.append(cell);
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
const days = (usageDashboard.days || []).map(item => ({ date: new Date(item.date + "T00:00:00"), tokens: item.total_tokens || 0 }));
|
||||
const maximum = Math.max(1, ...days.map(day => day.tokens));
|
||||
document.querySelector("#daily-chart").replaceChildren(...days.map(day => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "bar-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "bar-label";
|
||||
label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`;
|
||||
const track = document.createElement("span");
|
||||
track.className = "bar-track";
|
||||
const value = document.createElement("span");
|
||||
value.className = "bar-value";
|
||||
value.style.width = `${day.tokens / maximum * 100}%`;
|
||||
track.append(value);
|
||||
const count = document.createElement("span");
|
||||
count.className = "bar-count";
|
||||
count.textContent = compactNumber(day.tokens);
|
||||
row.append(label, track, count);
|
||||
return row;
|
||||
}));
|
||||
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()]);
|
||||
}
|
||||
|
||||
function renderQuotaMatrix(days, users) {
|
||||
const dates = days.map(day => dateKey(day.date));
|
||||
const rows = users.map(user => {
|
||||
const costs = new Map(user.days.map(day => [day.date, Number(day.cost_usd || 0)]));
|
||||
const values = dates.map(date => costs.get(date) || 0);
|
||||
return { name: user.name, values, total: values.reduce((sum, value) => sum + value, 0) };
|
||||
}).sort((left, right) => right.total - left.total || left.name.localeCompare(right.name));
|
||||
const maximum = Math.max(0.000001, ...rows.flatMap(row => row.values));
|
||||
const matrix = document.createElement("div");
|
||||
matrix.className = "quota-matrix";
|
||||
matrix.style.gridTemplateColumns = `minmax(100px, 1.25fr) repeat(${days.length}, minmax(74px, 1fr)) minmax(82px, 1fr)`;
|
||||
matrix.append(quotaCell("用户", "header user"), ...days.map(day => quotaCell(`${day.date.getMonth() + 1}/${day.date.getDate()}`, "header")), quotaCell("7 日合计", "header total"));
|
||||
rows.forEach(row => {
|
||||
matrix.append(quotaCell(row.name, "user"));
|
||||
row.values.forEach((value, index) => {
|
||||
const cell = quotaCell(value ? money(value) : "—", value ? "usage" : "");
|
||||
cell.style.setProperty("--quota-intensity", `${12 + value / maximum * 58}%`);
|
||||
cell.title = `${row.name} · ${dates[index]} · ${money(value)}`;
|
||||
matrix.append(cell);
|
||||
});
|
||||
matrix.append(quotaCell(money(row.total), "total"));
|
||||
});
|
||||
const weekTotal = days.reduce((sum, day) => sum + day.cost, 0);
|
||||
matrix.append(quotaCell("每日合计", "header user"), ...days.map(day => quotaCell(money(day.cost), "total")), quotaCell(money(weekTotal), "total"));
|
||||
document.querySelector("#quota-chart").replaceChildren(matrix);
|
||||
}
|
||||
|
||||
function quotaCell(text, classes = "") {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "quota-cell" + (classes ? " " + classes : "");
|
||||
cell.textContent = text;
|
||||
return cell;
|
||||
}
|
||||
|
||||
function dateKey(value) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
async function createManagedKey() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { closeKeyDrawer, initializeKeys, loadKeys } from "./features/keys.js";
|
||||
import { initializePricing, loadPricing } from "./features/pricing.js";
|
||||
import { activateUsage, initializeUsage, refreshVisibleUsage, resetUsage } from "./features/usage.js";
|
||||
|
||||
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing });
|
||||
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing, logs: () => {} });
|
||||
|
||||
initializeKeys();
|
||||
initializeUsage();
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; margin-bottom: 12px; }
|
||||
.summary-card { min-height: 78px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.summary-card span { color: var(--muted); font-size: 11px; }
|
||||
.summary-card strong { display: block; margin-top: 5px; font-size: 22px; font-weight: 650; letter-spacing: -.03em; }
|
||||
.summary-card small { display: block; margin-top: 2px; color: var(--text-quaternary); font-size: 11px; }
|
||||
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.surface-toolbar .status { min-height: 0; }
|
||||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 9px; margin-bottom: 12px; }
|
||||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 9px; margin-bottom: 12px; }
|
||||
.chart-card { min-height: 180px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.chart-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
|
||||
.chart-card h2 { margin-bottom: 2px; font-size: 12px; }
|
||||
.chart-card p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
|
||||
.bar-chart { display: grid; gap: 9px; }
|
||||
.bar-row { display: grid; grid-template-columns: minmax(70px, 110px) minmax(100px, 1fr) 42px; align-items: center; gap: 9px; font-size: 10px; }
|
||||
.bar-label { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: var(--bg-tertiary); }
|
||||
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--primary-active); }
|
||||
.bar-count { color: var(--muted); text-align: right; }
|
||||
.quota-matrix-wrap { overflow-x: auto; }
|
||||
.quota-matrix { display: grid; min-width: 720px; border-top: 1px solid var(--border-soft); border-left: 1px solid var(--border-soft); font-size: 9px; }
|
||||
.quota-cell { display: grid; min-width: 0; min-height: 32px; padding: 7px 6px; place-items: center; overflow: hidden; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); color: var(--text-secondary); text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quota-cell.header { min-height: 27px; background: var(--bg-primary); color: var(--muted); }
|
||||
.quota-cell.user { text-align: center; }
|
||||
.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: minmax(90px, 1.2fr) 68px minmax(90px, .8fr) 78px minmax(120px, 1fr); align-items: center; gap: 10px; min-width: 570px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
|
||||
.user-usage-row { display: grid; grid-template-columns: minmax(120px, 1.25fr) repeat(4, minmax(100px, 1fr)) minmax(150px, 1.4fr); align-items: center; gap: 10px; min-width: 790px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
|
||||
.user-usage-row:last-child { border-bottom: 0; }
|
||||
.user-usage-row.header { min-height: 26px; color: var(--muted); font-size: 9px; }
|
||||
.user-usage-row span:not(:first-child) { text-align: right; }
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
.app-shell { min-height: 100vh; padding-top: 18px; }
|
||||
/* CPA Manager overlays its own controls on the iframe's top-right corner. */
|
||||
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 58px; padding: 0 222px 0 22px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
|
||||
.brand strong { font-size: 14px; letter-spacing: .02em; }
|
||||
.brand span { color: var(--muted); font-size: 12px; }
|
||||
.workspace-bar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; min-height: 50px; padding-right: 222px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
|
||||
body.demo-mode .workspace-bar { padding-right: 22px; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.demo-perspective { display: flex; align-items: center; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); }
|
||||
.demo-perspective button { border: 0; border-radius: 5px; padding: 6px 10px; background: transparent; color: var(--muted); font-size: 11px; }
|
||||
.demo-perspective button:hover { border: 0; background: var(--bg-hover); color: var(--text); }
|
||||
.demo-perspective button.active { background: var(--bg-tertiary); color: var(--text); box-shadow: inset 0 0 0 1px var(--border-primary); }
|
||||
.auth-box { display: flex; align-items: center; gap: 8px; }
|
||||
#key { width: 188px; }
|
||||
.workspace-nav { display: flex; align-items: stretch; gap: 3px; height: 50px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: var(--bg-secondary); overflow-x: auto; overflow-y: hidden; }
|
||||
.workspace-nav { display: flex; flex: 1 1 auto; align-items: stretch; gap: 3px; min-width: 0; height: 50px; padding: 0 22px; overflow-x: auto; overflow-y: hidden; }
|
||||
.nav-button { position: relative; display: flex; flex: 0 0 auto; align-items: center; height: 49px; border: 0; padding: 0 13px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; }
|
||||
.nav-button:hover { color: var(--text); }
|
||||
.nav-button.active { color: var(--text); }
|
||||
|
||||
@@ -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)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .topbar { align-items: flex-start; min-height: 0; padding: 11px 12px; } .brand span { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: 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; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .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; } }
|
||||
@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 { 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; } .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; } .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; } }
|
||||
|
||||
+22
-14
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Billing 管理台</title>
|
||||
<title>CPA</title>
|
||||
<link rel="stylesheet" href="./styles/base.css">
|
||||
<link rel="stylesheet" href="./styles/layout.css">
|
||||
<link rel="stylesheet" href="./styles/keys.css">
|
||||
@@ -13,23 +13,27 @@
|
||||
</head>
|
||||
<body class="read-only">
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="brand-mark">B</div><div><strong>Billing</strong><span> · 管理控制台</span></div></div>
|
||||
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
|
||||
<header class="workspace-bar">
|
||||
<nav class="workspace-nav" aria-label="工作区">
|
||||
<button class="nav-button active" type="button" data-view="keys">用户</button>
|
||||
<button class="nav-button" type="button" data-view="usage">请求</button>
|
||||
<button class="nav-button" type="button" data-view="pricing">模型</button>
|
||||
<button class="nav-button" type="button" data-view="logs">日志</button>
|
||||
</nav>
|
||||
<div class="topbar-actions">
|
||||
<div id="demo-perspective" class="demo-perspective hidden" aria-label="演示视角">
|
||||
<button type="button" data-perspective="admin">管理员示教</button>
|
||||
<button type="button" data-perspective="user">普通用户视角</button>
|
||||
</div>
|
||||
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="workspace-nav" aria-label="工作区">
|
||||
<button class="nav-button active" type="button" data-view="keys">用户 Key</button>
|
||||
<button class="nav-button" type="button" data-view="usage">请求明细</button>
|
||||
<button class="nav-button" type="button" data-view="pricing">价格配置</button>
|
||||
</nav>
|
||||
<main>
|
||||
<section id="view-keys" class="view active">
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card"><span>今日请求</span><strong id="metric-requests">—</strong><small>自然日内实际请求</small></article>
|
||||
<article class="summary-card"><span>今日 Token</span><strong id="metric-tokens">—</strong><small>输入与输出合计</small></article>
|
||||
<article class="summary-card"><span>今日成本</span><strong id="metric-cost">—</strong><small>按已配置价格计算</small></article>
|
||||
<div class="chart-grid">
|
||||
<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="chart-grid"><article class="chart-card"><header><div><h2>今日用户用量</h2></div></header><div id="user-usage" class="user-usage"></div></article><article class="chart-card"><header><div><h2>近 7 日 Token</h2></div></header><div id="daily-chart" class="bar-chart"></div></article></div>
|
||||
<div id="key-management" class="surface admin-only">
|
||||
<div class="surface-toolbar"><span id="key-status" class="status"></span><button id="create-key" class="primary" type="button">创建 Key</button></div>
|
||||
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">Key</th><th class="left">状态</th><th class="left">余额 / 额度</th><th class="left">并发</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">查看</th></tr></thead><tbody id="key-rows"></tbody></table></div>
|
||||
@@ -75,6 +79,10 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-logs" class="view">
|
||||
<div class="surface"><div class="empty">暂无日志</div></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package webdemo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"billing/internal/plugin"
|
||||
)
|
||||
|
||||
// DatabaseInput exposes the billing management API against a standalone SQLite copy.
|
||||
type DatabaseInput struct {
|
||||
app *plugin.App
|
||||
}
|
||||
|
||||
func NewDatabaseInput(databasePath string) (*DatabaseInput, error) {
|
||||
absolutePath, err := filepath.Abs(databasePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve demo database path: %w", err)
|
||||
}
|
||||
app := plugin.NewApp()
|
||||
config := fmt.Sprintf("database_path: %q\nenabled: true\ncodex_only: false\n", absolutePath)
|
||||
lifecycle, err := json.Marshal(plugin.LifecycleRequest{ConfigYAML: []byte(config), SchemaVersion: plugin.SchemaVersion})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode demo lifecycle request: %w", err)
|
||||
}
|
||||
if _, err := app.HandleMethod(plugin.MethodPluginRegister, lifecycle); err != nil {
|
||||
app.Shutdown()
|
||||
return nil, fmt.Errorf("open demo database: %w", err)
|
||||
}
|
||||
return &DatabaseInput{app: app}, nil
|
||||
}
|
||||
|
||||
func (input *DatabaseInput) Close() error {
|
||||
input.app.Shutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (input *DatabaseInput) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
writeJSON(response, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "request_body_error", "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
wireRequest, err := json.Marshal(plugin.ManagementRequest{
|
||||
Method: request.Method, Path: request.URL.Path, Headers: request.Header.Clone(), Query: request.URL.Query(), Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "request_encode_error", "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
raw, err := input.app.HandleMethod(plugin.MethodManagementHandle, wireRequest)
|
||||
if err != nil {
|
||||
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "management_error", "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
var envelope plugin.Envelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || !envelope.OK {
|
||||
message := "invalid management response"
|
||||
if envelope.Error != nil {
|
||||
message = envelope.Error.Message
|
||||
}
|
||||
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "management_envelope_error", "message": message}})
|
||||
return
|
||||
}
|
||||
var result plugin.ManagementResponse
|
||||
if err := json.Unmarshal(envelope.Result, &result); err != nil {
|
||||
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "response_decode_error", "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
for name, values := range result.Headers {
|
||||
for _, value := range values {
|
||||
response.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
status := result.StatusCode
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
response.WriteHeader(status)
|
||||
_, _ = response.Write(result.Body)
|
||||
}
|
||||
@@ -280,11 +280,20 @@ func (input *fakeInput) dashboard() map[string]any {
|
||||
for _, key := range input.keys {
|
||||
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)
|
||||
for offset := 6; offset >= 0; offset-- {
|
||||
start := today.AddDate(0, 0, -offset)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
daySummary := summarize(filterUsage(records, func(record fakeUsage) bool {
|
||||
return !record.RequestedAt.Before(start) && record.RequestedAt.Before(end)
|
||||
}))
|
||||
userDays = append(userDays, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": daySummary["total_tokens"], "cost_usd": daySummary["cost_usd"]})
|
||||
}
|
||||
var last any
|
||||
if len(records) > 0 {
|
||||
last = records[0].RequestedAt
|
||||
}
|
||||
users = append(users, map[string]any{"key_id": key.ID, "key_alias": key.Name, "today": summarize(current), "last_used_at": last})
|
||||
users = append(users, map[string]any{"key_id": key.ID, "key_alias": key.Name, "today": summarize(current), "days": userDays, "last_used_at": last})
|
||||
}
|
||||
days := make([]map[string]any, 0, 7)
|
||||
for offset := 6; offset >= 0; offset-- {
|
||||
@@ -293,7 +302,8 @@ func (input *fakeInput) dashboard() map[string]any {
|
||||
records := filterUsage(input.usage, func(record fakeUsage) bool {
|
||||
return !record.RequestedAt.Before(start) && record.RequestedAt.Before(end)
|
||||
})
|
||||
days = append(days, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": summarize(records)["total_tokens"]})
|
||||
summary := summarize(records)
|
||||
days = append(days, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": summary["total_tokens"], "cost_usd": summary["cost_usd"]})
|
||||
}
|
||||
return map[string]any{"today": summarize(todayRecords), "users": users, "days": days}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewServer(input Input) http.Handler {
|
||||
http.Redirect(response, request, "/ui", http.StatusTemporaryRedirect)
|
||||
})
|
||||
mux.HandleFunc("GET /ui", asset("text/html; charset=utf-8", web.UI()))
|
||||
mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",managementKey:"demo",lockManagementKey:true,hideManagementKey:true});`)))
|
||||
mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",managementKey:"demo",lockManagementKey:true,hideManagementKey:true,demoPerspectives:true});`)))
|
||||
mux.HandleFunc("GET /styles/{name}", embeddedAsset("styles/"))
|
||||
mux.HandleFunc("GET /app/{path...}", embeddedAsset("app/"))
|
||||
mux.Handle(managementBase+"/", input)
|
||||
|
||||
@@ -2,6 +2,7 @@ package webdemo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -24,6 +25,19 @@ func TestServerServesSplitUIAndFakeInput(t *testing.T) {
|
||||
response.Body.Close()
|
||||
}
|
||||
|
||||
configResponse, err := http.Get(server.URL + "/ui-config.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer configResponse.Body.Close()
|
||||
configBody, err := io.ReadAll(configResponse.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(configBody), "demoPerspectives:true") {
|
||||
t.Fatalf("demo config does not enable perspective switch: %s", configBody)
|
||||
}
|
||||
|
||||
response, err := http.Get(server.URL + managementBase + "/usage?page=2&page_size=100&model=deepseek")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -86,3 +100,56 @@ func TestFakeInputSupportsKeyMutation(t *testing.T) {
|
||||
t.Fatalf("unexpected created key: status=%d key=%+v", response.StatusCode, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDashboardIncludesSevenDailyCosts(t *testing.T) {
|
||||
server := httptest.NewServer(NewServer(NewFakeInput()))
|
||||
defer server.Close()
|
||||
|
||||
response, err := http.Get(server.URL + managementBase + "/usage-summary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var dashboard struct {
|
||||
Days []struct {
|
||||
Date string `json:"date"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
} `json:"days"`
|
||||
Users []struct {
|
||||
Days []struct {
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
} `json:"days"`
|
||||
} `json:"users"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&dashboard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dashboard.Days) != 7 || dashboard.Days[6].CostUSD <= 0 || len(dashboard.Users) == 0 || len(dashboard.Users[0].Days) != 7 {
|
||||
t.Fatalf("unexpected daily costs: %+v", dashboard.Days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseInputServesStandaloneBillingDatabase(t *testing.T) {
|
||||
input, err := NewDatabaseInput(t.TempDir() + "/billing.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
server := httptest.NewServer(NewServer(input))
|
||||
defer server.Close()
|
||||
|
||||
response, err := http.Get(server.URL + managementBase + "/keys")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var payload struct {
|
||||
Keys []fakeKey `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || len(payload.Keys) != 1 {
|
||||
t.Fatalf("database input keys: status=%d keys=%d", response.StatusCode, len(payload.Keys))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user