260 lines
11 KiB
JavaScript
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) : "-";
|
|
}
|