Files

260 lines
11 KiB
JavaScript

import { dataFetch, isManagementAuthorized, routes, usesReadOnlyData } from "../core/runtime.js";
import { endpoint, isCompactEndpoint, number, option, result } from "../core/shared.js";
const statusNode = document.querySelector("#status");
const rowsNode = document.querySelector("#rows");
const headersNode = document.querySelector("#headers");
const columnOptionsNode = document.querySelector("#column-options");
const pageButtonsNode = document.querySelector("#page-buttons");
const columnStoreKey = "billing:usage-columns";
const adminPageSize = 100;
const userPageSize = 50;
let currentRecords = [];
let currentPage = 1;
let currentPagination = emptyPagination();
let loading = false;
const columns = [
{ id: "time", label: "时间", align: "left", value: record => new Date(record.requested_at).toLocaleString() },
{ id: "key", label: "Key / 别名", align: "left", value: record => record.key_alias || record.api_key || "-" },
{ id: "upstream", label: "上游账号", align: "left", value: record => record.auth_id || record.auth_index || record.auth_type || "-" },
{ id: "model", label: "模型", align: "left", value: record => record.model || "-" },
{ id: "reasoning_effort", label: "推理强度", align: "left", value: record => record.reasoning_effort || "-" },
{ id: "service_tier", label: "模式", align: "left", value: record => String(record.speed || "").toLowerCase() === "fast" ? "fast" : (record.service_tier || "-") },
{ id: "result", label: "结果", align: "left", value: result },
{ id: "request_type", label: "类型", align: "left", value: record => record.request_type || "-" },
{ id: "endpoint", label: "端点", align: "left", value: record => endpoint(record.endpoint) },
{ id: "ttft", label: "首字延迟", value: record => isCompactEndpoint(record.endpoint) ? "-" : duration(record.ttft_ms) },
{ id: "speed", label: "生成速度", value: record => isCompactEndpoint(record.endpoint) ? "-" : tokenSpeed(record.speed_tps) },
{ id: "input", label: "输入", value: record => number(record.input_tokens) },
{ id: "output", label: "输出", value: record => number(record.output_tokens) },
{ id: "reasoning", label: "推理", value: record => number(record.reasoning_tokens) },
{ id: "cache_read", label: "缓存读取", value: record => number(record.cache_read_tokens) },
{ id: "cache_write", label: "缓存写入", value: record => number(record.cache_write_tokens) },
{ id: "cache_rate", label: "缓存率", value: record => percent(record.cache_rate) },
{ id: "total", label: "Token 总数", value: record => number(record.total_tokens) },
{ id: "cost", label: "总成本", value: record => cost(record) },
{ id: "client_ip", label: "客户端 IP", align: "left", value: record => record.client_ip || "-" }
];
let visibleColumns = loadVisibleColumns();
export function initializeUsage() {
renderColumnControls();
document.querySelector("#refresh").addEventListener("click", () => loadUsage(true, currentPage));
document.querySelector("#apply-usage-filters").addEventListener("click", () => {
currentPage = 1;
loadUsage(true, 1);
});
document.querySelector("#clear-usage-filters").addEventListener("click", clearFilters);
document.querySelector("#usage-request-id").addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
currentPage = 1;
loadUsage(true, 1);
}
});
}
export async function activateUsage() {
await loadUsage(true, 1);
if (isManagementAuthorized()) await loadUsageFilterOptions();
}
export function refreshVisibleUsage() {
if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) loadUsage(false, 1);
}
export function resetUsage() {
currentPage = 1;
currentPagination = emptyPagination();
return activateUsage();
}
async function loadUsage(manual = false, page = currentPage, cursor = "") {
if (loading) return;
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const readOnly = usesReadOnlyData();
const params = readOnly ? new URLSearchParams() : usageFilterQuery();
params.set("page", String(readOnly ? 1 : page || 1));
params.set("page_size", String(readOnly ? userPageSize : adminPageSize));
if (!readOnly && cursor) params.set("cursor", cursor);
const payload = await dataFetch(routes.usage + "?" + params.toString());
currentPagination = payload.pagination || emptyPagination();
render(payload.records || []);
statusNode.textContent = "";
} catch (error) {
statusNode.textContent = "读取失败: " + error.message;
} finally {
loading = false;
}
}
async function loadUsageFilterOptions() {
try {
const [keys, upstreams, models] = await Promise.all([
dataFetch(routes.keys + "?include_archived=1"),
dataFetch(routes.upstreams),
dataFetch(routes.models)
]);
populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []);
} catch (error) {
statusNode.textContent = "读取筛选项失败: " + error.message;
}
}
function render(records) {
currentRecords = records;
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
rowsNode.replaceChildren(...records.map(record => {
const row = document.createElement("tr");
activeColumns.forEach(column => {
const cell = document.createElement("td");
cell.textContent = column.value(record);
if (column.align) cell.classList.add(column.align);
if (column.id === "result") cell.classList.add(record.failed ? "failed" : "ok");
row.appendChild(cell);
});
return row;
}));
renderPagination();
}
function renderColumnControls() {
headersNode.replaceChildren(...columns.filter(column => visibleColumns.has(column.id)).map(column => {
const header = document.createElement("th");
header.textContent = column.label;
if (column.align) header.className = column.align;
return header;
}));
columnOptionsNode.replaceChildren(...columns.map(column => {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.checked = visibleColumns.has(column.id);
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) {
input.checked = true;
return;
}
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
try {
localStorage.setItem(columnStoreKey, JSON.stringify(columns.filter(item => visibleColumns.has(item.id)).map(item => item.id)));
} catch (_) {}
renderColumnControls();
render(currentRecords);
});
label.append(input, column.label);
return label;
}));
}
function renderPagination() {
if (!isManagementAuthorized()) {
currentPage = 1;
pageButtonsNode.replaceChildren();
return;
}
const pageCount = currentPagination.total_pages;
currentPage = currentPagination.page || 1;
const controls = [];
const button = (label, page, disabled = false, active = false, cursor = "") => {
const node = document.createElement("button");
node.type = "button";
node.textContent = label;
node.disabled = disabled;
node.classList.toggle("active", active);
if (!disabled && !active) node.addEventListener("click", () => loadUsage(true, page, cursor));
return node;
};
controls.push(button("上一页", currentPage - 1, currentPage <= 1, false, currentPagination.previous_cursor || ""));
pageItems(currentPage, Math.max(1, pageCount)).forEach(item => {
if (typeof item === "string") {
const ellipsis = document.createElement("span");
ellipsis.className = "page-ellipsis";
ellipsis.textContent = "…";
controls.push(ellipsis);
} else {
controls.push(button(String(item), item, false, item === currentPage));
}
});
controls.push(button("下一页", currentPage + 1, pageCount === 0 || currentPage >= pageCount, false, currentPagination.next_cursor || ""));
pageButtonsNode.replaceChildren(...controls);
}
function usageFilterQuery() {
const params = new URLSearchParams();
const from = document.querySelector("#usage-from").value;
const to = document.querySelector("#usage-to").value;
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to).toISOString());
[["key_id", "#usage-key"], ["model", "#usage-model"], ["result", "#usage-result"], ["auth_id", "#usage-auth"], ["endpoint", "#usage-endpoint"], ["request_id", "#usage-request-id"]].forEach(([name, selector]) => {
const value = document.querySelector(selector).value.trim();
if (value) params.set(name, value);
});
return params;
}
function populateUsageFilterOptions(keys, upstreams, models) {
const keySelect = document.querySelector("#usage-key");
const authSelect = document.querySelector("#usage-auth");
const selectedKey = keySelect.value;
const selectedAuth = authSelect.value;
keySelect.replaceChildren(option("", "全部用户"), ...keys.map(key => option(key.id, key.name, key.id === selectedKey)));
authSelect.replaceChildren(option("", "全部上游"), ...upstreams.map(account => option(account.cpa_auth_id, account.display_name || account.cpa_auth_id, account.cpa_auth_id === selectedAuth)));
document.querySelector("#usage-model-options").replaceChildren(...models.map(model => option(model, model)));
}
function clearFilters() {
["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = "");
["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = "");
currentPage = 1;
loadUsage(true, 1);
}
function loadVisibleColumns() {
try {
const saved = JSON.parse(localStorage.getItem(columnStoreKey));
if (Array.isArray(saved)) {
const valid = saved.filter(id => columns.some(column => column.id === id));
if (valid.length) return new Set(valid);
}
} catch (_) {}
return new Set(columns.map(column => column.id));
}
function pageItems(page, count) {
if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);
const values = new Set([1, count, page - 1, page, page + 1]);
const sorted = [...values].filter(value => value >= 1 && value <= count).sort((left, right) => left - right);
const items = [];
sorted.forEach((value, index) => {
if (index && value - sorted[index - 1] > 1) items.push("ellipsis-" + value);
items.push(value);
});
return items;
}
function emptyPagination() {
return { page: 1, page_size: usesReadOnlyData() ? userPageSize : adminPageSize, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
}
function duration(value) {
return value > 0 ? value.toLocaleString() + " ms" : "-";
}
function percent(value) {
return Number.isFinite(value) ? value.toFixed(2) + "%" : "-";
}
function tokenSpeed(value) {
return Number.isFinite(value) ? value.toFixed(1) + " tok/s" : "-";
}
function cost(record) {
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
}