Update Js for logs.html
This commit is contained in:
@@ -12,7 +12,7 @@ server:
|
||||
|
||||
# 日志级别
|
||||
log:
|
||||
level: "debug"
|
||||
level: "info"
|
||||
|
||||
# 日志轮转配置
|
||||
max_size: 100 # MB
|
||||
|
||||
129
frontend/js/components/customSelectV2.js
Normal file
129
frontend/js/components/customSelectV2.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Filename: frontend/js/components/customSelectV2.js
|
||||
import { createPopper } from '../vendor/popper.esm.min.js';
|
||||
|
||||
export default class CustomSelectV2 {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.trigger = this.container.querySelector('.custom-select-trigger');
|
||||
this.nativeSelect = this.container.querySelector('select');
|
||||
this.template = this.container.querySelector('.custom-select-panel-template');
|
||||
|
||||
if (!this.trigger || !this.nativeSelect || !this.template) {
|
||||
console.warn('CustomSelectV2 cannot initialize: missing required elements.', this.container);
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel = null;
|
||||
this.popperInstance = null;
|
||||
this.isOpen = false;
|
||||
this.triggerText = this.trigger.querySelector('span');
|
||||
|
||||
if (typeof CustomSelectV2.openInstance === 'undefined') {
|
||||
CustomSelectV2.openInstance = null;
|
||||
CustomSelectV2.initGlobalListener();
|
||||
}
|
||||
|
||||
this.updateTriggerText();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
static initGlobalListener() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const instance = CustomSelectV2.openInstance;
|
||||
if (instance && !instance.container.contains(event.target) && (!instance.panel || !instance.panel.contains(event.target))) {
|
||||
instance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
const panelFragment = this.template.content.cloneNode(true);
|
||||
this.panel = panelFragment.querySelector('.custom-select-panel');
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
this.panel.innerHTML = '';
|
||||
Array.from(this.nativeSelect.options).forEach(option => {
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'custom-select-option block w-full text-left px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700';
|
||||
item.textContent = option.textContent;
|
||||
item.dataset.value = option.value;
|
||||
if (option.selected) { item.classList.add('is-selected'); }
|
||||
this.panel.appendChild(item);
|
||||
});
|
||||
|
||||
this.panel.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const optionEl = event.target.closest('.custom-select-option');
|
||||
if (optionEl) { this.selectOption(optionEl); }
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (CustomSelectV2.openInstance && CustomSelectV2.openInstance !== this) {
|
||||
CustomSelectV2.openInstance.close();
|
||||
}
|
||||
this.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
selectOption(optionEl) {
|
||||
const selectedValue = optionEl.dataset.value;
|
||||
if (this.nativeSelect.value !== selectedValue) {
|
||||
this.nativeSelect.value = selectedValue;
|
||||
this.nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
this.updateTriggerText();
|
||||
this.close();
|
||||
}
|
||||
|
||||
updateTriggerText() {
|
||||
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
|
||||
if (selectedOption) {
|
||||
this.triggerText.textContent = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
toggle() { this.isOpen ? this.close() : this.open(); }
|
||||
|
||||
open() {
|
||||
if (this.isOpen) return;
|
||||
this.isOpen = true;
|
||||
|
||||
if (!this.panel) { this.createPanel(); }
|
||||
|
||||
this.panel.style.display = 'block';
|
||||
this.panel.offsetHeight;
|
||||
|
||||
this.popperInstance = createPopper(this.trigger, this.panel, {
|
||||
placement: 'top-start',
|
||||
modifiers: [
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{ name: 'flip', options: { fallbackPlacements: ['bottom-start'] } }
|
||||
],
|
||||
});
|
||||
|
||||
CustomSelectV2.openInstance = this;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.isOpen = false;
|
||||
|
||||
if (this.popperInstance) {
|
||||
this.popperInstance.destroy();
|
||||
this.popperInstance = null;
|
||||
}
|
||||
|
||||
if (this.panel) {
|
||||
this.panel.remove();
|
||||
this.panel = null;
|
||||
}
|
||||
|
||||
if (CustomSelectV2.openInstance === this) {
|
||||
CustomSelectV2.openInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
frontend/js/components/filterPopover.js
Normal file
95
frontend/js/components/filterPopover.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Filename: frontend/js/components/filterPopover.js
|
||||
|
||||
import { createPopper } from '../vendor/popper.esm.min.js';
|
||||
|
||||
export default class FilterPopover {
|
||||
constructor(triggerElement, options, title) {
|
||||
if (!triggerElement || typeof createPopper !== 'function') {
|
||||
console.error('FilterPopover: Trigger element or Popper.js not found.');
|
||||
return;
|
||||
}
|
||||
this.triggerElement = triggerElement;
|
||||
this.options = options;
|
||||
this.title = title;
|
||||
this.selectedValues = new Set();
|
||||
|
||||
this._createPopoverHTML();
|
||||
this.popperInstance = createPopper(this.triggerElement, this.popoverElement, {
|
||||
placement: 'bottom-start',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 8] } }],
|
||||
});
|
||||
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_createPopoverHTML() {
|
||||
this.popoverElement = document.createElement('div');
|
||||
this.popoverElement.className = 'hidden z-50 min-w-[12rem] rounded-md border bg-popover bg-white dark:bg-zinc-800 p-2 text-popover-foreground shadow-md';
|
||||
this.popoverElement.innerHTML = `
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">${this.title}</div>
|
||||
<div class="space-y-1 p-1">
|
||||
${this.options.map(option => `
|
||||
<label class="flex items-center space-x-2 px-2 py-1.5 rounded-md hover:bg-accent cursor-pointer">
|
||||
<input type="checkbox" value="${option.value}" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm">${option.label}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="border-t border-border mt-2 pt-2 px-2 flex justify-end space-x-2">
|
||||
<button data-action="clear" class="btn btn-ghost h-7 px-2 text-xs">清空</button>
|
||||
<button data-action="apply" class="btn btn-primary h-7 px-2 text-xs">应用</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(this.popoverElement);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.triggerElement.addEventListener('click', () => this.toggle());
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!this.popoverElement.contains(event.target) && !this.triggerElement.contains(event.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.popoverElement.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('button');
|
||||
if (!target) return;
|
||||
const action = target.dataset.action;
|
||||
if (action === 'clear') this._handleClear();
|
||||
if (action === 'apply') this._handleApply();
|
||||
});
|
||||
}
|
||||
|
||||
_handleClear() {
|
||||
this.popoverElement.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
this.selectedValues.clear();
|
||||
this._handleApply();
|
||||
}
|
||||
|
||||
_handleApply() {
|
||||
this.selectedValues.clear();
|
||||
this.popoverElement.querySelectorAll('input:checked').forEach(cb => {
|
||||
this.selectedValues.add(cb.value);
|
||||
});
|
||||
|
||||
const filterChangeEvent = new CustomEvent('filter-change', {
|
||||
detail: {
|
||||
filterKey: this.triggerElement.id,
|
||||
selected: this.selectedValues
|
||||
}
|
||||
});
|
||||
this.triggerElement.dispatchEvent(filterChangeEvent);
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.popoverElement.classList.toggle('hidden');
|
||||
this.popperInstance.update();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popoverElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,235 @@
|
||||
// Filename: frontend/js/pages/logs/index.js
|
||||
|
||||
import { apiFetchJson } from '../../services/api.js';
|
||||
import LogList from './logList.js';
|
||||
|
||||
// [最终版] 创建一个共享的数据仓库,用于缓存 Groups 和 Keys
|
||||
import CustomSelectV2 from '../../components/customSelectV2.js';
|
||||
import { debounce } from '../../utils/utils.js';
|
||||
import FilterPopover from '../../components/filterPopover.js';
|
||||
import { STATIC_ERROR_MAP, STATUS_CODE_MAP } from './logList.js';
|
||||
const dataStore = {
|
||||
groups: new Map(),
|
||||
keys: new Map(),
|
||||
groups: new Map(),
|
||||
keys: new Map(),
|
||||
};
|
||||
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
pagination: { page: 1, pages: 1, total: 0, page_size: 20 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
// [优化] 统一将所有可筛选字段在此处初始化,便于管理
|
||||
filters: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
q: '',
|
||||
key_ids: new Set(),
|
||||
group_ids: new Set(),
|
||||
error_types: new Set(),
|
||||
status_codes: new Set(),
|
||||
},
|
||||
selectedLogIds: new Set(),
|
||||
};
|
||||
|
||||
this.elements = {
|
||||
tableBody: document.getElementById('logs-table-body'),
|
||||
selectedCount: document.querySelector('.flex-1.text-sm span.font-semibold:nth-child(1)'),
|
||||
totalCount: document.querySelector('.flex-1.text-sm span:last-child'),
|
||||
pageSizeSelect: document.querySelector('[data-component="custom-select-v2"] select'),
|
||||
pageInfo: document.querySelector('.flex.w-\\[100px\\]'),
|
||||
paginationBtns: document.querySelectorAll('[data-pagination-controls] button'),
|
||||
selectAllCheckbox: document.querySelector('thead .table-head-cell input[type="checkbox"]'),
|
||||
searchInput: document.getElementById('log-search-input'),
|
||||
errorTypeFilterBtn: document.getElementById('filter-error-type-btn'),
|
||||
errorCodeFilterBtn: document.getElementById('filter-error-code-btn'),
|
||||
};
|
||||
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
|
||||
if (this.initialized) {
|
||||
this.logList = new LogList(this.elements.tableBody, dataStore);
|
||||
const selectContainer = document.querySelector('[data-component="custom-select-v2"]');
|
||||
if (selectContainer) { new CustomSelectV2(selectContainer); }
|
||||
this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) return;
|
||||
this.initFilterPopovers();
|
||||
this.initEventListeners();
|
||||
// 页面初始化:先加载群组,再加载日志
|
||||
await this.loadGroupsOnce();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
initFilterPopovers() {
|
||||
const errorTypeOptions = [
|
||||
...Object.values(STATUS_CODE_MAP).map(v => ({ value: v.type, label: v.type })),
|
||||
...Object.values(STATIC_ERROR_MAP).map(v => ({ value: v.type, label: v.type }))
|
||||
];
|
||||
const uniqueErrorTypeOptions = Array.from(new Map(errorTypeOptions.map(item => [item.value, item])).values());
|
||||
if (this.elements.errorTypeFilterBtn) {
|
||||
new FilterPopover(this.elements.errorTypeFilterBtn, uniqueErrorTypeOptions, '筛选错误类型');
|
||||
}
|
||||
const statusCodeOptions = Object.keys(STATUS_CODE_MAP).map(code => ({ value: code, label: code }));
|
||||
if (this.elements.errorCodeFilterBtn) {
|
||||
new FilterPopover(this.elements.errorCodeFilterBtn, statusCodeOptions, '筛选状态码');
|
||||
}
|
||||
}
|
||||
initEventListeners() {
|
||||
if (this.elements.pageSizeSelect) {
|
||||
this.elements.pageSizeSelect.addEventListener('change', (e) => this.changePageSize(parseInt(e.target.value, 10)));
|
||||
}
|
||||
if (this.elements.paginationBtns.length >= 4) {
|
||||
this.elements.paginationBtns[0].addEventListener('click', () => this.goToPage(1));
|
||||
this.elements.paginationBtns[1].addEventListener('click', () => this.goToPage(this.state.pagination.page - 1));
|
||||
this.elements.paginationBtns[2].addEventListener('click', () => this.goToPage(this.state.pagination.page + 1));
|
||||
this.elements.paginationBtns[3].addEventListener('click', () => this.goToPage(this.state.pagination.pages));
|
||||
}
|
||||
if (this.elements.selectAllCheckbox) {
|
||||
this.elements.selectAllCheckbox.addEventListener('change', (event) => this.handleSelectAllChange(event));
|
||||
}
|
||||
if (this.elements.tableBody) {
|
||||
this.elements.tableBody.addEventListener('change', (event) => {
|
||||
if (event.target.type === 'checkbox') this.handleSelectionChange(event.target);
|
||||
});
|
||||
}
|
||||
if (this.elements.searchInput) {
|
||||
this.elements.searchInput.addEventListener('input', (event) => this.handleSearchInput(event));
|
||||
}
|
||||
if (this.elements.errorTypeFilterBtn) {
|
||||
this.elements.errorTypeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e));
|
||||
}
|
||||
if (this.elements.errorCodeFilterBtn) {
|
||||
this.elements.errorCodeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e));
|
||||
}
|
||||
}
|
||||
handleFilterChange(event) {
|
||||
const { filterKey, selected } = event.detail;
|
||||
if (filterKey === 'filter-error-type-btn') {
|
||||
this.state.filters.error_types = selected;
|
||||
} else if (filterKey === 'filter-error-code-btn') {
|
||||
this.state.filters.status_codes = selected;
|
||||
}
|
||||
this.state.filters.page = 1;
|
||||
this.loadAndRenderLogs();
|
||||
}
|
||||
|
||||
initEventListeners() { /* 分页和筛选的事件监听器 */ }
|
||||
handleSearchInput(event) {
|
||||
const searchTerm = event.target.value.trim().toLowerCase();
|
||||
|
||||
// 重置分页和与本次搜索相关的筛选条件
|
||||
this.state.filters.page = 1;
|
||||
this.state.filters.q = '';
|
||||
this.state.filters.key_ids = new Set();
|
||||
this.state.filters.group_ids = new Set();
|
||||
|
||||
if (searchTerm === '') {
|
||||
this.debouncedLoadAndRender();
|
||||
return;
|
||||
}
|
||||
const matchedGroupIds = new Set();
|
||||
dataStore.groups.forEach(group => {
|
||||
if (group.display_name.toLowerCase().includes(searchTerm)) {
|
||||
matchedGroupIds.add(group.id);
|
||||
}
|
||||
});
|
||||
const matchedKeyIds = new Set();
|
||||
dataStore.keys.forEach(key => {
|
||||
if (key.APIKey && key.APIKey.toLowerCase().includes(searchTerm)) {
|
||||
matchedKeyIds.add(key.ID);
|
||||
}
|
||||
});
|
||||
if (matchedGroupIds.size > 0) this.state.filters.group_ids = matchedGroupIds;
|
||||
if (matchedKeyIds.size > 0) this.state.filters.key_ids = matchedKeyIds;
|
||||
// 如果没有找到任何匹配的ID,则回退到原始的全局模糊搜索
|
||||
if (matchedGroupIds.size === 0 && matchedKeyIds.size === 0) {
|
||||
this.state.filters.q = searchTerm;
|
||||
}
|
||||
this.debouncedLoadAndRender();
|
||||
}
|
||||
|
||||
handleSelectionChange(checkbox) {
|
||||
const row = checkbox.closest('.table-row');
|
||||
if (!row) return;
|
||||
const logId = parseInt(row.dataset.logId, 10);
|
||||
if (isNaN(logId)) return;
|
||||
if (checkbox.checked) {
|
||||
this.state.selectedLogIds.add(logId);
|
||||
} else {
|
||||
this.state.selectedLogIds.delete(logId);
|
||||
}
|
||||
this.syncSelectionUI();
|
||||
}
|
||||
|
||||
handleSelectAllChange(event) {
|
||||
const isChecked = event.target.checked;
|
||||
this.state.logs.forEach(log => {
|
||||
if (isChecked) {
|
||||
this.state.selectedLogIds.add(log.ID);
|
||||
} else {
|
||||
this.state.selectedLogIds.delete(log.ID);
|
||||
}
|
||||
});
|
||||
this.syncRowCheckboxes();
|
||||
this.syncSelectionUI();
|
||||
}
|
||||
|
||||
syncRowCheckboxes() {
|
||||
const isAllChecked = this.elements.selectAllCheckbox.checked;
|
||||
this.elements.tableBody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = isAllChecked;
|
||||
});
|
||||
}
|
||||
|
||||
syncSelectionUI() {
|
||||
if (!this.elements.selectAllCheckbox || !this.elements.selectedCount) return;
|
||||
const selectedCount = this.state.selectedLogIds.size;
|
||||
const visibleLogsCount = this.state.logs.length;
|
||||
// 1. 更新表头“全选”复选框的状态
|
||||
if (selectedCount === 0) {
|
||||
this.elements.selectAllCheckbox.checked = false;
|
||||
this.elements.selectAllCheckbox.indeterminate = false;
|
||||
} else if (selectedCount < visibleLogsCount) {
|
||||
this.elements.selectAllCheckbox.checked = false;
|
||||
this.elements.selectAllCheckbox.indeterminate = true; // 半选状态
|
||||
} else if (selectedCount === visibleLogsCount && visibleLogsCount > 0) {
|
||||
this.elements.selectAllCheckbox.checked = true;
|
||||
this.elements.selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
// 2. 更新“已选择”计数
|
||||
this.elements.selectedCount.textContent = selectedCount;
|
||||
// 3. (未来扩展) 更新批量操作按钮的状态
|
||||
// const batchButton = document.querySelector('.batch-action-button');
|
||||
// if (batchButton) batchButton.disabled = selectedCount === 0;
|
||||
}
|
||||
|
||||
changePageSize(newSize) {
|
||||
this.state.filters.page_size = newSize;
|
||||
this.state.filters.page = 1;
|
||||
this.loadAndRenderLogs();
|
||||
}
|
||||
goToPage(page) {
|
||||
if (page < 1 || page > this.state.pagination.pages || this.state.isLoading) return;
|
||||
this.state.filters.page = page;
|
||||
this.loadAndRenderLogs();
|
||||
}
|
||||
|
||||
updatePaginationUI() {
|
||||
const { page, pages, total } = this.state.pagination;
|
||||
|
||||
if (this.elements.pageInfo) {
|
||||
this.elements.pageInfo.textContent = `第 ${page} / ${pages} 页`;
|
||||
}
|
||||
|
||||
if (this.elements.totalCount) {
|
||||
this.elements.totalCount.textContent = total;
|
||||
}
|
||||
|
||||
if (this.elements.paginationBtns.length >= 4) {
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = page === pages || pages === 0;
|
||||
this.elements.paginationBtns[0].disabled = isFirstPage;
|
||||
this.elements.paginationBtns[1].disabled = isFirstPage;
|
||||
this.elements.paginationBtns[2].disabled = isLastPage;
|
||||
this.elements.paginationBtns[3].disabled = isLastPage;
|
||||
}
|
||||
}
|
||||
|
||||
async loadGroupsOnce() {
|
||||
if (dataStore.groups.size > 0) return; // 防止重复加载
|
||||
if (dataStore.groups.size > 0) return;
|
||||
try {
|
||||
const { success, data } = await apiFetchJson("/admin/keygroups");
|
||||
if (success && Array.isArray(data)) {
|
||||
@@ -53,30 +242,69 @@ class LogsPage {
|
||||
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
|
||||
this.state.selectedLogIds.clear();
|
||||
this.logList.renderLoading();
|
||||
this.updatePaginationUI();
|
||||
this.syncSelectionUI();
|
||||
try {
|
||||
const query = new URLSearchParams(this.state.filters);
|
||||
const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
|
||||
// --- 查询参数准备阶段 ---
|
||||
const finalParams = {};
|
||||
const { filters } = this.state;
|
||||
|
||||
if (success && typeof data === 'object') {
|
||||
// 1. 复制所有非 Set 类型的参数
|
||||
Object.keys(filters).forEach(key => {
|
||||
if (!(filters[key] instanceof Set)) {
|
||||
finalParams[key] = filters[key];
|
||||
}
|
||||
});
|
||||
// 2. 翻译 'error_types'
|
||||
const translatedErrorCodes = new Set();
|
||||
// 从用户直接选择的状态码开始初始化
|
||||
const translatedStatusCodes = new Set(filters.status_codes);
|
||||
if (filters.error_types.size > 0) {
|
||||
filters.error_types.forEach(type => {
|
||||
for (const [code, obj] of Object.entries(STATUS_CODE_MAP)) {
|
||||
if (obj.type === type) translatedStatusCodes.add(code);
|
||||
}
|
||||
for (const [code, obj] of Object.entries(STATIC_ERROR_MAP)) {
|
||||
if (obj.type === type) translatedErrorCodes.add(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 3. 统一处理所有 Set 类型的参数,转换为字符串
|
||||
if (filters.key_ids.size > 0) finalParams.key_ids = [...filters.key_ids].join(',');
|
||||
if (filters.group_ids.size > 0) finalParams.group_ids = [...filters.group_ids].join(',');
|
||||
if (translatedErrorCodes.size > 0) finalParams.error_codes = [...translatedErrorCodes].join(',');
|
||||
if (translatedStatusCodes.size > 0) finalParams.status_codes = [...translatedStatusCodes].join(',');
|
||||
// 4. 清理空值
|
||||
Object.keys(finalParams).forEach(key => {
|
||||
if (finalParams[key] === '' || finalParams[key] === null || finalParams[key] === undefined) {
|
||||
delete finalParams[key];
|
||||
}
|
||||
});
|
||||
const query = new URLSearchParams(finalParams);
|
||||
const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
|
||||
if (success && typeof data === 'object' && data.items) {
|
||||
const { items, total, page, page_size } = data;
|
||||
this.state.logs = items;
|
||||
this.state.pagination = { page, page_size, total, pages: Math.ceil(total / page_size) };
|
||||
|
||||
// [核心] 在渲染前,按需批量加载本页日志所需的、尚未缓存的Key信息
|
||||
const totalPages = Math.ceil(total / page_size);
|
||||
this.state.pagination = { page, page_size, total, pages: totalPages > 0 ? totalPages : 1 };
|
||||
await this.enrichLogsWithKeyNames(items);
|
||||
|
||||
// 调用 render,此时 dataStore 中已包含所有需要的数据
|
||||
this.logList.render(this.state.logs, this.state.pagination);
|
||||
this.logList.render(this.state.logs, this.state.pagination, this.state.selectedLogIds);
|
||||
} else {
|
||||
this.state.logs = [];
|
||||
this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 };
|
||||
this.logList.render([], this.state.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
this.state.logs = [];
|
||||
this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 };
|
||||
this.logList.render([], this.state.pagination);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
this.updatePaginationUI();
|
||||
this.syncSelectionUI();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Filename: frontend/js/pages/logs/logList.js
|
||||
import { escapeHTML } from '../../utils/utils.js';
|
||||
|
||||
const STATIC_ERROR_MAP = {
|
||||
export const STATIC_ERROR_MAP = {
|
||||
'API_KEY_INVALID': { type: '密钥无效', style: 'red' },
|
||||
'INVALID_ARGUMENT': { type: '参数无效', style: 'red' },
|
||||
'PERMISSION_DENIED': { type: '权限不足', style: 'red' },
|
||||
@@ -13,8 +13,8 @@ const STATIC_ERROR_MAP = {
|
||||
'INTERNAL': { type: 'Google内部错误', style: 'yellow' },
|
||||
'UNAVAILABLE': { type: '服务不可用', style: 'yellow' },
|
||||
};
|
||||
// --- [更新] HTTP状态码到类型和样式的动态映射表 ---
|
||||
const STATUS_CODE_MAP = {
|
||||
// --- HTTP状态码到类型和样式的动态映射表 ---
|
||||
export const STATUS_CODE_MAP = {
|
||||
400: { type: '错误请求', style: 'red' },
|
||||
401: { type: '认证失败', style: 'red' },
|
||||
403: { type: '禁止访问', style: 'red' },
|
||||
@@ -55,7 +55,7 @@ class LogList {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> 加载日志中...</td></tr>`;
|
||||
}
|
||||
|
||||
render(logs, pagination) {
|
||||
render(logs, pagination, selectedLogIds) {
|
||||
if (!this.container) return;
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">没有找到相关的日志记录。</td></tr>`;
|
||||
@@ -63,7 +63,10 @@ class LogList {
|
||||
}
|
||||
const { page, page_size } = pagination;
|
||||
const startIndex = (page - 1) * page_size;
|
||||
const logsHtml = logs.map((log, index) => this.createLogRowHtml(log, startIndex + index + 1)).join('');
|
||||
const logsHtml = logs.map((log, index) => {
|
||||
const isChecked = selectedLogIds.has(log.ID);
|
||||
return this.createLogRowHtml(log, startIndex + index + 1, isChecked);
|
||||
}).join('');
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
|
||||
@@ -125,7 +128,7 @@ class LogList {
|
||||
return `<div class="inline-block rounded bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5"><span class="font-quinquefive text-xs tracking-wider ${styleClass}">${modelName}</span></div>`;
|
||||
}
|
||||
|
||||
createLogRowHtml(log, index) {
|
||||
createLogRowHtml(log, index, isChecked) {
|
||||
const group = this.dataStore.groups.get(log.GroupID);
|
||||
const groupName = group ? group.display_name : (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
|
||||
const key = this.dataStore.keys.get(log.KeyID);
|
||||
@@ -140,9 +143,13 @@ class LogList {
|
||||
const modelNameFormatted = this._formatModelName(log.ModelName);
|
||||
const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : '';
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
|
||||
const checkedAttr = isChecked ? 'checked' : '';
|
||||
return `
|
||||
<tr class="table-row" data-log-id="${log.ID}" ${errorMessageAttr}>
|
||||
<td class="table-cell"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="table-cell">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500" ${checkedAttr}>
|
||||
</td>
|
||||
<td class="table-cell font-mono text-muted-foreground">${index}</td>
|
||||
<td class="table-cell font-medium font-mono">${apiKeyDisplay}</td>
|
||||
<td class="table-cell">${groupName}</td>
|
||||
@@ -159,5 +166,4 @@ class LogList {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default LogList;
|
||||
|
||||
6
frontend/js/vendor/popper.esm.min.js
vendored
Normal file
6
frontend/js/vendor/popper.esm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -114,7 +114,7 @@ func (ch *GeminiChannel) ValidateKey(
|
||||
}
|
||||
|
||||
errorBody, _ := io.ReadAll(resp.Body)
|
||||
parsedMessage := CustomErrors.ParseUpstreamError(errorBody)
|
||||
parsedMessage, _ := CustomErrors.ParseUpstreamError(errorBody)
|
||||
|
||||
return &CustomErrors.APIError{
|
||||
HTTPStatus: resp.StatusCode,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type APIError struct {
|
||||
HTTPStatus int
|
||||
Code string
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string
|
||||
}
|
||||
|
||||
@@ -61,11 +62,13 @@ func NewAPIError(base *APIError, message string) *APIError {
|
||||
}
|
||||
|
||||
// NewAPIErrorWithUpstream creates a new APIError specifically for wrapping raw upstream errors.
|
||||
func NewAPIErrorWithUpstream(statusCode int, code string, upstreamMessage string) *APIError {
|
||||
func NewAPIErrorWithUpstream(statusCode int, code string, bodyBytes []byte) *APIError {
|
||||
msg, status := ParseUpstreamError(bodyBytes)
|
||||
return &APIError{
|
||||
HTTPStatus: statusCode,
|
||||
Code: code,
|
||||
Message: upstreamMessage,
|
||||
Message: msg,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -10,67 +9,37 @@ const (
|
||||
maxErrorBodyLength = 2048
|
||||
)
|
||||
|
||||
// standardErrorResponse matches formats like: {"error": {"message": "..."}}
|
||||
type standardErrorResponse struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// vendorErrorResponse matches formats like: {"error_msg": "..."}
|
||||
type vendorErrorResponse struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// simpleErrorResponse matches formats like: {"error": "..."}
|
||||
type simpleErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// rootMessageErrorResponse matches formats like: {"message": "..."}
|
||||
type rootMessageErrorResponse struct {
|
||||
type upstreamErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type upstreamErrorPayload struct {
|
||||
Error upstreamErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
// ParseUpstreamError attempts to parse a structured error message from an upstream response body
|
||||
func ParseUpstreamError(body []byte) string {
|
||||
// 1. Attempt to parse the standard OpenAI/Gemini format.
|
||||
var stdErr standardErrorResponse
|
||||
if err := json.Unmarshal(body, &stdErr); err == nil {
|
||||
if msg := strings.TrimSpace(stdErr.Error.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
func ParseUpstreamError(body []byte) (message string, status string) {
|
||||
if len(body) == 0 {
|
||||
return "Upstream returned an empty error body", ""
|
||||
}
|
||||
|
||||
// 2. Attempt to parse vendor-specific format (e.g., Baidu).
|
||||
var vendorErr vendorErrorResponse
|
||||
if err := json.Unmarshal(body, &vendorErr); err == nil {
|
||||
if msg := strings.TrimSpace(vendorErr.ErrorMsg); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
// 优先级 1: 尝试解析 OpenAI 兼容接口返回的 `[{"error": {...}}]` 数组格式
|
||||
var arrayPayload []upstreamErrorPayload
|
||||
if err := json.Unmarshal(body, &arrayPayload); err == nil && len(arrayPayload) > 0 {
|
||||
detail := arrayPayload[0].Error
|
||||
return truncateString(detail.Message, maxErrorBodyLength), detail.Status
|
||||
}
|
||||
|
||||
// 3. Attempt to parse simple error format.
|
||||
var simpleErr simpleErrorResponse
|
||||
if err := json.Unmarshal(body, &simpleErr); err == nil {
|
||||
if msg := strings.TrimSpace(simpleErr.Error); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
// 优先级 2: 尝试解析 Gemini 原生接口可能返回的 `{"error": {...}}` 单个对象格式
|
||||
var singlePayload upstreamErrorPayload
|
||||
if err := json.Unmarshal(body, &singlePayload); err == nil && singlePayload.Error.Message != "" {
|
||||
detail := singlePayload.Error
|
||||
return truncateString(detail.Message, maxErrorBodyLength), detail.Status
|
||||
}
|
||||
|
||||
// 4. Attempt to parse root-level message format.
|
||||
var rootMsgErr rootMessageErrorResponse
|
||||
if err := json.Unmarshal(body, &rootMsgErr); err == nil {
|
||||
if msg := strings.TrimSpace(rootMsgErr.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Graceful Degradation: If all parsing fails, return the raw (but safe) body.
|
||||
return truncateString(string(body), maxErrorBodyLength)
|
||||
// 最终回退: 对于无法识别的 JSON 或纯文本错误
|
||||
return truncateString(string(body), maxErrorBodyLength), ""
|
||||
}
|
||||
|
||||
// truncateString ensures a string does not exceed a maximum length.
|
||||
// truncateString remains unchanged.
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) > maxLength {
|
||||
return s[:maxLength]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Filename: internal/handlers/apikey_handler.go (最终决战版)
|
||||
// Filename: internal/handlers/apikey_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -88,7 +88,6 @@ func (h *APIKeyHandler) AddMultipleKeysToGroup(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyImportService.StartAddKeysTask(c.Request.Context(), req.KeyGroupID, req.Keys, req.ValidateOnImport)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -104,7 +103,6 @@ func (h *APIKeyHandler) UnlinkMultipleKeysFromGroup(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyImportService.StartUnlinkKeysTask(c.Request.Context(), req.KeyGroupID, req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -120,7 +118,6 @@ func (h *APIKeyHandler) HardDeleteMultipleKeys(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyImportService.StartHardDeleteKeysTask(c.Request.Context(), req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -136,7 +133,6 @@ func (h *APIKeyHandler) RestoreMultipleKeys(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyImportService.StartRestoreKeysTask(c.Request.Context(), req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -151,7 +147,6 @@ func (h *APIKeyHandler) TestMultipleKeys(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyValidationService.StartTestKeysTask(c.Request.Context(), req.KeyGroupID, req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -246,7 +241,6 @@ func (h *APIKeyHandler) TestKeysForGroup(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
taskStatus, err := h.keyValidationService.StartTestKeysTask(c.Request.Context(), uint(groupID), req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
@@ -366,7 +360,6 @@ func (h *APIKeyHandler) HandleBulkAction(c *gin.Context) {
|
||||
var apiErr *errors.APIError
|
||||
switch req.Action {
|
||||
case "revalidate":
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
task, err = h.keyValidationService.StartTestKeysByFilterTask(c.Request.Context(), uint(groupID), req.Filter.Status)
|
||||
case "set_status":
|
||||
if req.NewStatus == "" {
|
||||
@@ -376,7 +369,6 @@ func (h *APIKeyHandler) HandleBulkAction(c *gin.Context) {
|
||||
targetStatus := models.APIKeyStatus(req.NewStatus)
|
||||
task, err = h.apiKeyService.StartUpdateStatusByFilterTask(c.Request.Context(), uint(groupID), req.Filter.Status, targetStatus)
|
||||
case "delete":
|
||||
// [修正] 将请求的 context 传递给 service 层
|
||||
task, err = h.keyImportService.StartUnlinkKeysByFilterTask(c.Request.Context(), uint(groupID), req.Filter.Status)
|
||||
default:
|
||||
apiErr = errors.NewAPIError(errors.ErrBadRequest, "Unsupported action: "+req.Action)
|
||||
|
||||
@@ -262,7 +262,7 @@ func (h *ProxyHandler) createModifyResponseFunc(attemptErr **errors.APIError, is
|
||||
|
||||
bodyBytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
*attemptErr = errors.NewAPIError(errors.ErrBadGateway, "Failed to read upstream response")
|
||||
*attemptErr = errors.NewAPIErrorWithUpstream(http.StatusBadGateway, "UPSTREAM_GATEWAY_ERROR", nil)
|
||||
resp.Body = io.NopCloser(bytes.NewReader([]byte{}))
|
||||
return nil
|
||||
}
|
||||
@@ -271,8 +271,7 @@ func (h *ProxyHandler) createModifyResponseFunc(attemptErr **errors.APIError, is
|
||||
*isSuccess = true
|
||||
*pTokens, *cTokens = extractUsage(bodyBytes)
|
||||
} else {
|
||||
parsedMsg := errors.ParseUpstreamError(bodyBytes)
|
||||
*attemptErr = errors.NewAPIErrorWithUpstream(resp.StatusCode, fmt.Sprintf("UPSTREAM_%d", resp.StatusCode), parsedMsg)
|
||||
*attemptErr = errors.NewAPIErrorWithUpstream(resp.StatusCode, fmt.Sprintf("UPSTREAM_%d", resp.StatusCode), bodyBytes)
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
return nil
|
||||
@@ -359,11 +358,15 @@ func (h *ProxyHandler) publishFinalLogEvent(c *gin.Context, startTime time.Time,
|
||||
if !isSuccess {
|
||||
errToLog := finalErr
|
||||
if errToLog == nil && rec != nil {
|
||||
errToLog = errors.NewAPIErrorWithUpstream(rec.Code, fmt.Sprintf("UPSTREAM_%d", rec.Code), "Request failed after all retries.")
|
||||
errToLog = errors.NewAPIErrorWithUpstream(rec.Code, fmt.Sprintf("UPSTREAM_%d", rec.Code), rec.Body.Bytes())
|
||||
}
|
||||
if errToLog != nil {
|
||||
if errToLog.Code == "" && errToLog.HTTPStatus >= 400 {
|
||||
errToLog.Code = fmt.Sprintf("UPSTREAM_%d", errToLog.HTTPStatus)
|
||||
}
|
||||
event.Error = errToLog
|
||||
event.RequestLog.ErrorCode, event.RequestLog.ErrorMessage = errToLog.Code, errToLog.Message
|
||||
event.RequestLog.Status = errToLog.Status
|
||||
}
|
||||
}
|
||||
eventData, err := json.Marshal(event)
|
||||
@@ -385,6 +388,7 @@ func (h *ProxyHandler) publishRetryLogEvent(c *gin.Context, startTime time.Time,
|
||||
if attemptErr != nil {
|
||||
retryEvent.Error = attemptErr
|
||||
retryEvent.RequestLog.ErrorCode, retryEvent.RequestLog.ErrorMessage = attemptErr.Code, attemptErr.Message
|
||||
retryEvent.RequestLog.Status = attemptErr.Status
|
||||
}
|
||||
eventData, err := json.Marshal(retryEvent)
|
||||
if err != nil {
|
||||
|
||||
@@ -119,4 +119,4 @@ func extractBearerToken(c *gin.Context) string {
|
||||
}
|
||||
|
||||
return strings.TrimSpace(authHeader[len(prefix):])
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ type RequestLog struct {
|
||||
LatencyMs int
|
||||
IsSuccess bool
|
||||
StatusCode int
|
||||
Status string `gorm:"type:varchar(100);index"`
|
||||
ModelName string `gorm:"type:varchar(100);index"`
|
||||
GroupID *uint `gorm:"index"`
|
||||
KeyID *uint `gorm:"index"`
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
type AuthTokenRepository interface {
|
||||
GetAllTokensWithGroups() ([]*models.AuthToken, error)
|
||||
BatchUpdateTokens(updates []*models.TokenUpdateRequest) error
|
||||
GetTokenByHashedValue(tokenHash string) (*models.AuthToken, error) // <-- Add this line
|
||||
SeedAdminToken(encryptedToken, tokenHash string) error // <-- And this line for the seeder
|
||||
GetTokenByHashedValue(tokenHash string) (*models.AuthToken, error)
|
||||
SeedAdminToken(encryptedToken, tokenHash string) error
|
||||
}
|
||||
|
||||
type gormAuthTokenRepository struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
@@ -28,13 +29,16 @@ func (s *LogService) Record(ctx context.Context, log *models.RequestLog) error {
|
||||
|
||||
// LogQueryParams 解耦 Gin,使用结构体传参
|
||||
type LogQueryParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
ModelName string
|
||||
IsSuccess *bool // 使用指针区分"未设置"和"false"
|
||||
StatusCode *int
|
||||
KeyID *uint64
|
||||
GroupID *uint64
|
||||
Page int
|
||||
PageSize int
|
||||
ModelName string
|
||||
IsSuccess *bool // 使用指针区分"未设置"和"false"
|
||||
StatusCode *int
|
||||
KeyIDs []string
|
||||
GroupIDs []string
|
||||
Q string
|
||||
ErrorCodes []string
|
||||
StatusCodes []string
|
||||
}
|
||||
|
||||
func (s *LogService) GetLogs(ctx context.Context, params LogQueryParams) ([]models.RequestLog, int64, error) {
|
||||
@@ -52,17 +56,12 @@ func (s *LogService) GetLogs(ctx context.Context, params LogQueryParams) ([]mode
|
||||
// 构建基础查询
|
||||
query := s.db.WithContext(ctx).Model(&models.RequestLog{})
|
||||
query = s.applyFilters(query, params)
|
||||
|
||||
// 计算总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count logs: %w", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return []models.RequestLog{}, 0, nil
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
if err := query.Order("request_time DESC").
|
||||
Limit(params.PageSize).
|
||||
@@ -70,7 +69,6 @@ func (s *LogService) GetLogs(ctx context.Context, params LogQueryParams) ([]mode
|
||||
Find(&logs).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query logs: %w", err)
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
@@ -84,11 +82,24 @@ func (s *LogService) applyFilters(query *gorm.DB, params LogQueryParams) *gorm.D
|
||||
if params.StatusCode != nil {
|
||||
query = query.Where("status_code = ?", *params.StatusCode)
|
||||
}
|
||||
if params.KeyID != nil {
|
||||
query = query.Where("key_id = ?", *params.KeyID)
|
||||
if len(params.KeyIDs) > 0 {
|
||||
query = query.Where("key_id IN (?)", params.KeyIDs)
|
||||
}
|
||||
if params.GroupID != nil {
|
||||
query = query.Where("group_id = ?", *params.GroupID)
|
||||
if len(params.GroupIDs) > 0 {
|
||||
query = query.Where("group_id IN (?)", params.GroupIDs)
|
||||
}
|
||||
if len(params.ErrorCodes) > 0 {
|
||||
query = query.Where("error_code IN (?)", params.ErrorCodes)
|
||||
}
|
||||
if len(params.StatusCodes) > 0 {
|
||||
query = query.Where("status_code IN (?)", params.StatusCodes)
|
||||
}
|
||||
if params.Q != "" {
|
||||
searchQuery := "%" + params.Q + "%"
|
||||
query = query.Where(
|
||||
"model_name LIKE ? OR error_code LIKE ? OR error_message LIKE ? OR CAST(status_code AS CHAR) LIKE ?",
|
||||
searchQuery, searchQuery, searchQuery, searchQuery,
|
||||
)
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -132,21 +143,22 @@ func ParseLogQueryParams(queryParams map[string]string) (LogQueryParams, error)
|
||||
}
|
||||
}
|
||||
|
||||
if keyIDStr, ok := queryParams["key_id"]; ok {
|
||||
if keyID, err := strconv.ParseUint(keyIDStr, 10, 64); err == nil {
|
||||
params.KeyID = &keyID
|
||||
} else {
|
||||
return params, fmt.Errorf("invalid key_id parameter: %s", keyIDStr)
|
||||
}
|
||||
if keyIDsStr, ok := queryParams["key_ids"]; ok {
|
||||
params.KeyIDs = strings.Split(keyIDsStr, ",")
|
||||
}
|
||||
|
||||
if groupIDStr, ok := queryParams["group_id"]; ok {
|
||||
if groupID, err := strconv.ParseUint(groupIDStr, 10, 64); err == nil {
|
||||
params.GroupID = &groupID
|
||||
} else {
|
||||
return params, fmt.Errorf("invalid group_id parameter: %s", groupIDStr)
|
||||
}
|
||||
if groupIDsStr, ok := queryParams["group_ids"]; ok {
|
||||
params.GroupIDs = strings.Split(groupIDsStr, ",")
|
||||
}
|
||||
|
||||
if errorCodesStr, ok := queryParams["error_codes"]; ok {
|
||||
params.ErrorCodes = strings.Split(errorCodesStr, ",")
|
||||
}
|
||||
if statusCodesStr, ok := queryParams["status_codes"]; ok {
|
||||
params.StatusCodes = strings.Split(statusCodesStr, ",")
|
||||
}
|
||||
if q, ok := queryParams["q"]; ok {
|
||||
params.Q = q
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
@@ -420,6 +420,9 @@
|
||||
.bottom-6 {
|
||||
bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.bottom-full {
|
||||
bottom: 100%;
|
||||
}
|
||||
.left-0 {
|
||||
left: calc(var(--spacing) * 0);
|
||||
}
|
||||
@@ -447,6 +450,9 @@
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
.z-90 {
|
||||
z-index: 90;
|
||||
}
|
||||
.z-\[100\] {
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -495,6 +501,9 @@
|
||||
.my-1\.5 {
|
||||
margin-block: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.mt-0 {
|
||||
margin-top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.mt-0\.5 {
|
||||
margin-top: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -613,6 +622,9 @@
|
||||
width: calc(var(--spacing) * 6);
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-0 {
|
||||
height: calc(var(--spacing) * 0);
|
||||
}
|
||||
.h-0\.5 {
|
||||
height: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -637,6 +649,9 @@
|
||||
.h-6 {
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-7 {
|
||||
height: calc(var(--spacing) * 7);
|
||||
}
|
||||
.h-8 {
|
||||
height: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -694,6 +709,9 @@
|
||||
.w-0 {
|
||||
width: calc(var(--spacing) * 0);
|
||||
}
|
||||
.w-1 {
|
||||
width: calc(var(--spacing) * 1);
|
||||
}
|
||||
.w-1\/4 {
|
||||
width: calc(1/4 * 100%);
|
||||
}
|
||||
@@ -790,6 +808,9 @@
|
||||
.min-w-0 {
|
||||
min-width: calc(var(--spacing) * 0);
|
||||
}
|
||||
.min-w-\[12rem\] {
|
||||
min-width: 12rem;
|
||||
}
|
||||
.min-w-full {
|
||||
min-width: 100%;
|
||||
}
|
||||
@@ -799,6 +820,9 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -811,6 +835,9 @@
|
||||
.caption-bottom {
|
||||
caption-side: bottom;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.origin-center {
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -837,6 +864,10 @@
|
||||
--tw-translate-x: 100%;
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-y-1 {
|
||||
--tw-translate-y: calc(var(--spacing) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
@@ -993,6 +1024,9 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-x-1 {
|
||||
column-gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-x-1\.5 {
|
||||
column-gap: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
@@ -1138,6 +1172,9 @@
|
||||
--tw-border-style: none;
|
||||
border-style: none;
|
||||
}
|
||||
.border-black {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
.border-black\/10 {
|
||||
border-color: color-mix(in srgb, #000 10%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1165,6 +1202,9 @@
|
||||
.border-green-200 {
|
||||
border-color: var(--color-green-200);
|
||||
}
|
||||
.border-primary {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.border-primary\/20 {
|
||||
border-color: var(--color-primary);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1201,6 +1241,9 @@
|
||||
.border-zinc-300 {
|
||||
border-color: var(--color-zinc-300);
|
||||
}
|
||||
.border-zinc-700 {
|
||||
border-color: var(--color-zinc-700);
|
||||
}
|
||||
.border-zinc-700\/50 {
|
||||
border-color: color-mix(in srgb, oklch(37% 0.013 285.805) 50%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1273,6 +1316,9 @@
|
||||
.bg-gray-500 {
|
||||
background-color: var(--color-gray-500);
|
||||
}
|
||||
.bg-gray-950 {
|
||||
background-color: var(--color-gray-950);
|
||||
}
|
||||
.bg-gray-950\/5 {
|
||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 5%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1459,6 +1505,9 @@
|
||||
.bg-zinc-200 {
|
||||
background-color: var(--color-zinc-200);
|
||||
}
|
||||
.bg-zinc-400 {
|
||||
background-color: var(--color-zinc-400);
|
||||
}
|
||||
.bg-zinc-500 {
|
||||
background-color: var(--color-zinc-500);
|
||||
}
|
||||
@@ -1484,6 +1533,10 @@
|
||||
--tw-gradient-position: to right in oklab;
|
||||
background-image: linear-gradient(var(--tw-gradient-stops));
|
||||
}
|
||||
.from-blue-500 {
|
||||
--tw-gradient-from: var(--color-blue-500);
|
||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||
}
|
||||
.from-blue-500\/30 {
|
||||
--tw-gradient-from: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1554,6 +1607,9 @@
|
||||
.px-8 {
|
||||
padding-inline: calc(var(--spacing) * 8);
|
||||
}
|
||||
.py-0 {
|
||||
padding-block: calc(var(--spacing) * 0);
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -1602,6 +1658,9 @@
|
||||
.pr-20 {
|
||||
padding-right: calc(var(--spacing) * 20);
|
||||
}
|
||||
.pb-1 {
|
||||
padding-bottom: calc(var(--spacing) * 1);
|
||||
}
|
||||
.pb-1\.5 {
|
||||
padding-bottom: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
@@ -1849,6 +1908,9 @@
|
||||
.text-zinc-100 {
|
||||
color: var(--color-zinc-100);
|
||||
}
|
||||
.text-zinc-200 {
|
||||
color: var(--color-zinc-200);
|
||||
}
|
||||
.text-zinc-400 {
|
||||
color: var(--color-zinc-400);
|
||||
}
|
||||
@@ -1876,6 +1938,9 @@
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0%;
|
||||
}
|
||||
@@ -1933,6 +1998,10 @@
|
||||
--tw-inset-shadow: inset 0 2px 4px var(--tw-inset-shadow-color, oklab(from rgb(0 0 0 / 0.05) l a b / 25%));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.inset-shadow-sm {
|
||||
--tw-inset-shadow: inset 0 2px 4px var(--tw-inset-shadow-color, rgb(0 0 0 / 0.05));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-black {
|
||||
--tw-ring-color: var(--color-black);
|
||||
}
|
||||
@@ -1954,6 +2023,10 @@
|
||||
--tw-ring-color: color-mix(in oklab, var(--color-black) 15%, transparent);
|
||||
}
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
@@ -2672,6 +2745,11 @@
|
||||
border-color: var(--color-zinc-800);
|
||||
}
|
||||
}
|
||||
.dark\:bg-black {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
}
|
||||
.dark\:bg-blue-900 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-blue-900);
|
||||
@@ -2788,6 +2866,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:bg-zinc-500 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-zinc-500);
|
||||
}
|
||||
}
|
||||
.dark\:bg-zinc-600 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-zinc-600);
|
||||
}
|
||||
}
|
||||
.dark\:bg-zinc-700 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-zinc-700);
|
||||
@@ -5028,6 +5116,11 @@
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -5140,11 +5233,6 @@
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -5202,6 +5290,7 @@
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
--tw-contrast: initial;
|
||||
@@ -5229,7 +5318,6 @@
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-scale-z: 1;
|
||||
--tw-outline-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
web/static/js/chunk-JSBRDJBE.js
Normal file
30
web/static/js/chunk-JSBRDJBE.js
Normal file
@@ -0,0 +1,30 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __commonJS = (cb, mod) => function __require() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
export {
|
||||
__commonJS,
|
||||
__toESM
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./chunk-JSBRDJBE.js";
|
||||
|
||||
// frontend/js/pages/dashboard.js
|
||||
function init() {
|
||||
console.log("[Modern Frontend] Dashboard module loaded. Future logic will execute here.");
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
apiFetch,
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
import "./chunk-JSBRDJBE.js";
|
||||
|
||||
// frontend/js/components/tagInput.js
|
||||
var TagInput = class {
|
||||
@@ -1,240 +0,0 @@
|
||||
import {
|
||||
escapeHTML
|
||||
} from "./chunk-A4OOMLXK.js";
|
||||
import {
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
|
||||
// frontend/js/pages/logs/logList.js
|
||||
var STATIC_ERROR_MAP = {
|
||||
"API_KEY_INVALID": { type: "\u5BC6\u94A5\u65E0\u6548", style: "red" },
|
||||
"INVALID_ARGUMENT": { type: "\u53C2\u6570\u65E0\u6548", style: "red" },
|
||||
"PERMISSION_DENIED": { type: "\u6743\u9650\u4E0D\u8DB3", style: "red" },
|
||||
"NOT_FOUND": { type: "\u8D44\u6E90\u672A\u627E\u5230", style: "gray" },
|
||||
"RESOURCE_EXHAUSTED": { type: "\u8D44\u6E90\u8017\u5C3D", style: "orange" },
|
||||
"QUOTA_EXCEEDED": { type: "\u914D\u989D\u8017\u5C3D", style: "orange" },
|
||||
"DEADLINE_EXCEEDED": { type: "\u8BF7\u6C42\u8D85\u65F6", style: "yellow" },
|
||||
"CANCELLED": { type: "\u8BF7\u6C42\u5DF2\u53D6\u6D88", style: "gray" },
|
||||
"INTERNAL": { type: "Google\u5185\u90E8\u9519\u8BEF", style: "yellow" },
|
||||
"UNAVAILABLE": { type: "\u670D\u52A1\u4E0D\u53EF\u7528", style: "yellow" }
|
||||
};
|
||||
var STATUS_CODE_MAP = {
|
||||
400: { type: "\u9519\u8BEF\u8BF7\u6C42", style: "red" },
|
||||
401: { type: "\u8BA4\u8BC1\u5931\u8D25", style: "red" },
|
||||
403: { type: "\u7981\u6B62\u8BBF\u95EE", style: "red" },
|
||||
404: { type: "\u8D44\u6E90\u672A\u627E\u5230", style: "gray" },
|
||||
413: { type: "\u8BF7\u6C42\u4F53\u8FC7\u5927", style: "orange" },
|
||||
429: { type: "\u8BF7\u6C42\u9891\u7387\u8FC7\u9AD8", style: "orange" },
|
||||
500: { type: "\u5185\u90E8\u670D\u52A1\u9519\u8BEF", style: "yellow" },
|
||||
503: { type: "\u670D\u52A1\u4E0D\u53EF\u7528", style: "yellow" }
|
||||
};
|
||||
var SPECIAL_CASE_MAP = [
|
||||
{ code: 400, keyword: "api key not found", type: "\u65E0\u6548\u5BC6\u94A5", style: "red" },
|
||||
{ code: 404, keyword: "call listmodels", type: "\u6A21\u578B\u914D\u7F6E\u9519\u8BEF", style: "orange" }
|
||||
];
|
||||
var styleToClass = (style) => {
|
||||
switch (style) {
|
||||
case "red":
|
||||
return "bg-red-500/10 text-red-600";
|
||||
case "orange":
|
||||
return "bg-orange-500/10 text-orange-600";
|
||||
case "yellow":
|
||||
return "bg-yellow-500/10 text-yellow-600";
|
||||
case "gray":
|
||||
return "bg-zinc-500/10 text-zinc-600";
|
||||
default:
|
||||
return "bg-destructive/10 text-destructive";
|
||||
}
|
||||
};
|
||||
var errorCodeRegex = /(\d+)$/;
|
||||
var LogList = class {
|
||||
constructor(container, dataStore2) {
|
||||
this.container = container;
|
||||
this.dataStore = dataStore2;
|
||||
if (!this.container) console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
renderLoading() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> \u52A0\u8F7D\u65E5\u5FD7\u4E2D...</td></tr>`;
|
||||
}
|
||||
render(logs, pagination) {
|
||||
if (!this.container) return;
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">\u6CA1\u6709\u627E\u5230\u76F8\u5173\u7684\u65E5\u5FD7\u8BB0\u5F55\u3002</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const { page, page_size } = pagination;
|
||||
const startIndex = (page - 1) * page_size;
|
||||
const logsHtml = logs.map((log, index) => this.createLogRowHtml(log, startIndex + index + 1)).join("");
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
_interpretError(log) {
|
||||
if (log.IsSuccess) {
|
||||
return {
|
||||
type: "N/A",
|
||||
statusCodeHtml: `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">\u6210\u529F</span>`
|
||||
};
|
||||
}
|
||||
const codeMatch = log.ErrorCode ? log.ErrorCode.match(errorCodeRegex) : null;
|
||||
if (codeMatch && codeMatch[1] && log.ErrorMessage) {
|
||||
const code = parseInt(codeMatch[1], 10);
|
||||
const lowerCaseMsg = log.ErrorMessage.toLowerCase();
|
||||
for (const rule of SPECIAL_CASE_MAP) {
|
||||
if (code === rule.code && lowerCaseMsg.includes(rule.keyword)) {
|
||||
return {
|
||||
type: rule.type,
|
||||
statusCodeHtml: `<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${styleToClass(rule.style)}">${code}</span>`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (log.ErrorCode && STATIC_ERROR_MAP[log.ErrorCode]) {
|
||||
const mapping = STATIC_ERROR_MAP[log.ErrorCode];
|
||||
return {
|
||||
type: mapping.type,
|
||||
statusCodeHtml: `<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${styleToClass(mapping.style)}">${log.ErrorCode}</span>`
|
||||
};
|
||||
}
|
||||
if (codeMatch && codeMatch[1]) {
|
||||
const code = parseInt(codeMatch[1], 10);
|
||||
let mapping = STATUS_CODE_MAP[code];
|
||||
if (!mapping && code >= 500 && code < 600) {
|
||||
mapping = STATUS_CODE_MAP[500];
|
||||
}
|
||||
if (mapping) {
|
||||
return {
|
||||
type: mapping.type,
|
||||
statusCodeHtml: `<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${styleToClass(mapping.style)}">${code}</span>`
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!log.ErrorCode && !log.ErrorMessage) {
|
||||
return { type: "\u672A\u77E5", statusCodeHtml: `<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${styleToClass("gray")}">N/A</span>` };
|
||||
}
|
||||
return { type: "\u672A\u77E5\u9519\u8BEF", statusCodeHtml: `<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${styleToClass("default")}">\u5931\u8D25</span>` };
|
||||
}
|
||||
_formatModelName(modelName) {
|
||||
const styleClass = "";
|
||||
return `<div class="inline-block rounded bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5"><span class="font-quinquefive text-xs tracking-wider ${styleClass}">${modelName}</span></div>`;
|
||||
}
|
||||
createLogRowHtml(log, index) {
|
||||
const group = this.dataStore.groups.get(log.GroupID);
|
||||
const groupName = group ? group.display_name : log.GroupID ? `Group #${log.GroupID}` : "N/A";
|
||||
const key = this.dataStore.keys.get(log.KeyID);
|
||||
let apiKeyDisplay;
|
||||
if (key && key.APIKey && key.APIKey.length >= 8) {
|
||||
const masked = `${key.APIKey.substring(0, 4)}......${key.APIKey.substring(key.APIKey.length - 4)}`;
|
||||
apiKeyDisplay = escapeHTML(masked);
|
||||
} else {
|
||||
apiKeyDisplay = log.KeyID ? `Key #${log.KeyID}` : "N/A";
|
||||
}
|
||||
const errorInfo = this._interpretError(log);
|
||||
const modelNameFormatted = this._formatModelName(log.ModelName);
|
||||
const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : "";
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
return `
|
||||
<tr class="table-row" data-log-id="${log.ID}" ${errorMessageAttr}>
|
||||
<td class="table-cell"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="table-cell font-mono text-muted-foreground">${index}</td>
|
||||
<td class="table-cell font-medium font-mono">${apiKeyDisplay}</td>
|
||||
<td class="table-cell">${groupName}</td>
|
||||
<td class="table-cell">${errorInfo.type}</td>
|
||||
<td class="table-cell">${errorInfo.statusCodeHtml}</td>
|
||||
<td class="table-cell">${modelNameFormatted}</td>
|
||||
<td class="table-cell text-muted-foreground text-xs">${requestTime}</td>
|
||||
<td class="table-cell">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" aria-label="\u67E5\u770B\u8BE6\u60C5">
|
||||
<i class="fas fa-ellipsis-h h-4 w-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
};
|
||||
var logList_default = LogList;
|
||||
|
||||
// frontend/js/pages/logs/index.js
|
||||
var dataStore = {
|
||||
groups: /* @__PURE__ */ new Map(),
|
||||
keys: /* @__PURE__ */ new Map()
|
||||
};
|
||||
var LogsPage = class {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
pagination: { page: 1, pages: 1, total: 0, page_size: 20 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
this.elements = {
|
||||
tableBody: document.getElementById("logs-table-body")
|
||||
};
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
if (this.initialized) {
|
||||
this.logList = new logList_default(this.elements.tableBody, dataStore);
|
||||
}
|
||||
}
|
||||
async init() {
|
||||
if (!this.initialized) return;
|
||||
this.initEventListeners();
|
||||
await this.loadGroupsOnce();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
initEventListeners() {
|
||||
}
|
||||
async loadGroupsOnce() {
|
||||
if (dataStore.groups.size > 0) return;
|
||||
try {
|
||||
const { success, data } = await apiFetchJson("/admin/keygroups");
|
||||
if (success && Array.isArray(data)) {
|
||||
data.forEach((group) => dataStore.groups.set(group.id, group));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load key groups:", error);
|
||||
}
|
||||
}
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
try {
|
||||
const query = new URLSearchParams(this.state.filters);
|
||||
const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
|
||||
if (success && typeof data === "object") {
|
||||
const { items, total, page, page_size } = data;
|
||||
this.state.logs = items;
|
||||
this.state.pagination = { page, page_size, total, pages: Math.ceil(total / page_size) };
|
||||
await this.enrichLogsWithKeyNames(items);
|
||||
this.logList.render(this.state.logs, this.state.pagination);
|
||||
} else {
|
||||
this.logList.render([], this.state.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
this.logList.render([], this.state.pagination);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
async enrichLogsWithKeyNames(logs) {
|
||||
const missingKeyIds = [...new Set(
|
||||
logs.filter((log) => log.KeyID && !dataStore.keys.has(log.KeyID)).map((log) => log.KeyID)
|
||||
)];
|
||||
if (missingKeyIds.length === 0) return;
|
||||
try {
|
||||
const idsQuery = missingKeyIds.join(",");
|
||||
const { success, data } = await apiFetchJson(`/admin/apikeys?ids=${idsQuery}`);
|
||||
if (success && Array.isArray(data)) {
|
||||
data.forEach((key) => dataStore.keys.set(key.ID, key));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch key details:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
function logs_default() {
|
||||
const page = new LogsPage();
|
||||
page.init();
|
||||
}
|
||||
export {
|
||||
logs_default as default
|
||||
};
|
||||
1145
web/static/js/logs-OFCAHOEI.js
Normal file
1145
web/static/js/logs-OFCAHOEI.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
apiFetch,
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
import "./chunk-JSBRDJBE.js";
|
||||
|
||||
// frontend/js/components/slidingTabs.js
|
||||
var SlidingTabs = class {
|
||||
@@ -179,9 +180,9 @@ var base_default = initLayout;
|
||||
var pageModules = {
|
||||
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
|
||||
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
|
||||
"dashboard": () => import("./dashboard-CJJWKYPR.js"),
|
||||
"keys": () => import("./keys-4GCIJ7HW.js"),
|
||||
"logs": () => import("./logs-AG4TD2DO.js")
|
||||
"dashboard": () => import("./dashboard-XFUWX3IN.js"),
|
||||
"keys": () => import("./keys-HRP4JR7B.js"),
|
||||
"logs": () => import("./logs-OFCAHOEI.js")
|
||||
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
|
||||
// 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理
|
||||
};
|
||||
|
||||
@@ -41,16 +41,16 @@
|
||||
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
|
||||
<input class="input h-8 w-[150px] lg:w-[250px]" placeholder="筛选密钥..." value="">
|
||||
<input id="log-search-input" class="input h-8 w-[150px] lg:w-[250px]" placeholder="全局模糊查找..." value="">
|
||||
|
||||
<button class="btn btn-outline border-dashed h-8 px-3 text-xs">
|
||||
<button id="filter-error-type-btn" class="btn btn-outline border-dashed h-8 px-3 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 h-4 w-4">
|
||||
<circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path>
|
||||
</svg>
|
||||
错误类型
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline border-dashed h-8 px-3 text-xs">
|
||||
<button id="filter-error-code-btn" class="btn btn-outline border-dashed h-8 px-3 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 h-4 w-4">
|
||||
<circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path>
|
||||
</svg>
|
||||
@@ -100,23 +100,39 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 3.3 分页控制器 -->
|
||||
<!-- 分页控制器部分 -->
|
||||
<div class="flex items-center justify-between p-2 shrink-0 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div class="flex-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
已选择 <span class="font-semibold text-zinc-900 dark:text-white">0</span> / <span class="font-semibold text-zinc-900 dark:text-white">100</span>
|
||||
已选择 <span class="font-semibold text-zinc-900 dark:text-white">0</span> / <span class="font-semibold text-zinc-900 dark:text-white">0</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">每页行数</p>
|
||||
<button type="button" class="btn btn-secondary h-8 w-[70px] flex justify-between items-center px-2">
|
||||
<span>10</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 opacity-50"><path d="m6 9 6 6 6-6"></path></svg>
|
||||
</button>
|
||||
<p class="text-sm font-medium">每页行数</p>
|
||||
|
||||
<div data-component="custom-select-v2">
|
||||
<button type="button" class="custom-select-trigger btn btn-secondary h-8 w-[70px] flex justify-between items-center px-2">
|
||||
<span>20</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 opacity-50"><path d="m6 9 6 6 6-6"></path></svg>
|
||||
</button>
|
||||
|
||||
<select class="hidden">
|
||||
<option value="20" selected>20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
|
||||
<template class="custom-select-panel-template">
|
||||
<div class="custom-select-panel w-[70px] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg z-[100]">
|
||||
<!-- JS <select> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
第 1 / 10 页
|
||||
第 1 / 1 页
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- [修正 1.2] 为分页按钮组添加一个稳定的 data 属性,用于 JS 选择 -->
|
||||
<div class="flex items-center space-x-2" data-pagination-controls>
|
||||
<button class="btn btn-secondary h-8 w-8 p-0 hidden lg:flex" disabled>
|
||||
<span class="sr-only">Go to first page</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m11 17-5-5 5-5"></path><path d="m18 17-5-5 5-5"></path></svg>
|
||||
|
||||
Reference in New Issue
Block a user