Fix loglist

This commit is contained in:
XOF
2025-11-21 19:33:05 +08:00
parent 1f7aa70810
commit 6a0f344e5c
22 changed files with 380 additions and 357 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.vscode/ .vscode/
.idea/ .idea/
tmp/
*.code-workspace *.code-workspace

View File

@@ -3,11 +3,17 @@
import { apiFetchJson } from '../../services/api.js'; import { apiFetchJson } from '../../services/api.js';
import LogList from './logList.js'; import LogList from './logList.js';
// [最终版] 创建一个共享的数据仓库,用于缓存 Groups 和 Keys
const dataStore = {
groups: new Map(),
keys: new Map(),
};
class LogsPage { class LogsPage {
constructor() { constructor() {
this.state = { this.state = {
logs: [], logs: [],
pagination: { page: 1, pages: 1, total: 0, page_size: 20 }, // 包含 page_size pagination: { page: 1, pages: 1, total: 0, page_size: 20 },
isLoading: true, isLoading: true,
filters: { page: 1, page_size: 20 } filters: { page: 1, page_size: 20 }
}; };
@@ -19,21 +25,30 @@ class LogsPage {
this.initialized = !!this.elements.tableBody; this.initialized = !!this.elements.tableBody;
if (this.initialized) { if (this.initialized) {
this.logList = new LogList(this.elements.tableBody); this.logList = new LogList(this.elements.tableBody, dataStore);
} }
} }
async init() { async init() {
if (!this.initialized) { if (!this.initialized) return;
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
return;
}
this.initEventListeners(); this.initEventListeners();
// 页面初始化:先加载群组,再加载日志
await this.loadGroupsOnce();
await this.loadAndRenderLogs(); await this.loadAndRenderLogs();
} }
initEventListeners() { 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() { async loadAndRenderLogs() {
@@ -41,36 +56,45 @@ class LogsPage {
this.logList.renderLoading(); this.logList.renderLoading();
try { try {
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`; const query = new URLSearchParams(this.state.filters);
const responseData = await apiFetchJson(url); const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
if (responseData && responseData.success && Array.isArray(responseData.data)) { if (success && typeof data === 'object') {
this.state.logs = responseData.data; 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信息
// TODO: 当后端API返回分页对象时替换此处的模拟数据 await this.enrichLogsWithKeyNames(items);
this.state.pagination = {
page: this.state.filters.page,
page_size: this.state.filters.page_size,
total: responseData.data.length, // 这是一个不准确的临时值
pages: Math.ceil(responseData.data.length / this.state.filters.page_size) // 同样不准确
};
// [修改] 将分页状态传递给 render 方法 // 调用 render此时 dataStore 中已包含所有需要的数据
this.logList.render(this.state.logs, this.state.pagination); this.logList.render(this.state.logs, this.state.pagination);
} else { } else {
console.error("API response for logs is incorrect:", responseData);
this.logList.render([], this.state.pagination); this.logList.render([], this.state.pagination);
} }
} catch (error) } catch (error) {
{
console.error("Failed to load logs:", error); console.error("Failed to load logs:", error);
this.logList.render([], this.state.pagination); this.logList.render([], this.state.pagination);
} finally { } finally {
this.state.isLoading = false; 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);
}
}
} }
export default function() { export default function() {

View File

@@ -1,5 +1,6 @@
// Filename: frontend/js/pages/logs/logList.js // Filename: frontend/js/pages/logs/logList.js
// --- [扩展] 静态错误码与样式的映射表 (源自Gemini官方文档) --- import { escapeHTML } from '../../utils/utils.js';
const STATIC_ERROR_MAP = { const STATIC_ERROR_MAP = {
'API_KEY_INVALID': { type: '密钥无效', style: 'red' }, 'API_KEY_INVALID': { type: '密钥无效', style: 'red' },
'INVALID_ARGUMENT': { type: '参数无效', style: 'red' }, 'INVALID_ARGUMENT': { type: '参数无效', style: 'red' },
@@ -23,10 +24,8 @@ const STATUS_CODE_MAP = {
500: { type: '内部服务错误', style: 'yellow' }, 500: { type: '内部服务错误', style: 'yellow' },
503: { type: '服务不可用', style: 'yellow' } 503: { type: '服务不可用', style: 'yellow' }
}; };
// --- [新增] 特殊场景判断规则 (高优先级) ---
const SPECIAL_CASE_MAP = [ const SPECIAL_CASE_MAP = [
{ code: 400, keyword: 'api key not found', type: '无效密钥', style: 'red' }, { code: 400, keyword: 'api key not found', type: '无效密钥', style: 'red' },
// 之前实现的模型配置错误规则也可以移到这里,更加规范
{ code: 404, keyword: 'call listmodels', type: '模型配置错误', style: 'orange' } { code: 404, keyword: 'call listmodels', type: '模型配置错误', style: 'orange' }
]; ];
@@ -41,16 +40,13 @@ const styleToClass = (style) => {
} }
}; };
// [修正] 修正了正则表达式的名称,使其语义清晰
const errorCodeRegex = /(\d+)$/; const errorCodeRegex = /(\d+)$/;
// [修正] 移除了 MODEL_STYLE_MAP 的声明,因为它未在 _formatModelName 中使用
// 如果未来需要,可以重新添加
// const MODEL_STYLE_MAP = { ... };
class LogList { class LogList {
constructor(container) { constructor(container, dataStore) {
this.container = container; this.container = container;
this.dataStore = dataStore;
if (!this.container) console.error("LogList: container element (tbody) not found."); if (!this.container) console.error("LogList: container element (tbody) not found.");
} }
@@ -125,15 +121,21 @@ class LogList {
} }
_formatModelName(modelName) { _formatModelName(modelName) {
// [修正] 移除了对 MODEL_STYLE_MAP 的依赖,简化为统一的样式 const styleClass = '';
// 这样可以避免因 MODEL_STYLE_MAP 未定义而产生的潜在错误
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>`; 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) {
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : 'N/A'); const group = this.dataStore.groups.get(log.GroupID);
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : 'N/A'); 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 errorInfo = this._interpretError(log);
const modelNameFormatted = this._formatModelName(log.ModelName); const modelNameFormatted = this._formatModelName(log.ModelName);
const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : ''; const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : '';
@@ -142,7 +144,7 @@ class LogList {
<tr class="table-row" data-log-id="${log.ID}" ${errorMessageAttr}> <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"></td>
<td class="table-cell font-mono text-muted-foreground">${index}</td> <td class="table-cell font-mono text-muted-foreground">${index}</td>
<td class="table-cell font-medium font-mono">${apiKeyName}</td> <td class="table-cell font-medium font-mono">${apiKeyDisplay}</td>
<td class="table-cell">${groupName}</td> <td class="table-cell">${groupName}</td>
<td class="table-cell">${errorInfo.type}</td> <td class="table-cell">${errorInfo.type}</td>
<td class="table-cell">${errorInfo.statusCodeHtml}</td> <td class="table-cell">${errorInfo.statusCodeHtml}</td>
@@ -158,5 +160,4 @@ class LogList {
} }
} }
// [核心修正] 移除了文件末尾所有多余的代码,只保留最核心的默认导出
export default LogList; export default LogList;

View File

@@ -7,7 +7,9 @@ import (
"gemini-balancer/internal/response" "gemini-balancer/internal/response"
"gemini-balancer/internal/service" "gemini-balancer/internal/service"
"gemini-balancer/internal/task" "gemini-balancer/internal/task"
"net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -160,6 +162,29 @@ func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error())) response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error()))
return return
} }
if params.IDs != "" {
idStrs := strings.Split(params.IDs, ",")
ids := make([]uint, 0, len(idStrs))
for _, s := range idStrs {
id, err := strconv.ParseUint(s, 10, 64)
if err == nil {
ids = append(ids, uint(id))
}
}
if len(ids) > 0 {
keys, err := h.apiKeyService.GetKeysByIds(ids)
if err != nil {
response.Error(c, &errors.APIError{
HTTPStatus: http.StatusInternalServerError,
Code: "DATA_FETCH_ERROR",
Message: err.Error(),
})
return
}
response.Success(c, keys)
return
}
}
if params.Page <= 0 { if params.Page <= 0 {
params.Page = 1 params.Page = 1
} }

View File

@@ -3,14 +3,13 @@ package handlers
import ( import (
"gemini-balancer/internal/errors" "gemini-balancer/internal/errors"
"gemini-balancer/internal/models"
"gemini-balancer/internal/response" "gemini-balancer/internal/response"
"gemini-balancer/internal/service" "gemini-balancer/internal/service"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// LogHandler 负责处理与日志相关的HTTP请求
type LogHandler struct { type LogHandler struct {
logService *service.LogService logService *service.LogService
} }
@@ -20,14 +19,22 @@ func NewLogHandler(logService *service.LogService) *LogHandler {
} }
func (h *LogHandler) GetLogs(c *gin.Context) { func (h *LogHandler) GetLogs(c *gin.Context) {
// 直接将Gin的上下文传递给Service层让Service自己去解析查询参 // 调用新的服务函数,接收日志列表和总
logs, err := h.logService.GetLogs(c) logs, total, err := h.logService.GetLogs(c)
if err != nil { if err != nil {
response.Error(c, errors.ErrDatabase) response.Error(c, errors.ErrDatabase)
return return
} }
if logs == nil {
logs = []models.RequestLog{} // 解析分页参数用于响应体
} page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
response.Success(c, logs) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// 使用标准的分页响应结构
response.Success(c, gin.H{
"items": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
} }

View File

@@ -136,34 +136,48 @@ func (h *ProxyHandler) serveTransparentProxy(c *gin.Context, requestBody []byte,
var finalPromptTokens, finalCompletionTokens int var finalPromptTokens, finalCompletionTokens int
var actualRetries int = 0 var actualRetries int = 0
defer func() { defer func() {
// 如果一次尝试都未成功(例如,在第一次获取资源时就失败),则不记录日志
if lastUsedResources == nil { if lastUsedResources == nil {
h.logger.WithField("id", correlationID).Warn("No resources were used, skipping final log event.")
return return
} }
finalEvent := h.createLogEvent(c, startTime, correlationID, modelName, lastUsedResources, models.LogTypeFinal, isPreciseRouting) finalEvent := h.createLogEvent(c, startTime, correlationID, modelName, lastUsedResources, models.LogTypeFinal, isPreciseRouting)
finalEvent.LatencyMs = int(time.Since(startTime).Milliseconds())
finalEvent.IsSuccess = isSuccess finalEvent.RequestLog.LatencyMs = int(time.Since(startTime).Milliseconds())
finalEvent.Retries = actualRetries finalEvent.RequestLog.IsSuccess = isSuccess
finalEvent.RequestLog.Retries = actualRetries
if isSuccess { if isSuccess {
finalEvent.PromptTokens = finalPromptTokens finalEvent.RequestLog.PromptTokens = finalPromptTokens
finalEvent.CompletionTokens = finalCompletionTokens finalEvent.RequestLog.CompletionTokens = finalCompletionTokens
} }
// 确保即使在成功的情况下如果recorder存在也记录最终的状态码
if finalRecorder != nil { if finalRecorder != nil {
finalEvent.StatusCode = finalRecorder.Code finalEvent.RequestLog.StatusCode = finalRecorder.Code
} }
if !isSuccess { if !isSuccess {
// 将 finalProxyErr 的信息填充到 RequestLog 中
if finalProxyErr != nil { if finalProxyErr != nil {
finalEvent.Error = finalProxyErr finalEvent.Error = finalProxyErr // Error 字段用于事件传递,不会被序列化到数据库
finalEvent.ErrorCode = finalProxyErr.Code finalEvent.RequestLog.ErrorCode = finalProxyErr.Code
finalEvent.ErrorMessage = finalProxyErr.Message finalEvent.RequestLog.ErrorMessage = finalProxyErr.Message
} else if finalRecorder != nil { } else if finalRecorder != nil {
apiErr := errors.NewAPIErrorWithUpstream(finalRecorder.Code, "PROXY_ERROR", "Request failed after all retries.") // 降级处理:如果 finalProxyErr 为空但 recorder 存在且失败
apiErr := errors.NewAPIErrorWithUpstream(finalRecorder.Code, fmt.Sprintf("UPSTREAM_%d", finalRecorder.Code), "Request failed after all retries.")
finalEvent.Error = apiErr finalEvent.Error = apiErr
finalEvent.ErrorCode = apiErr.Code finalEvent.RequestLog.ErrorCode = apiErr.Code
finalEvent.ErrorMessage = apiErr.Message finalEvent.RequestLog.ErrorMessage = apiErr.Message
} }
} }
eventData, _ := json.Marshal(finalEvent) // 将完整的事件发布
_ = h.store.Publish(models.TopicRequestFinished, eventData) eventData, err := json.Marshal(finalEvent)
if err != nil {
h.logger.WithField("id", correlationID).WithError(err).Error("Failed to marshal final log event.")
return
}
if err := h.store.Publish(models.TopicRequestFinished, eventData); err != nil {
h.logger.WithField("id", correlationID).WithError(err).Error("Failed to publish final log event.")
}
}() }()
var maxRetries int var maxRetries int
if isPreciseRouting { if isPreciseRouting {
@@ -417,18 +431,24 @@ func (h *ProxyHandler) createLogEvent(c *gin.Context, startTime time.Time, corrI
} }
if authTokenValue, exists := c.Get("authToken"); exists { if authTokenValue, exists := c.Get("authToken"); exists {
if authToken, ok := authTokenValue.(*models.AuthToken); ok { if authToken, ok := authTokenValue.(*models.AuthToken); ok {
event.AuthTokenID = &authToken.ID event.RequestLog.AuthTokenID = &authToken.ID
} }
} }
if res != nil { if res != nil {
event.KeyID = res.APIKey.ID // [核心修正] 填充到内嵌的 RequestLog 结构体中
event.GroupID = res.KeyGroup.ID if res.APIKey != nil {
event.RequestLog.KeyID = &res.APIKey.ID
}
if res.KeyGroup != nil {
event.RequestLog.GroupID = &res.KeyGroup.ID
}
if res.UpstreamEndpoint != nil { if res.UpstreamEndpoint != nil {
event.UpstreamID = &res.UpstreamEndpoint.ID event.RequestLog.UpstreamID = &res.UpstreamEndpoint.ID
// UpstreamURL 是事件传递字段,不是数据库字段,所以在这里赋值是正确的
event.UpstreamURL = &res.UpstreamEndpoint.URL event.UpstreamURL = &res.UpstreamEndpoint.URL
} }
if res.ProxyConfig != nil { if res.ProxyConfig != nil {
event.ProxyID = &res.ProxyConfig.ID event.RequestLog.ProxyID = &res.ProxyConfig.ID
} }
} }
return event return event

View File

@@ -57,6 +57,7 @@ type APIKeyQueryParams struct {
PageSize int `form:"limit"` PageSize int `form:"limit"`
Status string `form:"status"` Status string `form:"status"`
Keyword string `form:"keyword"` Keyword string `form:"keyword"`
IDs string `form:"ids"`
} }
// APIKeyDetails is a DTO that combines APIKey info with its contextual status from the mapping. // APIKeyDetails is a DTO that combines APIKey info with its contextual status from the mapping.

View File

@@ -17,15 +17,10 @@ const (
type RequestFinishedEvent struct { type RequestFinishedEvent struct {
RequestLog RequestLog
KeyID uint Error *errors.APIError `json:"error,omitempty"` // Error 结构体不存入数据库,仅供事件传递
GroupID uint CorrelationID string `json:"correlation_id,omitempty"`
IsSuccess bool UpstreamURL *string `json:"upstream_url,omitempty"`
StatusCode int IsPreciseRouting bool `json:"is_precise_routing"`
Error *errors.APIError
CorrelationID string `json:"correlation_id,omitempty"`
UpstreamID *uint `json:"upstream_id"`
UpstreamURL *string `json:"upstream_url,omitempty"`
IsPreciseRouting bool `json:"is_precise_routing"`
} }
type KeyStatusChangedEvent struct { type KeyStatusChangedEvent struct {

View File

@@ -14,6 +14,7 @@ type MasterAPIKeyStatus string
type PollingStrategy string type PollingStrategy string
type FileProcessingState string type FileProcessingState string
type LogType string type LogType string
type ProtocolType string
const ( const (
// --- 运营状态 (在中间表中使用) --- // --- 运营状态 (在中间表中使用) ---
@@ -35,8 +36,12 @@ const (
FileActive FileProcessingState = "ACTIVE" FileActive FileProcessingState = "ACTIVE"
FileFailed FileProcessingState = "FAILED" FileFailed FileProcessingState = "FAILED"
LogTypeFinal LogType = "FINAL" // Represents the final outcome of a request, including all retries. LogTypeFinal LogType = "FINAL" // Represents the final outcome of a request, including all retries.
LogTypeRetry LogType = "RETRY" // Represents a single, failed attempt that triggered a retry. LogTypeRetry LogType = "RETRY" // Represents a single, failed attempt that triggered a retry.
LogTypeValidation LogType = "VALIDATION"
ProtocolOpenAI ProtocolType = "openai"
ProtocolGemini ProtocolType = "gemini"
) )
// ========= 核心数据库模型 ========= // ========= 核心数据库模型 =========

View File

@@ -11,6 +11,7 @@ import (
"io" "io"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -81,13 +82,20 @@ func (r *gormKeyRepository) SelectOneActiveKey(group *models.KeyGroup) (*models.
// SelectOneActiveKeyFromBasePool 为智能聚合模式设计的全新轮询器。 // SelectOneActiveKeyFromBasePool 为智能聚合模式设计的全新轮询器。
func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error) { func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error) {
protocol := "default"
if pool.Protocol != "" {
protocol = string(pool.Protocol)
}
// 生成唯一的池ID确保不同请求组合的轮询状态相互隔离 // 生成唯一的池ID确保不同请求组合的轮询状态相互隔离
poolID := generatePoolID(pool.CandidateGroups) poolID := generatePoolID(pool.CandidateGroups, protocol)
log := r.logger.WithField("pool_id", poolID) log := r.logger.WithField("pool_id", poolID).WithField("protocol", protocol)
if err := r.ensureBasePoolCacheExists(pool, poolID); err != nil { if err := r.ensureBasePoolCacheExists(pool, poolID); err != nil {
log.WithError(err).Error("Failed to ensure BasePool cache exists.") log.WithError(err).Error("Failed to ensure BasePool cache exists.")
return nil, nil, err if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
return nil, nil, fmt.Errorf("unexpected error while ensuring base pool cache: %w", err)
} }
var keyIDStr string var keyIDStr string
@@ -145,25 +153,40 @@ func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*mod
// ensureBasePoolCacheExists 动态创建 BasePool 的 Redis 结构 // ensureBasePoolCacheExists 动态创建 BasePool 的 Redis 结构
func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID string) error { func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID string) error {
// 使用 LIST 键作为存在性检查的标志
listKey := fmt.Sprintf(BasePoolSequential, poolID) listKey := fmt.Sprintf(BasePoolSequential, poolID)
exists, err := r.store.Exists(listKey) exists, err := r.store.Exists(listKey)
if err != nil { if err != nil {
r.logger.WithError(err).Errorf("Failed to check existence of basepool key: %s", listKey)
return err return err
} }
if exists { if exists {
val, err := r.store.LIndex(listKey, 0) val, err := r.store.LIndex(listKey, 0)
if err == nil && val == EmptyPoolPlaceholder { if err != nil {
return err
}
if val == EmptyPoolPlaceholder {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
return nil return nil
} }
lockKey := fmt.Sprintf("lock:basepool:%s", poolID)
acquired, err := r.store.SetNX(lockKey, []byte("1"), 10*time.Second)
if err != nil {
r.logger.WithError(err).Errorf("Failed to acquire distributed lock for basepool build: %s", lockKey)
return err
}
if !acquired {
time.Sleep(100 * time.Millisecond)
return r.ensureBasePoolCacheExists(pool, poolID)
}
defer r.store.Del(lockKey)
if exists, _ := r.store.Exists(listKey); exists {
return nil
}
r.logger.Infof("BasePool cache for pool_id '%s' not found. Building now...", poolID) r.logger.Infof("BasePool cache for pool_id '%s' not found. Building now...", poolID)
var allActiveKeyIDs []string var allActiveKeyIDs []string
lruMembers := make(map[string]float64) lruMembers := make(map[string]float64)
for _, group := range pool.CandidateGroups { for _, group := range pool.CandidateGroups {
activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID) activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID)
groupKeyIDs, err := r.store.SMembers(activeKeySetKey) groupKeyIDs, err := r.store.SMembers(activeKeySetKey)
@@ -171,17 +194,21 @@ func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID str
r.logger.WithError(err).Warnf("Failed to get active keys for group %d during BasePool build", group.ID) r.logger.WithError(err).Warnf("Failed to get active keys for group %d during BasePool build", group.ID)
continue continue
} }
allActiveKeyIDs = append(allActiveKeyIDs, groupKeyIDs...)
for _, keyIDStr := range groupKeyIDs { for _, keyIDStr := range groupKeyIDs {
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64) keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
_, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID) _, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID)
if err == nil && mapping != nil { if err != nil {
var score float64 if errors.Is(err, store.ErrNotFound) || strings.Contains(err.Error(), "failed to get") {
if mapping.LastUsedAt != nil { r.logger.WithError(err).Warnf("Cache inconsistency detected for KeyID %s in GroupID %d. Skipping.", keyIDStr, group.ID)
score = float64(mapping.LastUsedAt.UnixMilli()) continue
} else {
return err
} }
lruMembers[keyIDStr] = score }
allActiveKeyIDs = append(allActiveKeyIDs, keyIDStr)
if mapping != nil && mapping.LastUsedAt != nil {
lruMembers[keyIDStr] = float64(mapping.LastUsedAt.UnixMilli())
} }
} }
} }
@@ -194,23 +221,16 @@ func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID str
} }
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
// 使用管道填充所有轮询结构
pipe := r.store.Pipeline() pipe := r.store.Pipeline()
// 1. 顺序
pipe.LPush(fmt.Sprintf(BasePoolSequential, poolID), toInterfaceSlice(allActiveKeyIDs)...) pipe.LPush(fmt.Sprintf(BasePoolSequential, poolID), toInterfaceSlice(allActiveKeyIDs)...)
// 2. 随机
pipe.SAdd(fmt.Sprintf(BasePoolRandomMain, poolID), toInterfaceSlice(allActiveKeyIDs)...) pipe.SAdd(fmt.Sprintf(BasePoolRandomMain, poolID), toInterfaceSlice(allActiveKeyIDs)...)
// 设置合理的过期时间例如5分钟以防止孤儿数据
pipe.Expire(fmt.Sprintf(BasePoolSequential, poolID), CacheTTL) pipe.Expire(fmt.Sprintf(BasePoolSequential, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolRandomMain, poolID), CacheTTL) pipe.Expire(fmt.Sprintf(BasePoolRandomMain, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolRandomCooldown, poolID), CacheTTL) pipe.Expire(fmt.Sprintf(BasePoolRandomCooldown, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolLRU, poolID), CacheTTL) pipe.Expire(fmt.Sprintf(BasePoolLRU, poolID), CacheTTL)
if err := pipe.Exec(); err != nil { if err := pipe.Exec(); err != nil {
return err return err
} }
if len(lruMembers) > 0 { if len(lruMembers) > 0 {
r.store.ZAdd(fmt.Sprintf(BasePoolLRU, poolID), lruMembers) r.store.ZAdd(fmt.Sprintf(BasePoolLRU, poolID), lruMembers)
} }
@@ -226,7 +246,7 @@ func (r *gormKeyRepository) updateKeyUsageTimestampForPool(poolID string, keyID
} }
// generatePoolID 根据候选组ID列表生成一个稳定的、唯一的字符串ID // generatePoolID 根据候选组ID列表生成一个稳定的、唯一的字符串ID
func generatePoolID(groups []*models.KeyGroup) string { func generatePoolID(groups []*models.KeyGroup, protocol string) string {
ids := make([]int, len(groups)) ids := make([]int, len(groups))
for i, g := range groups { for i, g := range groups {
ids[i] = int(g.ID) ids[i] = int(g.ID)
@@ -234,7 +254,7 @@ func generatePoolID(groups []*models.KeyGroup) string {
sort.Ints(ids) sort.Ints(ids)
h := sha1.New() h := sha1.New()
io.WriteString(h, fmt.Sprintf("%v", ids)) io.WriteString(h, fmt.Sprintf("protocol:%s;groups:%v", protocol, ids))
return fmt.Sprintf("%x", h.Sum(nil)) return fmt.Sprintf("%x", h.Sum(nil))
} }

View File

@@ -17,6 +17,7 @@ import (
type BasePool struct { type BasePool struct {
CandidateGroups []*models.KeyGroup CandidateGroups []*models.KeyGroup
PollingStrategy models.PollingStrategy PollingStrategy models.PollingStrategy
Protocol models.ProtocolType
} }
type KeyRepository interface { type KeyRepository interface {

View File

@@ -84,26 +84,24 @@ func (s *AnalyticsService) eventListener() {
} }
func (s *AnalyticsService) handleAnalyticsEvent(event *models.RequestFinishedEvent) { func (s *AnalyticsService) handleAnalyticsEvent(event *models.RequestFinishedEvent) {
if event.GroupID == 0 { if event.RequestLog.GroupID == nil {
return return
} }
key := fmt.Sprintf("analytics:hourly:%s", time.Now().UTC().Format("2006-01-02T15")) key := fmt.Sprintf("analytics:hourly:%s", time.Now().UTC().Format("2006-01-02T15"))
fieldPrefix := fmt.Sprintf("%d:%s", event.GroupID, event.ModelName) fieldPrefix := fmt.Sprintf("%d:%s", *event.RequestLog.GroupID, event.RequestLog.ModelName)
pipe := s.store.Pipeline() pipe := s.store.Pipeline()
pipe.HIncrBy(key, fieldPrefix+":requests", 1) pipe.HIncrBy(key, fieldPrefix+":requests", 1)
if event.IsSuccess { if event.RequestLog.IsSuccess {
pipe.HIncrBy(key, fieldPrefix+":success", 1) pipe.HIncrBy(key, fieldPrefix+":success", 1)
} }
if event.PromptTokens > 0 { if event.RequestLog.PromptTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.PromptTokens)) pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.RequestLog.PromptTokens))
} }
if event.CompletionTokens > 0 { if event.RequestLog.CompletionTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.CompletionTokens)) pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.RequestLog.CompletionTokens))
} }
if err := pipe.Exec(); err != nil { if err := pipe.Exec(); err != nil {
s.logger.Warnf("[%s] Failed to record analytics event to store for group %d: %v", event.CorrelationID, event.GroupID, err) s.logger.Warnf("[%s] Failed to record analytics event to store for group %d: %v", event.CorrelationID, *event.RequestLog.GroupID, err)
} }
} }

View File

@@ -174,49 +174,40 @@ func (s *APIKeyService) Stop() {
} }
func (s *APIKeyService) handleKeyUsageEvent(event *models.RequestFinishedEvent) { func (s *APIKeyService) handleKeyUsageEvent(event *models.RequestFinishedEvent) {
if event.KeyID == 0 || event.GroupID == 0 { if event.RequestLog.KeyID == nil || event.RequestLog.GroupID == nil {
return return
} }
// Handle success case: key recovery and timestamp update. if event.RequestLog.IsSuccess {
if event.IsSuccess { mapping, err := s.keyRepo.GetMapping(*event.RequestLog.GroupID, *event.RequestLog.KeyID)
mapping, err := s.keyRepo.GetMapping(event.GroupID, event.KeyID)
if err != nil { if err != nil {
// Log if mapping is not found, but don't proceed. s.logger.Warnf("[%s] Could not find mapping for G:%d K:%d on successful request: %v", event.CorrelationID, *event.RequestLog.GroupID, *event.RequestLog.KeyID, err)
s.logger.Warnf("[%s] Could not find mapping for G:%d K:%d on successful request: %v", event.CorrelationID, event.GroupID, event.KeyID, err)
return return
} }
statusChanged := false
needsUpdate := false
oldStatus := mapping.Status oldStatus := mapping.Status
// If status was not active, it's a recovery.
if mapping.Status != models.StatusActive { if mapping.Status != models.StatusActive {
mapping.Status = models.StatusActive mapping.Status = models.StatusActive
mapping.ConsecutiveErrorCount = 0 mapping.ConsecutiveErrorCount = 0
mapping.LastError = "" mapping.LastError = ""
needsUpdate = true statusChanged = true
} }
// Always update LastUsedAt timestamp.
now := time.Now() now := time.Now()
mapping.LastUsedAt = &now mapping.LastUsedAt = &now
needsUpdate = true if err := s.keyRepo.UpdateMapping(mapping); err != nil {
s.logger.Errorf("[%s] Failed to update mapping for G:%d K:%d after successful request: %v", event.CorrelationID, *event.RequestLog.GroupID, *event.RequestLog.KeyID, err)
if needsUpdate { return
if err := s.keyRepo.UpdateMapping(mapping); err != nil { }
s.logger.Errorf("[%s] Failed to update mapping for G:%d K:%d after successful request: %v", event.CorrelationID, event.GroupID, event.KeyID, err) if statusChanged {
} else if oldStatus != models.StatusActive { go s.publishStatusChangeEvent(*event.RequestLog.GroupID, *event.RequestLog.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
// Only publish event if status actually changed.
go s.publishStatusChangeEvent(event.GroupID, event.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
}
} }
return return
} }
// Handle failure case: delegate to the centralized judgment function.
if event.Error != nil { if event.Error != nil {
s.judgeKeyErrors( s.judgeKeyErrors(
event.CorrelationID, event.CorrelationID,
event.GroupID, *event.RequestLog.GroupID,
event.KeyID, *event.RequestLog.KeyID,
event.Error, event.Error,
event.IsPreciseRouting, event.IsPreciseRouting,
) )
@@ -354,6 +345,10 @@ func (s *APIKeyService) ListAPIKeys(params *models.APIKeyQueryParams) (*Paginate
}, nil }, nil
} }
func (s *APIKeyService) GetKeysByIds(ids []uint) ([]models.APIKey, error) {
return s.keyRepo.GetKeysByIDs(ids)
}
func (s *APIKeyService) UpdateAPIKey(key *models.APIKey) error { func (s *APIKeyService) UpdateAPIKey(key *models.APIKey) error {
go func() { go func() {
var oldKey models.APIKey var oldKey models.APIKey

View File

@@ -1,4 +1,4 @@
// Filename: internal/service/db_log_writer_service.go (全新文件) // Filename: internal/service/db_log_writer_service.go
package service package service

View File

@@ -164,12 +164,15 @@ func (s *KeyValidationService) runTestKeysTask(taskID string, resourceID string,
var currentResult models.KeyTestResult var currentResult models.KeyTestResult
event := models.RequestFinishedEvent{ event := models.RequestFinishedEvent{
GroupID: groupID, RequestLog: models.RequestLog{
KeyID: apiKeyModel.ID, // GroupID 和 KeyID 在 RequestLog 模型中是指针,需要取地址
GroupID: &groupID,
KeyID: &apiKeyModel.ID,
},
} }
if validationErr == nil { if validationErr == nil {
currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "valid", Message: "Validation successful."} currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "valid", Message: "Validation successful."}
event.IsSuccess = true event.RequestLog.IsSuccess = true
} else { } else {
var apiErr *CustomErrors.APIError var apiErr *CustomErrors.APIError
if CustomErrors.As(validationErr, &apiErr) { if CustomErrors.As(validationErr, &apiErr) {
@@ -179,7 +182,7 @@ func (s *KeyValidationService) runTestKeysTask(taskID string, resourceID string,
currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "error", Message: "Validation check failed: " + validationErr.Error()} currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "error", Message: "Validation check failed: " + validationErr.Error()}
event.Error = &CustomErrors.APIError{Message: validationErr.Error()} event.Error = &CustomErrors.APIError{Message: validationErr.Error()}
} }
event.IsSuccess = false event.RequestLog.IsSuccess = false
} }
eventData, _ := json.Marshal(event) eventData, _ := json.Marshal(event)
if err := s.store.Publish(models.TopicRequestFinished, eventData); err != nil { if err := s.store.Publish(models.TopicRequestFinished, eventData); err != nil {

View File

@@ -1,3 +1,4 @@
// Filename: internal/service/log_service.go
package service package service
import ( import (
@@ -16,28 +17,35 @@ func NewLogService(db *gorm.DB) *LogService {
return &LogService{db: db} return &LogService{db: db}
} }
// Record 记录一条日志到数据库 (TODO 暂时保留简单实现,后续再重构为异步)
func (s *LogService) Record(log *models.RequestLog) error { func (s *LogService) Record(log *models.RequestLog) error {
return s.db.Create(log).Error return s.db.Create(log).Error
} }
func (s *LogService) GetLogs(c *gin.Context) ([]models.RequestLog, error) { func (s *LogService) GetLogs(c *gin.Context) ([]models.RequestLog, int64, error) {
var logs []models.RequestLog var logs []models.RequestLog
var total int64
query := s.db.Model(&models.RequestLog{}).Scopes(s.filtersScope(c)).Order("request_time desc") query := s.db.Model(&models.RequestLog{}).Scopes(s.filtersScope(c))
// 简单的分页 ( TODO 后续可以做得更复杂) // 先计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []models.RequestLog{}, 0, nil
}
// 再执行分页查询
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
// 执行查询 err := query.Order("request_time desc").Limit(pageSize).Offset(offset).Find(&logs).Error
err := query.Limit(pageSize).Offset(offset).Find(&logs).Error
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
return logs, nil return logs, total, nil
} }
func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB { func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
@@ -60,6 +68,11 @@ func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
db = db.Where("key_id = ?", keyID) db = db.Where("key_id = ?", keyID)
} }
} }
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
if groupID, err := strconv.ParseUint(groupIDStr, 10, 64); err == nil {
db = db.Where("group_id = ?", groupID)
}
}
return db return db
} }
} }

BIN
tmp/main

Binary file not shown.

View File

@@ -332,9 +332,6 @@
.pointer-events-none { .pointer-events-none {
pointer-events: none; pointer-events: none;
} }
.collapse {
visibility: collapse;
}
.invisible { .invisible {
visibility: hidden; visibility: hidden;
} }
@@ -490,9 +487,6 @@
.m-0 { .m-0 {
margin: calc(var(--spacing) * 0); margin: calc(var(--spacing) * 0);
} }
.m-7 {
margin: calc(var(--spacing) * 7);
}
.mx-1 { .mx-1 {
margin-inline: calc(var(--spacing) * 1); margin-inline: calc(var(--spacing) * 1);
} }
@@ -505,9 +499,6 @@
.my-1\.5 { .my-1\.5 {
margin-block: calc(var(--spacing) * 1.5); margin-block: calc(var(--spacing) * 1.5);
} }
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-0\.5 { .mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5); margin-top: calc(var(--spacing) * 0.5);
} }
@@ -604,9 +595,6 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@@ -629,9 +617,6 @@
width: calc(var(--spacing) * 6); width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6);
} }
.h-0 {
height: calc(var(--spacing) * 0);
}
.h-0\.5 { .h-0\.5 {
height: calc(var(--spacing) * 0.5); height: calc(var(--spacing) * 0.5);
} }
@@ -713,9 +698,6 @@
.w-0 { .w-0 {
width: calc(var(--spacing) * 0); width: calc(var(--spacing) * 0);
} }
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/4 { .w-1\/4 {
width: calc(1/4 * 100%); width: calc(1/4 * 100%);
} }
@@ -821,9 +803,6 @@
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
.flex-shrink {
flex-shrink: 1;
}
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -836,9 +815,6 @@
.caption-bottom { .caption-bottom {
caption-side: bottom; caption-side: bottom;
} }
.border-collapse {
border-collapse: collapse;
}
.origin-center { .origin-center {
transform-origin: center; transform-origin: center;
} }
@@ -865,10 +841,6 @@
--tw-translate-x: 100%; --tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y); 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 { .-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1); --tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1025,9 +997,6 @@
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); 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 { .gap-x-1\.5 {
column-gap: calc(var(--spacing) * 1.5); column-gap: calc(var(--spacing) * 1.5);
} }
@@ -1173,9 +1142,6 @@
--tw-border-style: none; --tw-border-style: none;
border-style: none; border-style: none;
} }
.border-black {
border-color: var(--color-black);
}
.border-black\/10 { .border-black\/10 {
border-color: color-mix(in srgb, #000 10%, transparent); border-color: color-mix(in srgb, #000 10%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1203,9 +1169,6 @@
.border-green-200 { .border-green-200 {
border-color: var(--color-green-200); border-color: var(--color-green-200);
} }
.border-primary {
border-color: var(--color-primary);
}
.border-primary\/20 { .border-primary\/20 {
border-color: var(--color-primary); border-color: var(--color-primary);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1242,9 +1205,6 @@
.border-zinc-300 { .border-zinc-300 {
border-color: var(--color-zinc-300); border-color: var(--color-zinc-300);
} }
.border-zinc-700 {
border-color: var(--color-zinc-700);
}
.border-zinc-700\/50 { .border-zinc-700\/50 {
border-color: color-mix(in srgb, oklch(37% 0.013 285.805) 50%, transparent); border-color: color-mix(in srgb, oklch(37% 0.013 285.805) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1257,9 +1217,6 @@
.border-b-border { .border-b-border {
border-bottom-color: var(--color-border); border-bottom-color: var(--color-border);
} }
.border-b-zinc-200 {
border-bottom-color: var(--color-zinc-200);
}
.bg-accent { .bg-accent {
background-color: var(--color-accent); background-color: var(--color-accent);
} }
@@ -1320,9 +1277,6 @@
.bg-gray-500 { .bg-gray-500 {
background-color: var(--color-gray-500); background-color: var(--color-gray-500);
} }
.bg-gray-950 {
background-color: var(--color-gray-950);
}
.bg-gray-950\/5 { .bg-gray-950\/5 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 5%, transparent); background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 5%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1413,15 +1367,6 @@
.bg-purple-100 { .bg-purple-100 {
background-color: var(--color-purple-100); background-color: var(--color-purple-100);
} }
.bg-purple-500 {
background-color: var(--color-purple-500);
}
.bg-purple-500\/10 {
background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-purple-500) 10%, transparent);
}
}
.bg-red-50 { .bg-red-50 {
background-color: var(--color-red-50); background-color: var(--color-red-50);
} }
@@ -1543,10 +1488,6 @@
--tw-gradient-position: to right in oklab; --tw-gradient-position: to right in oklab;
background-image: linear-gradient(var(--tw-gradient-stops)); 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 { .from-blue-500\/30 {
--tw-gradient-from: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent); --tw-gradient-from: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1617,9 +1558,6 @@
.px-8 { .px-8 {
padding-inline: calc(var(--spacing) * 8); padding-inline: calc(var(--spacing) * 8);
} }
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 { .py-0\.5 {
padding-block: calc(var(--spacing) * 0.5); padding-block: calc(var(--spacing) * 0.5);
} }
@@ -1668,9 +1606,6 @@
.pr-20 { .pr-20 {
padding-right: calc(var(--spacing) * 20); padding-right: calc(var(--spacing) * 20);
} }
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-1\.5 { .pb-1\.5 {
padding-bottom: calc(var(--spacing) * 1.5); padding-bottom: calc(var(--spacing) * 1.5);
} }
@@ -1710,9 +1645,6 @@
.align-middle { .align-middle {
vertical-align: middle; vertical-align: middle;
} }
.font-\[\'Pixelify_Sans\'\] {
font-family: 'Pixelify Sans';
}
.font-mono { .font-mono {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -1858,9 +1790,6 @@
.text-green-800 { .text-green-800 {
color: var(--color-green-800); color: var(--color-green-800);
} }
.text-indigo-500 {
color: var(--color-indigo-500);
}
.text-indigo-800 { .text-indigo-800 {
color: var(--color-indigo-800); color: var(--color-indigo-800);
} }
@@ -1885,9 +1814,6 @@
.text-primary-foreground { .text-primary-foreground {
color: var(--color-primary-foreground); color: var(--color-primary-foreground);
} }
.text-purple-600 {
color: var(--color-purple-600);
}
.text-purple-800 { .text-purple-800 {
color: var(--color-purple-800); color: var(--color-purple-800);
} }
@@ -1924,9 +1850,6 @@
.text-yellow-600 { .text-yellow-600 {
color: var(--color-yellow-600); color: var(--color-yellow-600);
} }
.text-yellow-700 {
color: var(--color-yellow-700);
}
.text-zinc-100 { .text-zinc-100 {
color: var(--color-zinc-100); color: var(--color-zinc-100);
} }
@@ -1957,9 +1880,6 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.underline {
text-decoration-line: underline;
}
.opacity-0 { .opacity-0 {
opacity: 0%; opacity: 0%;
} }
@@ -2017,10 +1937,6 @@
--tw-inset-shadow: inset 0 2px 4px var(--tw-inset-shadow-color, oklab(from rgb(0 0 0 / 0.05) l a b / 25%)); --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); 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 { .ring-black {
--tw-ring-color: var(--color-black); --tw-ring-color: var(--color-black);
} }
@@ -2042,10 +1958,6 @@
--tw-ring-color: color-mix(in oklab, var(--color-black) 15%, transparent); --tw-ring-color: color-mix(in oklab, var(--color-black) 15%, transparent);
} }
} }
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.blur { .blur {
--tw-blur: blur(8px); --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,); 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,);
@@ -2144,9 +2056,6 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.\[rows\:\%v\] {
rows: %v;
}
.group-hover\:opacity-100 { .group-hover\:opacity-100 {
&:is(:where(.group):hover *) { &:is(:where(.group):hover *) {
@media (hover: hover) { @media (hover: hover) {
@@ -2366,16 +2275,6 @@
} }
} }
} }
.hover\:bg-zinc-100\/50 {
&:hover {
@media (hover: hover) {
background-color: color-mix(in srgb, oklch(96.7% 0.001 286.375) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-zinc-100) 50%, transparent);
}
}
}
}
.hover\:bg-zinc-200 { .hover\:bg-zinc-200 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -2777,16 +2676,6 @@
border-color: var(--color-zinc-800); border-color: var(--color-zinc-800);
} }
} }
.dark\:border-b-zinc-700 {
&:where(.dark, .dark *) {
border-bottom-color: var(--color-zinc-700);
}
}
.dark\:border-b-zinc-800 {
&:where(.dark, .dark *) {
border-bottom-color: var(--color-zinc-800);
}
}
.dark\:bg-blue-900 { .dark\:bg-blue-900 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
background-color: var(--color-blue-900); background-color: var(--color-blue-900);
@@ -3038,11 +2927,6 @@
color: var(--color-white); color: var(--color-white);
} }
} }
.dark\:text-yellow-400 {
&:where(.dark, .dark *) {
color: var(--color-yellow-400);
}
}
.dark\:text-zinc-100 { .dark\:text-zinc-100 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
color: var(--color-zinc-100); color: var(--color-zinc-100);
@@ -3147,18 +3031,6 @@
} }
} }
} }
.dark\:hover\:bg-zinc-800\/50 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: color-mix(in srgb, oklch(27.4% 0.006 286.033) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-zinc-800) 50%, transparent);
}
}
}
}
}
.dark\:hover\:text-blue-300 { .dark\:hover\:text-blue-300 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:hover { &:hover {
@@ -5160,11 +5032,6 @@
inherits: false; inherits: false;
initial-value: 0 0 #0000; initial-value: 0 0 #0000;
} }
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur { @property --tw-blur {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -5277,6 +5144,11 @@
inherits: false; inherits: false;
initial-value: 1; initial-value: 1;
} }
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -5334,7 +5206,6 @@
--tw-ring-offset-width: 0px; --tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff; --tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000; --tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-blur: initial; --tw-blur: initial;
--tw-brightness: initial; --tw-brightness: initial;
--tw-contrast: initial; --tw-contrast: initial;
@@ -5362,6 +5233,7 @@
--tw-scale-x: 1; --tw-scale-x: 1;
--tw-scale-y: 1; --tw-scale-y: 1;
--tw-scale-z: 1; --tw-scale-z: 1;
--tw-outline-style: solid;
} }
} }
} }

View File

@@ -0,0 +1,52 @@
// frontend/js/utils/utils.js
function debounce(func, wait) {
let timeout;
const debounced = function(...args) {
const context = this;
const later = () => {
clearTimeout(timeout);
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
function isValidApiKeyFormat(key) {
const patterns = [
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
/^AIzaSy[\w-]{33}$/,
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
/^sk-[\w]{48}$/,
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
/^gsk_[\w-]{40,}$/,
// Anthropic API Key (示例): sk-ant-api03- + long string
/^sk-ant-api\d{2}-[\w-]{80,}$/,
// Fallback for other potential "sk-" keys with a reasonable length
/^sk-[\w-]{20,}$/
];
return patterns.some((pattern) => pattern.test(key));
}
function escapeHTML(str) {
if (typeof str !== "string") {
return str;
}
return str.replace(/[&<>"']/g, function(match) {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[match];
});
}
export {
debounce,
isValidApiKeyFormat,
escapeHTML
};

View File

@@ -4,6 +4,11 @@ import {
taskCenterManager, taskCenterManager,
toastManager toastManager
} from "./chunk-EZAP7GR4.js"; } from "./chunk-EZAP7GR4.js";
import {
debounce,
escapeHTML,
isValidApiKeyFormat
} from "./chunk-A4OOMLXK.js";
import { import {
apiFetch, apiFetch,
apiFetchJson apiFetchJson
@@ -670,53 +675,6 @@ var ApiKeyManager = class {
}; };
var apiKeyManager = new ApiKeyManager(); var apiKeyManager = new ApiKeyManager();
// frontend/js/utils/utils.js
function debounce(func, wait) {
let timeout;
const debounced = function(...args) {
const context = this;
const later = () => {
clearTimeout(timeout);
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
function isValidApiKeyFormat(key) {
const patterns = [
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
/^AIzaSy[\w-]{33}$/,
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
/^sk-[\w]{48}$/,
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
/^gsk_[\w-]{40,}$/,
// Anthropic API Key (示例): sk-ant-api03- + long string
/^sk-ant-api\d{2}-[\w-]{80,}$/,
// Fallback for other potential "sk-" keys with a reasonable length
/^sk-[\w-]{20,}$/
];
return patterns.some((pattern) => pattern.test(key));
}
function escapeHTML(str) {
if (typeof str !== "string") {
return str;
}
return str.replace(/[&<>"']/g, function(match) {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[match];
});
}
// frontend/js/pages/keys/addApiModal.js // frontend/js/pages/keys/addApiModal.js
var AddApiModal = class { var AddApiModal = class {
constructor({ onImportSuccess }) { constructor({ onImportSuccess }) {

View File

@@ -1,3 +1,6 @@
import {
escapeHTML
} from "./chunk-A4OOMLXK.js";
import { import {
apiFetchJson apiFetchJson
} from "./chunk-PLQL6WIO.js"; } from "./chunk-PLQL6WIO.js";
@@ -27,7 +30,6 @@ var STATUS_CODE_MAP = {
}; };
var SPECIAL_CASE_MAP = [ var SPECIAL_CASE_MAP = [
{ code: 400, keyword: "api key not found", type: "\u65E0\u6548\u5BC6\u94A5", style: "red" }, { 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" } { code: 404, keyword: "call listmodels", type: "\u6A21\u578B\u914D\u7F6E\u9519\u8BEF", style: "orange" }
]; ];
var styleToClass = (style) => { var styleToClass = (style) => {
@@ -46,8 +48,9 @@ var styleToClass = (style) => {
}; };
var errorCodeRegex = /(\d+)$/; var errorCodeRegex = /(\d+)$/;
var LogList = class { var LogList = class {
constructor(container) { constructor(container, dataStore2) {
this.container = container; this.container = container;
this.dataStore = dataStore2;
if (!this.container) console.error("LogList: container element (tbody) not found."); if (!this.container) console.error("LogList: container element (tbody) not found.");
} }
renderLoading() { renderLoading() {
@@ -115,8 +118,16 @@ var LogList = class {
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>`; 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) {
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : "N/A"); const group = this.dataStore.groups.get(log.GroupID);
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : "N/A"); 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 errorInfo = this._interpretError(log);
const modelNameFormatted = this._formatModelName(log.ModelName); const modelNameFormatted = this._formatModelName(log.ModelName);
const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : ""; const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : "";
@@ -125,7 +136,7 @@ var LogList = class {
<tr class="table-row" data-log-id="${log.ID}" ${errorMessageAttr}> <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"></td>
<td class="table-cell font-mono text-muted-foreground">${index}</td> <td class="table-cell font-mono text-muted-foreground">${index}</td>
<td class="table-cell font-medium font-mono">${apiKeyName}</td> <td class="table-cell font-medium font-mono">${apiKeyDisplay}</td>
<td class="table-cell">${groupName}</td> <td class="table-cell">${groupName}</td>
<td class="table-cell">${errorInfo.type}</td> <td class="table-cell">${errorInfo.type}</td>
<td class="table-cell">${errorInfo.statusCodeHtml}</td> <td class="table-cell">${errorInfo.statusCodeHtml}</td>
@@ -143,12 +154,15 @@ var LogList = class {
var logList_default = LogList; var logList_default = LogList;
// frontend/js/pages/logs/index.js // frontend/js/pages/logs/index.js
var dataStore = {
groups: /* @__PURE__ */ new Map(),
keys: /* @__PURE__ */ new Map()
};
var LogsPage = class { var LogsPage = class {
constructor() { constructor() {
this.state = { this.state = {
logs: [], logs: [],
pagination: { page: 1, pages: 1, total: 0, page_size: 20 }, pagination: { page: 1, pages: 1, total: 0, page_size: 20 },
// 包含 page_size
isLoading: true, isLoading: true,
filters: { page: 1, page_size: 20 } filters: { page: 1, page_size: 20 }
}; };
@@ -157,38 +171,41 @@ var LogsPage = class {
}; };
this.initialized = !!this.elements.tableBody; this.initialized = !!this.elements.tableBody;
if (this.initialized) { if (this.initialized) {
this.logList = new logList_default(this.elements.tableBody); this.logList = new logList_default(this.elements.tableBody, dataStore);
} }
} }
async init() { async init() {
if (!this.initialized) { if (!this.initialized) return;
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
return;
}
this.initEventListeners(); this.initEventListeners();
await this.loadGroupsOnce();
await this.loadAndRenderLogs(); await this.loadAndRenderLogs();
} }
initEventListeners() { 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() { async loadAndRenderLogs() {
this.state.isLoading = true; this.state.isLoading = true;
this.logList.renderLoading(); this.logList.renderLoading();
try { try {
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`; const query = new URLSearchParams(this.state.filters);
const responseData = await apiFetchJson(url); const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
if (responseData && responseData.success && Array.isArray(responseData.data)) { if (success && typeof data === "object") {
this.state.logs = responseData.data; const { items, total, page, page_size } = data;
this.state.pagination = { this.state.logs = items;
page: this.state.filters.page, this.state.pagination = { page, page_size, total, pages: Math.ceil(total / page_size) };
page_size: this.state.filters.page_size, await this.enrichLogsWithKeyNames(items);
total: responseData.data.length,
// 这是一个不准确的临时值
pages: Math.ceil(responseData.data.length / this.state.filters.page_size)
// 同样不准确
};
this.logList.render(this.state.logs, this.state.pagination); this.logList.render(this.state.logs, this.state.pagination);
} else { } else {
console.error("API response for logs is incorrect:", responseData);
this.logList.render([], this.state.pagination); this.logList.render([], this.state.pagination);
} }
} catch (error) { } catch (error) {
@@ -198,6 +215,21 @@ var LogsPage = class {
this.state.isLoading = false; 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() { function logs_default() {
const page = new LogsPage(); const page = new LogsPage();

View File

@@ -180,8 +180,8 @@ var pageModules = {
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise // 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件 // esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
"dashboard": () => import("./dashboard-CJJWKYPR.js"), "dashboard": () => import("./dashboard-CJJWKYPR.js"),
"keys": () => import("./keys-A2UAJYOX.js"), "keys": () => import("./keys-4GCIJ7HW.js"),
"logs": () => import("./logs-4C4JG7BT.js") "logs": () => import("./logs-AG4TD2DO.js")
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面 // 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
// 未来新增的页面只需在这里添加一行映射esbuild会自动处理 // 未来新增的页面只需在这里添加一行映射esbuild会自动处理
}; };