| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8" /> |
| <link rel="icon" type="image/x-icon" href="data:image/x-icon;," /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>请求日志查看</title> |
| |
| <link rel="stylesheet" href="/static/shared-styles.css" /> |
| <script src="/static/shared.js"></script> |
| <style> |
| |
| .filter-panel { |
| background: var(--card-background); |
| padding: 20px; |
| border-radius: var(--border-radius); |
| margin-bottom: var(--spacing); |
| border: 1px solid var(--border-color); |
| } |
| |
| .filter-section { |
| margin-bottom: 20px; |
| padding-bottom: 20px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| .filter-section:last-child { |
| margin-bottom: 0; |
| padding-bottom: 0; |
| border-bottom: none; |
| } |
| |
| .filter-section h3 { |
| margin: 0 0 15px 0; |
| color: var(--primary-color); |
| font-size: 16px; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .filter-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
| gap: 15px; |
| } |
| |
| .filter-group { |
| display: flex; |
| flex-direction: column; |
| gap: 5px; |
| } |
| |
| .filter-group label { |
| font-size: 13px; |
| color: var(--text-secondary); |
| } |
| |
| .filter-group input[type="datetime-local"], |
| .filter-group input[type="text"], |
| .filter-group input[type="number"], |
| .filter-group select { |
| width: 100%; |
| padding: 8px 12px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| background: var(--card-background); |
| color: var(--text-primary); |
| font-size: 14px; |
| } |
| |
| .filter-group input:focus, |
| .filter-group select:focus { |
| border-color: var(--primary-color); |
| outline: none; |
| } |
| |
| .range-input { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .range-input input { |
| flex: 1; |
| } |
| |
| .filter-actions { |
| display: flex; |
| justify-content: flex-end; |
| gap: 10px; |
| margin-top: 20px; |
| } |
| |
| |
| .stats-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
| gap: 20px; |
| margin-bottom: var(--spacing); |
| } |
| |
| .stat-card { |
| background: var(--card-background); |
| padding: 20px; |
| border-radius: var(--border-radius); |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); |
| transition: all var(--transition-fast); |
| border: 1px solid var(--border-color); |
| } |
| |
| .stat-card:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); |
| } |
| |
| .stat-card h4 { |
| margin: 0 0 8px 0; |
| color: var(--primary-color); |
| } |
| |
| .stat-value { |
| font-size: 28px; |
| font-weight: 600; |
| color: var(--primary-color); |
| margin-top: 4px; |
| } |
| |
| .refresh-container { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 15px; |
| } |
| |
| .auto-refresh { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| background: var(--card-background); |
| padding: 8px 16px; |
| border-radius: var(--border-radius); |
| border: 1px solid var(--border-color); |
| } |
| |
| .modal { |
| display: none; |
| position: fixed; |
| z-index: 1000; |
| left: 0; |
| top: 0; |
| width: 100%; |
| height: 100%; |
| background-color: rgba(0, 0, 0, 0.4); |
| overflow-y: hidden; |
| } |
| |
| .modal-content { |
| background-color: var(--card-background); |
| margin: 5% auto; |
| padding: 20px; |
| border-radius: var(--border-radius); |
| width: 90%; |
| max-width: 800px; |
| max-height: 85vh; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| border: 1px solid var(--border-color); |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); |
| } |
| |
| .modal-header { |
| position: sticky; |
| top: 0; |
| background: var(--card-background); |
| z-index: 10; |
| padding-bottom: 15px; |
| border-bottom: 1px solid var(--border-color); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| |
| .modal-header h3 { |
| margin: 0; |
| flex-grow: 1; |
| } |
| |
| #conversation-content-container { |
| flex: 1; |
| overflow-y: auto; |
| margin: 0 -20px; |
| padding: 20px; |
| } |
| |
| .close { |
| font-size: 24px; |
| font-weight: bold; |
| cursor: pointer; |
| padding: 5px 10px; |
| } |
| |
| .close:hover { |
| color: var(--primary-color); |
| } |
| |
| .info-button { |
| padding: 6px 12px; |
| font-size: 14px; |
| border-radius: var(--border-radius); |
| transition: all var(--transition-fast); |
| background: var(--primary-color-alpha); |
| color: var(--primary-color); |
| border: 1px solid var(--primary-color); |
| } |
| |
| .info-button:hover { |
| background: var(--primary-color); |
| color: white; |
| } |
| |
| .message-table { |
| width: 100%; |
| border-collapse: collapse; |
| margin-top: 10px; |
| margin: 0; |
| border: 1px solid var(--border-color); |
| } |
| |
| .message-table th, |
| .message-table td { |
| padding: 12px 16px; |
| border-bottom: 1px solid var(--border-color); |
| transition: background-color var(--transition-fast); |
| } |
| |
| .message-table td { |
| word-break: break-word; |
| } |
| |
| .message-table td:nth-child(2) { |
| max-width: 600px; |
| } |
| |
| .message-table td:first-child { |
| width: 80px; |
| white-space: nowrap; |
| } |
| |
| .usage-progress-container { |
| margin: 16px 0; |
| height: 8px; |
| background-color: var(--border-color); |
| border-radius: 4px; |
| overflow: hidden; |
| } |
| |
| .usage-progress-bar { |
| height: 100%; |
| width: 0%; |
| transition: width 0.3s ease; |
| background-color: var(--primary-color); |
| border-radius: 4px; |
| } |
| |
| .usage-progress-bar.low { |
| background-color: #4caf50; |
| } |
| |
| .usage-progress-bar.medium { |
| background-color: #ff9800; |
| } |
| |
| .usage-progress-bar.high { |
| background-color: #f44336; |
| } |
| |
| |
| .token-info-tooltip { |
| position: relative; |
| display: inline-block; |
| } |
| |
| .token-info-tooltip .tooltip-content { |
| visibility: hidden; |
| position: absolute; |
| z-index: 1002; |
| background-color: var(--card-background); |
| color: var(--text-primary); |
| padding: 12px 15px; |
| border-radius: var(--border-radius); |
| box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); |
| width: 280px; |
| left: 50%; |
| transform: translateX(-50%); |
| bottom: calc(100% + 8px); |
| opacity: 0; |
| transition: |
| opacity 0.3s, |
| visibility 0.3s; |
| text-align: left; |
| line-height: 1.6; |
| border: 1px solid var(--border-color); |
| pointer-events: none; |
| } |
| |
| .token-info-tooltip:hover .tooltip-content { |
| visibility: visible; |
| opacity: 1; |
| } |
| |
| .token-info-tooltip .tooltip-content::after { |
| content: ""; |
| position: absolute; |
| top: 100%; |
| left: 50%; |
| margin-left: -8px; |
| border-width: 8px; |
| border-style: solid; |
| border-color: var(--card-background) transparent transparent transparent; |
| } |
| |
| .token-info-tooltip::after { |
| content: ""; |
| position: absolute; |
| left: 50%; |
| transform: translateX(-50%); |
| bottom: 100%; |
| width: 100%; |
| height: 10px; |
| background: transparent; |
| } |
| |
| .token-info-tooltip .tooltip-info-row { |
| display: flex; |
| justify-content: space-between; |
| margin: 2px 0; |
| } |
| |
| .token-info-tooltip .tooltip-info-row .label { |
| color: var(--text-secondary); |
| margin-right: 10px; |
| } |
| |
| .token-info-tooltip .tooltip-info-row .value { |
| font-weight: 500; |
| word-break: break-word; |
| } |
| |
| .token-info-container { |
| flex: 1; |
| overflow-y: auto; |
| margin: 0 -20px; |
| padding: 20px; |
| } |
| |
| |
| .prompt-preview .tooltip-content { |
| width: 320px; |
| max-height: 200px; |
| overflow-y: auto; |
| overflow-x: hidden; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| |
| .prompt-preview .tooltip-content .message-meta { |
| font-size: 0.8em; |
| color: var(--text-secondary); |
| padding: 0; |
| margin: 0 0 4px 0; |
| } |
| |
| .prompt-preview .tooltip-content .last-message { |
| font-size: 0.9em; |
| line-height: 1.5; |
| color: var(--text-primary); |
| margin: 0; |
| padding: 0; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| |
| .prompt-preview .tooltip-content::-webkit-scrollbar { |
| width: 6px; |
| } |
| |
| .prompt-preview .tooltip-content::-webkit-scrollbar-thumb { |
| background-color: var(--border-color); |
| border-radius: 3px; |
| } |
| |
| .prompt-preview .tooltip-content::-webkit-scrollbar-track { |
| background-color: var(--card-background); |
| } |
| |
| |
| .table-container { |
| border-radius: var(--border-radius); |
| overflow: hidden; |
| border: 1px solid var(--border-color); |
| } |
| |
| #logsTable { |
| position: relative; |
| z-index: 1; |
| } |
| |
| #logsTable th { |
| position: sticky; |
| top: 0; |
| z-index: 2; |
| background: var(--primary-color); |
| white-space: nowrap; |
| transition: background-color 0.2s ease; |
| } |
| |
| #logsTable td { |
| padding: 12px 16px; |
| border-bottom: 1px solid var(--border-color); |
| transition: background-color var(--transition-fast); |
| } |
| |
| #logsTable tr:hover td { |
| background-color: var(--hover-color, rgba(0, 0, 0, 0.02)); |
| } |
| |
| .modal-actions { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .danger-button { |
| padding: 6px 12px; |
| font-size: 14px; |
| border-radius: var(--border-radius); |
| background: var(--error-color-alpha); |
| color: var(--error-color); |
| border: 1px solid var(--error-color); |
| cursor: pointer; |
| transition: all var(--transition-fast); |
| margin-right: 8px; |
| } |
| |
| .danger-button:hover { |
| background: var(--error-color); |
| color: white; |
| } |
| |
| .warning-button { |
| padding: 6px 12px; |
| font-size: 14px; |
| border-radius: var(--border-radius); |
| background: rgba(255, 193, 7, 0.1); |
| color: #e8a400; |
| border: 1px solid #e8a400; |
| cursor: pointer; |
| transition: all var(--transition-fast); |
| margin-right: 8px; |
| } |
| |
| .warning-button:hover { |
| background: #e8a400; |
| color: white; |
| } |
| |
| .success-button { |
| padding: 6px 12px; |
| font-size: 14px; |
| border-radius: var(--border-radius); |
| background: rgba(76, 175, 80, 0.1); |
| color: #4caf50; |
| border: 1px solid #4caf50; |
| cursor: pointer; |
| transition: all var(--transition-fast); |
| margin-right: 8px; |
| } |
| |
| .success-button:hover { |
| background: #4caf50; |
| color: white; |
| } |
| |
| |
| .chart-container { |
| background: var(--card-background); |
| padding: 20px; |
| border-radius: var(--border-radius); |
| margin-bottom: var(--spacing); |
| border: 1px solid var(--border-color); |
| height: 300px; |
| } |
| |
| |
| .tab-menu { |
| display: flex; |
| margin-bottom: 15px; |
| } |
| |
| .tab-button { |
| padding: 8px 16px; |
| background: var(--card-background); |
| color: var(--text-secondary); |
| border: 1px solid var(--border-color); |
| cursor: pointer; |
| transition: all var(--transition-fast); |
| font-size: 14px; |
| } |
| |
| .tab-button:first-child { |
| border-radius: var(--border-radius) 0 0 var(--border-radius); |
| } |
| |
| .tab-button:last-child { |
| border-radius: 0 var(--border-radius) var(--border-radius) 0; |
| } |
| |
| .tab-button.active { |
| background: var(--primary-color); |
| color: white; |
| border-color: var(--primary-color); |
| } |
| |
| .tab-content { |
| display: none; |
| } |
| |
| .tab-content.active { |
| display: block; |
| } |
| |
| .delay-chart-container { |
| height: 200px; |
| margin-bottom: 20px; |
| } |
| |
| .delay-table-container { |
| max-height: none; |
| overflow-y: visible; |
| } |
| |
| .pagination-container { |
| margin-top: 20px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .pagination-info { |
| flex: 1; |
| } |
| |
| .pagination-controls { |
| display: flex; |
| gap: 10px; |
| align-items: center; |
| } |
| |
| .page-jumper { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| margin-left: 15px; |
| } |
| |
| .page-input { |
| width: 60px; |
| padding: 5px; |
| border-radius: var(--border-radius); |
| border: 1px solid var(--border-color); |
| text-align: center; |
| } |
| |
| .page-input:focus { |
| border-color: var(--primary-color); |
| outline: none; |
| } |
| |
| .page-go-btn { |
| padding: 5px 10px; |
| border-radius: var(--border-radius); |
| border: 1px solid var(--border-color); |
| background: var(--card-background); |
| cursor: pointer; |
| transition: all var(--transition-fast); |
| } |
| |
| .page-go-btn:hover { |
| background: var(--primary-color); |
| color: white; |
| border-color: var(--primary-color); |
| } |
| |
| |
| @media (max-width: 768px) { |
| .filter-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .stats-grid { |
| grid-template-columns: repeat(2, 1fr); |
| gap: 12px; |
| } |
| |
| .stat-card { |
| padding: 16px; |
| } |
| |
| .stat-value { |
| font-size: 24px; |
| } |
| |
| .modal-content { |
| margin: 2% auto; |
| width: 95%; |
| padding: 16px; |
| } |
| |
| .chart-container { |
| height: 200px; |
| padding: 16px; |
| } |
| |
| .modal-header { |
| flex-direction: column; |
| align-items: flex-start; |
| } |
| |
| .tab-menu { |
| width: 100%; |
| } |
| |
| .tab-button { |
| flex: 1; |
| } |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <h1>请求日志查看</h1> |
|
|
| <div class="container"> |
| <div class="form-group"> |
| <label>认证令牌:</label> |
| <input type="password" id="authToken" placeholder="输入 AUTH_TOKEN" /> |
| </div> |
|
|
| |
| <div class="filter-panel"> |
| |
| <div class="filter-section"> |
| <h3>📅 时间范围</h3> |
| <div class="filter-grid"> |
| <div class="filter-group"> |
| <label for="fromDate">开始时间</label> |
| <input type="datetime-local" id="fromDate" /> |
| </div> |
| <div class="filter-group"> |
| <label for="toDate">结束时间</label> |
| <input type="datetime-local" id="toDate" /> |
| </div> |
| </div> |
| <div style="margin-top: 10px"> |
| <label |
| style=" |
| display: block; |
| margin-bottom: 5px; |
| font-size: 13px; |
| color: var(--text-secondary); |
| " |
| >快速选择</label |
| > |
| <div class="button-group" style="justify-content: flex-start"> |
| <button |
| onclick="setTimeRange('1h')" |
| style="padding: 6px 12px; font-size: 14px" |
| > |
| 最近1小时 |
| </button> |
| <button |
| onclick="setTimeRange('24h')" |
| style="padding: 6px 12px; font-size: 14px" |
| > |
| 最近24小时 |
| </button> |
| <button |
| onclick="setTimeRange('7d')" |
| style="padding: 6px 12px; font-size: 14px" |
| > |
| 最近7天 |
| </button> |
| <button |
| onclick="setTimeRange('30d')" |
| style="padding: 6px 12px; font-size: 14px" |
| > |
| 最近30天 |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="filter-section"> |
| <h3>👤 用户筛选</h3> |
| <div class="filter-grid"> |
| <div class="filter-group"> |
| <label for="userId">用户ID</label> |
| <input type="text" id="userId" placeholder="精确匹配用户ID" /> |
| </div> |
| <div class="filter-group"> |
| <label for="email">邮箱</label> |
| <input type="text" id="email" placeholder="支持部分匹配" /> |
| </div> |
| <div class="filter-group"> |
| <label for="membershipType">会员类型</label> |
| <select id="membershipType"> |
| <option value="">全部</option> |
| <option value="free">免费</option> |
| <option value="free_trial">试用</option> |
| <option value="pro">专业版</option> |
| <option value="enterprise">企业版</option> |
| </select> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="filter-section"> |
| <h3>🤖 模型筛选</h3> |
| <div class="filter-grid"> |
| <div class="filter-group"> |
| <label for="model">模型名称</label> |
| <input type="text" id="model" placeholder="支持部分匹配" /> |
| </div> |
| <div class="filter-group"> |
| <label for="includeModels">包含模型</label> |
| <input |
| type="text" |
| id="includeModels" |
| placeholder="逗号分隔,如: gpt-4,claude" |
| /> |
| </div> |
| <div class="filter-group"> |
| <label for="excludeModels">排除模型</label> |
| <input |
| type="text" |
| id="excludeModels" |
| placeholder="逗号分隔,如: gpt-3.5,dall-e" |
| /> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="filter-section"> |
| <h3>📊 性能筛选</h3> |
| <div class="filter-grid"> |
| <div class="filter-group"> |
| <label>耗时范围(秒)</label> |
| <div class="range-input"> |
| <input |
| type="number" |
| id="minTotalTime" |
| placeholder="最小" |
| step="0.1" |
| min="0" |
| /> |
| <span>~</span> |
| <input |
| type="number" |
| id="maxTotalTime" |
| placeholder="最大" |
| step="0.1" |
| min="0" |
| /> |
| </div> |
| </div> |
| <div class="filter-group"> |
| <label>Tokens范围</label> |
| <div class="range-input"> |
| <input |
| type="number" |
| id="minTokens" |
| placeholder="最小" |
| min="0" |
| /> |
| <span>~</span> |
| <input |
| type="number" |
| id="maxTokens" |
| placeholder="最大" |
| min="0" |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="filter-section"> |
| <h3>🔧 其他选项</h3> |
| <div class="filter-grid"> |
| <div class="filter-group"> |
| <label for="status">状态</label> |
| <select id="status"> |
| <option value="">全部</option> |
| <option value="pending">处理中</option> |
| <option value="success">成功</option> |
| <option value="failure">失败</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="stream">流式响应</label> |
| <select id="stream"> |
| <option value="">全部</option> |
| <option value="true">是</option> |
| <option value="false">否</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="hasError">包含错误</label> |
| <select id="hasError"> |
| <option value="">全部</option> |
| <option value="true">是</option> |
| <option value="false">否</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="hasChain">包含对话</label> |
| <select id="hasChain"> |
| <option value="">全部</option> |
| <option value="true">是</option> |
| <option value="false">否</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="hasUsage">包含用量</label> |
| <select id="hasUsage"> |
| <option value="">全部</option> |
| <option value="true">是</option> |
| <option value="false">否</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="reverse">排序方式</label> |
| <select id="reverse"> |
| <option value="false">从旧到新</option> |
| <option value="true" selected>从新到旧</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="pageSize">每页显示</label> |
| <select id="pageSize"> |
| <option value="10">10 条</option> |
| <option value="20" selected>20 条</option> |
| <option value="50">50 条</option> |
| <option value="100">100 条</option> |
| </select> |
| </div> |
| <div class="filter-group"> |
| <label for="errorText">错误信息</label> |
| <input type="text" id="errorText" placeholder="支持部分匹配" /> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="filter-actions"> |
| <button onclick="resetFilters()">重置</button> |
| <button onclick="applyFilters()">应用筛选</button> |
| </div> |
| </div> |
|
|
| <div class="refresh-container"> |
| <div class="button-group"> |
| <button onclick="fetchLogs()">刷新日志</button> |
| <button onclick="exportLogs()" style="margin-left: 10px"> |
| 导出数据 |
| </button> |
| </div> |
| <div class="auto-refresh"> |
| <input type="checkbox" id="autoRefresh" checked /> |
| <label for="autoRefresh">自动刷新 (60秒)</label> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="container"> |
| <div class="stats-grid"> |
| <div class="stat-card"> |
| <h4>总请求数</h4> |
| <div id="totalRequests" class="stat-value">-</div> |
| </div> |
| <div class="stat-card"> |
| <h4>成功请求</h4> |
| <div id="successRequests" class="stat-value">-</div> |
| </div> |
| <div class="stat-card"> |
| <h4>失败请求</h4> |
| <div id="failureRequests" class="stat-value">-</div> |
| </div> |
| <div class="stat-card"> |
| <h4>最后更新</h4> |
| <div id="lastUpdate" class="stat-value">-</div> |
| </div> |
| </div> |
|
|
| |
| <div class="chart-container"> |
| <canvas id="requestsChart"></canvas> |
| </div> |
|
|
| <div class="table-container"> |
| <table id="logsTable"> |
| <thead> |
| <tr> |
| <th>序号</th> |
| <th>时间</th> |
| <th>模型</th> |
| <th>Token信息</th> |
| <th>对话</th> |
| <th>用时</th> |
| <th>输入/输出</th> |
| <th>流式</th> |
| <th>状态</th> |
| <th>错误信息</th> |
| </tr> |
| </thead> |
| <tbody id="logsBody"></tbody> |
| </table> |
| </div> |
|
|
| |
| <div class="pagination-container"> |
| <div class="pagination-info"> |
| 共 <span id="totalRecords">0</span> 条记录, 每页 |
| <input |
| type="number" |
| id="customPageSize" |
| class="page-input" |
| min="5" |
| max="100" |
| value="20" |
| /> |
| 条 |
| <button class="page-go-btn" onclick="changePageSize()">应用</button> |
| </div> |
| <div class="pagination-controls"> |
| <button id="prevPage" onclick="changePage(-1)" disabled> |
| « 上一页 |
| </button> |
| <span id="currentPage">第 1 页</span> |
| <button id="nextPage" onclick="changePage(1)" disabled> |
| 下一页 » |
| </button> |
| <div class="page-jumper"> |
| 跳转到第 |
| <input |
| type="number" |
| id="pageNumberInput" |
| class="page-input" |
| min="1" |
| value="1" |
| /> |
| 页 |
| <button class="page-go-btn" onclick="jumpToPage()">GO</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="tokenModal" class="modal"> |
| <div class="modal-content"> |
| <div class="modal-header"> |
| <h3>Token 详细信息</h3> |
| <span class="close">×</span> |
| </div> |
| <div class="token-info-container"> |
| <table class="message-table"> |
| <tbody> |
| <tr> |
| <td>Token</td> |
| <td id="modalToken">-</td> |
| </tr> |
| <tr> |
| <td>Checksum</td> |
| <td id="modalChecksum">-</td> |
| </tr> |
| <tr> |
| <td>Client Key</td> |
| <td id="modalClientKey">-</td> |
| </tr> |
| <tr> |
| <td>Config Version</td> |
| <td id="modalConfigVersion">-</td> |
| </tr> |
| <tr> |
| <td>Session ID</td> |
| <td id="modalSessionId">-</td> |
| </tr> |
| <tr> |
| <td>Proxy</td> |
| <td id="modalProxy">-</td> |
| </tr> |
| <tr> |
| <td>Timezone</td> |
| <td id="modalTimezone">-</td> |
| </tr> |
| <tr> |
| <td>Email</td> |
| <td id="modalEmail">-</td> |
| </tr> |
| <tr> |
| <td>Name</td> |
| <td id="modalName">-</td> |
| </tr> |
| <tr> |
| <td>Updated At</td> |
| <td id="modalUpdatedAt">-</td> |
| </tr> |
| <tr> |
| <td>Member Type</td> |
| <td id="modalMemberType">-</td> |
| </tr> |
| <tr> |
| <td>Payment ID</td> |
| <td id="modalPaymentId">-</td> |
| </tr> |
| <tr> |
| <td>Trial Days</td> |
| <td id="modalTrialDays">-</td> |
| </tr> |
| </tbody> |
| </table> |
| <div id="usageProgressContainer"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="conversationModal" class="modal"> |
| <div class="modal-content"> |
| <div class="modal-header"> |
| <h3>对话详情</h3> |
| <div class="tab-menu"> |
| <button id="tab-prompt" class="tab-button active">对话内容</button> |
| <button id="tab-think" class="tab-button">思考过程</button> |
| <button id="tab-delays" class="tab-button">延迟分析</button> |
| </div> |
| <span class="close">×</span> |
| </div> |
| <div id="conversation-content-container"> |
| <div id="dialogContent" class="tab-content active"></div> |
| <div id="thinkContent" class="tab-content"> |
| <pre id="thinkText"></pre> |
| </div> |
| <div id="delaysContent" class="tab-content"> |
| <div class="delay-chart-container"> |
| <canvas id="delayChart"></canvas> |
| </div> |
| <div class="delay-table-container"> |
| <table id="delaysTable" class="message-table"> |
| <thead> |
| <tr> |
| <th>序号</th> |
| <th>文本块</th> |
| <th>延迟时间(s)</th> |
| <th>速率(字符/秒)</th> |
| </tr> |
| </thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script> |
|
|
| <script> |
| |
| let currentPageIndex = 0; |
| let pageSize = 20; |
| let totalRecords = 0; |
| let currentFilters = {}; |
| let refreshInterval = null; |
| let requestsChart = null; |
| |
| |
| let currentTokenDetails = {}; |
| |
| |
| let tokenInfoCache = {}; |
| let allTokensLoaded = false; |
| |
| |
| function updateStats(data) { |
| |
| const totalRequests = data.total || 0; |
| const successRequests = data.logs.filter( |
| (log) => log.status === "success", |
| ).length; |
| const failureRequests = data.logs.filter( |
| (log) => log.status === "failure", |
| ).length; |
| |
| |
| document.getElementById("totalRequests").textContent = |
| totalRequests.toLocaleString(); |
| document.getElementById("successRequests").textContent = |
| successRequests.toLocaleString(); |
| document.getElementById("failureRequests").textContent = |
| failureRequests.toLocaleString(); |
| document.getElementById("lastUpdate").textContent = |
| new Date().toLocaleTimeString("zh-CN"); |
| } |
| |
| |
| async function showTokenModal(tokenInfo) { |
| const modal = document.getElementById("tokenModal"); |
| |
| |
| if (tokenInfo.key && tokenInfoCache[tokenInfo.key]) { |
| |
| tokenInfo = { ...tokenInfo, ...tokenInfoCache[tokenInfo.key] }; |
| } |
| |
| |
| document.getElementById("modalToken").textContent = |
| tokenInfo.primary_token || "-"; |
| |
| |
| if (tokenInfo.checksum) { |
| document.getElementById("modalChecksum").textContent = `${ |
| tokenInfo.checksum.first || "-" |
| } / ${tokenInfo.checksum.second || "-"}`; |
| } else { |
| document.getElementById("modalChecksum").textContent = "-"; |
| } |
| |
| |
| document.getElementById("modalClientKey").textContent = |
| tokenInfo.client_key || "-"; |
| document.getElementById("modalConfigVersion").textContent = |
| tokenInfo.config_version || "-"; |
| document.getElementById("modalSessionId").textContent = |
| tokenInfo.session_id || "-"; |
| document.getElementById("modalProxy").textContent = |
| tokenInfo.proxy || "-"; |
| document.getElementById("modalTimezone").textContent = |
| tokenInfo.timezone || "-"; |
| |
| |
| if (tokenInfo.user) { |
| document.getElementById("modalEmail").textContent = |
| tokenInfo.user.email || "-"; |
| document.getElementById("modalName").textContent = |
| tokenInfo.user.name || "-"; |
| document.getElementById("modalUpdatedAt").textContent = tokenInfo.user |
| .updated_at |
| ? new Date(tokenInfo.user.updated_at).toLocaleString() |
| : "-"; |
| } else { |
| document.getElementById("modalEmail").textContent = "-"; |
| document.getElementById("modalName").textContent = "-"; |
| document.getElementById("modalUpdatedAt").textContent = "-"; |
| } |
| |
| |
| if (tokenInfo.stripe) { |
| document.getElementById("modalMemberType").textContent = |
| formatMembershipType(tokenInfo.stripe.membership_type); |
| document.getElementById("modalPaymentId").textContent = |
| tokenInfo.stripe.payment_id || "-"; |
| document.getElementById("modalTrialDays").textContent = |
| tokenInfo.stripe.days_remaining_on_trial > 0 |
| ? `${tokenInfo.stripe.days_remaining_on_trial}天` |
| : "-"; |
| } else { |
| document.getElementById("modalMemberType").textContent = "-"; |
| document.getElementById("modalPaymentId").textContent = "-"; |
| document.getElementById("modalTrialDays").textContent = "-"; |
| } |
| |
| |
| const container = document.getElementById("usageProgressContainer"); |
| container.innerHTML = ""; |
| |
| if (tokenInfo.usage) { |
| const models = { |
| Premium: tokenInfo.usage.premium, |
| Standard: tokenInfo.usage.standard, |
| }; |
| |
| Object.entries(models).forEach(([modelName, modelData]) => { |
| if (modelData) { |
| const div = document.createElement("div"); |
| div.style.marginTop = "10px"; |
| |
| const label = document.createElement("strong"); |
| label.textContent = `${modelName} 使用量: `; |
| div.appendChild(label); |
| |
| const text = document.createElement("span"); |
| if (modelData.max_requests) { |
| const percentage = ( |
| (modelData.num_requests / modelData.max_requests) * |
| 100 |
| ).toFixed(1); |
| text.textContent = `${modelData.num_requests}/${modelData.max_requests} 请求 (${percentage}%), ${modelData.num_tokens} tokens`; |
| |
| const progressDiv = document.createElement("div"); |
| progressDiv.className = "usage-progress-container"; |
| const colorClass = |
| percentage < 50 ? "low" : percentage < 80 ? "medium" : "high"; |
| progressDiv.innerHTML = `<div class="usage-progress-bar ${colorClass}" style="width: ${percentage}%"></div>`; |
| div.appendChild(text); |
| div.appendChild(progressDiv); |
| } else { |
| text.textContent = `${modelData.num_requests} 请求, ${modelData.num_tokens} tokens`; |
| div.appendChild(text); |
| } |
| |
| container.appendChild(div); |
| } |
| }); |
| } |
| |
| modal.style.display = "block"; |
| } |
| |
| |
| function formatSimpleTokenInfo(tokenInfo) { |
| if (!tokenInfo) return "无Token信息"; |
| |
| |
| let fullTokenInfo = tokenInfo; |
| if (tokenInfo.key && tokenInfoCache[tokenInfo.key]) { |
| fullTokenInfo = { ...tokenInfo, ...tokenInfoCache[tokenInfo.key] }; |
| } |
| |
| const rows = []; |
| |
| if (fullTokenInfo.key) { |
| rows.push(["Key", fullTokenInfo.key]); |
| } |
| |
| if (fullTokenInfo.user) { |
| rows.push(["邮箱", fullTokenInfo.user.email || "-"]); |
| if (fullTokenInfo.user.name) { |
| rows.push(["用户名", fullTokenInfo.user.name]); |
| } |
| } |
| |
| if (fullTokenInfo.stripe) { |
| rows.push([ |
| "会员类型", |
| formatMembershipType(fullTokenInfo.stripe.membership_type), |
| ]); |
| } |
| |
| if (fullTokenInfo.usage?.premium) { |
| const premium = fullTokenInfo.usage.premium; |
| const usage = premium.max_requests |
| ? `${premium.num_requests}/${premium.max_requests}` |
| : `${premium.num_requests}`; |
| rows.push(["Premium使用", usage]); |
| } |
| |
| if (rows.length === 0) { |
| return "无详细信息"; |
| } |
| |
| return rows |
| .map( |
| ([label, value]) => |
| `<div class="tooltip-info-row"><span class="label">${label}:</span><span class="value">${value}</span></div>`, |
| ) |
| .join(""); |
| } |
| |
| |
| function setTimeRange(range) { |
| const now = new Date(); |
| const toDate = now.toISOString().slice(0, 16); |
| let fromDate; |
| |
| switch (range) { |
| case "1h": |
| fromDate = new Date(now - 60 * 60 * 1000) |
| .toISOString() |
| .slice(0, 16); |
| break; |
| case "24h": |
| fromDate = new Date(now - 24 * 60 * 60 * 1000) |
| .toISOString() |
| .slice(0, 16); |
| break; |
| case "7d": |
| fromDate = new Date(now - 7 * 24 * 60 * 60 * 1000) |
| .toISOString() |
| .slice(0, 16); |
| break; |
| case "30d": |
| fromDate = new Date(now - 30 * 24 * 60 * 60 * 1000) |
| .toISOString() |
| .slice(0, 16); |
| break; |
| } |
| |
| document.getElementById("fromDate").value = fromDate; |
| document.getElementById("toDate").value = toDate; |
| |
| |
| applyFilters(); |
| } |
| |
| function initChart() { |
| const ctx = document.getElementById("requestsChart").getContext("2d"); |
| requestsChart = new Chart(ctx, { |
| type: "line", |
| data: { |
| labels: [], |
| datasets: [ |
| { |
| label: "每小时请求数", |
| data: [], |
| borderColor: "rgb(75, 192, 192)", |
| tension: 0.1, |
| fill: false, |
| }, |
| ], |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| scales: { |
| y: { |
| beginAtZero: true, |
| ticks: { |
| stepSize: 10, |
| }, |
| }, |
| }, |
| plugins: { |
| title: { |
| display: true, |
| text: "24小时请求统计", |
| }, |
| }, |
| }, |
| }); |
| } |
| |
| |
| function updateChart(data) { |
| if (!requestsChart) { |
| initChart(); |
| } |
| |
| |
| const hourlyStats = new Map(); |
| const now = new Date(); |
| const past24Hours = new Date(now - 24 * 60 * 60 * 1000); |
| |
| |
| for (let i = 0; i < 24; i++) { |
| const hour = new Date(now - i * 60 * 60 * 1000); |
| const hourKey = hour.toLocaleString("zh-CN", { |
| month: "numeric", |
| day: "numeric", |
| hour: "numeric", |
| }); |
| hourlyStats.set(hourKey, 0); |
| } |
| |
| |
| data.logs.forEach((log) => { |
| const logTime = new Date(log.timestamp); |
| if (logTime >= past24Hours) { |
| const hourKey = logTime.toLocaleString("zh-CN", { |
| month: "numeric", |
| day: "numeric", |
| hour: "numeric", |
| }); |
| if (hourlyStats.has(hourKey)) { |
| hourlyStats.set(hourKey, hourlyStats.get(hourKey) + 1); |
| } |
| } |
| }); |
| |
| |
| const sortedHours = Array.from(hourlyStats.keys()).reverse(); |
| const counts = sortedHours.map((hour) => hourlyStats.get(hour)); |
| |
| requestsChart.data.labels = sortedHours; |
| requestsChart.data.datasets[0].data = counts; |
| requestsChart.update(); |
| } |
| |
| async function updateTable(data) { |
| const tbody = document.getElementById("logsBody"); |
| updateStats(data); |
| updateChart(data); |
| |
| |
| totalRecords = data.total; |
| document.getElementById("totalRecords").textContent = totalRecords; |
| updatePaginationControls(); |
| |
| |
| const tokenKeys = new Set(); |
| data.logs.forEach((log) => { |
| if (log.token_info && log.token_info.key) { |
| tokenKeys.add(log.token_info.key); |
| } |
| }); |
| |
| |
| if (!allTokensLoaded && tokenKeys.size > 0) { |
| await loadAllTokenInfo(Array.from(tokenKeys)); |
| } |
| |
| tbody.innerHTML = data.logs |
| .map((log) => { |
| |
| let attributes = {}; |
| |
| |
| if (log.chain && typeof log.chain.prompt === "string") { |
| try { |
| const promptJson = JSON.stringify(log.chain.prompt); |
| const escapedPrompt = escapeHtml(promptJson); |
| attributes.prompt = `data-prompt='${escapedPrompt}'`; |
| } catch (e) { |
| console.error( |
| "Failed to process prompt data:", |
| e, |
| log.chain.prompt, |
| ); |
| } |
| } |
| |
| |
| if (log.chain && typeof log.chain.think === "string") { |
| try { |
| const thinkJson = JSON.stringify(log.chain.think); |
| const escapedThink = escapeHtml(thinkJson); |
| attributes.think = `data-think='${escapedThink}'`; |
| } catch (e) { |
| console.error( |
| "Failed to process think data:", |
| e, |
| log.chain.think, |
| ); |
| } |
| } |
| |
| |
| if ( |
| log.chain && |
| Array.isArray(log.chain.delays) && |
| log.chain.delays.length === 2 && |
| typeof log.chain.delays[0] === "string" && |
| Array.isArray(log.chain.delays[1]) |
| ) { |
| try { |
| const delaysJson = JSON.stringify(log.chain.delays); |
| const escapedDelays = escapeHtml(delaysJson); |
| attributes.delays = `data-delays='${escapedDelays}'`; |
| } catch (e) { |
| console.error( |
| "Failed to stringify delays data:", |
| e, |
| log.chain.delays, |
| ); |
| } |
| } |
| |
| |
| const allAttributes = Object.values(attributes).join(" "); |
| |
| return `<tr> |
| <td>${log.id}</td> |
| <td>${new Date(log.timestamp).toLocaleString()}</td> |
| <td>${log.model}</td> |
| <td><div class="token-info-tooltip"><button class="info-button" onclick='showTokenModal(${JSON.stringify( |
| log.token_info, |
| )})'>查看详情<div class="tooltip-content">${formatSimpleTokenInfo( |
| log.token_info, |
| )}</div></button></div></td> |
| <td>${ |
| log.chain |
| ? `<div class="token-info-tooltip prompt-preview"><button class="info-button view-conversation" ${allAttributes}>查看对话<div class="tooltip-content">${formatDialogPreview( |
| JSON.stringify(log.chain.prompt || ""), |
| )}</div></button></div>` |
| : "-" |
| }</td> |
| <td>${formatTiming(log.timing.total)}</td> |
| <td>${formatUsage(log.chain?.usage)}</td> |
| <td>${log.stream ? "是" : "否"}</td> |
| <td>${log.status}</td> |
| <td>${ |
| typeof log.error === "string" ? log.error : (log.error?.error ?? "-") |
| }</td> |
| </tr>`; |
| }) |
| .join(""); |
| |
| |
| tbody.querySelectorAll(".view-conversation").forEach((button) => { |
| button.addEventListener("click", function () { |
| const prompt = this.hasAttribute("data-prompt") |
| ? JSON.parse(this.dataset.prompt) |
| : ""; |
| const think = this.hasAttribute("data-think") |
| ? JSON.parse(this.dataset.think) |
| : ""; |
| const delays = this.hasAttribute("data-delays") |
| ? JSON.parse(this.dataset.delays) |
| : []; |
| |
| showConversationModal(prompt, think, delays); |
| }); |
| }); |
| } |
| |
| function formatTiming(total) { |
| return `${total.toFixed(2)}s`; |
| } |
| |
| function formatDialogPreview(promptStr) { |
| try { |
| const messages = parsePrompt(promptStr); |
| if (!messages || messages.length === 0) { |
| return "无对话内容"; |
| } |
| |
| |
| const lastMessage = messages[messages.length - 1]; |
| const roleLabels = { |
| system: "系统", |
| user: "用户", |
| assistant: "助手", |
| }; |
| |
| return `<div class="message-meta">最后一条消息 (${ |
| roleLabels[lastMessage.role] || lastMessage.role |
| }):</div><div class="last-message">${lastMessage.content |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/\n/g, "<br>")}</div>`; |
| } catch (e) { |
| console.error("预览对话内容失败:", e); |
| return "无法解析对话内容"; |
| } |
| } |
| |
| |
| function updatePaginationControls() { |
| const prevPageBtn = document.getElementById("prevPage"); |
| const nextPageBtn = document.getElementById("nextPage"); |
| const currentPageSpan = document.getElementById("currentPage"); |
| const pageNumberInput = document.getElementById("pageNumberInput"); |
| const customPageSize = document.getElementById("customPageSize"); |
| |
| |
| const totalPages = Math.ceil(totalRecords / pageSize) || 1; |
| |
| |
| prevPageBtn.disabled = currentPageIndex <= 0; |
| |
| |
| const hasMorePages = (currentPageIndex + 1) * pageSize < totalRecords; |
| nextPageBtn.disabled = !hasMorePages; |
| |
| |
| currentPageSpan.textContent = `第 ${ |
| currentPageIndex + 1 |
| } 页 / 共 ${totalPages} 页`; |
| |
| |
| pageNumberInput.value = currentPageIndex + 1; |
| pageNumberInput.max = totalPages; |
| customPageSize.value = pageSize; |
| } |
| |
| |
| function changePage(delta) { |
| currentPageIndex += delta; |
| if (currentPageIndex < 0) currentPageIndex = 0; |
| |
| fetchLogs(); |
| } |
| |
| |
| function jumpToPage() { |
| const pageNumberInput = document.getElementById("pageNumberInput"); |
| const pageNumber = parseInt(pageNumberInput.value); |
| const totalPages = Math.ceil(totalRecords / pageSize) || 1; |
| |
| if (isNaN(pageNumber) || pageNumber < 1) { |
| showGlobalMessage("请输入有效的页码", true); |
| pageNumberInput.value = currentPageIndex + 1; |
| return; |
| } |
| |
| if (pageNumber > totalPages) { |
| showGlobalMessage(`页码超出范围,最大页码为 ${totalPages}`, true); |
| pageNumberInput.value = totalPages; |
| return; |
| } |
| |
| currentPageIndex = pageNumber - 1; |
| fetchLogs(); |
| } |
| |
| |
| function changePageSize() { |
| const customPageSize = document.getElementById("customPageSize"); |
| const newPageSize = parseInt(customPageSize.value); |
| |
| if (isNaN(newPageSize) || newPageSize < 5 || newPageSize > 100) { |
| showGlobalMessage("每页显示数量必须在5-100之间", true); |
| customPageSize.value = pageSize; |
| return; |
| } |
| |
| |
| currentPageIndex = 0; |
| pageSize = newPageSize; |
| fetchLogs(); |
| } |
| |
| |
| function getTimeRangeFilter() { |
| const fromDate = document.getElementById("fromDate").value; |
| const toDate = document.getElementById("toDate").value; |
| |
| const filter = {}; |
| |
| if (fromDate) { |
| |
| filter.from_date = new Date(fromDate).toISOString(); |
| } |
| |
| if (toDate) { |
| filter.to_date = new Date(toDate).toISOString(); |
| } |
| |
| return filter; |
| } |
| |
| |
| function getUserFilter() { |
| const filter = {}; |
| |
| const userId = document.getElementById("userId").value.trim(); |
| if (userId) filter.user_id = userId; |
| |
| const email = document.getElementById("email").value.trim(); |
| if (email) filter.email = email; |
| |
| const membership = document.getElementById("membershipType").value; |
| if (membership) filter.membership_type = membership; |
| |
| return filter; |
| } |
| |
| |
| function getModelFilter() { |
| const filter = {}; |
| |
| const model = document.getElementById("model").value.trim(); |
| if (model) filter.model = model; |
| |
| const includeModels = document |
| .getElementById("includeModels") |
| .value.trim(); |
| if (includeModels) { |
| filter.include_models = includeModels |
| .split(",") |
| .map((m) => m.trim()) |
| .filter((m) => m); |
| } |
| |
| const excludeModels = document |
| .getElementById("excludeModels") |
| .value.trim(); |
| if (excludeModels) { |
| filter.exclude_models = excludeModels |
| .split(",") |
| .map((m) => m.trim()) |
| .filter((m) => m); |
| } |
| |
| return filter; |
| } |
| |
| |
| function getPerformanceFilter() { |
| const filter = {}; |
| |
| const minTime = parseFloat( |
| document.getElementById("minTotalTime").value, |
| ); |
| if (!isNaN(minTime)) filter.min_total_time = minTime; |
| |
| const maxTime = parseFloat( |
| document.getElementById("maxTotalTime").value, |
| ); |
| if (!isNaN(maxTime)) filter.max_total_time = maxTime; |
| |
| const minTokens = parseInt(document.getElementById("minTokens").value); |
| if (!isNaN(minTokens)) filter.min_tokens = minTokens; |
| |
| const maxTokens = parseInt(document.getElementById("maxTokens").value); |
| if (!isNaN(maxTokens)) filter.max_tokens = maxTokens; |
| |
| return filter; |
| } |
| |
| |
| function getOtherFilters() { |
| const filter = {}; |
| |
| const status = document.getElementById("status").value; |
| if (status) filter.status = status; |
| |
| const stream = document.getElementById("stream").value; |
| if (stream) filter.stream = stream === "true"; |
| |
| const hasError = document.getElementById("hasError").value; |
| if (hasError) filter.has_error = hasError === "true"; |
| |
| const hasChain = document.getElementById("hasChain").value; |
| if (hasChain) filter.has_chain = hasChain === "true"; |
| |
| const hasUsage = document.getElementById("hasUsage").value; |
| if (hasUsage) filter.has_usage = hasUsage === "true"; |
| |
| const error = document.getElementById("errorText").value.trim(); |
| if (error) filter.error = error; |
| |
| return filter; |
| } |
| |
| |
| function applyFilters() { |
| |
| currentPageIndex = 0; |
| |
| |
| const pageSizeSelect = document.getElementById("pageSize"); |
| if (pageSizeSelect) { |
| pageSize = parseInt(pageSizeSelect.value); |
| |
| document.getElementById("customPageSize").value = pageSize; |
| } |
| |
| |
| fetchLogs(); |
| } |
| |
| |
| function resetFilters() { |
| |
| document.getElementById("fromDate").value = ""; |
| document.getElementById("toDate").value = ""; |
| document.getElementById("userId").value = ""; |
| document.getElementById("email").value = ""; |
| document.getElementById("membershipType").value = ""; |
| document.getElementById("model").value = ""; |
| document.getElementById("includeModels").value = ""; |
| document.getElementById("excludeModels").value = ""; |
| document.getElementById("minTotalTime").value = ""; |
| document.getElementById("maxTotalTime").value = ""; |
| document.getElementById("minTokens").value = ""; |
| document.getElementById("maxTokens").value = ""; |
| document.getElementById("status").value = ""; |
| document.getElementById("stream").value = ""; |
| document.getElementById("hasError").value = ""; |
| document.getElementById("hasChain").value = ""; |
| document.getElementById("hasUsage").value = ""; |
| document.getElementById("reverse").value = "false"; |
| document.getElementById("pageSize").value = "20"; |
| document.getElementById("errorText").value = ""; |
| |
| |
| currentPageIndex = 0; |
| pageSize = 20; |
| |
| |
| fetchLogs(); |
| } |
| |
| async function fetchLogs() { |
| |
| const query = { |
| |
| limit: pageSize, |
| offset: currentPageIndex * pageSize, |
| reverse: document.getElementById("reverse").value === "true", |
| |
| |
| ...getTimeRangeFilter(), |
| |
| |
| ...getUserFilter(), |
| |
| |
| ...getModelFilter(), |
| |
| |
| ...getPerformanceFilter(), |
| |
| |
| ...getOtherFilters(), |
| }; |
| |
| |
| const data = await makeAuthenticatedRequest("/logs/get", { |
| body: JSON.stringify({ query }), |
| }); |
| |
| if (data) { |
| updateTable(data); |
| showGlobalMessage("日志获取成功"); |
| } |
| } |
| |
| |
| async function fetchTokenDetails(tokens) { |
| const data = await makeAuthenticatedRequest("/logs/tokens/get", { |
| body: JSON.stringify(tokens), |
| }); |
| |
| if (data) { |
| return data.tokens; |
| } |
| |
| return null; |
| } |
| |
| |
| async function loadAllTokenInfo(tokenKeys) { |
| if (tokenKeys.length === 0) return; |
| |
| console.log(`正在加载 ${tokenKeys.length} 个token的详细信息...`); |
| |
| try { |
| const detailsData = await fetchTokenDetails(tokenKeys); |
| if (detailsData) { |
| |
| Object.entries(detailsData).forEach(([key, info]) => { |
| tokenInfoCache[key] = info; |
| }); |
| |
| allTokensLoaded = true; |
| console.log( |
| `成功加载 ${Object.keys(detailsData).length} 个token的详细信息`, |
| ); |
| showGlobalMessage( |
| `已缓存 ${Object.keys(detailsData).length} 个Token信息`, |
| ); |
| } |
| } catch (error) { |
| console.error("加载Token信息失败:", error); |
| } |
| } |
| |
| |
| async function exportLogs() { |
| |
| const query = { |
| |
| limit: 10000, |
| offset: 0, |
| reverse: document.getElementById("reverse").value === "true", |
| |
| |
| ...getTimeRangeFilter(), |
| |
| |
| ...getUserFilter(), |
| |
| |
| ...getModelFilter(), |
| |
| |
| ...getPerformanceFilter(), |
| |
| |
| ...getOtherFilters(), |
| }; |
| |
| const data = await makeAuthenticatedRequest("/logs/get", { |
| method: "POST", |
| body: JSON.stringify({ query }), |
| }); |
| |
| if (data) { |
| |
| const csvContent = createCSVFromLogs(data.logs); |
| |
| |
| const blob = new Blob([csvContent], { |
| type: "text/csv;charset=utf-8;", |
| }); |
| const link = document.createElement("a"); |
| const url = URL.createObjectURL(blob); |
| link.setAttribute("href", url); |
| link.setAttribute("download", `logs_${new Date().toISOString()}.csv`); |
| link.style.visibility = "hidden"; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| |
| showGlobalMessage(`成功导出 ${data.logs.length} 条日志记录`); |
| } |
| } |
| |
| |
| function createCSVFromLogs(logs) { |
| const headers = [ |
| "ID", |
| "时间", |
| "模型", |
| "Token Key", |
| "用户邮箱", |
| "会员类型", |
| "耗时(秒)", |
| "输入Tokens", |
| "输出Tokens", |
| "流式", |
| "状态", |
| "错误信息", |
| ]; |
| |
| const rows = logs.map((log) => { |
| const tokenInfo = log.token_info || {}; |
| const stripe = tokenInfo.stripe || {}; |
| const usage = log.chain?.usage || {}; |
| const user = tokenInfo.user || {}; |
| |
| return [ |
| log.id, |
| new Date(log.timestamp).toLocaleString(), |
| log.model, |
| tokenInfo.key || "-", |
| user.email || "-", |
| stripe.membership_type || "-", |
| log.timing.total.toFixed(2), |
| usage.input || 0, |
| usage.output || 0, |
| log.stream ? "是" : "否", |
| log.status, |
| typeof log.error === "string" ? log.error : log.error?.error || "-", |
| ]; |
| }); |
| |
| |
| const csvRows = [headers, ...rows].map((row) => |
| row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), |
| ); |
| |
| return csvRows.join("\n"); |
| } |
| |
| // 自动刷新控制 |
| document |
| .getElementById("autoRefresh") |
| .addEventListener("change", function (e) { |
| if (e.target.checked) { |
| refreshInterval = setInterval(fetchLogs, 60000); |
| } else { |
| clearInterval(refreshInterval); |
| } |
| }); |
| |
| // 页面加载完成后自动获取日志 |
| document.addEventListener("DOMContentLoaded", () => { |
| const authToken = getAuthToken(); |
| if (authToken) { |
| document.getElementById("authToken").value = authToken; |
| |
| // 设置默认页大小 |
| pageSize = parseInt( |
| document.getElementById("pageSize").value || "20", |
| ); |
| document.getElementById("customPageSize").value = pageSize; |
| |
| fetchLogs(); |
| } |
| // 启动自动刷新 |
| refreshInterval = setInterval(fetchLogs, 60000); |
| |
| // 添加页码输入框回车键支持 |
| document |
| .getElementById("pageNumberInput") |
| .addEventListener("keyup", function (event) { |
| if (event.key === "Enter") { |
| jumpToPage(); |
| } |
| }); |
| |
| // 添加页面大小输入框回车键支持 |
| document |
| .getElementById("customPageSize") |
| .addEventListener("keyup", function (event) { |
| if (event.key === "Enter") { |
| changePageSize(); |
| } |
| }); |
| }); |
| |
| // 初始化 token 处理 |
| initializeTokenHandling("authToken"); |
| |
| // 添加清理逻辑 |
| window.addEventListener("beforeunload", () => { |
| if (refreshInterval) { |
| clearInterval(refreshInterval); |
| } |
| }); |
| |
| // 添加模态框关闭逻辑 |
| document.querySelectorAll(".modal .close").forEach((closeBtn) => { |
| closeBtn.onclick = function () { |
| this.closest(".modal").style.display = "none"; |
| }; |
| }); |
| |
| window.onclick = function (event) { |
| if (event.target.classList.contains("modal")) { |
| event.target.style.display = "none"; |
| } |
| }; |
| |
| /** |
| * 解析对话内容 |
| * @param {string} promptStr - 原始prompt字符串 |
| * @returns {Array<{role: string, content: string}>} 解析后的对话数组 |
| */ |
| function parsePrompt(promptStr) { |
| if (!promptStr) return []; |
| |
| // 尝试解析JSON格式的prompt |
| try { |
| const parsed = JSON.parse(promptStr); |
| |
| // 如果是已解析的格式 |
| if (Array.isArray(parsed)) { |
| return parsed.map((msg) => ({ |
| role: msg.role, |
| content: msg.content, |
| })); |
| } |
| } catch (e) { |
| // 如果解析JSON失败,说明是原始字符串格式,使用原来的解析逻辑 |
| } |
| |
| const messages = []; |
| const lines = promptStr.split("\n"); |
| let currentRole = ""; |
| let currentContent = ""; |
| |
| const roleMap = { |
| BEGIN_SYSTEM: "system", |
| BEGIN_USER: "user", |
| BEGIN_ASSISTANT: "assistant", |
| }; |
| |
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i]; |
| |
| // 检查是否是角色标记行 |
| let foundRole = false; |
| for (const [marker, role] of Object.entries(roleMap)) { |
| if (line.includes(marker)) { |
| // 保存之前的消息(如果有) |
| if (currentRole && currentContent.trim()) { |
| messages.push({ |
| role: currentRole, |
| content: currentContent.trim(), |
| }); |
| } |
| // 设置新角色 |
| currentRole = role; |
| currentContent = ""; |
| foundRole = true; |
| break; |
| } |
| } |
| |
| // 如果不是角色标记行且不是END标记行,则添加到当前内容 |
| if (!foundRole && !line.includes("END_")) { |
| currentContent += line + "\n"; |
| } |
| } |
| |
| // 添加最后一条消息 |
| if (currentRole && currentContent.trim()) { |
| messages.push({ |
| role: currentRole, |
| content: currentContent.trim(), |
| }); |
| } |
| |
| return messages; |
| } |
| |
| function escapeHtml(content) { |
| // 先转义HTML特殊字符 |
| const escaped = content |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| return escaped; |
| } |
| |
| function escapeHtmlAndControlChars(content) { |
| // 先转义HTML特殊字符 |
| let escaped = content |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| |
| // 然后转义控制字符 |
| escaped = escaped |
| .replace(/\\/g, "\\\\") |
| .replace(/\n/g, "\\n") |
| .replace(/\t/g, "\\t") |
| .replace(/\r/g, "\\r"); |
| |
| return escaped; |
| } |
| |
| /** |
| * 格式化对话内容为HTML表格 |
| * @param {Array<{role: string, content: string}>} messages - 对话消息数组 |
| * @returns {string} HTML表格字符串 |
| */ |
| function formatPromptToTable(messages) { |
| if (!messages || messages.length === 0) { |
| return "<p>无对话内容</p>"; |
| } |
| |
| const roleLabels = { |
| system: "系统", |
| user: "用户", |
| assistant: "助手", |
| }; |
| |
| return `<table class="message-table"><thead><tr><th>角色</th><th>内容</th></tr></thead><tbody>${messages |
| .map( |
| (msg) => |
| `<tr><td>${roleLabels[msg.role] || msg.role}</td><td>${escapeHtml( |
| msg.content, |
| ).replace(/\n/g, "<br>")}</td></tr>`, |
| ) |
| .join("")}</tbody></table>`; |
| } |
| |
| /** |
| * 显示对话详情弹窗 |
| * @param {string} promptStr - 对话提示字符串 |
| * @param {string} thinkStr - 思考过程字符串 |
| * @param {Array} delaysTuple - 延迟数据数组 |
| */ |
| function showConversationModal(promptStr, thinkStr, delaysTuple) { |
| try { |
| const modal = document.getElementById("conversationModal"); |
| const dialogContent = document.getElementById("dialogContent"); |
| const thinkContent = document.getElementById("thinkContent"); |
| const delaysContent = document.getElementById("delaysContent"); |
| const tabPrompt = document.getElementById("tab-prompt"); |
| const tabThink = document.getElementById("tab-think"); |
| const tabDelays = document.getElementById("tab-delays"); |
| const delaysTableBody = document.querySelector("#delaysTable tbody"); |
| const delayChartContainer = document.querySelector( |
| ".delay-chart-container", |
| ); |
| |
| if ( |
| !modal || |
| !dialogContent || |
| !thinkContent || |
| !delaysContent || |
| !tabPrompt || |
| !tabThink || |
| !tabDelays || |
| !delaysTableBody || |
| !delayChartContainer |
| ) { |
| console.error("Modal elements not found"); |
| return; |
| } |
| |
| // 处理 Prompt |
| try { |
| const messages = parsePrompt(promptStr); |
| dialogContent.innerHTML = formatPromptToTable(messages); |
| } catch (e) { |
| console.error("解析 Prompt 数据失败:", e); |
| dialogContent.innerHTML = "<p>无法加载对话内容。</p>"; |
| } |
| |
| // 处理思考过程 |
| if (thinkStr && typeof thinkStr === "string") { |
| document.getElementById("thinkText").textContent = thinkStr; |
| tabThink.style.display = ""; |
| } else { |
| document.getElementById("thinkText").textContent = "无思考过程"; |
| tabThink.style.display = "none"; |
| } |
| |
| // 处理 Delays |
| delaysTableBody.innerHTML = ""; |
| delayChartContainer.innerHTML = '<canvas id="delayChart"></canvas>'; |
| |
| let fullText = ""; |
| let delayPoints = []; |
| let chartDataPoints = [{ time: 0, chars: 0, text: "" }]; |
| |
| if (delaysTuple) { |
| if ( |
| Array.isArray(delaysTuple) && |
| delaysTuple.length === 2 && |
| typeof delaysTuple[0] === "string" && |
| Array.isArray(delaysTuple[1]) |
| ) { |
| fullText = delaysTuple[0]; |
| delayPoints = delaysTuple[1]; |
| } else { |
| console.warn("Delays data format is incorrect:", delaysTuple); |
| } |
| } |
| |
| if (delayPoints.length > 0) { |
| const textChars = Array.from(fullText); |
| let currentIndex = 0; |
| let totalChars = 0; |
| let totalTime = 0; |
| |
| delayPoints.forEach(([length, deltaTime], index) => { |
| if ( |
| typeof length !== "number" || |
| typeof deltaTime !== "number" || |
| length < 0 || |
| deltaTime < 0 |
| ) { |
| console.warn( |
| `Skipping invalid delay point at index ${index}:`, |
| [length, deltaTime], |
| ); |
| return; |
| } |
| |
| const chunkChars = textChars.slice(currentIndex, currentIndex + length); |
| const chunkText = chunkChars.join(''); |
| currentIndex += length; |
| totalChars += length; |
| totalTime += deltaTime; |
| |
| const rate = deltaTime > 0 ? length / deltaTime : Infinity; |
| const avgRate = totalTime > 0 ? totalChars / totalTime : Infinity; |
| |
| const row = document.createElement("tr"); |
| row.innerHTML = ` |
| <td>${index + 1}</td> |
| <td>${escapeHtmlAndControlChars(chunkText)}</td> |
| <td>${deltaTime.toFixed(3)}</td> |
| <td>${isFinite(rate) ? rate.toFixed(1) : "N/A"} (平均: ${ |
| isFinite(avgRate) ? avgRate.toFixed(1) : "N/A" |
| })</td> |
| `; |
| delaysTableBody.appendChild(row); |
| |
| chartDataPoints.push({ |
| time: totalTime, |
| chars: totalChars, |
| text: chunkText, |
| }); |
| }); |
| |
| initDelayChart(chartDataPoints); |
| tabDelays.style.display = ""; |
| } else { |
| delaysTableBody.innerHTML = |
| '<tr><td colspan="4">无延迟数据</td></tr>'; |
| delayChartContainer.innerHTML = |
| '<div style="text-align: center; padding: 20px;">无延迟数据可供分析</div>'; |
| tabDelays.style.display = "none"; |
| } |
| |
| // 设置标签页 |
| tabPrompt.onclick = () => setActiveTab("prompt"); |
| tabThink.onclick = () => setActiveTab("think"); |
| tabDelays.onclick = () => setActiveTab("delays"); |
| |
| setActiveTab("prompt"); |
| modal.style.display = "block"; |
| } catch (e) { |
| console.error("显示对话详情失败:", e); |
| } |
| } |
| |
| /** |
| * 设置当前活动标签页 |
| * @param {string} tabName - 标签页名称 ('prompt' 或 'delays') |
| */ |
| function setActiveTab(tabName) { |
| const dialogContent = document.getElementById("dialogContent"); |
| const thinkContent = document.getElementById("thinkContent"); |
| const delaysContent = document.getElementById("delaysContent"); |
| const tabPrompt = document.getElementById("tab-prompt"); |
| const tabThink = document.getElementById("tab-think"); |
| const tabDelays = document.getElementById("tab-delays"); |
| |
| if ( |
| !dialogContent || |
| !thinkContent || |
| !delaysContent || |
| !tabPrompt || |
| !tabThink || |
| !tabDelays |
| ) { |
| console.error("Tab elements not found"); |
| return; |
| } |
| |
| if (tabName === "prompt") { |
| dialogContent.classList.add("active"); |
| thinkContent.classList.remove("active"); |
| delaysContent.classList.remove("active"); |
| tabPrompt.classList.add("active"); |
| tabThink.classList.remove("active"); |
| tabDelays.classList.remove("active"); |
| } else if (tabName === "think") { |
| dialogContent.classList.remove("active"); |
| thinkContent.classList.add("active"); |
| delaysContent.classList.remove("active"); |
| tabPrompt.classList.remove("active"); |
| tabThink.classList.add("active"); |
| tabDelays.classList.remove("active"); |
| } else { |
| dialogContent.classList.remove("active"); |
| thinkContent.classList.remove("active"); |
| delaysContent.classList.add("active"); |
| tabPrompt.classList.remove("active"); |
| tabThink.classList.remove("active"); |
| tabDelays.classList.add("active"); |
| } |
| } |
| |
| /** |
| * 初始化延迟图表 |
| * @param {Array<{time: number, chars: number, text: string}>} chartDataPoints - 包含累计时间和字符数以及块文本的数据点数组 |
| */ |
| function initDelayChart(chartDataPoints) { |
| if (!chartDataPoints || chartDataPoints.length <= 1) { |
| const container = document.querySelector(".delay-chart-container"); |
| if (container) { |
| container.innerHTML = |
| '<div style="text-align: center; padding: 20px;">延迟数据不足,无法绘制图表</div>'; |
| } |
| return; |
| } |
| |
| const ctx = document.getElementById("delayChart"); |
| if (!ctx) { |
| console.error("找不到图表canvas元素"); |
| return; |
| } |
| |
| const existingChart = Chart.getChart(ctx); |
| if (existingChart) { |
| existingChart.destroy(); |
| } |
| |
| const maxPoints = 100; |
| let sampledPoints = chartDataPoints; |
| |
| if (chartDataPoints.length > maxPoints) { |
| sampledPoints = []; |
| const interval = (chartDataPoints.length - 2) / (maxPoints - 2); |
| |
| sampledPoints.push(chartDataPoints[0]); |
| |
| for (let i = 1; i < maxPoints - 1; i++) { |
| const rawIndex = Math.round(i * interval); |
| const actualIndex = Math.min( |
| rawIndex + 1, |
| chartDataPoints.length - 2, |
| ); |
| sampledPoints.push(chartDataPoints[actualIndex]); |
| } |
| |
| sampledPoints.push(chartDataPoints[chartDataPoints.length - 1]); |
| } |
| |
| new Chart(ctx, { |
| type: "line", |
| data: { |
| datasets: [ |
| { |
| label: "累计输出字符数", |
| data: sampledPoints.map((point) => ({ |
| x: point.time, |
| y: point.chars, |
| })), |
| borderColor: "rgb(75,192,192)", |
| backgroundColor: "rgba(75,192,192,0.2)", |
| tension: 0.1, |
| fill: true, |
| pointRadius: 3, |
| pointHoverRadius: 5, |
| }, |
| ], |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| title: { |
| display: true, |
| text: "字符输出进度", |
| }, |
| tooltip: { |
| callbacks: { |
| title: function (context) { |
| const pointIndex = context[0].dataIndex; |
| if (pointIndex < sampledPoints.length) { |
| return `用时: ${sampledPoints[pointIndex].time.toFixed( |
| 1, |
| )}秒`; |
| } |
| return ""; |
| }, |
| label: function (context) { |
| const pointIndex = context.dataIndex; |
| if (pointIndex < sampledPoints.length) { |
| const point = sampledPoints[pointIndex]; |
| const prevPoint = |
| pointIndex > 0 |
| ? sampledPoints[pointIndex - 1] |
| : { time: 0, chars: 0, text: "" }; |
| |
| const deltaTime = point.time - prevPoint.time; |
| const deltaChars = point.chars - prevPoint.chars; |
| const currentRate = |
| deltaTime > 0 |
| ? (deltaChars / deltaTime).toFixed(1) |
| : "N/A"; |
| const avgRate = |
| point.time > 0 |
| ? (point.chars / point.time).toFixed(1) |
| : "N/A"; |
| |
| const chunkText = point.text || ""; |
| |
| return [ |
| `累计字符: ${point.chars}`, |
| `平均速率: ${avgRate} 字符/秒`, |
| `当前块速率: ${currentRate} 字符/秒`, |
| `当前块文本: ${escapeHtmlAndControlChars( |
| chunkText.length > 50 |
| ? chunkText.substring(0, 50) + "..." |
| : chunkText, |
| )}`, |
| ]; |
| } |
| return ""; |
| }, |
| }, |
| }, |
| }, |
| scales: { |
| y: { |
| beginAtZero: true, |
| title: { |
| display: true, |
| text: "累计字符数", |
| }, |
| }, |
| x: { |
| type: "linear", |
| beginAtZero: true, |
| title: { |
| display: true, |
| text: "累计用时(秒)", |
| }, |
| ticks: { |
| callback: function (value) { |
| return value.toFixed(1) + "s"; |
| }, |
| }, |
| }, |
| }, |
| }, |
| }); |
| } |
| |
| /** |
| * 格式化使用量信息 |
| * @param {Object} usage - 使用量对象 { input: number, output: number } |
| * @returns {string} 格式化后的字符串 |
| */ |
| function formatUsage(usage) { |
| if (!usage) return "-"; |
| return `${usage.input} / ${usage.output}`; |
| } |
| </script> |
| </body> |
| </html> |