加载中...
diff --git a/_public/static/i18n/locales/en.json b/_public/static/i18n/locales/en.json
index 7bcd1526..5d2dc5d7 100644
--- a/_public/static/i18n/locales/en.json
+++ b/_public/static/i18n/locales/en.json
@@ -72,6 +72,7 @@
"tokenManage": "Token Mgmt",
"configManage": "Config Mgmt",
"cacheManage": "Cache Mgmt",
+ "logsView": "Logs",
"chat": "Chat",
"imagine": "Imagine Gallery",
"video": "Video Generation",
@@ -230,6 +231,35 @@
}
}
},
+ "logs": {
+ "pageTitle": "Grok2API - Logs",
+ "title": "Log Viewer",
+ "subtitle": "Filter structured logs by file, level, and keyword with a cleaner reading experience.",
+ "filesTitle": "Log Files",
+ "rawTitle": "Raw Log",
+ "empty": "No matching log records",
+ "filters": {
+ "file": "Log File",
+ "level": "Level",
+ "limit": "Rows",
+ "keyword": "Keyword",
+ "keywordPlaceholder": "traceID / path / error keyword",
+ "apply": "Apply",
+ "reset": "Reset"
+ },
+ "levels": {
+ "all": "All Levels"
+ },
+ "stats": {
+ "file": "Current File",
+ "entries": "Loaded Rows",
+ "entriesHint": "Shown with current filters",
+ "errorWarn": "Warnings / Errors",
+ "errorWarnHint": "warning + error",
+ "size": "File Size",
+ "sizeHint": "Disk usage"
+ }
+ },
"token": {
"pageTitle": "Grok2API - Token Mgmt",
"title": "Token List",
diff --git a/_public/static/i18n/locales/zh.json b/_public/static/i18n/locales/zh.json
index 95157910..c0d48c79 100644
--- a/_public/static/i18n/locales/zh.json
+++ b/_public/static/i18n/locales/zh.json
@@ -72,6 +72,7 @@
"tokenManage": "Token管理",
"configManage": "配置管理",
"cacheManage": "缓存管理",
+ "logsView": "日志查看",
"chat": "Chat 聊天",
"imagine": "Imagine 瀑布流",
"video": "Video 视频生成",
@@ -230,6 +231,35 @@
}
}
},
+ "logs": {
+ "pageTitle": "Grok2API - 日志查看",
+ "title": "日志查看",
+ "subtitle": "按文件、级别和关键词快速筛选,并以更易读的方式查看结构化日志。",
+ "filesTitle": "日志文件",
+ "rawTitle": "原始日志",
+ "empty": "没有匹配的日志记录",
+ "filters": {
+ "file": "日志文件",
+ "level": "级别",
+ "limit": "条数",
+ "keyword": "关键词",
+ "keywordPlaceholder": "traceID / path / 错误关键词",
+ "apply": "应用筛选",
+ "reset": "重置"
+ },
+ "levels": {
+ "all": "全部级别"
+ },
+ "stats": {
+ "file": "当前文件",
+ "entries": "已加载条目",
+ "entriesHint": "按当前筛选展示",
+ "errorWarn": "告警 / 错误",
+ "errorWarnHint": "warning + error",
+ "size": "文件大小",
+ "sizeHint": "磁盘占用"
+ }
+ },
"token": {
"pageTitle": "Grok2API - Token 管理",
"title": "Token 列表",
diff --git a/app/api/pages/admin.py b/app/api/pages/admin.py
index 75c9e3d2..b45206f8 100644
--- a/app/api/pages/admin.py
+++ b/app/api/pages/admin.py
@@ -37,3 +37,8 @@ async def admin_cache():
@router.get("/admin/token", include_in_schema=False)
async def admin_token():
return _admin_page_response("admin/pages/token.html")
+
+
+@router.get("/admin/logs", include_in_schema=False)
+async def admin_logs():
+ return _admin_page_response("admin/pages/logs.html")
diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py
index 63db93d7..c259a9a7 100644
--- a/app/api/v1/admin/__init__.py
+++ b/app/api/v1/admin/__init__.py
@@ -4,6 +4,7 @@
from app.api.v1.admin.cache import router as cache_router
from app.api.v1.admin.config import router as config_router
+from app.api.v1.admin.logs import router as logs_router
from app.api.v1.admin.token import router as tokens_router
router = APIRouter()
@@ -11,5 +12,6 @@
router.include_router(config_router)
router.include_router(tokens_router)
router.include_router(cache_router)
+router.include_router(logs_router)
__all__ = ["router"]
diff --git a/app/api/v1/admin/logs.py b/app/api/v1/admin/logs.py
new file mode 100644
index 00000000..36012a25
--- /dev/null
+++ b/app/api/v1/admin/logs.py
@@ -0,0 +1,53 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from app.core.auth import verify_app_key
+from app.core.log_viewer import (
+ LogViewerError,
+ delete_log_files,
+ list_log_files,
+ read_log_entries,
+)
+
+router = APIRouter()
+
+
+@router.get("/logs/files", dependencies=[Depends(verify_app_key)])
+async def get_log_files():
+ return {"files": list_log_files()}
+
+
+@router.get("/logs", dependencies=[Depends(verify_app_key)])
+async def get_logs(
+ file: str,
+ limit: int = Query(default=200, ge=1, le=1000),
+ level: str | None = Query(default=None),
+ keyword: str | None = Query(default=None),
+ exclude_admin_routes: bool = Query(default=False),
+):
+ try:
+ exclude_prefixes = ["/v1/admin/"] if exclude_admin_routes else []
+ return read_log_entries(
+ file,
+ limit=limit,
+ level=level,
+ keyword=keyword,
+ exclude_prefixes=exclude_prefixes,
+ )
+ except FileNotFoundError as exc:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Log file not found: {exc.args[0]}",
+ )
+ except LogViewerError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+
+@router.post("/logs/delete", dependencies=[Depends(verify_app_key)])
+async def remove_logs(payload: dict):
+ try:
+ files = payload.get("files") or []
+ if not isinstance(files, list) or not files:
+ raise HTTPException(status_code=400, detail="No log files selected")
+ return delete_log_files([str(item) for item in files])
+ except LogViewerError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
diff --git a/app/core/log_viewer.py b/app/core/log_viewer.py
new file mode 100644
index 00000000..0536cdf9
--- /dev/null
+++ b/app/core/log_viewer.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import json
+import os
+from collections import Counter
+from collections.abc import Iterator
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from app.core.logger import LOG_DIR
+
+ALLOWED_SUFFIXES = {".log", ".txt", ".jsonl"}
+DEFAULT_LIMIT = 200
+MAX_LIMIT = 1000
+
+
+class LogViewerError(ValueError):
+ """Raised when log viewer input is invalid."""
+
+
+def list_log_files() -> list[dict[str, Any]]:
+ if not LOG_DIR.exists():
+ return []
+
+ files: list[dict[str, Any]] = []
+ for path in sorted(LOG_DIR.iterdir(), key=_sort_key, reverse=True):
+ if not path.is_file() or path.suffix.lower() not in ALLOWED_SUFFIXES:
+ continue
+ stat = path.stat()
+ files.append(
+ {
+ "name": path.name,
+ "size": stat.st_size,
+ "updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
+ }
+ )
+ return files
+
+
+def read_log_entries(
+ file_name: str,
+ *,
+ limit: int = DEFAULT_LIMIT,
+ level: str | None = None,
+ keyword: str | None = None,
+ exclude_prefixes: list[str] | None = None,
+) -> dict[str, Any]:
+ path = _resolve_log_path(file_name)
+ if not path.exists() or not path.is_file():
+ raise FileNotFoundError(file_name)
+
+ limit = max(1, min(int(limit or DEFAULT_LIMIT), MAX_LIMIT))
+ level_normalized = (level or "").strip().lower()
+ keyword_normalized = (keyword or "").strip().lower()
+ prefixes = [prefix.strip() for prefix in (exclude_prefixes or []) if prefix.strip()]
+
+ entries: list[dict[str, Any]] = []
+ counts: Counter[str] = Counter()
+ search_count = 0
+
+ for line in _iter_lines_reverse(path):
+ parsed = _parse_log_line(line)
+ if not _matches(parsed, level_normalized, keyword_normalized, prefixes):
+ continue
+ search_count += 1
+ log_level = str(parsed.get("level") or "unknown").lower()
+ counts[log_level] += 1
+ entries.append(parsed)
+ if len(entries) >= limit:
+ break
+
+ stat = path.stat()
+ return {
+ "file": {
+ "name": path.name,
+ "size": stat.st_size,
+ "updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
+ },
+ "entries": entries,
+ "stats": {
+ "matched": search_count,
+ "levels": dict(counts),
+ },
+ }
+
+
+def delete_log_files(file_names: list[str]) -> dict[str, Any]:
+ deleted: list[str] = []
+ missing: list[str] = []
+
+ for file_name in file_names:
+ path = _resolve_log_path(file_name)
+ if not path.exists() or not path.is_file():
+ missing.append(path.name)
+ continue
+ path.unlink()
+ deleted.append(path.name)
+
+ return {
+ "deleted": deleted,
+ "missing": missing,
+ }
+
+
+def _resolve_log_path(file_name: str) -> Path:
+ candidate = Path(file_name)
+ if candidate.name != file_name:
+ raise LogViewerError("Invalid log file name")
+ if candidate.suffix.lower() not in ALLOWED_SUFFIXES:
+ raise LogViewerError("Unsupported log file")
+ return LOG_DIR / candidate.name
+
+
+def _sort_key(path: Path) -> tuple[float, str]:
+ try:
+ return (path.stat().st_mtime, path.name)
+ except OSError:
+ return (0, path.name)
+
+
+def _iter_lines_reverse(path: Path, chunk_size: int = 8192) -> Iterator[str]:
+ with path.open("rb") as handle:
+ handle.seek(0, os.SEEK_END)
+ position = handle.tell()
+ buffer = b""
+
+ while position > 0:
+ read_size = min(chunk_size, position)
+ position -= read_size
+ handle.seek(position)
+ chunk = handle.read(read_size)
+ buffer = chunk + buffer
+ lines = buffer.split(b"\n")
+ buffer = lines[0]
+ for raw_line in reversed(lines[1:]):
+ line = raw_line.decode("utf-8", errors="replace").strip()
+ if line:
+ yield line
+
+ if buffer:
+ line = buffer.decode("utf-8", errors="replace").strip()
+ if line:
+ yield line
+
+
+def _parse_log_line(line: str) -> dict[str, Any]:
+ try:
+ data = json.loads(line)
+ if isinstance(data, dict):
+ parsed = data
+ else:
+ parsed = {"msg": line}
+ except json.JSONDecodeError:
+ parsed = {"msg": line}
+
+ extra = parsed.get("extra")
+ if isinstance(extra, dict):
+ for key, value in extra.items():
+ parsed.setdefault(key, value)
+
+ timestamp = parsed.get("time")
+ parsed["time_display"] = _format_time(timestamp)
+ parsed["level"] = str(parsed.get("level") or "unknown").lower()
+ parsed["msg"] = str(parsed.get("msg") or "")
+ parsed["caller"] = str(parsed.get("caller") or "-")
+ parsed["path"] = str(parsed.get("path") or "")
+ parsed["raw"] = line
+ return parsed
+
+
+def _format_time(value: Any) -> str:
+ if not value:
+ return "-"
+ try:
+ normalized = str(value).replace("Z", "+00:00")
+ dt = datetime.fromisoformat(normalized)
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ return str(value)
+
+
+def _matches(
+ entry: dict[str, Any],
+ level: str,
+ keyword: str,
+ exclude_prefixes: list[str],
+) -> bool:
+ if level and entry.get("level") != level:
+ return False
+
+ path = str(entry.get("path") or "")
+ if path and any(path.startswith(prefix) for prefix in exclude_prefixes):
+ return False
+
+ if keyword:
+ haystack = json.dumps(entry, ensure_ascii=False).lower()
+ if keyword not in haystack:
+ return False
+ return True
diff --git a/app/core/response_middleware.py b/app/core/response_middleware.py
index 4c0a07ec..64c582eb 100644
--- a/app/core/response_middleware.py
+++ b/app/core/response_middleware.py
@@ -37,6 +37,7 @@ async def dispatch(self, request: Request, call_next):
"/admin/config",
"/admin/cache",
"/admin/token",
+ "/admin/logs",
):
return await call_next(request)
From befebe00698c355c564a2bb3de9983a2206ad3c4 Mon Sep 17 00:00:00 2001
From: piexian <64474352+piexian@users.noreply.github.com>
Date: Fri, 6 Mar 2026 22:17:54 +0800
Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=97=A5=E5=BF=97?=
=?UTF-8?q?=E9=A1=B5=E4=BA=A4=E4=BA=92=E4=B8=8E=E6=9C=AC=E5=9C=B0=E7=BC=93?=
=?UTF-8?q?=E5=AD=98=E9=80=89=E6=8B=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
_public/static/admin/js/cache.js | 34 +-
_public/static/admin/js/logs.js | 252 +++--
_public/static/admin/pages/logs.html | 14 +-
_public/static/i18n/locales/en.json | 1438 +++++++++++++++-----------
_public/static/i18n/locales/zh.json | 1438 +++++++++++++++-----------
app/core/log_viewer.py | 9 +-
6 files changed, 1901 insertions(+), 1284 deletions(-)
diff --git a/_public/static/admin/js/cache.js b/_public/static/admin/js/cache.js
index f257059d..f8246270 100644
--- a/_public/static/admin/js/cache.js
+++ b/_public/static/admin/js/cache.js
@@ -574,8 +574,38 @@ function selectVisibleLocal(type) {
updateSelectedCount();
}
-function selectAllLocal(type) {
- selectVisibleLocal(type);
+async function selectAllLocal(type) {
+ const state = cacheListState[type];
+ const set = selectedLocal[type];
+ if (!state || !set) return;
+
+ const total = Number(state.total || 0);
+ if (total === 0) return;
+
+ try {
+ const params = new URLSearchParams({
+ type,
+ page: '1',
+ page_size: String(total),
+ });
+ const res = await fetch(`/v1/admin/cache/list?${params.toString()}`, {
+ headers: buildAuthHeaders(apiKey),
+ });
+ if (!res.ok) {
+ showToast(t('common.loadFailed'), 'error');
+ return;
+ }
+ const data = await res.json();
+ const items = Array.isArray(data.items) ? data.items : [];
+ set.clear();
+ items.forEach((item) => {
+ if (item && item.name) set.add(item.name);
+ });
+ syncLocalRowCheckboxes(type);
+ updateSelectedCount();
+ } catch (error) {
+ showToast(t('common.loadFailed'), 'error');
+ }
}
function clearAllLocalSelection(type) {
diff --git a/_public/static/admin/js/logs.js b/_public/static/admin/js/logs.js
index 73c631c9..ed1941dc 100644
--- a/_public/static/admin/js/logs.js
+++ b/_public/static/admin/js/logs.js
@@ -5,6 +5,7 @@ let selectedFiles = new Set();
let autoRefreshTimer = null;
let autoRefreshIndex = -1;
let autoRefreshLongPressTimer = null;
+let autoRefreshLongPressTriggered = false;
let isRefreshing = false;
let currentFileName = '';
let isMobileFilePanelCollapsed = false;
@@ -12,6 +13,7 @@ let isMobileFilePanelCollapsed = false;
const AUTO_REFRESH_OPTIONS = [5000, 10000, 30000];
const logsById = (id) => document.getElementById(id);
const isMobileViewport = () => window.innerWidth <= 768;
+const t = (key, vars = {}) => (window.I18n?.t ? I18n.t(key, vars) : key);
document.addEventListener('DOMContentLoaded', async () => {
adminAuthHeader = await ensureAdminKey();
@@ -30,7 +32,12 @@ function bindEvents() {
});
const autoRefreshBtn = logsById('auto-refresh-btn');
- autoRefreshBtn?.addEventListener('click', () => {
+ autoRefreshBtn?.addEventListener('click', (event) => {
+ if (autoRefreshLongPressTriggered) {
+ autoRefreshLongPressTriggered = false;
+ event.preventDefault();
+ return;
+ }
cycleAutoRefresh();
});
autoRefreshBtn?.addEventListener('mousedown', startAutoRefreshLongPress);
@@ -108,7 +115,7 @@ async function loadFiles(keepSelection = false) {
renderEmptyFiles();
}
} catch (error) {
- showToast(error.message || '加载日志文件失败', 'error');
+ showToast(error.message || t('logs.loadFilesFailed'), 'error');
renderEmptyFiles();
} finally {
setLoading(false);
@@ -169,7 +176,7 @@ async function loadLogs() {
renderEntries([]);
renderLevelBreakdown({});
updateStats(null);
- showToast(error.message || '加载日志失败', 'error');
+ showToast(error.message || t('logs.loadFailed'), 'error');
} finally {
setLoading(false);
}
@@ -181,25 +188,40 @@ function renderFileList() {
const selectAll = logsById('select-all-files');
if (!container || !count || !selectAll) return;
- count.textContent = `${logFiles.length} 个日志文件`;
- container.innerHTML = '';
+ count.textContent = t('logs.fileCount', { count: logFiles.length });
+ container.replaceChildren();
logFiles.forEach((item) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `log-file-item ${item.name === currentFileName ? 'active' : ''}`;
- button.innerHTML = `
-
-
-
-
${escapeHtml(item.name)}
-
- ${escapeHtml(formatDate(item.updated_at))}
- ${escapeHtml(formatBytes(item.size))}
-
-
-
- `;
+
+ const row = document.createElement('div');
+ row.className = 'log-file-row';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.className = 'checkbox log-file-check';
+ checkbox.checked = selectedFiles.has(item.name);
+
+ const body = document.createElement('div');
+ body.className = 'log-file-body';
+
+ const name = document.createElement('div');
+ name.className = 'log-file-name font-mono';
+ name.textContent = item.name;
+
+ const meta = document.createElement('div');
+ meta.className = 'log-file-meta';
+ const updated = document.createElement('span');
+ updated.textContent = formatDate(item.updated_at);
+ const size = document.createElement('span');
+ size.textContent = formatBytes(item.size);
+ meta.append(updated, size);
+
+ body.append(name, meta);
+ row.append(checkbox, body);
+ button.appendChild(row);
button.addEventListener('click', async () => {
currentFileName = item.name;
@@ -210,11 +232,10 @@ function renderFileList() {
await loadLogs();
});
- const checkbox = button.querySelector('.log-file-check');
- checkbox?.addEventListener('click', (event) => {
+ checkbox.addEventListener('click', (event) => {
event.stopPropagation();
});
- checkbox?.addEventListener('change', (event) => {
+ checkbox.addEventListener('change', (event) => {
if (event.target.checked) {
selectedFiles.add(item.name);
} else {
@@ -234,53 +255,102 @@ function renderEntries(entries) {
const empty = logsById('empty-state');
if (!list || !empty) return;
- list.innerHTML = '';
+ list.replaceChildren();
empty.classList.toggle('hidden', entries.length > 0);
entries.forEach((entry) => {
const card = document.createElement('article');
card.className = 'log-entry';
+
+ const header = document.createElement('div');
+ header.className = 'log-entry-header';
+
+ const main = document.createElement('div');
+ main.className = 'log-entry-main';
+
+ const levelBtn = document.createElement('button');
+ levelBtn.type = 'button';
+ levelBtn.className = `log-badge ${sanitizeLevelClass(entry.level)}`;
+ levelBtn.dataset.level = entry.level || '';
+ levelBtn.textContent = entry.level || 'unknown';
+
+ const time = document.createElement('div');
+ time.className = 'log-entry-time font-mono';
+ time.textContent = entry.time_display || '-';
+
+ const caller = document.createElement('div');
+ caller.className = 'text-xs text-[var(--accents-4)] font-mono';
+ caller.textContent = entry.caller || '-';
+
+ main.append(levelBtn, time, caller);
+
+ const actions = document.createElement('div');
+ actions.className = 'log-entry-actions';
+
+ const copyBtn = document.createElement('button');
+ copyBtn.type = 'button';
+ copyBtn.className = 'geist-button-outline text-xs h-8 px-3';
+ copyBtn.dataset.action = 'copy';
+ copyBtn.textContent = t('common.copy');
+
+ const rawBtn = document.createElement('button');
+ rawBtn.type = 'button';
+ rawBtn.className = 'geist-button-outline text-xs h-8 px-3';
+ rawBtn.dataset.action = 'raw';
+ rawBtn.textContent = t('common.raw');
+
+ actions.append(copyBtn, rawBtn);
+ header.append(main, actions);
+
+ const body = document.createElement('div');
+ body.className = 'log-entry-body';
+ const scroll = document.createElement('div');
+ scroll.className = 'log-entry-scroll';
+
+ const message = document.createElement('div');
+ message.className = 'log-message';
+ message.textContent = entry.msg || '';
+ scroll.appendChild(message);
+
const extras = extractExtras(entry);
- const extrasHtml = extras.map(([key, value]) => `
-
- `).join('');
- const stacktrace = entry.stacktrace
- ? `
${escapeHtml(entry.stacktrace)}`
- : '';
-
- card.innerHTML = `
-
-
-
-
${escapeHtml(entry.msg || '')}
- ${extrasHtml ? `
${extrasHtml}
` : ''}
- ${stacktrace}
-
-
- `;
-
- card.querySelector('[data-action="copy"]')?.addEventListener('click', async () => {
+ if (extras.length) {
+ const extrasGrid = document.createElement('div');
+ extrasGrid.className = 'log-meta-grid';
+ extras.forEach(([key, value]) => {
+ const extraCard = document.createElement('div');
+ extraCard.className = 'log-meta-card';
+ const extraKey = document.createElement('div');
+ extraKey.className = 'log-meta-key';
+ extraKey.textContent = key;
+ const extraValue = document.createElement('div');
+ extraValue.className = 'log-meta-value font-mono';
+ extraValue.textContent = stringifyValue(value);
+ extraCard.append(extraKey, extraValue);
+ extrasGrid.appendChild(extraCard);
+ });
+ scroll.appendChild(extrasGrid);
+ }
+
+ if (entry.stacktrace) {
+ const stacktrace = document.createElement('pre');
+ stacktrace.className = 'log-stacktrace font-mono';
+ stacktrace.textContent = entry.stacktrace;
+ scroll.appendChild(stacktrace);
+ }
+
+ body.appendChild(scroll);
+ card.append(header, body);
+
+ copyBtn.addEventListener('click', async () => {
await copyText(entry.raw || JSON.stringify(entry, null, 2));
});
- card.querySelector('[data-action="raw"]')?.addEventListener('click', () => {
+ rawBtn.addEventListener('click', () => {
openModal(entry);
});
- card.querySelector('[data-level]')?.addEventListener('click', async () => {
+ levelBtn.addEventListener('click', async () => {
await setLevelFilter(entry.level || '');
});
+
list.appendChild(card);
});
}
@@ -288,19 +358,24 @@ function renderEntries(entries) {
function renderLevelBreakdown(levels) {
const container = logsById('level-breakdown');
if (!container) return;
- container.innerHTML = '';
+ container.replaceChildren();
const activeLevel = logsById('log-level').value;
const names = Object.keys(levels).sort((a, b) => (levels[b] || 0) - (levels[a] || 0));
names.forEach((level) => {
const pill = document.createElement('button');
pill.type = 'button';
- pill.className = `level-pill ${escapeHtml(level)} ${activeLevel === level ? 'active' : ''}`;
- pill.innerHTML = `
-
-
${escapeHtml(level)}
-
${levels[level]}
- `;
+ pill.className = `level-pill ${sanitizeLevelClass(level)} ${activeLevel === level ? 'active' : ''}`;
+
+ const dot = document.createElement('span');
+ dot.className = `level-pill-dot ${sanitizeLevelClass(level)}`;
+ const label = document.createElement('span');
+ label.className = 'font-mono';
+ label.textContent = level;
+ const count = document.createElement('strong');
+ count.textContent = String(levels[level]);
+
+ pill.append(dot, label, count);
pill.addEventListener('click', async () => {
await setLevelFilter(activeLevel === level ? '' : level);
});
@@ -336,7 +411,13 @@ function updateStats(response) {
}
function renderEmptyFiles() {
- logsById('log-file-list').innerHTML = '
暂无日志文件
';
+ const list = logsById('log-file-list');
+ if (list) {
+ const empty = document.createElement('div');
+ empty.className = 'table-empty';
+ empty.textContent = t('logs.noFiles');
+ list.replaceChildren(empty);
+ }
currentFileName = '';
selectedFiles.clear();
updateFileCountSummary();
@@ -349,7 +430,7 @@ function renderEmptyFiles() {
function updateFileCountSummary() {
const summary = logsById('file-count-summary');
if (summary) {
- summary.textContent = `${logFiles.length} 个日志文件`;
+ summary.textContent = t('logs.fileCount', { count: logFiles.length });
}
}
@@ -384,10 +465,10 @@ function updateFileSelectionState() {
async function deleteSelectedFiles() {
const files = [...selectedFiles];
if (!files.length) {
- showToast('请先选择要清理的日志文件', 'error');
+ showToast(t('logs.deleteNone'), 'error');
return;
}
- if (!window.confirm(`确认清理 ${files.length} 个日志文件?`)) {
+ if (!window.confirm(t('logs.deleteConfirm', { count: files.length }))) {
return;
}
@@ -403,10 +484,12 @@ async function deleteSelectedFiles() {
if (!res.ok) throw new Error(await getErrorMessage(res));
const data = await res.json();
selectedFiles.clear();
- showToast(`已清理 ${data.deleted?.length || 0} 个日志文件`, 'success');
+ const deleted = Number(data.deleted?.length || 0);
+ const failed = Number(data.failed?.length || 0);
+ showToast(t('logs.deleteResult', { deleted, failed }), failed > 0 ? 'info' : 'success');
await loadFiles(true);
} catch (error) {
- showToast(error.message || '清理日志失败', 'error');
+ showToast(error.message || t('logs.deleteFailed'), 'error');
}
}
@@ -417,8 +500,10 @@ function cycleAutoRefresh() {
}
syncAutoRefresh();
updateAutoRefreshButton();
- const label = autoRefreshIndex >= 0 ? `${AUTO_REFRESH_OPTIONS[autoRefreshIndex] / 1000}s` : '关闭';
- showToast(`自动刷新:${label}`, 'success');
+ const label = autoRefreshIndex >= 0
+ ? t('logs.autoRefresh.intervalLabel', { seconds: AUTO_REFRESH_OPTIONS[autoRefreshIndex] / 1000 })
+ : t('logs.autoRefresh.offShort');
+ showToast(t('logs.autoRefresh.changed', { label }), 'success');
}
function syncAutoRefresh() {
@@ -441,19 +526,24 @@ function syncAutoRefresh() {
function updateAutoRefreshButton() {
const button = logsById('auto-refresh-btn');
if (!button) return;
- const text = autoRefreshIndex < 0 ? '自动刷新:关' : `自动刷新:${AUTO_REFRESH_OPTIONS[autoRefreshIndex] / 1000}s`;
+ const text = autoRefreshIndex < 0
+ ? t('logs.autoRefresh.off')
+ : t('logs.autoRefresh.interval', { seconds: AUTO_REFRESH_OPTIONS[autoRefreshIndex] / 1000 });
button.textContent = text;
+ button.setAttribute('aria-label', t('logs.autoRefresh.buttonAria'));
button.classList.toggle('auto-refresh-active', autoRefreshIndex >= 0);
}
function startAutoRefreshLongPress() {
cancelAutoRefreshLongPress();
+ autoRefreshLongPressTriggered = false;
autoRefreshLongPressTimer = window.setTimeout(() => {
if (autoRefreshIndex >= 0) {
autoRefreshIndex = -1;
+ autoRefreshLongPressTriggered = true;
syncAutoRefresh();
updateAutoRefreshButton();
- showToast('自动刷新已关闭', 'success');
+ showToast(t('logs.autoRefresh.disabled'), 'success');
}
autoRefreshLongPressTimer = null;
}, 600);
@@ -497,12 +587,12 @@ function setMobileFilePanelCollapsed(collapsed) {
toggle.classList.remove('desktop-hidden');
body.classList.toggle('mobile-collapsed', collapsed);
toggle.setAttribute('aria-expanded', String(!collapsed));
- text.textContent = collapsed ? '展开' : '收起';
+ text.textContent = collapsed ? t('logs.toggle.expand') : t('logs.toggle.collapse');
} else {
body.classList.remove('mobile-collapsed');
toggle.classList.add('desktop-hidden');
toggle.setAttribute('aria-expanded', 'true');
- text.textContent = '展开';
+ text.textContent = t('logs.toggle.expand');
}
}
@@ -524,9 +614,9 @@ function closeModal() {
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
- showToast(I18n?.t('common.copied') || '已复制', 'success');
+ showToast(t('common.copied'), 'success');
} catch (error) {
- showToast(I18n?.t('common.copyFailed') || '复制失败', 'error');
+ showToast(t('common.copyFailed'), 'error');
}
}
@@ -572,11 +662,7 @@ function formatBytes(bytes) {
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
-function escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+function sanitizeLevelClass(level) {
+ const normalized = String(level || '').toLowerCase();
+ return ['debug', 'info', 'warning', 'error'].includes(normalized) ? normalized : 'unknown';
}
diff --git a/_public/static/admin/pages/logs.html b/_public/static/admin/pages/logs.html
index afc01a17..1d8c5615 100644
--- a/_public/static/admin/pages/logs.html
+++ b/_public/static/admin/pages/logs.html
@@ -34,8 +34,8 @@
日志
刷新
-