diff --git a/.env.example b/.env.example
index 71a70d9..b292782 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,8 @@ CODEBUDDY2API_ADMIN_CSRF=true
# Extra trusted management origins behind reverse proxies (comma separated; bare domains use HTTPS).
# Unset leaves the list configurable in the WebUI; an explicit value locks it.
# CODEBUDDY2API_ADMIN_ORIGINS=https://chat.example.com
+# 可信代理 IP/CIDR(逗号分隔);命中时审计改记 X-Forwarded-For/X-Real-IP 的真实客户端地址
+# CODEBUDDY2API_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8
# Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it.
# CODEBUDDY2API_KEEP_TOOL_METADATA=true
diff --git a/app/admin_api.py b/app/admin_api.py
index a64d242..029ef37 100644
--- a/app/admin_api.py
+++ b/app/admin_api.py
@@ -486,7 +486,7 @@ async def logs_get(request):
raise ValueError("limit 必须为整数") from None
if not 1 <= limit <= 200:
raise ValueError("limit 必须为 1 至 200")
- filters = {key: params[key] for key in ("model", "credential", "profile", "status", "search") if key in params}
+ filters = {key: params[key] for key in ("model", "credential", "profile", "status", "search", "client_ip") if key in params}
if any(len(value) > 256 for value in filters.values()) or len(params.get("cursor", "")) > 2048:
raise ValueError("日志筛选参数过长")
result = await run_in_threadpool(audit.list_records, kind, limit, params.get("cursor"), **filters)
diff --git a/app/audit_store.py b/app/audit_store.py
index 3ab4bdc..1b12322 100644
--- a/app/audit_store.py
+++ b/app/audit_store.py
@@ -15,7 +15,7 @@
import uuid
from typing import Any
-SCHEMA_VERSION = 2
+SCHEMA_VERSION = 3
_CLEANUP_BATCH = 128
_CLEANUP_SECONDS = 0.05
METRICS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens",
@@ -33,6 +33,16 @@ def safe_label(value: Any, limit: int = 160) -> str | None:
return value
+
+def safe_agent(value: Any) -> str | None:
+ """Single-line printable User-Agent snippet; never header-shaped secrets."""
+ if not isinstance(value, str):
+ return None
+ cleaned = " ".join(value.split())[:200]
+ if not cleaned or not all(0x20 <= ord(char) <= 0x7E for char in cleaned):
+ return None
+ return None if _SECRET.search(cleaned) else cleaned
+
def number(value: Any) -> int | float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
@@ -119,7 +129,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30,
self._db.execute("BEGIN IMMEDIATE")
version = self._db.execute("PRAGMA user_version").fetchone()[0]
tables = self._db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
- if version not in (0, 1, SCHEMA_VERSION) or (version == 0 and tables):
+ if version not in (0, 1, 2, SCHEMA_VERSION) or (version == 0 and tables):
raise ValueError("unsupported audit schema")
self._db.execute("CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL, detail_generation INTEGER NOT NULL, cleared_at REAL NOT NULL)")
self._db.execute("INSERT OR IGNORE INTO state VALUES(1,0,0,0)")
@@ -133,6 +143,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30,
for table in ("stats_hourly", "stats_daily", "stats_totals"):
self._db.execute(f"CREATE TABLE IF NOT EXISTS {table} (bucket INTEGER NOT NULL, dimension TEXT NOT NULL, dimension_key TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(bucket,dimension,dimension_key))")
self._migrate_ingest_time()
+ self._migrate_client_ip()
self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
self._epoch = self._db.execute("SELECT epoch FROM state").fetchone()[0]
self._db.execute("COMMIT")
@@ -150,6 +161,15 @@ def _migrate_ingest_time(self):
(SELECT started_at FROM events WHERE events.id=ingest.id), ?)""", (time.time(),))
self._db.execute("CREATE INDEX IF NOT EXISTS ingest_time ON ingest(created_at)")
+ def _migrate_client_ip(self):
+ # Additive v3 migration: record the audit-visible client address per request.
+ columns = [row[1] for row in self._db.execute("PRAGMA table_info(requests)").fetchall()]
+ if "client_ip" not in columns:
+ self._db.execute("ALTER TABLE requests ADD COLUMN client_ip TEXT")
+ # Exact-match client filters must not scan the retained time range.
+ self._db.execute(
+ "CREATE INDEX IF NOT EXISTS requests_client ON requests(client_ip, started_at)")
+
def _init_accounting(self):
# Additive v1 migration: scan legacy details only once, under the same
# write transaction that installs triggers. Reopening never recounts.
@@ -265,6 +285,8 @@ def _aggregate(self, record):
def _sanitize_record(self, source):
result = {key: safe_label(source.get(key)) for key in
("upstream_model", "profile", "credential", "protocol", "error_code", "usage_source")}
+ result["client_ip"] = safe_label(source.get("client_ip"))
+ result["user_agent"] = safe_agent(source.get("user_agent"))
result["stream_mode"] = (source.get("stream_mode")
if source.get("stream_mode") in ("compatible", "realtime") else None)
projection_mode = source.get("responses_projection_mode")
@@ -318,10 +340,12 @@ def commit():
(generation is None or generation == state["detail_generation"]))
if keep:
payload = json.dumps(data, separators=(",", ":"))
- self._db.execute("INSERT INTO requests VALUES(?,?,?,?,?,?,?,?,?)", (
- data["id"], data["started_at"], data["model"], data["profile"], data["credential"],
- data["outcome"], data["status_code"], payload,
- len(payload.encode()) + sum(len(json.dumps(a).encode()) + 32 for a in data["attempts"]) + 256))
+ self._db.execute(
+ "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes,client_ip) VALUES(?,?,?,?,?,?,?,?,?,?)",
+ (data["id"], data["started_at"], data["model"], data["profile"], data["credential"],
+ data["outcome"], data["status_code"], payload,
+ len(payload.encode()) + sum(len(json.dumps(a).encode()) + 32 for a in data["attempts"]) + 256,
+ data["client_ip"]))
for i, attempt in enumerate(data["attempts"]):
self._db.execute("INSERT INTO attempts VALUES(?,?,?)", (data["id"], i, json.dumps(attempt)))
self._prune()
@@ -435,7 +459,7 @@ def list_records(self, kind="request", limit=50, cursor=None, **filters):
clauses.append("(started_at OR (started_at=? AND id))")
params.extend((stamp, stamp, record_id))
if kind == "request":
- for key in ("model", "profile", "credential", "outcome", "status_code"):
+ for key in ("model", "profile", "credential", "outcome", "status_code", "client_ip"):
value = filters.get(key)
if value is not None and value != "":
clauses.append(f"{key}=?")
diff --git a/app/observability.py b/app/observability.py
index 80ae552..fa18a1e 100644
--- a/app/observability.py
+++ b/app/observability.py
@@ -1,23 +1,98 @@
"""Observe inference metadata with bounded response parsing; never inspect request bodies or headers.
+
+The only headers read are audit client metadata (user-agent and forwarded-for
+chains behind a configured trusted proxy); they are never forwarded upstream.
Preserve the ASGI wire and exclude credential files or tokens from route metadata.
"""
from __future__ import annotations
import asyncio
+import ipaddress
from contextvars import ContextVar
from dataclasses import dataclass, field
import json
import time
from typing import Any
-from app.audit_store import AuditStore, METRICS, number, safe_attempt, safe_label
+from app.audit_store import AuditStore, METRICS, number, safe_agent, safe_attempt, safe_label
from app.request_context import current_context, ensure_context
_PATHS = {"/v1/chat/completions": "chat", "/v1/responses": "responses", "/v1/messages": "messages"}
_PARSE_LIMIT = 16384
+_META_HEADERS = ("user-agent", "x-forwarded-for", "x-real-ip")
+_proxy_networks: dict[str, tuple] = {}
_current: ContextVar[_Observation | None] = ContextVar("audit_observation", default=None)
+def _trusted_networks(raw):
+ """Parse a normalized trusted_proxies setting; cached per setting value."""
+ if not isinstance(raw, str) or not raw.strip():
+ return ()
+ cached = _proxy_networks.get(raw)
+ if cached is None:
+ import ipaddress
+ try:
+ cached = tuple(ipaddress.ip_network(entry.strip(), strict=False)
+ for entry in raw.split(",") if entry.strip())
+ except ValueError:
+ return ()
+ if len(_proxy_networks) > 8:
+ _proxy_networks.clear()
+ _proxy_networks[raw] = cached
+ return cached
+
+
+def client_meta(scope, trusted_proxies):
+ """(client_ip, user_agent) for the audit record.
+
+ Forwarded headers count only when the direct peer is a trusted proxy;
+ otherwise they are ignored so clients cannot spoof their address.
+ """
+ headers = {}
+ forwarded: list[str] = []
+ for key, value in scope.get("headers") or []:
+ name = key.decode("latin-1").lower() if isinstance(key, (bytes, bytearray)) else str(key).lower()
+ clean = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip()
+ if name == "x-forwarded-for":
+ # A trusted proxy may append its hop as a separate header field;
+ # combine the fields in order before walking the chain.
+ if clean:
+ forwarded.append(clean)
+ elif name in _META_HEADERS and name not in headers:
+ headers[name] = clean
+ client = scope.get("client")
+ remote = client[0] if client and isinstance(client[0], str) else None
+
+ def valid_ip(candidate: str | None) -> str | None:
+ try:
+ return str(ipaddress.ip_address(candidate)) if candidate else None
+ except ValueError:
+ return None
+
+ remote_ip = valid_ip(remote)
+ remote_obj = ipaddress.ip_address(remote_ip) if remote_ip else None
+ networks = _trusted_networks(trusted_proxies)
+ ip = remote
+ if remote_obj is not None and any(remote_obj in network for network in networks):
+ hops = [hop.strip() for hop in (",".join(forwarded)).split(",") if hop.strip()][-32:]
+ chosen = None
+ broken = False
+ for hop in reversed(hops):
+ hop_ip = valid_ip(hop)
+ if hop_ip is None:
+ # A malformed hop breaks the chain; never fall back to the
+ # client-controlled leftmost entry in that case.
+ broken = True
+ break
+ chosen = hop_ip
+ if not any(ipaddress.ip_address(hop_ip) in network for network in networks):
+ break
+ if chosen is None or broken:
+ chosen = valid_ip(headers.get("x-real-ip"))
+ ip = chosen or remote
+ return ip, safe_agent(headers.get("user-agent"))
+
+
def _mapping(value):
return value if isinstance(value, dict) else {}
@@ -333,6 +408,9 @@ async def lifespan_receive():
"detail_generation": -1, "started_at": started,
"protocol": _PATHS[scope["path"]],
**{key: None for key in METRICS}}, monotonic_start)
+ observation.record.update(zip(("client_ip", "user_agent"),
+ client_meta(scope, self.config.get("trusted_proxies")
+ if isinstance(self.config, dict) else None)))
token = _current.set(observation)
parser = _Parser(observation)
cancelled = False
diff --git a/app/settings.py b/app/settings.py
index de4f7a5..ca33ba2 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -33,6 +33,23 @@ def normalize_allowed_origins(value):
return ",".join(normalized)
+def normalize_trusted_proxies(value):
+ """Normalize a comma/space separated list of trusted proxy IPs or CIDRs."""
+ import ipaddress
+ entries = [entry for entry in re.split(r"[\s,]+", value.strip()) if entry]
+ if len(entries) > 32:
+ raise ValueError("trusted_proxies: 代理数量超出上限")
+ normalized = []
+ for entry in entries:
+ try:
+ network = str(ipaddress.ip_network(entry, strict=False))
+ except ValueError:
+ raise ValueError(f"trusted_proxies: 地址无效 {entry!r}") from None
+ if network not in normalized:
+ normalized.append(network)
+ return ",".join(normalized)
+
+
def validate_projection_max_bytes(value):
"""Allow zero or enough room for a bounded head/tail warning."""
if value != 0 and value < 256:
@@ -105,6 +122,9 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=
"audit_max_bytes": _item(256 * 1024 * 1024, "integer", "审计明细预算", minimum=1024**2, maximum=1024**4),
"audit_retention_days": _item(30, "integer", "审计明细保留天数", minimum=1, maximum=36500),
"audit_diagnostic_bytes": _item(8192, "integer", "失败诊断最大字节", minimum=0, maximum=8192),
+ "trusted_proxies": _item("", "string", "可信代理地址(IP/CIDR,逗号分隔)",
+ env="CODEBUDDY2API_TRUSTED_PROXIES", allow_empty=True,
+ max_length=2000, validator=normalize_trusted_proxies),
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 42ef8db..ee6628f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -22,6 +22,8 @@ services:
CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false}
# Unset variables remain configurable through the WebUI.
CODEBUDDY2API_ADMIN_ORIGINS:
+ # Trusted proxy IPs/CIDRs; forwarded headers count only from these peers.
+ CODEBUDDY2API_TRUSTED_PROXIES:
CODEBUDDY2API_KEEP_TOOL_METADATA:
CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16}
CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate}
diff --git a/docs/deployment.md b/docs/deployment.md
index 8a1e49a..27a3257 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -13,6 +13,7 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o
| `CODEBUDDY2API_IMAGE` | Compose image; the template uses `codebuddy2api:local` |
| `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | Native listener and Compose host mapping; default `127.0.0.1:8787` |
| `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` |
+| `CODEBUDDY2API_TRUSTED_PROXIES` | Trusted proxy IPs/CIDRs (comma separated); when the direct peer matches, the audit log records the client from `X-Forwarded-For`/`X-Real-IP` instead of the proxy address |
| `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container |
| `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose |
The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, Responses projection, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit.
@@ -75,6 +76,8 @@ Without uv, run `python3 -m venv .venv`, activate it, install dependencies with
Both commands optionally read `.env` in the current working directory, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` are Compose-only.
+> The audit schema is single-writer: stop the old instance before starting a new version, and never run two gateway versions against one auth directory.
+
### Upgrade and state migration
Stop the gateway and back up the entire data directory first. Startup imports legacy JSON sessions, cooldowns, credit/trial history, catalogs and usage into `control.sqlite3` once. Old files remain as backups and are no longer read or updated. Invalid critical state or a failed migration stops startup; repair it or restore a backup rather than deleting ledgers to bypass validation.
diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md
index be1f8c9..b867106 100644
--- a/docs/deployment.zh-CN.md
+++ b/docs/deployment.zh-CN.md
@@ -13,6 +13,7 @@
| `CODEBUDDY2API_IMAGE` | Compose 镜像;模板默认 `codebuddy2api:local` |
| `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | 本地监听与 Compose 宿主机映射;默认 `127.0.0.1:8787` |
| `CODEBUDDY2API_AUTH_PATH` | Compose 宿主机数据目录;默认 `./auth`,挂载到容器 `/data/auth` |
+| `CODEBUDDY2API_TRUSTED_PROXIES` | 可信代理 IP/CIDR(逗号分隔);直连地址命中时审计改记 `X-Forwarded-For`/`X-Real-IP` 里的真实客户端地址,而不是代理地址 |
| `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` |
| `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 |
@@ -76,6 +77,8 @@ uv run converter.py
两种启动方式均可选读取当前工作目录的 `.env`,不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 仅用于 Compose。
+> 审计库为单写者设计:升级新版本前先停止旧实例,不要让两个版本的网关共用同一 auth 目录。
+
### 升级与数据迁移
升级前停止网关并备份整个数据目录。首次启动将旧 JSON 会话、冷却、积分/领取、目录和用量状态一次性导入 `control.sqlite3`;原文件保留作备份,不再参与读写。关键数据损坏或迁移失败会阻止启动,请修复或恢复备份,不要删除账本绕过检查。
diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py
index 01d080e..1e73209 100644
--- a/tests/test_audit_store.py
+++ b/tests/test_audit_store.py
@@ -12,7 +12,7 @@
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-from app.audit_store import AuditStore, _CLEANUP_BATCH
+from app.audit_store import SCHEMA_VERSION, AuditStore, _CLEANUP_BATCH
class AuditStoreTests(unittest.TestCase):
@@ -94,7 +94,7 @@ def test_all_dimensions_and_periods_persist_without_details(self):
self.store.record_request(self.record(input_tokens=0))
self.store.clear("details")
with closing(sqlite3.connect(self.path)) as db:
- self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 2)
+ self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], SCHEMA_VERSION)
for table in ("stats_hourly", "stats_daily", "stats_totals"):
rows = db.execute(f"SELECT dimension,payload FROM {table}").fetchall()
self.assertEqual({r[0] for r in rows}, {"global", "model", "profile", "credential"})
@@ -148,7 +148,7 @@ def test_v1_ingest_table_migrates_with_backfilled_timestamps(self):
store = AuditStore(path)
try:
with closing(sqlite3.connect(str(path))) as db:
- self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 2)
+ self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], SCHEMA_VERSION)
columns = [row[1] for row in db.execute("PRAGMA table_info(ingest)").fetchall()]
self.assertIn("created_at", columns)
self.assertGreater(db.execute(
@@ -520,8 +520,9 @@ def counted():
with closing(sqlite3.connect(self.path)) as fixture:
first = samples[-1][0] if samples else 0
for index in range(first, population):
- fixture.execute("INSERT INTO requests VALUES(?,?,?,?,?,?,?,?,?)", (
- f"seed{index}", stamp, None, None, None, "success", 200, "{}", 256))
+ fixture.execute(
+ "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes) VALUES(?,?,?,?,?,?,?,?,?)",
+ (f"seed{index}", stamp, None, None, None, "success", 200, "{}", 256))
fixture.execute("INSERT INTO attempts VALUES(?,?,?)", (f"seed{index}", 0, "{}"))
fixture.execute("INSERT INTO events VALUES(?,?,?,?,?,?)", (
f"event{index}", stamp, "runtime", "test", "{}", 128))
@@ -586,6 +587,54 @@ def test_closed_store_degrades_instead_of_throwing(self):
self.assertFalse(self.store.record_request(record)["ok"])
self.assertTrue(self.store.storage()["degraded"])
+ def test_client_ip_and_agent_are_sanitized_persisted_and_filterable(self):
+ good = self.record(id="meta-1", client_ip="2001:db8::1", user_agent="synthetic-agent/1.2")
+ self.assertTrue(self.store.record_request(good)["ok"])
+ dirty = self.record(id="meta-2", client_ip="fe80::1%eth0",
+ user_agent="evil\nBearer sk-secret\r\nX")
+ self.assertTrue(self.store.record_request(dirty)["ok"])
+ items = {item["id"]: item for item in self.store.list_records()["items"]}
+ self.assertEqual(items["meta-1"]["client_ip"], "2001:db8::1")
+ self.assertEqual(items["meta-1"]["user_agent"], "synthetic-agent/1.2")
+ self.assertIsNone(items["meta-2"]["client_ip"])
+ self.assertIsNone(items["meta-2"]["user_agent"])
+ self.assertEqual(self.store.list_records(client_ip="2001:db8::1")["items"][0]["id"], "meta-1")
+ self.assertEqual(self.store.list_records(client_ip="198.51.100.1")["items"], [])
+
+ def test_v2_database_gains_client_ip_without_losing_rows(self):
+ legacy_path = Path(self.temp.name) / "private" / "v2.sqlite3"
+ raw = sqlite3.connect(legacy_path)
+ with raw:
+ raw.execute("CREATE TABLE state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL,"
+ " detail_generation INTEGER NOT NULL, cleared_at REAL NOT NULL)")
+ raw.execute("INSERT INTO state VALUES(1,0,0,0)")
+ raw.execute("CREATE TABLE requests (id TEXT PRIMARY KEY, started_at REAL NOT NULL, model TEXT,"
+ " profile TEXT, credential TEXT, outcome TEXT, status_code INTEGER,"
+ " payload TEXT NOT NULL, logical_bytes INTEGER NOT NULL)")
+ raw.execute(
+ "INSERT INTO requests VALUES('legacy-1', ?, 'm', NULL, NULL, 'success', 200, ?, 128)",
+ (time.time(), json.dumps({"id": "legacy-1", "model": "m", "outcome": "success",
+ "status_code": 200, "attempts": []})))
+ raw.execute("PRAGMA user_version=2")
+ raw.close()
+ reopened = AuditStore(legacy_path)
+ self.addCleanup(reopened.close)
+ self.assertIn("client_ip",
+ [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")])
+ indexes = [row[1] for row in reopened._db.execute("PRAGMA index_list(requests)")]
+ self.assertIn("requests_client", indexes)
+ items = reopened.list_records()["items"]
+ self.assertEqual([item["id"] for item in items], ["legacy-1"])
+ self.assertIsNone(items[0].get("client_ip"))
+ self.assertTrue(reopened.record_request(
+ {"id": "meta-3", **reopened.ticket(), "started_at": time.time(),
+ "public_model": "test-model", "client_ip": "203.0.113.9",
+ "protocol": "chat", "status_code": 200, "outcome": "success"})["ok"])
+ self.assertEqual(reopened.list_records(client_ip="203.0.113.9")["items"][0]["id"], "meta-3")
+ accounting = reopened._db.execute("SELECT request_count FROM detail_accounting").fetchone()[0]
+ raw_count = reopened._db.execute("SELECT COUNT(*) FROM requests").fetchone()[0]
+ self.assertEqual(accounting, raw_count)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_observability.py b/tests/test_observability.py
index 332528a..9e001a4 100644
--- a/tests/test_observability.py
+++ b/tests/test_observability.py
@@ -10,7 +10,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.audit_store import AuditStore
-from app.observability import (AuditMiddleware, _Observation, _Parser, normalize_usage,
+from app.observability import (AuditMiddleware, _Observation, _Parser, client_meta, normalize_usage,
observe_attempt, observe_failure, observe_responses_projection,
observe_route, observe_usage)
@@ -23,14 +23,19 @@ async def asyncTearDown(self):
self.store.close()
self.temp.cleanup()
- async def invoke(self, app, path="/v1/chat/completions", method="POST", receiver=None, sender=None):
+ async def invoke(self, app, path="/v1/chat/completions", method="POST", receiver=None, sender=None,
+ attrs=None, trusted_proxies=None):
sent = []
async def receive():
self.fail("request body must not be inspected by middleware")
async def send(message):
sent.append(message)
- middleware = AuditMiddleware(app, {"audit_store": self.store})
- await middleware({"type": "http", "method": method, "path": path}, receiver or receive, sender or send)
+ config = {"audit_store": self.store}
+ if trusted_proxies is not None:
+ config["trusted_proxies"] = trusted_proxies
+ middleware = AuditMiddleware(app, config)
+ scope = {"type": "http", "method": method, "path": path, **(attrs or {})}
+ await middleware(scope, receiver or receive, sender or send)
return sent
def only_record(self):
@@ -339,6 +344,23 @@ async def app(scope, receive, send):
await task
self.assertEqual(self.only_record()["outcome"], "cancelled")
+ async def test_client_meta_recorded_and_proxy_unwrapped(self):
+ async def app(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"{}"})
+ attrs = {"client": ("127.0.0.1", 4000),
+ "headers": [(b"x-forwarded-for", b"198.51.100.9"), (b"user-agent", b"ua-test/2.0")]}
+ await self.invoke(app, attrs=attrs, trusted_proxies="127.0.0.1")
+ record = self.only_record()
+ self.assertEqual(record["client_ip"], "198.51.100.9")
+ self.assertEqual(record["user_agent"], "ua-test/2.0")
+ self.store.clear("all")
+ # Without a trusted proxy the forwarded header must not be trusted.
+ await self.invoke(app, attrs=attrs)
+ record = self.only_record()
+ self.assertEqual(record["client_ip"], "127.0.0.1")
+ self.assertEqual(record["user_agent"], "ua-test/2.0")
+
async def test_failure_hook_preserves_specific_code_on_truncated_stream(self):
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": 200,
@@ -394,5 +416,72 @@ def test_usage_invalid_values_are_unknown(self):
self.assertEqual(result, {"credit": 0, "cache_read_tokens": 0})
+class ClientMetaTests(unittest.TestCase):
+ def scope(self, client=("203.0.113.7", 443), headers=()):
+ return {"type": "http", "client": client,
+ "headers": [(key.encode(), value.encode()) for key, value in headers]}
+
+ def test_untrusted_direct_peer_ignores_forwarded_headers(self):
+ scope = self.scope(headers=[("X-Forwarded-For", "9.9.9.9"), ("User-Agent", "synthetic/1.0")])
+ self.assertEqual(client_meta(scope, "127.0.0.1"), ("203.0.113.7", "synthetic/1.0"))
+ self.assertEqual(client_meta(scope, ""), ("203.0.113.7", "synthetic/1.0"))
+
+ def test_repeated_forwarded_fields_are_combined_in_order(self):
+ # Client spoofs one XFF field; the trusted proxy appends the real
+ # chain as a second field. The combined chain must prefer the
+ # proxy-appended hop, not the client-supplied first field.
+ scope = self.scope(
+ client=("127.0.0.1", 8080),
+ headers=[
+ ("X-Forwarded-For", "6.6.6.6"),
+ ("X-Forwarded-For", "198.51.100.9, 10.0.0.9"),
+ ],
+ )
+ self.assertEqual(client_meta(scope, "127.0.0.1, 10.0.0.0/8")[0], "198.51.100.9")
+
+ def test_malformed_fallback_values_are_rejected(self):
+ real = self.scope(
+ client=("127.0.0.1", 8080),
+ headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip"), ("X-Real-IP", "garbage")],
+ )
+ # The malformed hop stops the walk; the X-Real-IP fallback is not an
+ # address either, so the trusted proxy address itself is recorded.
+ self.assertEqual(client_meta(real, "127.0.0.1")[0], "127.0.0.1")
+ # Even with a trusted proxy ahead of the garbage, a broken chain never
+ # promotes the client-controlled leftmost entry.
+ first = self.scope(
+ client=("127.0.0.1", 8080),
+ headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip")],
+ )
+ self.assertEqual(client_meta(first, "127.0.0.1, 10.0.0.0/8")[0], "127.0.0.1")
+
+ def test_trusted_proxy_unwraps_rightmost_untrusted_hop(self):
+ scope = self.scope(client=("127.0.0.1", 8080),
+ headers=[("X-Forwarded-For", "198.51.100.9, 10.0.0.9")])
+ self.assertEqual(client_meta(scope, "127.0.0.1,10.0.0.0/8")[0], "198.51.100.9")
+
+ def test_forged_entries_beyond_the_trusted_chain_are_ignored(self):
+ scope = self.scope(client=("127.0.0.1", 8080),
+ headers=[("X-Forwarded-For", "6.6.6.6, 198.51.100.9, 10.0.0.9")])
+ self.assertEqual(client_meta(scope, "127.0.0.1, 10.0.0.0/8")[0], "198.51.100.9")
+
+ def test_all_trusted_chain_falls_back_to_leftmost_and_real_ip(self):
+ chain = self.scope(client=("127.0.0.1", 1),
+ headers=[("X-Forwarded-For", "10.0.0.9, 10.0.1.9")])
+ self.assertEqual(client_meta(chain, "127.0.0.1, 10.0.0.0/8")[0], "10.0.0.9")
+ real = self.scope(client=("127.0.0.1", 1), headers=[("X-Real-IP", "198.51.100.9")])
+ self.assertEqual(client_meta(real, "127.0.0.1")[0], "198.51.100.9")
+
+ def test_user_agent_is_sanitized(self):
+ dirty = self.scope(headers=[("User-Agent", "a\nb Bearer x y")])
+ self.assertIsNone(client_meta(dirty, "")[1])
+ long = self.scope(headers=[("User-Agent", "x" * 500)])
+ self.assertEqual(client_meta(long, "")[1], "x" * 200)
+
+ def test_missing_client_scope_still_records_agent(self):
+ scope = {"type": "http", "headers": [(b"user-agent", b"ua/9")]}
+ self.assertEqual(client_meta(scope, ""), (None, "ua/9"))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/src/App.tsx b/web/src/App.tsx
index d0fd1c3..3865bdd 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -9,20 +9,22 @@ import { Logs } from "./pages/Logs";
import { Settings } from "./pages/Settings";
import { Appearance } from "./appearance";
import s from "./ui.module.scss";
+import { LanguageProvider, LanguageToggle, useT } from "./i18n";
const navigation = [
- ["", "概览", "grid"],
- ["/models", "模型路由", "model"],
- ["/credentials", "凭证管理", "key"],
- ["/logs", "日志审计", "logs"],
- ["/settings", "系统设置", "settings"],
+ ["", "nav.overview", "grid"],
+ ["/models", "nav.models", "model"],
+ ["/credentials", "nav.credentials", "key"],
+ ["/logs", "nav.logs", "logs"],
+ ["/settings", "nav.settings", "settings"],
];
export function Guard() {
+ const t = useT();
const { status, error } = useSession();
if (status === "checking")
return (
- 正在验证登录状态…
+ {t("app.verifying")}
);
if (status === "error")
@@ -34,12 +36,13 @@ export function Guard() {
void establishSession();
}}
/>
- 返回登录
+ {t("app.backToSignIn")}
);
return status === "authenticated" ? : ;
}
function Shell() {
+ const t = useT();
const location = useLocation();
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
@@ -62,61 +65,66 @@ function Shell() {
return (
- 跳转到内容
+ {t("app.skip")}
- 工作空间 /{" "}
- {navigation.find(([path]) => `/dashboard${path}` === location.pathname)?.[1] ?? "概览"}
+ {t("app.workspace")} /{" "}
+ {navigation.find(([path]) => `/dashboard${path}` === location.pathname)?.[1] ??
+ "nav.overview"}
- 会话已认证
+ {t("app.sessionActive")}
@@ -143,6 +151,7 @@ function Shell() {
);
}
function Login() {
+ const t = useT();
const status = useSession((state) => state.status);
const [key, setKey] = useState("");
const [busy, setBusy] = useState(false);
@@ -151,6 +160,7 @@ function Login() {
return (
@@ -160,14 +170,14 @@ function Login() {
CodeBuddy
-
网关管理
+
{t("login.subtitle")}
- 登录管理控制台
+ {t("login.title")}
@@ -223,5 +233,9 @@ export default function App() {
useEffect(() => {
void establishSession();
}, []);
- return ;
+ return (
+
+
+
+ );
}
diff --git a/web/src/Trend.tsx b/web/src/Trend.tsx
index 70a8388..a2976e6 100644
--- a/web/src/Trend.tsx
+++ b/web/src/Trend.tsx
@@ -1,6 +1,7 @@
import { useId, useState, type PointerEvent } from "react";
import { metric, number, text, type RecordValue } from "./api";
import { Empty } from "./components";
+import { useT } from "./i18n";
import s from "./ui.module.scss";
export function Trend({
@@ -12,11 +13,12 @@ export function Trend({
granularity?: string;
partial?: boolean;
}) {
+ const t = useT();
const id = useId();
const [selection, setSelection] = useState<{ rows: RecordValue[]; index: number } | null>(null);
const active = selection?.rows === rows ? selection.index : null;
if (!rows.length)
- return ;
+ return ;
const max = Math.max(1, ...rows.map((r) => number(r.requests) ?? 0));
const first = number(rows[0].bucket),
last = number(rows.at(-1)?.bucket);
@@ -53,7 +55,7 @@ export function Trend({
className={s.chart}
role="img"
tabIndex={0}
- aria-label={granularity === "hour" ? "按小时请求量趋势" : "按日请求量趋势"}
+ aria-label={granularity === "hour" ? t("dash.trend.hourly") : t("dash.trend.daily")}
aria-describedby={`${id}-hint${selected ? ` ${id}-tooltip` : ""}`}
onPointerMove={pointer}
onPointerDown={pointer}
@@ -139,15 +141,15 @@ export function Trend({
{text(selected.date)} · UTC
-
- 请求
+ - {t("dash.requests")}
- {metric(selected.requests)}
-
- 成功
+ - {t("logs.outcome.success")}
- {metric(selected.success)}
-
- 失败
+ - {t("logs.outcome.error")}
- {metric(selected.error)}
diff --git a/web/src/api.test.tsx b/web/src/api.test.tsx
index 0b8c6d4..9d0abe5 100644
--- a/web/src/api.test.tsx
+++ b/web/src/api.test.tsx
@@ -27,8 +27,8 @@ function response(data: unknown, config: InternalAxiosRequestConfig) {
}
describe("API and session state", () => {
it("keeps unknown distinct from explicit zero", () => {
- expect(metric(undefined)).toBe("未知");
- expect(metric(null)).toBe("未知");
+ expect(metric(undefined)).toBe("—");
+ expect(metric(null)).toBe("—");
expect(metric(0)).toBe("0");
});
it("rejects malformed DTO instead of fabricating empty success", () => {
diff --git a/web/src/api.ts b/web/src/api.ts
index 3b9ba95..848d6de 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -39,21 +39,19 @@ export function list(value: unknown, context = "列表"): RecordValue[] {
return value.map((item) => object(item, context));
}
export function text(value: unknown): string {
- if (value === undefined || value === null) return "未知";
+ if (value === undefined || value === null) return "—";
if (typeof value === "object") return JSON.stringify(value);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
return `${value}`;
- return "未知";
+ return "—";
}
export function number(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function metric(value: unknown): string {
const n = number(value);
- return n === null
- ? "未知"
- : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n);
+ return n === null ? "—" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n);
}
export function errorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
diff --git a/web/src/components.tsx b/web/src/components.tsx
index d318574..878ef9d 100644
--- a/web/src/components.tsx
+++ b/web/src/components.tsx
@@ -4,6 +4,7 @@ import { lockPage } from "./modal";
import { useExiting } from "./presence";
export { DrawerPresence } from "./presence";
import s from "./ui.module.scss";
+import { useT } from "./i18n";
export function Icon({ name = "grid" }: { name?: string }) {
const paths: Record = {
@@ -63,19 +64,22 @@ export function PageTitle({
);
}
export function ErrorNotice({ message, retry }: { message: string | null; retry?: () => void }) {
+ const t = useT();
return message ? (
{message}
- {retry && }
+ {retry && }
) : null;
}
-export function Empty({ title = "暂无数据", children }: { title?: string; children?: ReactNode }) {
+export function Empty({ title, children }: { title?: string; children?: ReactNode }) {
+ const t = useT();
+ const heading = title ?? t("common.empty");
return (
-
{title}
+
{heading}
{children &&
{children}
}
);
@@ -89,12 +93,13 @@ export function ResourceState({
error: string | null;
reload: () => void;
}) {
+ const t = useT();
return (
<>
{loading && (
- 加载中…
+ {t("common.loading")}
)}
@@ -145,6 +150,7 @@ export function Drawer({
dismissDisabled?: boolean;
className?: string;
}) {
+ const t = useT();
const exiting = useExiting();
const ref = useRef(null);
const outside = useRef(false);
@@ -184,7 +190,12 @@ export function Drawer({
{title}
-
@@ -195,13 +206,14 @@ export function Drawer({
}
export const CLEAR_CONFIRMATION = "清空全部日志与统计";
export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
+ const t = useT();
const [scope, setScope] = useState<"details" | "all">("details");
const [confirmation, setConfirmation] = useState("");
const [key, setKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
return (
-
+
diff --git a/web/src/copy.test.tsx b/web/src/copy.test.tsx
index 002d6f2..a42721a 100644
--- a/web/src/copy.test.tsx
+++ b/web/src/copy.test.tsx
@@ -154,7 +154,7 @@ describe("concise user-facing copy", () => {
expect(screen.getByRole("alert").textContent).toBe("统计可能不完整,请检查日志存储状态。");
expect(screen.getByText("人工停用")).toBeTruthy();
expect(screen.getByText("1 个模型冷却")).toBeTruthy();
- expect(screen.getAllByText("未知").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("—").length).toBeGreaterThan(0);
expect(screen.getByText("网关已知 Credit").closest("section")?.textContent).toContain("0");
expect(container.textContent).not.toMatch(
/一个客户端请求只统计一次|未知用量不计为零|人工状态与熔断独立|未导入的旧文本日志/,
diff --git a/web/src/i18n.test.tsx b/web/src/i18n.test.tsx
new file mode 100644
index 0000000..c789221
--- /dev/null
+++ b/web/src/i18n.test.tsx
@@ -0,0 +1,44 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+import { LanguageProvider, LanguageToggle, translate, useT } from "./i18n";
+
+beforeEach(() => {
+ try {
+ localStorage.clear();
+ } catch {
+ /* ignore */
+ }
+});
+
+function ProbeText() {
+ const t = useT();
+ return (
+ <>
+ {t("nav.overview")}
+ {t("logs.attemptN", { n: 3 })}
+ >
+ );
+}
+function Probe() {
+ return (
+
+
+
+
+ );
+}
+
+describe("i18n", () => {
+ it("defaults to Chinese and switches to English with persistence", () => {
+ render();
+ expect(screen.getByTestId("probe").textContent).toBe("概览");
+ fireEvent.click(screen.getByRole("button"));
+ // eslint-disable-next-line no-console
+ expect(screen.getByTestId("probe").textContent).toBe("Overview");
+ expect(screen.getByTestId("interp").textContent).toBe("Attempt 3");
+ expect(localStorage.getItem("codebuddy2api.lang")).toBe("en");
+ });
+ it("falls back to the key itself when an entry is missing", () => {
+ expect(translate("missing.key.entirely")).toBe("missing.key.entirely");
+ });
+});
diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx
new file mode 100644
index 0000000..ce26460
--- /dev/null
+++ b/web/src/i18n.tsx
@@ -0,0 +1,504 @@
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+import s from "./ui.module.scss";
+
+export type Lang = "zh" | "en";
+
+// Variable keys; both languages ship fully loaded. A missing entry falls back
+// to zh and finally to the key itself so partial coverage stays readable.
+const zh: Record = {
+ "app.skip": "跳转到内容",
+ "app.workspace": "工作空间",
+ "app.primaryNav": "主导航",
+ "app.brandSubtitle": "个人管理工作台",
+ "app.brandAria": "CodeBuddy 概览",
+ "app.expandSidebar": "展开侧栏",
+ "app.collapseSidebar": "收起侧栏",
+ "app.sessionActive": "会话已认证",
+ "app.signOut": "退出登录",
+ "app.verifying": "正在验证登录状态…",
+ "app.backToSignIn": "返回登录",
+ "app.language": "切换语言 / Switch language",
+ "nav.overview": "概览",
+ "nav.models": "模型路由",
+ "nav.credentials": "凭证管理",
+ "nav.logs": "日志审计",
+ "nav.settings": "系统设置",
+ "login.subtitle": "网关管理",
+ "login.title": "登录管理控制台",
+ "login.keyPlaceholder": "输入当前生效的 API key",
+ "login.verifying": "正在验证…",
+ "login.enter": "进入工作台",
+ "common.retry": "重试",
+ "common.empty": "暂无数据",
+ "common.loading": "加载中…",
+ "common.closeDrawer": "关闭抽屉",
+ "common.currentApiKey": "当前 API key",
+ "logs.title": "日志审计",
+ "logs.refresh": "刷新",
+ "logs.clear": "清理日志",
+ "logs.tab.request": "请求审计",
+ "logs.tab.runtime": "运行事件",
+ "logs.tab.admin": "管理操作记录",
+ "logs.panel.request": "请求记录",
+ "logs.panel.runtime": "运行事件",
+ "logs.panel.admin": "管理操作记录",
+ "logs.perPage": "每页 50 条",
+ "logs.filter.model": "筛选模型",
+ "logs.filter.credential": "筛选凭证",
+ "logs.filter.profile": "筛选产品",
+ "logs.filter.status": "筛选状态",
+ "logs.filter.clientIp": "筛选客户端 IP",
+ "logs.filter.search": "搜索日志",
+ "logs.placeholder.model": "模型 ID",
+ "logs.placeholder.credential": "凭证 account_key",
+ "logs.placeholder.clientIp": "客户端 IP",
+ "logs.placeholder.search": "搜索标识 / 操作…",
+ "logs.allProfiles": "全部产品",
+ "logs.allStatuses": "全部状态",
+ "logs.apply": "应用筛选",
+ "logs.reset": "重置",
+ "logs.col.time": "时间 / ID",
+ "logs.col.action": "操作 / 类型",
+ "logs.col.status": "状态",
+ "logs.col.duration": "耗时",
+ "logs.col.tokens": "Token",
+ "logs.viewDetail": "查看详情",
+ "logs.prevPage": "上一页",
+ "logs.nextPage": "下一页",
+ "logs.noMatch": "没有匹配的日志",
+ "logs.adjustFilters": "请尝试调整筛选。",
+ "logs.degraded": "日志读取已降级,列表可能不完整,请检查审计存储状态。",
+ "logs.detailTitle": "日志详情与实际尝试",
+ "logs.detailLoading": "正在加载详情…",
+ "logs.detailNeedsId": "请求记录缺少 ID,无法加载详情",
+ "logs.attempts": "实际尝试",
+ "logs.noAttempts": "暂无尝试记录。",
+ "logs.attemptN": "尝试 {n}",
+ "logs.outcome.success": "成功",
+ "logs.outcome.error": "失败",
+ "logs.outcome.cancelled": "取消",
+ "logs.outcome.warning": "警告",
+ "logs.kind.runtime": "运行事件",
+ "logs.kind.admin": "管理操作",
+ "logs.action.credits": "积分",
+ "logs.action.cred": "凭证",
+ "logs.action.models": "模型目录",
+ "logs.action.usage": "用量",
+ "logs.action.checkin": "签到",
+ "logs.action.dailychat": "每日对话",
+ "logs.action.buddy": "首领",
+ "logs.action.buddy_attention": "首领需要关注",
+ "logs.action.buddy_consent": "首领同意",
+ "logs.action.buddy_agreement": "首领协议",
+ "logs.action.buddy_departure": "首领出发",
+ "logs.action.buddy_departure_requested": "首领请求出发",
+ "logs.action.buddy_first": "首领领取",
+ "logs.action.buddy_task_accept": "首领任务接取",
+ "logs.action.buddy_task_chat": "首领任务对话",
+ "logs.action.buddy_task_completed": "首领任务完成",
+ "logs.action.buddy_authorization_used": "首领授权已使用",
+ "logs.action.credential_sync": "凭证同步",
+ "logs.action.credential_travel": "凭证旅行",
+ "logs.action.credential_travel_status": "凭证旅行状态",
+ "logs.action.credential_refresh": "凭证刷新",
+ "logs.action.credential_checkin": "凭证签到",
+ "logs.action.credential_daily_chat": "凭证每日对话",
+ "logs.action.credential_auto_daily_chat": "凭证自动每日对话",
+ "logs.action.credential_trial": "凭证试用",
+ "logs.action.credential_enabled": "凭证启停",
+ "logs.action.credential_reset_cooldown": "凭证清除冷却",
+ "logs.action.oauth_saved": "OAuth 凭证已保存",
+ "logs.action.settings_updated": "设置已更新",
+ "logs.action.model_created": "模型已创建",
+ "logs.action.model_updated": "模型已更新",
+ "logs.action.model_deleted": "模型已删除",
+ "dash.refresh": "刷新",
+ "dash.runtime": "运行概览",
+ "dash.trend": "请求趋势",
+ "dash.range": "统计时间范围",
+ "dash.granularity": "统计粒度",
+ "dash.granularityAuto": "自动粒度",
+ "dash.granularityDay": "按天",
+ "dash.granularityHour": "按小时",
+ "dash.granularityByRequests": "按请求量",
+ "dash.summary": "统计",
+ "dash.totalRequests": "请求总量",
+ "dash.successRate": "成功率",
+ "dash.totalTokens": "Token 用量",
+ "dash.requests": "请求",
+ "dash.models": "模型",
+ "dash.modelRank": "模型使用排行",
+ "dash.modelRankEmpty": "尚无模型请求记录",
+ "dash.profileUsage": "产品分布",
+ "dash.profileUsageEmpty": "暂无产品用量",
+ "dash.unknownModel": "未识别模型",
+ "dash.credentialHealth": "凭证健康",
+ "dash.credentialHealthEmpty": "尚未添加凭证",
+ "dash.health": "健康状态",
+ "dash.balance": "官方余额",
+ "dash.balanceNote": "账号余额,并非本网关消费统计。",
+ "dash.balanceEmpty": "暂无余额数据,可在凭证管理查看。",
+ "dash.balanceSnapshotEmpty": "暂无官方余额快照",
+ "dash.syncStatus": "同步状态",
+ "dash.syncTimeUnknown": "同步时间未知",
+ "dash.status.ready": "就绪",
+ "dash.status.done": "已完成",
+ "dash.status.normal": "正常",
+ "dash.status.manual": "人工停用",
+ "dash.status.syncError": "同步异常",
+ "dash.status.unknown": "状态未知",
+ "dash.status.batch": "分批清理中",
+ "dash.storage": "日志存储",
+ "dash.storageDegraded": "统计可能不完整,请检查日志存储状态。",
+ "dash.storagePartial": "小时记录不完整,仅展示已记录时段;可切换按天查看完整统计。",
+ "dash.storageDiagnose": "查看存储诊断",
+ "dash.storage.wal": "WAL 文件",
+ "dash.storage.shm": "SHM 文件",
+ "dash.storage.credits": "网关已知 Credit",
+ "dash.storage.creditsNote": "官方账号余额",
+ "dash.storage.unknown": "未知",
+ "dash.storage.collectDegraded": "采集降级",
+ "dash.credentials": "凭证",
+ "dash.window": "统计窗口",
+ "val.success": "成功",
+ "val.error": "失败",
+ "val.cancelled": "已取消",
+ "val.e.invalid_request": "无效请求",
+ "val.e.upstream": "上游错误",
+ "val.e.upstream_timeout": "上游超时",
+ "val.e.incomplete_stream": "流未完整结束",
+ "val.e.incomplete_response": "响应不完整",
+ "val.e.application": "应用异常",
+ "val.e.content_filter": "内容过滤",
+ "val.e.model_disabled": "模型已停用",
+ "val.e.sse": "SSE 错误",
+ "val.mode.compatible": "兼容模式",
+ "val.mode.realtime": "实时模式",
+ "val.usage.mixed": "混合来源",
+ "val.usage.wire_sse": "流式解析",
+ "val.usage.upstream_hook": "上游读取",
+ "val.catalog.shared": "共享目录",
+ "val.catalog.direct": "直接声明",
+ "val.code.cooldown": "熔断冷却",
+ "val.code.failure": "失败",
+ "val.code.updated": "更新",
+ "val.stage.route": "路由",
+ "val.stage.send": "发送",
+ "val.stage.collect": "聚合",
+ "val.stage.refresh": "刷新",
+ "val.level.info": "信息",
+ "trend.partialRange": "该范围缺少小时记录",
+ "trend.emptyRange": "当前时间范围内暂无请求",
+ "dash.trend.daily": "按日请求量趋势",
+ "dash.trend.hourly": "按小时请求量趋势",
+ "dash.lastNDays": "最近 {n} 天",
+ "dash.rangeHint": "最近 {n} 天 · UTC 自然日 · {grain}",
+ "dash.updatedAt": "更新于",
+ "dash.shareOfRequests": "请求占比",
+ "dash.modelCooldowns": "{n} 个模型冷却",
+ "dash.storage.cleanupStatus": "清理状态:",
+ "dash.storage.detailsLine": "明细 {used} / {max}",
+ "dash.balanceTitle": "官方账号余额",
+ "logs.clear.scope": "清理范围",
+ "logs.clear.detailsOnly": "仅清空明细 · 保留统计",
+ "logs.clear.everything": "全部日志与统计",
+ "logs.clear.detailsStrong": "仅清空日志明细,保留累计统计",
+ "logs.clear.detailsWarning": "将删除请求明细和事件日志,保留历史统计。不可撤销,建议先备份。",
+ "logs.clear.allWarning":
+ "将永久删除全部请求明细、事件日志和历史统计,不可撤销。建议先备份;凭证及网关配置不受影响。",
+ "logs.clear.confirm": "确认不可撤销的清理",
+ "logs.clear.busy": "正在清理…",
+ "logs.clear.unconfirmed": "后端未确认清理成功,请检查存储状态",
+ "logs.http429": "HTTP 429",
+ "logs.col.actions": "操作",
+ "logs.col.modelProfile": "模型 / 产品",
+ "logs.logTypes": "日志类型",
+ "logs.pageIncomplete": "日志分页响应不完整",
+};
+
+const en: Record = {
+ "app.skip": "Skip to content",
+ "app.workspace": "Workspace",
+ "app.primaryNav": "Primary navigation",
+ "app.brandSubtitle": "Personal workspace",
+ "app.brandAria": "CodeBuddy overview",
+ "app.expandSidebar": "Expand sidebar",
+ "app.collapseSidebar": "Collapse sidebar",
+ "app.sessionActive": "Session active",
+ "app.signOut": "Sign out",
+ "app.verifying": "Verifying session…",
+ "app.backToSignIn": "Back to sign-in",
+ "app.language": "切换语言 / Switch language",
+ "nav.overview": "Overview",
+ "nav.models": "Model routing",
+ "nav.credentials": "Credentials",
+ "nav.logs": "Audit logs",
+ "nav.settings": "Settings",
+ "login.subtitle": "Gateway management",
+ "login.title": "Sign in to the console",
+ "login.keyPlaceholder": "Enter the active API key",
+ "login.verifying": "Verifying…",
+ "login.enter": "Enter workspace",
+ "common.retry": "Retry",
+ "common.empty": "Nothing here yet",
+ "common.loading": "Loading…",
+ "common.closeDrawer": "Close drawer",
+ "common.currentApiKey": "Current API key",
+ "logs.title": "Audit logs",
+ "logs.refresh": "Refresh",
+ "logs.clear": "Clear logs",
+ "logs.tab.request": "Requests",
+ "logs.tab.runtime": "Runtime events",
+ "logs.tab.admin": "Admin actions",
+ "logs.panel.request": "Request records",
+ "logs.panel.runtime": "Runtime events",
+ "logs.panel.admin": "Admin action records",
+ "logs.perPage": "50 per page",
+ "logs.filter.model": "Filter model",
+ "logs.filter.credential": "Filter credential",
+ "logs.filter.profile": "Filter product",
+ "logs.filter.status": "Filter status",
+ "logs.filter.clientIp": "Filter client IP",
+ "logs.filter.search": "Search logs",
+ "logs.placeholder.model": "Model ID",
+ "logs.placeholder.credential": "Credential account_key",
+ "logs.placeholder.clientIp": "Client IP",
+ "logs.placeholder.search": "Search id / action…",
+ "logs.allProfiles": "All products",
+ "logs.allStatuses": "All statuses",
+ "logs.apply": "Apply filters",
+ "logs.reset": "Reset",
+ "logs.col.time": "Time / ID",
+ "logs.col.action": "Action / type",
+ "logs.col.status": "Status",
+ "logs.col.duration": "Duration",
+ "logs.col.tokens": "Tokens",
+ "logs.viewDetail": "Details",
+ "logs.prevPage": "Previous page",
+ "logs.nextPage": "Next page",
+ "logs.noMatch": "No matching logs",
+ "logs.adjustFilters": "Try adjusting the filters.",
+ "logs.degraded": "Log reads are degraded; the list may be incomplete. Check audit storage.",
+ "logs.detailTitle": "Log details and attempts",
+ "logs.detailLoading": "Loading details…",
+ "logs.detailNeedsId": "Request record has no ID; cannot load details",
+ "logs.attempts": "Attempts",
+ "logs.noAttempts": "No attempts recorded.",
+ "logs.attemptN": "Attempt {n}",
+ "logs.outcome.success": "Success",
+ "logs.outcome.error": "Failed",
+ "logs.outcome.cancelled": "Cancelled",
+ "logs.outcome.warning": "Warning",
+ "logs.kind.runtime": "Runtime event",
+ "logs.kind.admin": "Admin action",
+ "logs.action.credits": "Credits",
+ "logs.action.cred": "Credential",
+ "logs.action.models": "Model catalog",
+ "logs.action.usage": "Usage",
+ "logs.action.checkin": "Check-in",
+ "logs.action.dailychat": "Daily chat",
+ "logs.action.buddy": "Buddy",
+ "logs.action.buddy_attention": "Buddy needs attention",
+ "logs.action.buddy_consent": "Buddy consent",
+ "logs.action.buddy_agreement": "Buddy agreement",
+ "logs.action.buddy_departure": "Buddy departure",
+ "logs.action.buddy_departure_requested": "Buddy departure requested",
+ "logs.action.buddy_first": "Buddy claimed",
+ "logs.action.buddy_task_accept": "Buddy task accepted",
+ "logs.action.buddy_task_chat": "Buddy task chat",
+ "logs.action.buddy_task_completed": "Buddy task completed",
+ "logs.action.buddy_authorization_used": "Buddy authorization used",
+ "logs.action.credential_sync": "Credential sync",
+ "logs.action.credential_travel": "Credential travel",
+ "logs.action.credential_travel_status": "Credential travel status",
+ "logs.action.credential_refresh": "Credential refresh",
+ "logs.action.credential_checkin": "Credential check-in",
+ "logs.action.credential_daily_chat": "Credential daily chat",
+ "logs.action.credential_auto_daily_chat": "Credential auto daily chat",
+ "logs.action.credential_trial": "Credential trial",
+ "logs.action.credential_enabled": "Credential enabled",
+ "logs.action.credential_reset_cooldown": "Credential cooldown reset",
+ "logs.action.oauth_saved": "OAuth credential saved",
+ "logs.action.settings_updated": "Settings updated",
+ "logs.action.model_created": "Model created",
+ "logs.action.model_updated": "Model updated",
+ "logs.action.model_deleted": "Model deleted",
+ "dash.refresh": "Refresh",
+ "dash.runtime": "Runtime overview",
+ "dash.trend": "Request trend",
+ "dash.range": "Statistics range",
+ "dash.granularity": "Granularity",
+ "dash.granularityAuto": "Auto",
+ "dash.granularityDay": "By day",
+ "dash.granularityHour": "By hour",
+ "dash.granularityByRequests": "By requests",
+ "dash.summary": "Summary",
+ "dash.totalRequests": "Total requests",
+ "dash.successRate": "Success rate",
+ "dash.totalTokens": "Token usage",
+ "dash.requests": "Requests",
+ "dash.models": "Models",
+ "dash.modelRank": "Model usage ranking",
+ "dash.modelRankEmpty": "No model requests yet",
+ "dash.profileUsage": "Product distribution",
+ "dash.profileUsageEmpty": "No product usage yet",
+ "dash.unknownModel": "Unidentified model",
+ "dash.credentialHealth": "Credential health",
+ "dash.credentialHealthEmpty": "No credentials yet",
+ "dash.health": "Health",
+ "dash.balance": "Official balance",
+ "dash.balanceNote": "Account balance, not this gateway's consumption.",
+ "dash.balanceEmpty": "No balance data yet; check credential management.",
+ "dash.balanceSnapshotEmpty": "No official balance snapshot",
+ "dash.syncStatus": "Sync status",
+ "dash.syncTimeUnknown": "Sync time unknown",
+ "dash.status.ready": "Ready",
+ "dash.status.done": "Completed",
+ "dash.status.normal": "Normal",
+ "dash.status.manual": "Manually disabled",
+ "dash.status.syncError": "Sync error",
+ "dash.status.unknown": "Unknown status",
+ "dash.status.batch": "Batch cleanup running",
+ "dash.storage": "Log storage",
+ "dash.storageDegraded": "Statistics may be incomplete; check log storage.",
+ "dash.storagePartial":
+ "Hourly records are incomplete; only recorded hours are shown. Switch to daily for full totals.",
+ "dash.storageDiagnose": "View storage diagnostics",
+ "dash.storage.wal": "WAL file",
+ "dash.storage.shm": "SHM file",
+ "dash.storage.credits": "Gateway-known credits",
+ "dash.storage.creditsNote": "Official account balance",
+ "dash.storage.unknown": "Unknown",
+ "dash.storage.collectDegraded": "Collection degraded",
+ "dash.credentials": "Credentials",
+ "dash.window": "Statistics window",
+ "val.success": "Success",
+ "val.error": "Failed",
+ "val.cancelled": "Cancelled",
+ "val.e.invalid_request": "Invalid request",
+ "val.e.upstream": "Upstream error",
+ "val.e.upstream_timeout": "Upstream timeout",
+ "val.e.incomplete_stream": "Incomplete stream",
+ "val.e.incomplete_response": "Incomplete response",
+ "val.e.application": "Application error",
+ "val.e.content_filter": "Content filter",
+ "val.e.model_disabled": "Model disabled",
+ "val.e.sse": "SSE error",
+ "val.mode.compatible": "Compatible mode",
+ "val.mode.realtime": "Realtime mode",
+ "val.usage.mixed": "Mixed sources",
+ "val.usage.wire_sse": "Stream parsing",
+ "val.usage.upstream_hook": "Upstream read",
+ "val.catalog.shared": "Shared catalog",
+ "val.catalog.direct": "Direct declaration",
+ "val.code.cooldown": "Cooldown",
+ "val.code.failure": "Failure",
+ "val.code.updated": "Updated",
+ "val.stage.route": "Route",
+ "val.stage.send": "Send",
+ "val.stage.collect": "Collect",
+ "val.stage.refresh": "Refresh",
+ "val.level.info": "Info",
+ "trend.partialRange": "This range lacks hourly records",
+ "trend.emptyRange": "No requests in this range",
+ "dash.trend.daily": "Daily request trend",
+ "dash.trend.hourly": "Hourly request trend",
+ "dash.lastNDays": "Last {n} days",
+ "dash.rangeHint": "Last {n} days · UTC days · {grain}",
+ "dash.updatedAt": "Updated",
+ "dash.shareOfRequests": "share of requests",
+ "dash.modelCooldowns": "{n} model cooldowns",
+ "dash.storage.cleanupStatus": "Cleanup:",
+ "dash.storage.detailsLine": "Details {used} / {max}",
+ "dash.balanceTitle": "Official account balance",
+ "logs.clear.scope": "Scope",
+ "logs.clear.detailsOnly": "Details only · keep aggregates",
+ "logs.clear.everything": "All logs and aggregates",
+ "logs.clear.detailsStrong": "Clear log details only, keep aggregates",
+ "logs.clear.detailsWarning":
+ "Deletes request details and event logs, keeping aggregates. Irreversible; back up first.",
+ "logs.clear.allWarning":
+ "Permanently deletes all request details, event logs and historical aggregates. Irreversible. Back up first; credentials and gateway config are unaffected.",
+ "logs.clear.confirm": "Confirm irreversible cleanup",
+ "logs.clear.busy": "Clearing…",
+ "logs.clear.unconfirmed": "Backend did not confirm the cleanup; check storage state",
+ "logs.http429": "HTTP 429",
+ "logs.col.actions": "Actions",
+ "logs.col.modelProfile": "Model / product",
+ "logs.logTypes": "Log types",
+ "logs.pageIncomplete": "Log page response incomplete",
+};
+
+const messages: Record> = { zh, en };
+const STORAGE_KEY = "codebuddy2api.lang";
+const LANG_EVENT = "codebuddy.lang";
+
+let currentLang: Lang = "zh";
+const LangContext = createContext("zh");
+
+export function translate(key: string, vars?: Record): string {
+ const template = messages[currentLang][key] ?? messages.zh[key] ?? key;
+ return vars
+ ? template.replace(/\{(\w+)\}/g, (name) => String(vars[name.slice(1, -1)] ?? name))
+ : template;
+}
+
+// Global translator: the provider re-renders the whole tree on language
+// change, so plain t() calls stay reactive without per-component hooks.
+export const t = translate;
+export function useLang(): Lang {
+ return useContext(LangContext);
+}
+export function useT() {
+ const lang = useLang();
+ return useMemo(
+ () => (key: string, vars?: Record) => translate(key, vars),
+ [lang],
+ );
+}
+
+export function LanguageProvider({ children }: { children: ReactNode }) {
+ const [lang, setLang] = useState(() => {
+ try {
+ return localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "zh";
+ } catch {
+ return "zh";
+ }
+ });
+ currentLang = lang;
+ useEffect(() => {
+ // 只更新文档语言;localStorage 仅在用户主动切换时写入,
+ // 保证未操作的访客在站点上不留下任何存储痕迹。
+ document.documentElement.lang = lang === "en" ? "en" : "zh-CN";
+ }, [lang]);
+ useEffect(() => {
+ const apply = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ if (detail === "zh" || detail === "en") setLang(detail);
+ };
+ window.addEventListener(LANG_EVENT, apply);
+ return () => window.removeEventListener(LANG_EVENT, apply);
+ }, []);
+ return {children};
+}
+
+export function LanguageToggle() {
+ return (
+ {
+ const next = currentLang === "zh" ? "en" : "zh";
+ try {
+ localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ /* ignore */
+ }
+ window.dispatchEvent(new CustomEvent(LANG_EVENT, { detail: next }));
+ }}
+ aria-label={translate("app.language")}
+ title={translate("app.language")}
+ >
+ {currentLang === "zh" ? "EN" : "中"}
+
+ );
+}
diff --git a/web/src/interaction.test.tsx b/web/src/interaction.test.tsx
index 3da7968..31363e8 100644
--- a/web/src/interaction.test.tsx
+++ b/web/src/interaction.test.tsx
@@ -101,7 +101,7 @@ describe("structured metadata", () => {
expect(screen.getByText("已停用")).toBeTruthy();
expect(screen.getByText("1.0 KiB")).toBeTruthy();
expect(screen.getByText("Example")).toBeTruthy();
- expect(screen.getByText("未知")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(container.textContent).not.toContain('"name":');
});
diff --git a/web/src/logs.test.tsx b/web/src/logs.test.tsx
index e8aa07a..f006312 100644
--- a/web/src/logs.test.tsx
+++ b/web/src/logs.test.tsx
@@ -23,7 +23,7 @@ beforeEach(() => {
vi.mocked(useResource).mockReturnValue({
data: {
items: [
- { id: "request-a", model: "model-a", outcome: "success" },
+ { id: "request-a", model: "model-a", outcome: "success", client_ip: "198.51.100.9" },
{ id: "request-b", model: "model-b", outcome: "success" },
],
has_more: false,
@@ -43,6 +43,48 @@ function close() {
fireEvent.click(screen.getByRole("button", { name: "关闭抽屉" }));
}
+describe("runtime event labels", () => {
+ it("renders kind, action and outcome in Chinese with raw values on hover", () => {
+ vi.mocked(useResource).mockReturnValue({
+ data: {
+ items: [
+ { id: "event-1", kind: "runtime", action: "credential.sync", started_at: 1 },
+ {
+ id: "event-2",
+ kind: "admin",
+ action: "mystery.action",
+ details: { outcome: "success" },
+ },
+ ],
+ has_more: false,
+ next_cursor: null,
+ },
+ loading: false,
+ error: null,
+ reload: vi.fn(),
+ });
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "运行事件" }));
+ expect(screen.getByTitle("credential.sync").textContent).toBe("凭证同步");
+ expect(screen.getByTitle("runtime").textContent).toBe("运行事件");
+ expect(screen.getByText("mystery.action")).toBeTruthy();
+ expect(screen.getByText("成功")).toBeTruthy();
+ });
+});
+
+describe("request client metadata", () => {
+ it("shows the client IP and filters requests by it", () => {
+ render();
+ expect(screen.getByText("198.51.100.9")).toBeTruthy();
+ fireEvent.change(screen.getByLabelText("筛选客户端 IP"), {
+ target: { value: "198.51.100.9" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "应用筛选" }));
+ const call = vi.mocked(useResource).mock.calls.at(-1)![0] as string;
+ expect(call).toContain("client_ip=198.51.100.9");
+ });
+});
+
describe("log detail request lifetime", () => {
it("does not reopen a closed drawer when its response arrives", async () => {
const request = pending();
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx
index 3d6dabb..d0d38dd 100644
--- a/web/src/pages/Dashboard.tsx
+++ b/web/src/pages/Dashboard.tsx
@@ -27,19 +27,20 @@ function normalize(value: unknown): RecordValue & {
}
return {
...d,
- summary: object(d.summary, "统计"),
+ summary: object(d.summary, t("dash.summary")),
series: list(d.series, "趋势"),
models: list(d.models, "模型排行"),
- profiles: list(d.profiles, "产品分布"),
+ profiles: list(d.profiles, t("dash.profileUsage")),
health: object(d.health, "健康状态"),
storage: object(d.storage, "存储状态"),
- range: d.range === undefined ? {} : object(d.range, "统计窗口"),
+ range: d.range === undefined ? {} : object(d.range, t("dash.window")),
};
}
function Health({ data }: { data: RecordValue }) {
+ const t = useT();
if (!Array.isArray(data.credentials)) return ;
const credentials = list(data.credentials);
- if (!credentials.length) return ;
+ if (!credentials.length) return ;
return (
{credentials.map((c, i) => (
@@ -50,10 +51,14 @@ function Health({ data }: { data: RecordValue }) {
- {c.enabled === false ? "人工停用" : c.health === "ready" ? "就绪" : text(c.health)}
+ {c.enabled === false
+ ? t("dash.status.manual")
+ : c.health === "ready"
+ ? t("dash.status.ready")
+ : text(c.health)}
{Array.isArray(c.cooldowns) && c.cooldowns.length > 0 && (
- {c.cooldowns.length} 个模型冷却
+ {t("dash.modelCooldowns", { n: c.cooldowns.length })}
)}
@@ -62,16 +67,17 @@ function Health({ data }: { data: RecordValue }) {
);
}
function OfficialCredits({ value }: { value: unknown }) {
+ const t = useT();
const rows = Object.entries(object(value));
- if (!rows.length) return
;
+ if (!rows.length) return
;
return (
- | 凭证 |
- 官方余额 |
- 同步状态 |
+ {t("dash.credentials")} |
+ {t("dash.balance")} |
+ {t("dash.syncStatus")} |
@@ -84,12 +90,12 @@ function OfficialCredits({ value }: { value: unknown }) {
{metric(credits?.credits ?? credits?.remaining)} |
{row.error ? (
- 同步异常
+ {t("dash.status.syncError")}
) : (
{typeof row.fetched_at === "number"
? new Date(row.fetched_at * 1000).toLocaleString("zh-CN")
- : "同步时间未知"}
+ : t("dash.syncTimeUnknown")}
)}
|
@@ -103,14 +109,16 @@ function OfficialCredits({ value }: { value: unknown }) {
}
function bytes(value: unknown) {
const n = number(value);
- if (n === null) return "未知";
+ if (n === null) return t("dash.storage.unknown");
if (n < 1024) return `${metric(n)} B`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / 1048576).toFixed(1)} MiB`;
}
import { Trend } from "../Trend";
+import { t, useT } from "../i18n";
export { Trend } from "../Trend";
export function Dashboard() {
+ const t = useT();
const [days, setDays] = useState("7");
const [granularity, setGranularity] = useState("auto");
const resource = useResource(`/dashboard?days=${days}&granularity=${granularity}`, normalize);
@@ -121,32 +129,32 @@ export function Dashboard() {
return (
<>
- 刷新
+ {t("dash.refresh")}
>
}
@@ -156,15 +164,19 @@ export function Dashboard() {
<>
{(d.degraded === true || d.storage.degraded === true) && (
- 统计可能不完整,请检查日志存储状态。
+ {t("dash.storageDegraded")}
)}
{[
- ["请求总量", metric(d.summary.requests), "arrow"],
- ["成功率", rate === null ? "未知" : `${(rate * 100).toFixed(1)}%`, "shield"],
- ["Token 用量", metric(d.summary.total_tokens), "model"],
- ["网关已知 Credit", metric(d.summary.credit), "leaf"],
+ [t("dash.totalRequests"), metric(d.summary.requests), "arrow"],
+ [
+ t("dash.successRate"),
+ rate === null ? t("dash.storage.unknown") : `${(rate * 100).toFixed(1)}%`,
+ "shield",
+ ],
+ [t("dash.totalTokens"), metric(d.summary.total_tokens), "model"],
+ [t("dash.storage.credits"), metric(d.summary.credit), "leaf"],
].map(([label, value, icon]) => (
@@ -179,18 +191,22 @@ export function Dashboard() {
{period.partial === true && (
- 小时记录不完整,仅展示已记录时段;可切换按天查看完整统计。
+ {t("dash.storagePartial")}
)}
-
+
{d.profiles.length ? (
{d.profiles.map((p, i) => {
@@ -205,24 +221,24 @@ export function Dashboard() {
);
})}
) : (
-
+
)}
-
+
{d.models.length ? (
- | 模型 |
- 请求 |
+ {t("dash.models")} |
+ {t("dash.requests")} |
Token |
Credit |
@@ -235,7 +251,7 @@ export function Dashboard() {
|
{String(i + 1).padStart(2, "0")}
- {text(m.model) || "未识别模型"}
+ {text(m.model) || t("dash.unknownModel")}
|
{metric(m.requests)} |
{metric(m.total_tokens)} |
@@ -246,13 +262,13 @@ export function Dashboard() {
) : (
-
+
)}
-
+
-
+
{d.storage.degraded === true
- ? "采集降级"
+ ? t("dash.storage.collectDegraded")
: d.storage.degraded === false
- ? "正常"
- : "状态未知"}
+ ? t("dash.status.normal")
+ : t("dash.status.unknown")}
- 明细 {bytes(d.storage.logical_bytes)} / {bytes(d.storage.max_bytes)}
+ {t("dash.storage.detailsLine", {
+ used: bytes(d.storage.logical_bytes),
+ max: bytes(d.storage.max_bytes),
+ })}
- 查看存储诊断
+ {t("dash.storageDiagnose")}
-
- 账号余额,并非本网关消费统计。
+
+ {t("dash.balanceNote")}
{d.official_credits !== undefined ? (
) : (
- 暂无余额数据,可在凭证管理查看。
+ {t("dash.balanceEmpty")}
)}
- 更新于{" "}
+ {t("dash.updatedAt")}{" "}
{typeof d.generated_at === "number"
? new Date(d.generated_at * 1000).toLocaleString("zh-CN")
: text(d.generated_at)}{" "}
diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx
index 3f1a44e..2548a3c 100644
--- a/web/src/pages/Logs.tsx
+++ b/web/src/pages/Logs.tsx
@@ -22,12 +22,14 @@ import {
Panel,
ResourceState,
} from "../components";
+import { eventActionLabel, eventKindLabel } from "../values";
import { profiles } from "./Models";
import s from "../ui.module.scss";
+import { t, useT } from "../i18n";
function normalize(value: unknown) {
const data = object(value);
if (typeof data.has_more !== "boolean" || (data.has_more && typeof data.next_cursor !== "string"))
- throw new Error("日志分页响应不完整");
+ throw new Error(t("logs.pageIncomplete"));
return {
items: list(data.items),
next_cursor: typeof data.next_cursor === "string" ? data.next_cursor : null,
@@ -35,8 +37,22 @@ function normalize(value: unknown) {
degraded: data.degraded === true,
};
}
-const emptyFilters = { model: "", credential: "", profile: "", status: "", search: "" };
+const emptyFilters = {
+ model: "",
+ credential: "",
+ profile: "",
+ status: "",
+ client_ip: "",
+ search: "",
+};
+const outcomeLabels: Record = {
+ success: "logs.outcome.success",
+ error: "logs.outcome.error",
+ cancelled: "logs.outcome.cancelled",
+ warning: "logs.outcome.warning",
+};
export function Logs() {
+ const t = useT();
const [kind, setKind] = useState("request");
const [draft, setDraft] = useState(emptyFilters);
const [filters, setFilters] = useState(emptyFilters);
@@ -68,7 +84,7 @@ export function Logs() {
setDetailLoading(false);
if (kind !== "request") return;
if (typeof item.id !== "string") {
- setDetailError("请求记录缺少 ID,无法加载详情");
+ setDetailError(t("logs.detailNeedsId"));
return;
}
const controller = new AbortController();
@@ -93,22 +109,22 @@ export function Logs() {
return (
<>
- 刷新
+ {t("logs.refresh")}
- setClear(true)}>清理日志
+ setClear(true)}>{t("logs.clear")}
>
}
/>
-
+
{[
- ["request", "请求审计"],
- ["runtime", "运行事件"],
- ["admin", "管理操作"],
+ ["request", t("logs.tab.request")],
+ ["runtime", t("logs.tab.runtime")],
+ ["admin", t("logs.kind.admin")],
].map(([value, label]) => (
{resource.data?.degraded && (
- 日志读取已降级,列表可能不完整,请检查审计存储状态。
+ {t("logs.degraded")}
)}
{resource.data &&
@@ -206,16 +234,18 @@ export function Logs() {
- | 时间 / ID |
- {kind === "request" ? "模型 / 产品" : "操作 / 类型"} |
- 状态 |
+ {t("logs.col.time")} |
+
+ {kind === "request" ? t("logs.col.modelProfile") : t("logs.col.action")}
+ |
+ {t("logs.col.status")} |
{kind === "request" && (
<>
- 耗时 |
+ {t("logs.col.duration")} |
Token / Credit |
>
)}
- 操作 |
+ {t("logs.col.actions")} |
@@ -231,8 +261,27 @@ export function Logs() {
{text(item.id)}
- {text(kind === "request" ? item.model : item.action)}
- {text(kind === "request" ? item.profile : item.kind)}
+
+ {text(
+ kind === "request"
+ ? item.model
+ : eventActionLabel(item.action)
+ ? t(eventActionLabel(item.action)!)
+ : item.action,
+ )}
+
+
+ {text(
+ kind === "request"
+ ? item.profile
+ : eventKindLabel(item.kind)
+ ? t(eventKindLabel(item.kind)!)
+ : item.kind,
+ )}
+
+ {kind === "request" && item.client_ip ? (
+ {text(item.client_ip)}
+ ) : null}
|
- {outcome === "warning"
- ? "警告"
- : text(outcome ?? item.level ?? item.status)}
+ {(typeof outcome === "string"
+ ? t(outcomeLabels[outcome])
+ : undefined) ?? text(outcome ?? item.level ?? item.status)}
{item.status_code !== undefined && (
HTTP {text(item.status_code)}
@@ -265,7 +314,7 @@ export function Logs() {
)}
|
openDetail(item)}>
- 查看详情
+ {t("logs.viewDetail")}
|
@@ -275,7 +324,7 @@ export function Logs() {
) : (
-
请尝试调整筛选。
+
{t("logs.adjustFilters")}
))}
第 {cursors.length + 1} 页
@@ -284,7 +333,7 @@ export function Logs() {
disabled={!cursors.length || resource.loading}
onClick={() => setCursors((old) => old.slice(0, -1))}
>
- 上一页
+ {t("logs.prevPage")}
[...old, resource.data!.next_cursor!]);
}}
>
- 下一页
+ {t("logs.nextPage")}
{detail && (
-
+
{detailLoading ? (
- 正在加载详情…
+ {t("logs.detailLoading")}
) : (
<>
{Array.isArray(detail.attempts) && (
-
+
{detail.attempts.length ? (
list(detail.attempts).map((attempt, i) => (
-
尝试 {i + 1}
+ {t("logs.attemptN", { n: i + 1 })}
))
) : (
- 暂无尝试记录。
+ {t("logs.noAttempts")}
)}
)}
diff --git a/web/src/values.tsx b/web/src/values.tsx
index 4d36a34..8742e20 100644
--- a/web/src/values.tsx
+++ b/web/src/values.tsx
@@ -1,4 +1,5 @@
import { metric, type RecordValue } from "./api";
+import { t as translate } from "./i18n";
import s from "./ui.module.scss";
const labels: Record = {
@@ -143,6 +144,8 @@ const labels: Record = {
schema_version: "存储版本",
epoch: "统计代次",
degraded: "存储降级",
+ client_ip: "客户端 IP",
+ user_agent: "客户端 UA",
failure_count: "失败次数",
dropped_records: "未写入记录数",
closed: "存储已关闭",
@@ -241,12 +244,86 @@ const ownLabel = (map: Record, key: string) =>
export function profileLabel(value: string) {
return ownLabel(profiles, value) ?? value;
}
+const eventKinds: Record = {
+ runtime: "logs.kind.runtime",
+ admin: "logs.kind.admin",
+};
+const eventActions: Record = {
+ credits: "logs.action.credits",
+ cred: "logs.action.cred",
+ models: "logs.action.models",
+ usage: "logs.action.usage",
+ checkin: "logs.action.checkin",
+ dailychat: "logs.action.dailychat",
+ buddy: "logs.action.buddy",
+ "buddy.attention_required": "logs.action.buddy_attention",
+ "buddy.consent": "logs.action.buddy_consent",
+ "buddy.agreement": "logs.action.buddy_agreement",
+ "buddy.departure": "logs.action.buddy_departure",
+ "buddy.departure_requested": "logs.action.buddy_departure_requested",
+ "buddy.first": "logs.action.buddy_first",
+ "buddy.task_accept": "logs.action.buddy_task_accept",
+ "buddy.task_chat": "logs.action.buddy_task_chat",
+ "buddy.task_completed": "logs.action.buddy_task_completed",
+ "buddy.authorization_used": "logs.action.buddy_authorization_used",
+ "credential.sync": "logs.action.credential_sync",
+ "credential.travel": "logs.action.credential_travel",
+ "credential.travel-status": "logs.action.credential_travel_status",
+ "credential.refresh": "logs.action.credential_refresh",
+ "credential.checkin": "logs.action.credential_checkin",
+ "credential.daily-chat": "logs.action.credential_daily_chat",
+ "credential.auto_daily_chat": "logs.action.credential_auto_daily_chat",
+ "credential.trial": "logs.action.credential_trial",
+ "credential.enabled": "logs.action.credential_enabled",
+ "credential.reset-cooldown": "logs.action.credential_reset_cooldown",
+ "oauth.saved": "logs.action.oauth_saved",
+ "settings.updated": "logs.action.settings_updated",
+ "model.created": "logs.action.model_created",
+ "model.updated": "logs.action.model_updated",
+ "model.deleted": "logs.action.model_deleted",
+};
+export function eventActionLabel(action: unknown) {
+ return typeof action === "string" ? ownLabel(eventActions, action) : undefined;
+}
+export function eventKindLabel(kind: unknown) {
+ return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined;
+}
export function bytes(value: number) {
if (value < 1024) return `${metric(value)} B`;
if (value < 1048576) return `${(value / 1024).toFixed(1)} KiB`;
if (value < 1073741824) return `${(value / 1048576).toFixed(1)} MiB`;
return `${(value / 1073741824).toFixed(2)} GiB`;
}
+const valueKeys: Record = {
+ success: "val.success",
+ error: "val.error",
+ cancelled: "val.cancelled",
+ invalid_request_error: "val.e.invalid_request",
+ upstream_error: "val.e.upstream",
+ upstream_timeout: "val.e.upstream_timeout",
+ incomplete_stream: "val.e.incomplete_stream",
+ incomplete_response: "val.e.incomplete_response",
+ application_error: "val.e.application",
+ content_filter: "val.e.content_filter",
+ model_disabled: "val.e.model_disabled",
+ sse_error: "val.e.sse",
+ compatible: "val.mode.compatible",
+ realtime: "val.mode.realtime",
+ mixed: "val.usage.mixed",
+ wire_sse: "val.usage.wire_sse",
+ upstream_hook: "val.usage.upstream_hook",
+ shared: "val.catalog.shared",
+ direct: "val.catalog.direct",
+ cooldown: "val.code.cooldown",
+ failure: "val.code.failure",
+ updated: "val.code.updated",
+ route: "val.stage.route",
+ send: "val.stage.send",
+ collect: "val.stage.collect",
+ refresh: "val.stage.refresh",
+ info: "val.level.info",
+};
+
export function DataValue({
value,
name = "",
@@ -256,7 +333,14 @@ export function DataValue({
name?: string;
depth?: number;
}) {
- if (value === null || value === undefined) return 未知;
+ if (value === null || value === undefined) return —;
+ if (typeof value === "string" && Object.hasOwn(valueKeys, value)) {
+ return (
+
+ {translate(valueKeys[value] as string)}
+
+ );
+ }
if (typeof value === "boolean") {
const warning = ["partial", "degraded", "token_expired", "closed"].includes(name);
return (
@@ -266,7 +350,7 @@ export function DataValue({
);
}
if (typeof value === "number") {
- if (!Number.isFinite(value)) return 未知;
+ if (!Number.isFinite(value)) return —;
if (times.has(name)) {
if (value === 0) return <>未设置>;
const date = new Date(value > 1e11 ? value : value * 1000);