Fix loglist
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.vscode/
|
||||
.idea/
|
||||
tmp/
|
||||
|
||||
*.code-workspace
|
||||
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
import { apiFetchJson } from '../../services/api.js';
|
||||
import LogList from './logList.js';
|
||||
|
||||
// [最终版] 创建一个共享的数据仓库,用于缓存 Groups 和 Keys
|
||||
const dataStore = {
|
||||
groups: new Map(),
|
||||
keys: new Map(),
|
||||
};
|
||||
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
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,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
@@ -19,21 +25,30 @@ class LogsPage {
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
|
||||
if (this.initialized) {
|
||||
this.logList = new LogList(this.elements.tableBody);
|
||||
this.logList = new LogList(this.elements.tableBody, dataStore);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
if (!this.initialized) return;
|
||||
this.initEventListeners();
|
||||
// 页面初始化:先加载群组,再加载日志
|
||||
await this.loadGroupsOnce();
|
||||
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() {
|
||||
@@ -41,36 +56,45 @@ class LogsPage {
|
||||
this.logList.renderLoading();
|
||||
|
||||
try {
|
||||
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
const query = new URLSearchParams(this.state.filters);
|
||||
const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
|
||||
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
this.state.logs = responseData.data;
|
||||
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) };
|
||||
|
||||
// [假设] 由于当前响应不包含分页信息,我们基于请求和返回的数据来模拟
|
||||
// TODO: 当后端API返回分页对象时,替换此处的模拟数据
|
||||
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) // 同样不准确
|
||||
};
|
||||
// [核心] 在渲染前,按需批量加载本页日志所需的、尚未缓存的Key信息
|
||||
await this.enrichLogsWithKeyNames(items);
|
||||
|
||||
// [修改] 将分页状态传递给 render 方法
|
||||
// 调用 render,此时 dataStore 中已包含所有需要的数据
|
||||
this.logList.render(this.state.logs, this.state.pagination);
|
||||
|
||||
} else {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([], this.state.pagination);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Filename: frontend/js/pages/logs/logList.js
|
||||
// --- [扩展] 静态错误码与样式的映射表 (源自Gemini官方文档) ---
|
||||
import { escapeHTML } from '../../utils/utils.js';
|
||||
|
||||
const STATIC_ERROR_MAP = {
|
||||
'API_KEY_INVALID': { type: '密钥无效', style: 'red' },
|
||||
'INVALID_ARGUMENT': { type: '参数无效', style: 'red' },
|
||||
@@ -23,10 +24,8 @@ const STATUS_CODE_MAP = {
|
||||
500: { type: '内部服务错误', style: 'yellow' },
|
||||
503: { type: '服务不可用', style: 'yellow' }
|
||||
};
|
||||
// --- [新增] 特殊场景判断规则 (高优先级) ---
|
||||
const SPECIAL_CASE_MAP = [
|
||||
{ code: 400, keyword: 'api key not found', type: '无效密钥', style: 'red' },
|
||||
// 之前实现的模型配置错误规则也可以移到这里,更加规范
|
||||
{ code: 404, keyword: 'call listmodels', type: '模型配置错误', style: 'orange' }
|
||||
];
|
||||
|
||||
@@ -41,16 +40,13 @@ const styleToClass = (style) => {
|
||||
}
|
||||
};
|
||||
|
||||
// [修正] 修正了正则表达式的名称,使其语义清晰
|
||||
|
||||
const errorCodeRegex = /(\d+)$/;
|
||||
|
||||
// [修正] 移除了 MODEL_STYLE_MAP 的声明,因为它未在 _formatModelName 中使用
|
||||
// 如果未来需要,可以重新添加
|
||||
// const MODEL_STYLE_MAP = { ... };
|
||||
|
||||
class LogList {
|
||||
constructor(container) {
|
||||
constructor(container, dataStore) {
|
||||
this.container = container;
|
||||
this.dataStore = dataStore;
|
||||
if (!this.container) console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
|
||||
@@ -125,15 +121,21 @@ class LogList {
|
||||
}
|
||||
|
||||
_formatModelName(modelName) {
|
||||
// [修正] 移除了对 MODEL_STYLE_MAP 的依赖,简化为统一的样式
|
||||
// 这样可以避免因 MODEL_STYLE_MAP 未定义而产生的潜在错误
|
||||
const styleClass = ''; // 可根据需要添加回样式逻辑
|
||||
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 groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : 'N/A');
|
||||
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)}"` : '';
|
||||
@@ -142,7 +144,7 @@ class LogList {
|
||||
<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">${apiKeyName}</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>
|
||||
@@ -158,5 +160,4 @@ class LogList {
|
||||
}
|
||||
}
|
||||
|
||||
// [核心修正] 移除了文件末尾所有多余的代码,只保留最核心的默认导出
|
||||
export default LogList;
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/task"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -160,6 +162,29 @@ func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error()))
|
||||
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 {
|
||||
params.Page = 1
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LogHandler 负责处理与日志相关的HTTP请求
|
||||
type LogHandler struct {
|
||||
logService *service.LogService
|
||||
}
|
||||
@@ -20,14 +19,22 @@ func NewLogHandler(logService *service.LogService) *LogHandler {
|
||||
}
|
||||
|
||||
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 {
|
||||
response.Error(c, errors.ErrDatabase)
|
||||
return
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []models.RequestLog{}
|
||||
}
|
||||
response.Success(c, logs)
|
||||
|
||||
// 解析分页参数用于响应体
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 使用标准的分页响应结构
|
||||
response.Success(c, gin.H{
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,34 +136,48 @@ func (h *ProxyHandler) serveTransparentProxy(c *gin.Context, requestBody []byte,
|
||||
var finalPromptTokens, finalCompletionTokens int
|
||||
var actualRetries int = 0
|
||||
defer func() {
|
||||
// 如果一次尝试都未成功(例如,在第一次获取资源时就失败),则不记录日志
|
||||
if lastUsedResources == nil {
|
||||
h.logger.WithField("id", correlationID).Warn("No resources were used, skipping final log event.")
|
||||
return
|
||||
}
|
||||
finalEvent := h.createLogEvent(c, startTime, correlationID, modelName, lastUsedResources, models.LogTypeFinal, isPreciseRouting)
|
||||
finalEvent.LatencyMs = int(time.Since(startTime).Milliseconds())
|
||||
finalEvent.IsSuccess = isSuccess
|
||||
finalEvent.Retries = actualRetries
|
||||
|
||||
finalEvent.RequestLog.LatencyMs = int(time.Since(startTime).Milliseconds())
|
||||
finalEvent.RequestLog.IsSuccess = isSuccess
|
||||
finalEvent.RequestLog.Retries = actualRetries
|
||||
if isSuccess {
|
||||
finalEvent.PromptTokens = finalPromptTokens
|
||||
finalEvent.CompletionTokens = finalCompletionTokens
|
||||
finalEvent.RequestLog.PromptTokens = finalPromptTokens
|
||||
finalEvent.RequestLog.CompletionTokens = finalCompletionTokens
|
||||
}
|
||||
|
||||
// 确保即使在成功的情况下,如果recorder存在,也记录最终的状态码
|
||||
if finalRecorder != nil {
|
||||
finalEvent.StatusCode = finalRecorder.Code
|
||||
finalEvent.RequestLog.StatusCode = finalRecorder.Code
|
||||
}
|
||||
if !isSuccess {
|
||||
// 将 finalProxyErr 的信息填充到 RequestLog 中
|
||||
if finalProxyErr != nil {
|
||||
finalEvent.Error = finalProxyErr
|
||||
finalEvent.ErrorCode = finalProxyErr.Code
|
||||
finalEvent.ErrorMessage = finalProxyErr.Message
|
||||
finalEvent.Error = finalProxyErr // Error 字段用于事件传递,不会被序列化到数据库
|
||||
finalEvent.RequestLog.ErrorCode = finalProxyErr.Code
|
||||
finalEvent.RequestLog.ErrorMessage = finalProxyErr.Message
|
||||
} 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.ErrorCode = apiErr.Code
|
||||
finalEvent.ErrorMessage = apiErr.Message
|
||||
finalEvent.RequestLog.ErrorCode = apiErr.Code
|
||||
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
|
||||
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 authToken, ok := authTokenValue.(*models.AuthToken); ok {
|
||||
event.AuthTokenID = &authToken.ID
|
||||
event.RequestLog.AuthTokenID = &authToken.ID
|
||||
}
|
||||
}
|
||||
if res != nil {
|
||||
event.KeyID = res.APIKey.ID
|
||||
event.GroupID = res.KeyGroup.ID
|
||||
// [核心修正] 填充到内嵌的 RequestLog 结构体中
|
||||
if res.APIKey != nil {
|
||||
event.RequestLog.KeyID = &res.APIKey.ID
|
||||
}
|
||||
if res.KeyGroup != nil {
|
||||
event.RequestLog.GroupID = &res.KeyGroup.ID
|
||||
}
|
||||
if res.UpstreamEndpoint != nil {
|
||||
event.UpstreamID = &res.UpstreamEndpoint.ID
|
||||
event.RequestLog.UpstreamID = &res.UpstreamEndpoint.ID
|
||||
// UpstreamURL 是事件传递字段,不是数据库字段,所以在这里赋值是正确的
|
||||
event.UpstreamURL = &res.UpstreamEndpoint.URL
|
||||
}
|
||||
if res.ProxyConfig != nil {
|
||||
event.ProxyID = &res.ProxyConfig.ID
|
||||
event.RequestLog.ProxyID = &res.ProxyConfig.ID
|
||||
}
|
||||
}
|
||||
return event
|
||||
|
||||
@@ -57,6 +57,7 @@ type APIKeyQueryParams struct {
|
||||
PageSize int `form:"limit"`
|
||||
Status string `form:"status"`
|
||||
Keyword string `form:"keyword"`
|
||||
IDs string `form:"ids"`
|
||||
}
|
||||
|
||||
// APIKeyDetails is a DTO that combines APIKey info with its contextual status from the mapping.
|
||||
|
||||
@@ -17,15 +17,10 @@ const (
|
||||
|
||||
type RequestFinishedEvent struct {
|
||||
RequestLog
|
||||
KeyID uint
|
||||
GroupID uint
|
||||
IsSuccess bool
|
||||
StatusCode int
|
||||
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"`
|
||||
Error *errors.APIError `json:"error,omitempty"` // Error 结构体不存入数据库,仅供事件传递
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
UpstreamURL *string `json:"upstream_url,omitempty"`
|
||||
IsPreciseRouting bool `json:"is_precise_routing"`
|
||||
}
|
||||
|
||||
type KeyStatusChangedEvent struct {
|
||||
|
||||
@@ -14,6 +14,7 @@ type MasterAPIKeyStatus string
|
||||
type PollingStrategy string
|
||||
type FileProcessingState string
|
||||
type LogType string
|
||||
type ProtocolType string
|
||||
|
||||
const (
|
||||
// --- 运营状态 (在中间表中使用) ---
|
||||
@@ -35,8 +36,12 @@ const (
|
||||
FileActive FileProcessingState = "ACTIVE"
|
||||
FileFailed FileProcessingState = "FAILED"
|
||||
|
||||
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.
|
||||
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.
|
||||
LogTypeValidation LogType = "VALIDATION"
|
||||
|
||||
ProtocolOpenAI ProtocolType = "openai"
|
||||
ProtocolGemini ProtocolType = "gemini"
|
||||
)
|
||||
|
||||
// ========= 核心数据库模型 =========
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -81,13 +82,20 @@ func (r *gormKeyRepository) SelectOneActiveKey(group *models.KeyGroup) (*models.
|
||||
|
||||
// SelectOneActiveKeyFromBasePool 为智能聚合模式设计的全新轮询器。
|
||||
func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error) {
|
||||
protocol := "default"
|
||||
if pool.Protocol != "" {
|
||||
protocol = string(pool.Protocol)
|
||||
}
|
||||
// 生成唯一的池ID,确保不同请求组合的轮询状态相互隔离
|
||||
poolID := generatePoolID(pool.CandidateGroups)
|
||||
log := r.logger.WithField("pool_id", poolID)
|
||||
poolID := generatePoolID(pool.CandidateGroups, protocol)
|
||||
log := r.logger.WithField("pool_id", poolID).WithField("protocol", protocol)
|
||||
|
||||
if err := r.ensureBasePoolCacheExists(pool, poolID); err != nil {
|
||||
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
|
||||
@@ -145,25 +153,40 @@ func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*mod
|
||||
|
||||
// ensureBasePoolCacheExists 动态创建 BasePool 的 Redis 结构
|
||||
func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID string) error {
|
||||
// 使用 LIST 键作为存在性检查的标志
|
||||
listKey := fmt.Sprintf(BasePoolSequential, poolID)
|
||||
exists, err := r.store.Exists(listKey)
|
||||
if err != nil {
|
||||
r.logger.WithError(err).Errorf("Failed to check existence of basepool key: %s", listKey)
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
|
||||
val, err := r.store.LIndex(listKey, 0)
|
||||
if err == nil && val == EmptyPoolPlaceholder {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if val == EmptyPoolPlaceholder {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
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)
|
||||
|
||||
var allActiveKeyIDs []string
|
||||
lruMembers := make(map[string]float64)
|
||||
|
||||
for _, group := range pool.CandidateGroups {
|
||||
activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
allActiveKeyIDs = append(allActiveKeyIDs, groupKeyIDs...)
|
||||
|
||||
for _, keyIDStr := range groupKeyIDs {
|
||||
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
|
||||
|
||||
_, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID)
|
||||
if err == nil && mapping != nil {
|
||||
var score float64
|
||||
if mapping.LastUsedAt != nil {
|
||||
score = float64(mapping.LastUsedAt.UnixMilli())
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) || strings.Contains(err.Error(), "failed to get") {
|
||||
r.logger.WithError(err).Warnf("Cache inconsistency detected for KeyID %s in GroupID %d. Skipping.", keyIDStr, group.ID)
|
||||
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
|
||||
}
|
||||
// 使用管道填充所有轮询结构
|
||||
pipe := r.store.Pipeline()
|
||||
// 1. 顺序
|
||||
pipe.LPush(fmt.Sprintf(BasePoolSequential, poolID), toInterfaceSlice(allActiveKeyIDs)...)
|
||||
// 2. 随机
|
||||
pipe.SAdd(fmt.Sprintf(BasePoolRandomMain, poolID), toInterfaceSlice(allActiveKeyIDs)...)
|
||||
|
||||
// 设置合理的过期时间,例如5分钟,以防止孤儿数据
|
||||
pipe.Expire(fmt.Sprintf(BasePoolSequential, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolRandomMain, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolRandomCooldown, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolLRU, poolID), CacheTTL)
|
||||
|
||||
if err := pipe.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(lruMembers) > 0 {
|
||||
r.store.ZAdd(fmt.Sprintf(BasePoolLRU, poolID), lruMembers)
|
||||
}
|
||||
@@ -226,7 +246,7 @@ func (r *gormKeyRepository) updateKeyUsageTimestampForPool(poolID string, keyID
|
||||
}
|
||||
|
||||
// generatePoolID 根据候选组ID列表生成一个稳定的、唯一的字符串ID
|
||||
func generatePoolID(groups []*models.KeyGroup) string {
|
||||
func generatePoolID(groups []*models.KeyGroup, protocol string) string {
|
||||
ids := make([]int, len(groups))
|
||||
for i, g := range groups {
|
||||
ids[i] = int(g.ID)
|
||||
@@ -234,7 +254,7 @@ func generatePoolID(groups []*models.KeyGroup) string {
|
||||
sort.Ints(ids)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type BasePool struct {
|
||||
CandidateGroups []*models.KeyGroup
|
||||
PollingStrategy models.PollingStrategy
|
||||
Protocol models.ProtocolType
|
||||
}
|
||||
|
||||
type KeyRepository interface {
|
||||
|
||||
@@ -84,26 +84,24 @@ func (s *AnalyticsService) eventListener() {
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) handleAnalyticsEvent(event *models.RequestFinishedEvent) {
|
||||
if event.GroupID == 0 {
|
||||
if event.RequestLog.GroupID == nil {
|
||||
return
|
||||
}
|
||||
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.HIncrBy(key, fieldPrefix+":requests", 1)
|
||||
if event.IsSuccess {
|
||||
if event.RequestLog.IsSuccess {
|
||||
pipe.HIncrBy(key, fieldPrefix+":success", 1)
|
||||
}
|
||||
if event.PromptTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.PromptTokens))
|
||||
if event.RequestLog.PromptTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.RequestLog.PromptTokens))
|
||||
}
|
||||
if event.CompletionTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.CompletionTokens))
|
||||
if event.RequestLog.CompletionTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.RequestLog.CompletionTokens))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,49 +174,40 @@ func (s *APIKeyService) Stop() {
|
||||
}
|
||||
|
||||
func (s *APIKeyService) handleKeyUsageEvent(event *models.RequestFinishedEvent) {
|
||||
if event.KeyID == 0 || event.GroupID == 0 {
|
||||
if event.RequestLog.KeyID == nil || event.RequestLog.GroupID == nil {
|
||||
return
|
||||
}
|
||||
// Handle success case: key recovery and timestamp update.
|
||||
if event.IsSuccess {
|
||||
mapping, err := s.keyRepo.GetMapping(event.GroupID, event.KeyID)
|
||||
if event.RequestLog.IsSuccess {
|
||||
mapping, err := s.keyRepo.GetMapping(*event.RequestLog.GroupID, *event.RequestLog.KeyID)
|
||||
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.GroupID, event.KeyID, err)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
needsUpdate := false
|
||||
statusChanged := false
|
||||
oldStatus := mapping.Status
|
||||
|
||||
// If status was not active, it's a recovery.
|
||||
if mapping.Status != models.StatusActive {
|
||||
mapping.Status = models.StatusActive
|
||||
mapping.ConsecutiveErrorCount = 0
|
||||
mapping.LastError = ""
|
||||
needsUpdate = true
|
||||
statusChanged = true
|
||||
}
|
||||
// Always update LastUsedAt timestamp.
|
||||
|
||||
now := time.Now()
|
||||
mapping.LastUsedAt = &now
|
||||
needsUpdate = true
|
||||
|
||||
if needsUpdate {
|
||||
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)
|
||||
} else if oldStatus != models.StatusActive {
|
||||
// Only publish event if status actually changed.
|
||||
go s.publishStatusChangeEvent(event.GroupID, event.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
if statusChanged {
|
||||
go s.publishStatusChangeEvent(*event.RequestLog.GroupID, *event.RequestLog.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Handle failure case: delegate to the centralized judgment function.
|
||||
if event.Error != nil {
|
||||
s.judgeKeyErrors(
|
||||
event.CorrelationID,
|
||||
event.GroupID,
|
||||
event.KeyID,
|
||||
*event.RequestLog.GroupID,
|
||||
*event.RequestLog.KeyID,
|
||||
event.Error,
|
||||
event.IsPreciseRouting,
|
||||
)
|
||||
@@ -354,6 +345,10 @@ func (s *APIKeyService) ListAPIKeys(params *models.APIKeyQueryParams) (*Paginate
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) GetKeysByIds(ids []uint) ([]models.APIKey, error) {
|
||||
return s.keyRepo.GetKeysByIDs(ids)
|
||||
}
|
||||
|
||||
func (s *APIKeyService) UpdateAPIKey(key *models.APIKey) error {
|
||||
go func() {
|
||||
var oldKey models.APIKey
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Filename: internal/service/db_log_writer_service.go (全新文件)
|
||||
// Filename: internal/service/db_log_writer_service.go
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -164,12 +164,15 @@ func (s *KeyValidationService) runTestKeysTask(taskID string, resourceID string,
|
||||
|
||||
var currentResult models.KeyTestResult
|
||||
event := models.RequestFinishedEvent{
|
||||
GroupID: groupID,
|
||||
KeyID: apiKeyModel.ID,
|
||||
RequestLog: models.RequestLog{
|
||||
// GroupID 和 KeyID 在 RequestLog 模型中是指针,需要取地址
|
||||
GroupID: &groupID,
|
||||
KeyID: &apiKeyModel.ID,
|
||||
},
|
||||
}
|
||||
if validationErr == nil {
|
||||
currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "valid", Message: "Validation successful."}
|
||||
event.IsSuccess = true
|
||||
event.RequestLog.IsSuccess = true
|
||||
} else {
|
||||
var apiErr *CustomErrors.APIError
|
||||
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()}
|
||||
event.Error = &CustomErrors.APIError{Message: validationErr.Error()}
|
||||
}
|
||||
event.IsSuccess = false
|
||||
event.RequestLog.IsSuccess = false
|
||||
}
|
||||
eventData, _ := json.Marshal(event)
|
||||
if err := s.store.Publish(models.TopicRequestFinished, eventData); err != nil {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Filename: internal/service/log_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -16,28 +17,35 @@ func NewLogService(db *gorm.DB) *LogService {
|
||||
return &LogService{db: db}
|
||||
}
|
||||
|
||||
// Record 记录一条日志到数据库 (TODO 暂时保留简单实现,后续再重构为异步)
|
||||
func (s *LogService) Record(log *models.RequestLog) 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 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"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 执行查询
|
||||
err := query.Limit(pageSize).Offset(offset).Find(&logs).Error
|
||||
err := query.Order("request_time desc").Limit(pageSize).Offset(offset).Find(&logs).Error
|
||||
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 {
|
||||
@@ -60,6 +68,11 @@ func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,9 +332,6 @@
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -490,9 +487,6 @@
|
||||
.m-0 {
|
||||
margin: calc(var(--spacing) * 0);
|
||||
}
|
||||
.m-7 {
|
||||
margin: calc(var(--spacing) * 7);
|
||||
}
|
||||
.mx-1 {
|
||||
margin-inline: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -505,9 +499,6 @@
|
||||
.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);
|
||||
}
|
||||
@@ -604,9 +595,6 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -629,9 +617,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -713,9 +698,6 @@
|
||||
.w-0 {
|
||||
width: calc(var(--spacing) * 0);
|
||||
}
|
||||
.w-1 {
|
||||
width: calc(var(--spacing) * 1);
|
||||
}
|
||||
.w-1\/4 {
|
||||
width: calc(1/4 * 100%);
|
||||
}
|
||||
@@ -821,9 +803,6 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -836,9 +815,6 @@
|
||||
.caption-bottom {
|
||||
caption-side: bottom;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.origin-center {
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -865,10 +841,6 @@
|
||||
--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);
|
||||
@@ -1025,9 +997,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -1173,9 +1142,6 @@
|
||||
--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)) {
|
||||
@@ -1203,9 +1169,6 @@
|
||||
.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)) {
|
||||
@@ -1242,9 +1205,6 @@
|
||||
.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)) {
|
||||
@@ -1257,9 +1217,6 @@
|
||||
.border-b-border {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
.border-b-zinc-200 {
|
||||
border-bottom-color: var(--color-zinc-200);
|
||||
}
|
||||
.bg-accent {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
@@ -1320,9 +1277,6 @@
|
||||
.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)) {
|
||||
@@ -1413,15 +1367,6 @@
|
||||
.bg-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 {
|
||||
background-color: var(--color-red-50);
|
||||
}
|
||||
@@ -1543,10 +1488,6 @@
|
||||
--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)) {
|
||||
@@ -1617,9 +1558,6 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1668,9 +1606,6 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1710,9 +1645,6 @@
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.font-\[\'Pixelify_Sans\'\] {
|
||||
font-family: 'Pixelify Sans';
|
||||
}
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -1858,9 +1790,6 @@
|
||||
.text-green-800 {
|
||||
color: var(--color-green-800);
|
||||
}
|
||||
.text-indigo-500 {
|
||||
color: var(--color-indigo-500);
|
||||
}
|
||||
.text-indigo-800 {
|
||||
color: var(--color-indigo-800);
|
||||
}
|
||||
@@ -1885,9 +1814,6 @@
|
||||
.text-primary-foreground {
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
.text-purple-600 {
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
.text-purple-800 {
|
||||
color: var(--color-purple-800);
|
||||
}
|
||||
@@ -1924,9 +1850,6 @@
|
||||
.text-yellow-600 {
|
||||
color: var(--color-yellow-600);
|
||||
}
|
||||
.text-yellow-700 {
|
||||
color: var(--color-yellow-700);
|
||||
}
|
||||
.text-zinc-100 {
|
||||
color: var(--color-zinc-100);
|
||||
}
|
||||
@@ -1957,9 +1880,6 @@
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.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%));
|
||||
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);
|
||||
}
|
||||
@@ -2042,10 +1958,6 @@
|
||||
--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,);
|
||||
@@ -2144,9 +2056,6 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.\[rows\:\%v\] {
|
||||
rows: %v;
|
||||
}
|
||||
.group-hover\:opacity-100 {
|
||||
&:is(:where(.group):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 {
|
||||
@media (hover: hover) {
|
||||
@@ -2777,16 +2676,6 @@
|
||||
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 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-blue-900);
|
||||
@@ -3038,11 +2927,6 @@
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
.dark\:text-yellow-400 {
|
||||
&:where(.dark, .dark *) {
|
||||
color: var(--color-yellow-400);
|
||||
}
|
||||
}
|
||||
.dark\:text-zinc-100 {
|
||||
&:where(.dark, .dark *) {
|
||||
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 {
|
||||
&:where(.dark, .dark *) {
|
||||
&:hover {
|
||||
@@ -5160,11 +5032,6 @@
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -5277,6 +5144,11 @@
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -5334,7 +5206,6 @@
|
||||
--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;
|
||||
@@ -5362,6 +5233,7 @@
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-scale-z: 1;
|
||||
--tw-outline-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
web/static/js/chunk-A4OOMLXK.js
Normal file
52
web/static/js/chunk-A4OOMLXK.js
Normal 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 {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[match];
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
debounce,
|
||||
isValidApiKeyFormat,
|
||||
escapeHTML
|
||||
};
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
taskCenterManager,
|
||||
toastManager
|
||||
} from "./chunk-EZAP7GR4.js";
|
||||
import {
|
||||
debounce,
|
||||
escapeHTML,
|
||||
isValidApiKeyFormat
|
||||
} from "./chunk-A4OOMLXK.js";
|
||||
import {
|
||||
apiFetch,
|
||||
apiFetchJson
|
||||
@@ -670,53 +675,6 @@ var ApiKeyManager = class {
|
||||
};
|
||||
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 {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[match];
|
||||
});
|
||||
}
|
||||
|
||||
// frontend/js/pages/keys/addApiModal.js
|
||||
var AddApiModal = class {
|
||||
constructor({ onImportSuccess }) {
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
escapeHTML
|
||||
} from "./chunk-A4OOMLXK.js";
|
||||
import {
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
@@ -27,7 +30,6 @@ var STATUS_CODE_MAP = {
|
||||
};
|
||||
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) => {
|
||||
@@ -46,8 +48,9 @@ var styleToClass = (style) => {
|
||||
};
|
||||
var errorCodeRegex = /(\d+)$/;
|
||||
var LogList = class {
|
||||
constructor(container) {
|
||||
constructor(container, dataStore2) {
|
||||
this.container = container;
|
||||
this.dataStore = dataStore2;
|
||||
if (!this.container) console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
createLogRowHtml(log, index) {
|
||||
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : "N/A");
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : "N/A");
|
||||
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)}"` : "";
|
||||
@@ -125,7 +136,7 @@ var LogList = class {
|
||||
<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">${apiKeyName}</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>
|
||||
@@ -143,12 +154,15 @@ var LogList = class {
|
||||
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 },
|
||||
// 包含 page_size
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
@@ -157,38 +171,41 @@ var LogsPage = class {
|
||||
};
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
if (this.initialized) {
|
||||
this.logList = new logList_default(this.elements.tableBody);
|
||||
this.logList = new logList_default(this.elements.tableBody, dataStore);
|
||||
}
|
||||
}
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
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 url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
this.state.logs = responseData.data;
|
||||
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)
|
||||
// 同样不准确
|
||||
};
|
||||
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 {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([], this.state.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -198,6 +215,21 @@ var LogsPage = class {
|
||||
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();
|
||||
@@ -180,8 +180,8 @@ var pageModules = {
|
||||
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
|
||||
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
|
||||
"dashboard": () => import("./dashboard-CJJWKYPR.js"),
|
||||
"keys": () => import("./keys-A2UAJYOX.js"),
|
||||
"logs": () => import("./logs-4C4JG7BT.js")
|
||||
"keys": () => import("./keys-4GCIJ7HW.js"),
|
||||
"logs": () => import("./logs-AG4TD2DO.js")
|
||||
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
|
||||
// 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user