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 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")}

{ e.preventDefault(); @@ -189,12 +199,12 @@ function Login() { required value={key} onChange={(e) => setKey(e.target.value)} - placeholder="输入当前生效的 API key" + placeholder={t("login.keyPlaceholder")} /> @@ -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 ( - +
{ e.preventDefault(); @@ -216,8 +228,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () ...(scope === "all" ? { api_key: currentKey } : {}), }) .then((response) => { - if (object(response.data).ok !== true) - throw new Error("后端未确认清理成功,请检查存储状态"); + if (object(response.data).ok !== true) throw new Error(t("logs.clear.unconfirmed")); onDone(); onClose(); }) @@ -226,7 +237,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () }} >
- {scope === "all" ? CLEAR_CONFIRMATION : "仅清空日志明细,保留累计统计"} -

- {scope === "all" - ? "将永久删除全部请求明细、事件日志和历史统计,不可撤销。建议先备份;凭证及网关配置不受影响。" - : "将删除请求明细和事件日志,保留历史统计。不可撤销,建议先备份。"} -

+ {scope === "all" ? CLEAR_CONFIRMATION : t("logs.clear.detailsStrong")} +

{scope === "all" ? t("logs.clear.allWarning") : t("logs.clear.detailsWarning")}

{scope === "all" && ( @@ -261,7 +268,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () />
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 ( + + ); +} 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 (
- - - + + + @@ -84,12 +90,12 @@ function OfficialCredits({ value }: { value: unknown }) { @@ -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 ( <> } @@ -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.credentials")}{t("dash.balance")}{t("dash.syncStatus")}
{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")} )}
- - + + @@ -235,7 +251,7 @@ export function Dashboard() { @@ -246,13 +262,13 @@ export function Dashboard() {
模型请求{t("dash.models")}{t("dash.requests")} Token Credit
{String(i + 1).padStart(2, "0")} - {text(m.model) || "未识别模型"} + {text(m.model) || t("dash.unknownModel")} {metric(m.requests)} {metric(m.total_tokens)}
) : ( - + )} - + - +
{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 ( <> - + } /> -

+
{[ - ["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() { - - - + + + {kind === "request" && ( <> - + )} - + @@ -231,8 +261,27 @@ export function Logs() { {text(item.id)} @@ -275,7 +324,7 @@ export function Logs() {
时间 / ID{kind === "request" ? "模型 / 产品" : "操作 / 类型"}状态{t("logs.col.time")} + {kind === "request" ? t("logs.col.modelProfile") : t("logs.col.action")} + {t("logs.col.status")}耗时{t("logs.col.duration")} Token / Credit操作{t("logs.col.actions")}
- {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() { )}
) : ( - 请尝试调整筛选。 + {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")}
{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);