diff --git a/app/admin_api.py b/app/admin_api.py index 029ef37..ca89845 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -56,12 +56,12 @@ def _public_credential(item): fields = {"id", "account_key", "name", "filename", "enabled", "label", "profile", "region", "site", "uid", "nickname", "expires_at", "expiresAt", "health", "cooldown", "cooldowns", "credits", "credits_by_profile", "catalog", "catalog_sync", "sync", "generation", "auth_broken", - "models", "remaining", "enterprise_id", "product", "status", "sync_pending", "sync_error", + "models", "remaining", "enterprise_id", "enterprise", "enterpriseName", "product", "status", "sync_pending", "sync_error", "fail_until", "cooldown_until", "cooldown_remaining", "last_failure_at", "catalog_ready", "bindings", "auto_checkin", "auto_travel", "travel_supported", "checkin", "travel", "trial_supported", "trial", "daily_chat_supported", "auto_daily_chat", "daily_chat", - "token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code"} + "token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code", "usage_stats"} result = {key: value for key, value in item.items() if key in fields} identity = item.get("account_key") or item.get("id") if identity and ("/" in str(identity) or "\\" in str(identity)): @@ -180,7 +180,21 @@ async def dispatch(request): if (path, method) in (("/admin/oauth/start", "POST"), ("/admin/oauth/poll", "GET")): return await run_in_threadpool(oauth_dispatch, request) if path == "/admin/credentials" and method == "GET": - return JSONResponse({"credentials": [_public_credential(item) for item in await run_in_threadpool(inventory)]}) + def list_credentials(): + items = inventory() + stats_map = audit.credential_stats() if hasattr(audit, "credential_stats") else None + if not isinstance(stats_map, dict): + stats_map = {} + out = [] + for item in items: + pub = _public_credential(item) + if stats_map and "usage_stats" not in pub: + stats = stats_map.get(pub.get("id")) or stats_map.get(pub.get("name")) + if stats: + pub["usage_stats"] = stats + out.append(pub) + return out + return JSONResponse({"credentials": await run_in_threadpool(list_credentials)}) if path.startswith("/admin/credentials/") and method == "DELETE": name = path.removeprefix("/admin/credentials/") if not _valid_name(name): @@ -285,8 +299,14 @@ def build_models(): rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": []}) models.append({**item, **rule}) - return JSONResponse({"revision": snapshot["revision"], "models": models, - "model_capability_guard": config.get("model_capability_guard", True)}) + resolved = {item["key"]: item for item in resolve_settings(config)} + return JSONResponse({ + "revision": snapshot["revision"], "models": models, + "model_guard": config.get("model_guard", True), + "model_guard_locked": bool(resolved.get("model_guard", {}).get("locked")), + "model_capability_guard": config.get("model_capability_guard", True), + "model_capability_guard_locked": bool(resolved.get("model_capability_guard", {}).get("locked")), + }) return await run_in_threadpool(build_models) def checked_rule(source, data, *, creating=False): @@ -514,6 +534,34 @@ async def logs_clear(request): return error_response(503, "日志清理未完成,请检查审计存储状态") return JSONResponse(result) + @route("GET", "/admin/storage") + async def storage_get(request): + result = await run_in_threadpool(audit.storage) + if result.get("degraded"): + return error_response(503, "存储状态暂时无法读取,请检查审计存储") + archives = [] + try: + directory = Path(str(audit.path)).parent / "archives" + archives = sorted((entry.name for entry in directory.glob("audit-*.jsonl")), reverse=True)[:20] + except OSError as exc: + audit._fault(exc) + return error_response(503, "存储状态暂时无法读取,请检查审计存储") + return JSONResponse({**result, "archives": archives}) + + @route("POST", "/admin/storage/archive") + async def storage_archive(request): + body = await _body(request, 8192) + days = body.get("days") + if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650: + raise ValueError("归档窗口必须为 1 至 3650 天") + result = await run_in_threadpool(audit.archive, days) + if not result.get("ok"): + return error_response(503, "归档未完成,请检查审计存储状态") + event("storage_archive", {"days": days, "file": result.get("file"), + "requests": result.get("archived_requests"), + "events": result.get("archived_events")}) + return JSONResponse(result) + @route("GET", "/admin/dashboard") async def dashboard_get(request): try: diff --git a/app/audit_store.py b/app/audit_store.py index 127d641..770bff8 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -116,6 +116,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, self._validate(max_bytes, retention_days, preview_limit) self.max_bytes, self.retention_days, self.preview_limit = max_bytes, retention_days, preview_limit self._lock = threading.Lock() + self._archive_lock = threading.Lock() self._health_lock = threading.Lock() self.failure_count = self.dropped_records = 0 self.failure_events: list[float] = [] @@ -555,18 +556,75 @@ def fetch(): for row in series: row["date"] = time.strftime("%Y-%m-%d %H:00" if grain == "hour" else "%Y-%m-%d", time.gmtime(row["bucket"])) summary["success_rate"] = summary["success"] / summary["requests"] if summary["requests"] else None - return {"summary": summary, "series": series, + previous = self._empty_stats() + for row in self._db.execute( + "SELECT payload FROM stats_daily WHERE dimension='global' AND bucket>=? AND bucket=? AND started_at<=?", + (start, now)).fetchall() if r[0] is not None) + # Detail rows can be pruned, archived, or cleared independently of daily + # aggregates; only report P95 when the detail sample covers the period. + summary["p95_duration_ms"] = ( + durations[max(0, math.ceil(len(durations) * 0.95) - 1)] + if durations and len(durations) >= summary["duration_ms_known"] + else None) + return {"summary": summary, "previous": previous, "series": series, "models": [{"model": key, **value} for key, value in models.items()], "profiles": [{"profile": key, **value} for key, value in profiles.items()], "generated_at": now, "range": {**period, "partial": partial}} - return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": period, "degraded": True}) + return self._run(fetch, {"summary": {**self._empty_stats(), "p95_duration_ms": None}, + "previous": None, "series": [], "models": [], "profiles": [], + "generated_at": now, "range": period, "degraded": True}) + + def credential_stats(self): + def fetch(): + out = {} + for row in self._db.execute( + "SELECT dimension_key,payload FROM stats_totals WHERE dimension='credential' AND dimension_key!=''").fetchall(): + stats = json.loads(row["payload"]) + reqs = stats.get("requests") or 0 + dur_known = stats.get("duration_ms_known") or 0 + out[row["dimension_key"]] = { + "requests": reqs, + "success": stats.get("success") or 0, + "error": stats.get("error") or 0, + "success_rate": (stats.get("success") or 0) / reqs if reqs else None, + "input_tokens": stats.get("input_tokens"), + "output_tokens": stats.get("output_tokens"), + "cache_read_tokens": stats.get("cache_read_tokens"), + "total_tokens": stats.get("total_tokens"), + "credit": stats.get("credit"), + "avg_duration_ms": round((stats.get("duration_ms_sum") or 0) / dur_known, 1) if dur_known else None, + } + for row in self._db.execute( + "SELECT credential,MAX(started_at) AS last_at,model,outcome,status_code " + "FROM requests WHERE credential IS NOT NULL AND credential!='' GROUP BY credential").fetchall(): + entry = out.setdefault(row["credential"], { + "requests": 0, "success": 0, "error": 0, "success_rate": None, + "input_tokens": None, "output_tokens": None, "cache_read_tokens": None, + "total_tokens": None, "credit": None, "avg_duration_ms": None, + }) + entry["last_request_at"] = row["last_at"] + entry["last_model"] = row["model"] + entry["last_outcome"] = row["outcome"] + entry["last_status_code"] = row["status_code"] + return out + return self._run(fetch, {}) def storage(self): def fetch(): self._prune() self._epoch = self._db.execute("SELECT epoch FROM state").fetchone()[0] + page_size = self._db.execute("PRAGMA page_size").fetchone()[0] + freelist = self._db.execute("PRAGMA freelist_count").fetchone()[0] return {**dict(self._db.execute("SELECT logical_bytes,request_count,event_count,ingest_count FROM detail_accounting WHERE id=1").fetchone()), - "pending_cleanup": self._cleanup_pending()} - result = self._run(fetch, {"logical_bytes": None, "pending_cleanup": None}, write=True) + "pending_cleanup": self._cleanup_pending(), + "page_count": self._db.execute("PRAGMA page_count").fetchone()[0], + "freelist_pages": freelist, "reclaimable_bytes": freelist * page_size} + result = self._run(fetch, {"logical_bytes": None, "pending_cleanup": None, + "reclaimable_bytes": None}, write=True) sizes = {} for key, suffix in (("db_bytes", ""), ("wal_bytes", "-wal"), ("shm_bytes", "-shm")): try: @@ -603,6 +661,125 @@ def commit(): return self._run(commit, {"ok": False, "scope": scope}, write=True, on_commit=lambda result: setattr(self, "_epoch", result["epoch"])) + def archive(self, days): + if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650: + raise ValueError("invalid archive window") + cutoff = time.time() - days * 86400 + if not self._archive_lock.acquire(timeout=0.25): + self._fault(TimeoutError()) + return {"ok": False} + def step(callback, write=False): + if not self._lock.acquire(timeout=0.25): + raise TimeoutError() + try: + if self._closed: + raise RuntimeError("closed") + deadline = time.monotonic() + 1 + self._sql_deadline = deadline + self._db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000) + if write: + self._db.execute("BEGIN IMMEDIATE") + result = callback() + if write: + self._db.execute("COMMIT") + return result + except Exception: + if not self._closed: + try: + if self._db.in_transaction: + self._db.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + finally: + if not self._closed: + self._db.set_progress_handler(None, 0) + self._lock.release() + target = None + try: + has_old = step(lambda: ( + self._db.execute("SELECT 1 FROM requests WHERE started_at(?,?) " + "ORDER BY started_at,id LIMIT 2000", + (cutoff, *last)).fetchall()) + if not rows: + break + for row in rows: + handle.write(json.dumps( + {"kind": "request", "id": row[0], "started_at": row[1], "model": row[2], + "profile": row[3], "credential": row[4], "outcome": row[5], + "status_code": row[6], "payload": json.loads(row[7]), "client_ip": row[8]}, + ensure_ascii=False) + "\n") + request_ids.append(row[0]) + last = (rows[-1][1], rows[-1][0]) + last = (0.0, "") + while True: + rows = step(lambda last=last: self._db.execute( + "SELECT id,started_at,kind,action,payload FROM events " + "WHERE started_at(?,?) ORDER BY started_at,id LIMIT 2000", + (cutoff, *last)).fetchall()) + if not rows: + break + for row in rows: + handle.write(json.dumps( + {"kind": "event", "id": row[0], "started_at": row[1], "event_kind": row[2], + "action": row[3], "payload": json.loads(row[4])}, + ensure_ascii=False) + "\n") + event_ids.append(row[0]) + last = (rows[-1][1], rows[-1][0]) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp, 0o600) + os.replace(tmp, target) + dir_fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except Exception: + try: + tmp.unlink() + except OSError: + pass + raise + # Delete only the exact exported detail rows in short slices; ingest dedup + # keys stay until _expire() prunes them at retention_days. + for sql, ids in (("DELETE FROM requests WHERE id=?", request_ids), + ("DELETE FROM events WHERE id=?", event_ids)): + for i in range(0, len(ids), 500): + params = [(item,) for item in ids[i:i + 500]] + step(lambda sql=sql, params=params: self._db.executemany(sql, params), write=True) + return {"ok": True, "archived_requests": len(request_ids), + "archived_events": len(event_ids), "file": name} + except Exception as exc: + if target is not None: + try: + target.unlink() + except OSError: + pass + self._fault(exc) + return {"ok": False} + finally: + self._archive_lock.release() + def configure(self, max_bytes=None, retention_days=None, preview_limit=None): values = (self.max_bytes if max_bytes is None else max_bytes, self.retention_days if retention_days is None else retention_days, diff --git a/app/observability.py b/app/observability.py index fa18a1e..4f32842 100644 --- a/app/observability.py +++ b/app/observability.py @@ -103,9 +103,6 @@ def normalize_usage(usage): result = {} aliases = {"input_tokens": ("input_tokens", "prompt_tokens"), "output_tokens": ("output_tokens", "completion_tokens"), - "cache_read_tokens": ("cache_read_tokens", "cache_read_input_tokens", "cached_tokens", "cache"), - "cache_creation_tokens": ("cache_creation_tokens", "cache_creation_input_tokens"), - "reasoning_tokens": ("reasoning_tokens", "reasoning"), "total_tokens": ("total_tokens",), "credit": ("credit",)} for key, names in aliases.items(): for name in names: @@ -113,16 +110,29 @@ def normalize_usage(usage): if value is not None: result[key] = value break - for key, parents, child in ( - ("cache_read_tokens", ("input_tokens_details", "prompt_tokens_details"), "cached_tokens"), - ("reasoning_tokens", ("output_tokens_details", "completion_tokens_details"), "reasoning_tokens"), - ): - if key not in result: - for parent in parents: - value = number(_mapping(usage.get(parent)).get(child)) - if value is not None: - result[key] = value - break + # Providers report cache/reasoning counters through several fields at once, + # and some ship a permanent zero alias next to the real counter (CodeBuddy's + # top-level cached_tokens: 0 beside prompt_tokens_details.cached_tokens). + # The largest candidate wins so a broken zero cannot shadow a real value. + best_of = { + "cache_read_tokens": ( + ("cache_read_tokens", "cache_read_input_tokens", "cached_tokens", "cache", + "prompt_cache_hit_tokens"), + ((("input_tokens_details", "prompt_tokens_details"), "cached_tokens"),)), + "cache_creation_tokens": ( + ("cache_creation_tokens", "cache_creation_input_tokens", "prompt_cache_write_tokens"), + ()), + "reasoning_tokens": ( + ("reasoning_tokens", "reasoning", "completion_thinking_tokens"), + ((("output_tokens_details", "completion_tokens_details"), "reasoning_tokens"),)), + } + for key, (names, nested) in best_of.items(): + candidates = [number(usage.get(name)) for name in names] + for parents, child in nested: + candidates += [number(_mapping(usage.get(parent)).get(child)) for parent in parents] + present = [value for value in candidates if value is not None] + if present: + result[key] = max(present) return result diff --git a/app/settings.py b/app/settings.py index ca33ba2..c2cbebd 100644 --- a/app/settings.py +++ b/app/settings.py @@ -58,9 +58,10 @@ def validate_projection_max_bytes(value): def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=None, - choices=None, sensitive=False, allow_empty=False, max_length=255, validator=None): + choices=None, sensitive=False, allow_empty=False, max_length=255, validator=None, + hidden=False): value = {"default": default, "type": type_, "label": label, "mode": mode, - "env": env, "sensitive": sensitive} + "env": env, "sensitive": sensitive, "hidden": hidden} if minimum is not None: value["min"] = minimum if maximum is not None: @@ -83,7 +84,7 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "auth_file": _item(None, "paths", "显式凭证文件", mode="startup", sensitive=True), "auth_dir": _item(None, "path", "凭证目录", mode="startup", env="CODEBUDDY_AUTH_DIR", sensitive=True), "import_dir": _item(None, "path", "导入目录", mode="startup", env="CODEBUDDY_IMPORT_DIR", sensitive=True), - "log_path": _item(None, "path", "旧文本日志(已停用)", mode="startup", env="CODEBUDDY2API_LOG", sensitive=True), + "log_path": _item(None, "path", "旧文本日志(已停用)", mode="startup", env="CODEBUDDY2API_LOG", sensitive=True, hidden=True), "admin_allowed_origins": _item("", "string", "管理页额外信任来源", env="CODEBUDDY2API_ADMIN_ORIGINS", allow_empty=True, max_length=2000, validator=normalize_allowed_origins), "desensitize": _item(False, "boolean", "提示词脱敏"), @@ -99,7 +100,7 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "max_images": _item(16, "integer", "单请求图片上限", env="CODEBUDDY2API_MAX_IMAGES", minimum=0, maximum=10000), "image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]), "max_request_bytes": _item(32 * 1024 * 1024, "integer", "请求字节上限", env="CODEBUDDY2API_MAX_REQUEST_BYTES", minimum=1, maximum=1024**3), - "log_body_limit": _item(65536, "integer", "旧文本预览(已停用)", env="CODEBUDDY2API_LOG_BODY_LIMIT", minimum=0, maximum=1024**2), + "log_body_limit": _item(65536, "integer", "旧文本预览(已停用)", env="CODEBUDDY2API_LOG_BODY_LIMIT", minimum=0, maximum=1024**2, hidden=True), "failover_max": _item(0, "integer", "换凭证重放次数", env="CODEBUDDY2API_FAILOVER_MAX", minimum=0, maximum=10), "retry_write_timeout": _item(False, "boolean", "写超时参与重放", @@ -195,6 +196,8 @@ def resolve_settings(config): sources = config.get("settings_sources", {}) result = [] for key, spec in SCHEMA.items(): + if spec.get("hidden"): # Retired knobs stay out of the management UI. + continue source = sources.get(key, "default") locked = spec["sensitive"] or source in ("cli", "environment", "env", "dotenv") item = {"key": key, "value": None if spec["sensitive"] else (config[key] if config.get(key) is not None else spec["default"]), diff --git a/converter.py b/converter.py index 2c9af7e..a6f8458 100644 --- a/converter.py +++ b/converter.py @@ -402,6 +402,7 @@ def summary(self) -> dict: "profile": profile, "region": profile_region(profile), "product": profile_product(profile), "nickname": acct.get("nickname"), "enterpriseName": acct.get("enterpriseName"), + "enterprise": is_enterprise_account(acct), "token_expires_at": auth.get("expiresAt", 0), "token_expired": self._is_expired(), "last_refresh_time": auth.get("lastRefreshTime") or 0, diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 4a5e471..04d4841 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -50,7 +50,7 @@ 编辑未锁定的选项;即时生效项立即应用,标记为重启的项需手动重启。被锁定的选项请在启动配置中修改;见[配置优先级](advanced.zh-CN.md#配置与-cli)。 - 「保留工具描述」默认关闭,对三种协议生效,与提示词压缩相互独立;见[工具元数据保留](advanced.zh-CN.md#工具元数据保留)。 -- 「模型能力预检」默认开启。关闭只影响新请求,不影响元数据展示、国际图片消息合并与既有安全限制;见[模型声明](advanced.zh-CN.md#模型声明与图片兼容)。 +- 「表外模型拦截」与「模型能力预检」开关位于「模型路由」顶部栏,默认开启;关闭能力预检只影响新请求,不影响元数据展示、国际图片消息合并与既有安全限制;见[模型声明](advanced.zh-CN.md#模型声明与图片兼容)。 - 「管理页额外信任来源」(`admin_allowed_origins`)用于修复 HTTPS 反代后的登录;见[管理 Origin 校验](advanced.zh-CN.md#管理-origin--csrf-开关)。 ## 界面行为 diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 7175f0c..9e3f4dc 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -664,5 +664,35 @@ def test_external_errors_are_redacted(self): self.assertNotIn("synthetic-secret-token", response.text) +class StorageAdminTests(AdminApiTests): + def test_storage_reports_sizes_and_archive_listing(self): + self.audit.storage.return_value = {"db_bytes": 10, "wal_bytes": 0, "reclaimable_bytes": 4096, + "degraded": False} + self.audit.path = self.root / "logs.sqlite3" + (self.root / "archives").mkdir() + (self.root / "archives" / "audit-20260101-000000-deadbeef.jsonl").write_text("{}\n") + response = self.client.get("/admin/storage", headers=self.headers) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["reclaimable_bytes"], 4096) + self.assertEqual(data["archives"], ["audit-20260101-000000-deadbeef.jsonl"]) + + def test_storage_archive_validates_window_and_reports_result(self): + self.audit.archive.return_value = {"ok": True, "archived_requests": 2, + "archived_events": 1, "file": "audit-x.jsonl"} + response = self.client.post("/admin/storage/archive", headers=self.headers, json={"days": 30}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["archived_requests"], 2) + self.audit.archive.assert_called_once_with(30) + for bad in (0, 3651, "30", 1.5, True): + self.assertEqual( + self.client.post("/admin/storage/archive", headers=self.headers, + json={"days": bad}).status_code, 400) + self.audit.archive.return_value = {"ok": False} + self.assertEqual( + self.client.post("/admin/storage/archive", headers=self.headers, + json={"days": 30}).status_code, 503) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index 378149d..7980165 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -15,6 +15,72 @@ from app.audit_store import SCHEMA_VERSION, AuditStore, _CLEANUP_BATCH +class ArchiveAndStorageStatsTests(unittest.TestCase): + """Archive exports old rows before deleting them; storage reports reclaimable pages.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.path = Path(self.temp.name) / "audit.sqlite3" + self.store = AuditStore(self.path) + + def tearDown(self): + self.store.close() + self.temp.cleanup() + + def record(self, id, started_at, duration_ms=100): + return {"id": id, **self.store.ticket(), "started_at": started_at, + "public_model": "m", "upstream_model": "u", "profile": "p", + "credential": "c", "protocol": "chat", "status_code": 200, + "duration_ms": duration_ms, "outcome": "success"} + + def test_archive_exports_then_deletes_and_keeps_recent_rows(self): + old = time.time() - 10 * 86400 + self.store.record_request(self.record("old", old, duration_ms=200)) + self.store.record_request(self.record("new", time.time(), duration_ms=100)) + self.store.event("runtime", "rotate", {"note": "old"}) + self.assertEqual(self.store.dashboard(30)["summary"]["p95_duration_ms"], 200) + result = self.store.archive(7) + self.assertTrue(result["ok"]) + self.assertEqual(result["archived_requests"], 1) + self.assertEqual(result["archived_events"], 0) # the event is fresh + archive = self.path.parent / "archives" / result["file"] + lines = archive.read_text(encoding="utf-8").splitlines() + self.assertEqual(len(lines), 1) + self.assertEqual(json.loads(lines[0])["id"], "old") + self.assertEqual(oct(archive.stat().st_mode & 0o777), "0o600") + self.assertIsNone(self.store.get_request("old")) + self.assertIsNotNone(self.store.get_request("new")) + # Aggregates survive, replay dedup stays intact, and partial detail samples hide P95. + replay = self.store.record_request(self.record("old", old, duration_ms=200)) + self.assertEqual(replay, {"ok": True, "recorded": False, "reason": "duplicate"}) + summary = self.store.dashboard(30)["summary"] + self.assertEqual(summary["requests"], 2) + self.assertIsNone(summary["p95_duration_ms"]) + + def test_archive_empty_window_is_a_noop_without_a_file(self): + self.store.record_request(self.record("fresh", time.time())) + result = self.store.archive(7) + self.assertTrue(result["ok"]) + self.assertEqual(result["archived_requests"], 0) + self.assertIsNone(result["file"]) + self.assertFalse((self.path.parent / "archives").exists()) + + def test_archive_rejects_invalid_windows(self): + for days in (0, -1, 3651, 1.5, "30", True): + with self.assertRaises(ValueError): + self.store.archive(days) + + def test_storage_reports_reclaimable_bytes(self): + storage = self.store.storage() + self.assertIn("reclaimable_bytes", storage) + self.assertGreaterEqual(storage["reclaimable_bytes"], 0) + self.assertGreater(storage["page_count"], 0) + for _ in range(50): + self.store.record_request(self.record(f"bulk-{_}", time.time())) + self.store.clear("details") + self.assertGreater(self.store.storage()["reclaimable_bytes"], 0) + + class AuditStoreTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() diff --git a/tests/test_observability.py b/tests/test_observability.py index 9e001a4..91221fe 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -415,6 +415,26 @@ def test_usage_invalid_values_are_unknown(self): "credit": 0, "cache_read_input_tokens": 0, "reasoning_tokens": "3"}) self.assertEqual(result, {"credit": 0, "cache_read_tokens": 0}) + def test_usage_zero_alias_does_not_shadow_real_cache_counters(self): + # CodeBuddy ships a permanent top-level cached_tokens: 0 while the real + # hit count lives in prompt_tokens_details / prompt_cache_hit_tokens. + result = normalize_usage({"prompt_tokens": 552, "cached_tokens": 0, + "cache_read_input_tokens": 0, + "prompt_cache_hit_tokens": 448, + "prompt_cache_write_tokens": 104, + "completion_thinking_tokens": 5, + "prompt_tokens_details": {"cached_tokens": 448}, + "completion_tokens_details": {"reasoning_tokens": 5}}) + self.assertEqual(result["cache_read_tokens"], 448) + self.assertEqual(result["cache_creation_tokens"], 104) + self.assertEqual(result["reasoning_tokens"], 5) + + def test_usage_anthropic_style_cache_fields_still_win(self): + result = normalize_usage({"cache_read_input_tokens": 1200, + "cache_creation_input_tokens": 300}) + self.assertEqual(result["cache_read_tokens"], 1200) + self.assertEqual(result["cache_creation_tokens"], 300) + class ClientMetaTests(unittest.TestCase): def scope(self, client=("203.0.113.7", 443), headers=()): diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts index 0d3a736..4a34f13 100644 --- a/web/e2e/admin.spec.ts +++ b/web/e2e/admin.spec.ts @@ -198,7 +198,10 @@ test("isolated dashboard, deep routes, model conflict and schema settings", asyn await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); await page.getByRole("link", { name: "系统设置" }).click(); + // Externally locked items live behind their own tab now. + await page.getByRole("tab", { name: /外部锁定/ }).click(); await expect(page.getByText("外部锁定", { exact: true })).toBeVisible(); + await page.getByRole("tab", { name: /可修改/ }).click(); await page.getByLabel("审计明细保留天数").fill("60"); await page.getByRole("button", { name: "保存更改" }).click(); await expect @@ -226,6 +229,7 @@ test("credential OAuth terminates, upload/export and safe-name deletion are wire await page.waitForTimeout(4500); expect(mock.getPolls()).toBe(1); await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "批量操作" }).click(); await page.getByRole("button", { name: "导入文件" }).click(); await page.getByLabel("选择 .info 或 ZIP").setInputFiles({ name: "import.info", diff --git a/web/e2e/automation.spec.ts b/web/e2e/automation.spec.ts index 2c5f5dc..994cba1 100644 --- a/web/e2e/automation.spec.ts +++ b/web/e2e/automation.spec.ts @@ -65,19 +65,28 @@ test("per-account automation persists with scoped actions, dark mode and mobile return route.fulfill({ status: 404, json: {} }); }); await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "任务配置 cn.info" }).click(); await expect(page.getByRole("switch", { name: "自动签到 cn.info" })).toBeChecked(); await expect(page.getByRole("switch", { name: "自动旅行 cn.info" })).toBeChecked(); + await page.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: "任务配置 intl.info" }).click(); await expect(page.getByRole("switch", { name: "自动签到 intl.info" })).not.toBeChecked(); await expect(page.getByRole("switch", { name: "自动旅行 intl.info" })).toBeDisabled(); await page.getByRole("switch", { name: "自动签到 intl.info" }).click(); await expect(page.getByText(/保存不会立即执行/)).toBeVisible(); expect(writes).toEqual(["PATCH /admin/credentials/intl"]); await page.reload(); + await page.getByRole("button", { name: "任务配置 intl.info" }).click(); await expect(page.getByRole("switch", { name: "自动签到 intl.info" })).toBeChecked(); + await page.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: "任务配置 cn.info" }).click(); await page.getByRole("switch", { name: "自动旅行 cn.info" }).click(); await expect(page.getByText(/自动旅行已关闭/)).toBeVisible(); + await page.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: "更多 cn.info" }).click(); await page.getByRole("button", { name: "旅行状态 cn.info" }).click(); await expect(page.getByText("部分完成")).toBeVisible(); + await page.getByRole("button", { name: "更多 cn.info" }).click(); await page.getByRole("button", { name: "旅行领派 cn.info" }).click(); await expect(page.getByText(/领取已确认,后续状态查询失败/)).toBeVisible(); expect(writes).toEqual([ @@ -90,7 +99,7 @@ test("per-account automation persists with scoped actions, dark mode and mobile await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await page.getByRole("switch", { name: "自动签到 cn.info" }).scrollIntoViewIfNeeded(); + await page.getByRole("button", { name: "任务配置 cn.info" }).scrollIntoViewIfNeeded(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: "test-results/automation-mobile-dark.png", fullPage: true }); expect(errors).toEqual([]); diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index e5cf656..cb48b2d 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -113,6 +113,7 @@ test("manual trial uses the real admin API and persists success and failed-attem expect((await response.json()).results[0].ok).toBe(true); } await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "更多 trial-timeout.info" }).click(); await page.getByRole("button", { name: "领取体验积分 trial-timeout.info" }).click(); const drawer = page.getByRole("dialog"); await expect(drawer.getByRole("button", { name: "确认领取" })).toBeEnabled(); @@ -126,6 +127,7 @@ test("manual trial uses the real admin API and persists success and failed-attem await expect(drawer).toHaveCount(0); await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "更多 trial-success.info" }).click(); await page.getByRole("button", { name: "领取体验积分 trial-success.info" }).click(); await drawer.getByRole("button", { name: "确认领取" }).click(); await expect(drawer.getByText("已领取", { exact: true })).toBeVisible(); @@ -133,6 +135,7 @@ test("manual trial uses the real admin API and persists success and failed-attem expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: "test-results/manual-trial-success-mobile.png", fullPage: true }); await page.reload(); + await page.getByRole("button", { name: "更多 trial-success.info" }).click(); await page.getByRole("button", { name: "领取体验积分 trial-success.info" }).click(); await expect(page.getByRole("dialog").getByRole("button", { name: "确认领取" })).toBeDisabled(); const rows = (await (await page.request.get("/admin/credentials")).json()).credentials; diff --git a/web/e2e/enhancements.spec.ts b/web/e2e/enhancements.spec.ts index e47575f..514041e 100644 --- a/web/e2e/enhancements.spec.ts +++ b/web/e2e/enhancements.spec.ts @@ -101,7 +101,7 @@ test("appearance persists, follows the system, and animates modal exit", async ( test("trend has hover, keyboard and touch values without mobile overflow", async ({ page }) => { await fixture(page); await page.goto("/dashboard"); - const chart = page.getByRole("img", { name: "按小时请求量趋势" }); + const chart = page.getByRole("img", { name: "按小时用量趋势" }); let box = (await chart.boundingBox())!; await page.mouse.move(box.x + box.width * 0.97, box.y + box.height * 0.5); await expect(page.getByRole("tooltip")).toContainText("03:00"); @@ -111,6 +111,7 @@ test("trend has hover, keyboard and touch values without mobile overflow", async await page.keyboard.press("End"); await expect(page.getByRole("tooltip")).toContainText("03:00"); await page.setViewportSize({ width: 390, height: 844 }); + await chart.scrollIntoViewIfNeeded(); box = (await chart.boundingBox())!; await page.touchscreen.tap(box.x + box.width * 0.65, box.y + box.height * 0.6); await expect(page.getByRole("tooltip")).toContainText("02:00"); @@ -122,13 +123,21 @@ test("credential refresh, checkin, sync and batch buttons stay separate", async const calls = await fixture(page); await page.goto("/dashboard/credentials"); for (const label of [ - "刷新凭证 one.info", - "签到 one.info", + "menu:刷新凭证 one.info", + "menu:签到 one.info", "同步余额 one.info", - "批量签到", + "batch:批量签到", "同步全部余额", ]) { - await page.getByRole("button", { name: label, exact: true }).click(); + if (label.startsWith("menu:")) { + await page.getByRole("button", { name: "更多 one.info" }).click(); + await page.getByRole("button", { name: label.slice(5), exact: true }).click(); + } else if (label.startsWith("batch:")) { + await page.getByRole("button", { name: "批量操作" }).click(); + await page.getByRole("button", { name: label.slice(6), exact: true }).click(); + } else { + await page.getByRole("button", { name: label, exact: true }).click(); + } await expect(page.getByText("隔离测试操作成功")).toBeVisible(); } expect(calls).toEqual([ diff --git a/web/e2e/experience.spec.ts b/web/e2e/experience.spec.ts index 127d43f..5897b2b 100644 --- a/web/e2e/experience.spec.ts +++ b/web/e2e/experience.spec.ts @@ -207,17 +207,19 @@ test("daily range uses hourly points, with desktop and mobile glass layouts", as await experienceAPI(page); await page.goto("/dashboard"); await page.getByLabel("统计时间范围").selectOption("1"); - await expect(page.getByRole("img", { name: "按小时请求量趋势" }).locator("circle")).toHaveCount( - 24, - ); + await expect( + page.getByRole("img", { name: "按小时用量趋势" }).locator('[data-series="requests"] circle'), + ).toHaveCount(24); await page.getByRole("button", { name: "收起侧栏" }).click(); await expect(page.locator("aside")).toHaveCSS("width", "76px"); await page.screenshot({ path: "test-results/glass-overview-hourly.png" }); await page.getByLabel("统计粒度").selectOption("day"); - await expect(page.getByRole("img", { name: "按日请求量趋势" }).locator("circle")).toHaveCount(1); + await expect( + page.getByRole("img", { name: "按日用量趋势" }).locator('[data-series="requests"] circle'), + ).toHaveCount(1); await page.getByLabel("统计粒度").selectOption("hour"); await page.setViewportSize({ width: 390, height: 844 }); - await expect(page.getByRole("img", { name: "按小时请求量趋势" })).toBeVisible(); + await expect(page.getByRole("img", { name: "按小时用量趋势" })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: "test-results/glass-overview-mobile.png" }); await page.getByRole("link", { name: "模型路由", exact: true }).click(); diff --git a/web/e2e/model-capabilities.spec.ts b/web/e2e/model-capabilities.spec.ts index 774d1cb..f77a509 100644 --- a/web/e2e/model-capabilities.spec.ts +++ b/web/e2e/model-capabilities.spec.ts @@ -132,19 +132,16 @@ for (const width of [1440, 375]) { await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await expect(trigger).toBeFocused(); - await page.goto("/dashboard/settings"); - const toggle = page.getByRole("checkbox", { name: /模型能力预检/ }); + const toggle = page.getByRole("switch", { name: /模型能力预检/ }); await expect(toggle).toBeChecked(); - await toggle.uncheck(); const saved = page.waitForResponse( (response) => response.url().endsWith("/admin/settings") && response.request().method() === "PATCH", ); - await page.getByRole("button", { name: "保存更改" }).click(); + await toggle.click(); expect((await saved).status()).toBe(200); await expect(toggle).not.toBeChecked(); await expect(page.getByText(/国际图片归并及其他安全限制不变/)).toBeVisible(); - await page.goto("/dashboard/models"); await expect(page.getByText("能力预检:关闭")).toBeVisible(); await expect(page.getByText("图片因路由而异")).toBeVisible(); expect(external).toEqual([]); diff --git a/web/e2e/trend-zoom.spec.ts b/web/e2e/trend-zoom.spec.ts new file mode 100644 index 0000000..acebf9a --- /dev/null +++ b/web/e2e/trend-zoom.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; + +// Regression: zooming a daily series into a narrow window swaps in hourly +// buckets; the geometry helpers must follow the swapped rows or the chart +// crashes (white screen) once the cursor hovers past the daily row count. +test("trend zoom swaps to hourly granularity without crashing", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); + const days = Array.from({ length: 7 }, (_, i) => ({ + date: `2026-10-0${i + 7}`, + bucket: 1791417600 + i * 86400, + requests: 100 + i, + success: 100 + i, + error: 0, + total_tokens: 1000, + credit: 1.5, + })); + const hours = Array.from({ length: 168 }, (_, i) => ({ + date: `2026-10-07 ${String(Math.floor(i / 24)).padStart(2, "0")}:${String(i % 24).padStart(2, "0")}`, + bucket: 1791417600 + i * 3600, + requests: 10 + i, + success: 10 + i, + error: 0, + total_tokens: 100, + credit: 0.1, + })); + await page.route("**/admin/**", (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/admin/session") + return route.fulfill({ json: { authenticated: true, csrf_token: "x" } }); + if (url.pathname === "/admin/dashboard") { + const hourly = url.searchParams.get("granularity") === "hour"; + return route.fulfill({ + json: { + summary: { requests: 1, success_rate: 1, total_tokens: 1, credit: 1 }, + series: hourly ? hours : days, + models: [], + profiles: [], + health: { credentials: [] }, + storage: { degraded: false }, + range: { granularity: hourly ? "hour" : "day" }, + }, + }); + } + return route.fulfill({ json: {} }); + }); + await page.goto("/dashboard"); + const chart = page.getByRole("img", { name: "按日用量趋势" }); + await expect(chart).toBeVisible(); + const box = (await chart.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + for (let i = 0; i < 12; i++) { + await page.mouse.wheel(0, -240); + await page.waitForTimeout(80); + } + // hourly granularity takes over in the narrow window + await expect(page.getByRole("img", { name: "按小时用量趋势" })).toBeVisible(); + // hover past where the daily series would have ended + for (let f = 0.2; f <= 0.9; f += 0.1) { + await page.mouse.move(box.x + box.width * f, box.y + box.height * 0.5); + await page.waitForTimeout(80); + } + await page.mouse.wheel(0, 240); + await page.mouse.wheel(0, 240); + await page.waitForTimeout(300); + expect(pageErrors).toEqual([]); +}); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index dcb3b4d..c32eef0 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ fullyParallel: false, use: { baseURL: "http://127.0.0.1:5174", + // The UI follows the browser language by default; the specs are written in Chinese. + locale: "zh-CN", viewport: { width: 1440, height: 1050 }, browserName: "chromium", launchOptions: { diff --git a/web/playwright.integration.config.ts b/web/playwright.integration.config.ts index 0df0a30..e19ea4b 100644 --- a/web/playwright.integration.config.ts +++ b/web/playwright.integration.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ workers: 1, use: { baseURL: "http://127.0.0.1:5175", + locale: "zh-CN", viewport: { width: 1440, height: 1050 }, launchOptions: { executablePath: process.env.WEBUI_CHROMIUM, diff --git a/web/src/App.tsx b/web/src/App.tsx index 3865bdd..0f594d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,15 +7,17 @@ import { Models } from "./pages/Models"; import { Credentials } from "./pages/Credentials"; import { Logs } from "./pages/Logs"; import { Settings } from "./pages/Settings"; +import { Storage } from "./pages/Storage"; import { Appearance } from "./appearance"; import s from "./ui.module.scss"; -import { LanguageProvider, LanguageToggle, useT } from "./i18n"; +import { LanguageProvider, useT } from "./i18n"; const navigation = [ ["", "nav.overview", "grid"], ["/models", "nav.models", "model"], ["/credentials", "nav.credentials", "key"], ["/logs", "nav.logs", "logs"], + ["/storage", "nav.storage", "database"], ["/settings", "nav.settings", "settings"], ]; export function Guard() { @@ -77,7 +79,6 @@ function Shell() { CodeBuddy{t("app.brandSubtitle")} - + ) : ( + bucketsValid && {t("dash.zoomHint")} + )} + +
+ setDomain(null)} + onPointerLeave={(e) => { + if (e.pointerType !== "touch") setSelection(null); + }} + onFocus={() => choose(0)} + onBlur={() => setSelection(null)} + onKeyDown={(e) => { + if (e.key === "Escape") { + setSelection(null); + setDomain(null); + return; + } + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return; + e.preventDefault(); + choose( + e.key === "Home" + ? 0 + : e.key === "End" + ? displayRows.length - 1 + : (active ?? 0) + (e.key === "ArrowRight" ? 1 : -1), + ); + }} + > + + {text(visible[0]?.date ?? displayRows[0].date)} + + {displayRows.length > 1 && ( + + {text(visible.at(-1)?.date ?? displayRows.at(-1)?.date)} + + )} + + + + {ticks.map((i) => { + const y = BOTTOM - (i * (BOTTOM - TOP)) / 4; + return ( + + + + {compact(Math.round((maxima.requests * i) / 4))} + + + {compact((maxima.credit * i) / 4)} + + {!narrow && ( + + {compact((maxima.total_tokens * i) / 4)} + + )} + + ); + })} + + {displayRows.length > 1 && + !displayPartial && + TREND_SERIES.map(({ key, color }) => ( + + ))} + {visible.length <= 62 && + TREND_SERIES.map(({ key, color }) => ( + + {displayRows.map((_, i) => ( + + ))} + + ))} + {active !== null && ( + + )} + + + {selected && active !== null && ( + + )} +
+

+ 指向或点按查看时段;滚轮缩放区间,双击或 Esc 复位;键盘左右方向键逐点查看,Home 和 End + 跳到首尾。 +

+
+ {t("dash.trendData")} + + + + + + + + + + + {displayRows.map((r, i) => ( + + + + + + + ))} + +
+ {displayRows !== rows || granularity === "hour" ? "时段(UTC)" : "日期(UTC)"} + {t("dash.kpi.requests")}{t("dash.kpi.totalTokens")}{t("dash.kpi.credit")}
{text(r.date)}{metric(r.requests)}{metric(r.total_tokens)}{metric(r.credit)}
+
+ + ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 848d6de..c94d7f2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -53,6 +53,16 @@ export function metric(value: unknown): string { const n = number(value); return n === null ? "—" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n); } +// Axis-scale abbreviation: 1234 → 1.2K, 429669080 → 429.7M; exact values stay in tooltips. +export function compact(value: unknown): string { + const n = number(value); + if (n === null) return "—"; + const abs = Math.abs(n); + if (abs >= 1e9) return `${(n / 1e9).toFixed(1)}B`; + if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (abs >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n); +} export function errorMessage(error: unknown): string { if (axios.isAxiosError(error)) { const bodyData: unknown = error.response?.data; @@ -138,30 +148,71 @@ export async function establishSession(apiKey?: string) { if (apiKey !== undefined) throw error; } } -export function useResource(path: string, normalize: (value: unknown) => T) { - const [state, setState] = useState<{ data: T | null; loading: boolean; error: string | null }>({ - data: null, - loading: true, +// Session-level response cache for heavy pages (model routing): reopening the +// page renders the last snapshot instantly while a fresh fetch replaces it. +const resourceCache = new Map(); +useSession.subscribe((state, prev) => { + if (state.status !== "authenticated" || state.csrf !== prev.csrf) resourceCache.clear(); +}); +export function useResource( + path: string, + normalize: (value: unknown) => T, + options?: { cache?: boolean }, +) { + const cached = options?.cache ? resourceCache.get(path) : undefined; + const [state, setState] = useState<{ + path: string; + data: T | null; + loading: boolean; + error: string | null; + }>({ + path, + data: cached === undefined ? null : normalize(cached), + loading: cached === undefined, error: null, }); const [revision, setRevision] = useState(0); const reload = useCallback(() => setRevision((v) => v + 1), []); useEffect(() => { const controller = new AbortController(); - setState({ data: null, loading: true, error: null }); + const cachedForPath = options?.cache ? resourceCache.get(path) : undefined; + // Keep same-path data (or a cached snapshot) on screen during refresh; + // clear stale data when switching to a different path without a cache hit. + setState((prev) => { + const data = + prev.path === path + ? prev.data + : cachedForPath === undefined + ? null + : normalize(cachedForPath); + return { path, data, loading: data === null, error: null }; + }); void api .get(path, { signal: controller.signal }) .then((response) => { - if (!controller.signal.aborted) - setState({ data: normalize(response.data), loading: false, error: null }); + if (controller.signal.aborted) return; + if (options?.cache) resourceCache.set(path, response.data); + setState({ path, data: normalize(response.data), loading: false, error: null }); }) .catch((error: unknown) => { - if (!controller.signal.aborted) - setState({ data: null, loading: false, error: errorMessage(error) }); + if (controller.signal.aborted) return; + setState((prev) => ({ + path, + data: prev.path === path ? prev.data : null, + loading: false, + error: errorMessage(error), + })); }); return () => controller.abort(); - }, [path, revision, normalize]); - return { ...state, reload }; + }, [path, revision, normalize, options?.cache]); + const currentData = + state.path === path ? state.data : cached === undefined ? null : normalize(cached); + return { + data: currentData, + loading: state.path === path ? state.loading : cached === undefined, + error: state.path === path ? state.error : null, + reload, + }; } export type ModelRule = { id: string; @@ -183,7 +234,10 @@ export type ModelRule = { export function modelResponse(value: unknown): { revision: number; models: ModelRule[]; + model_guard?: boolean; + model_guard_locked?: boolean; model_capability_guard?: boolean; + model_capability_guard_locked?: boolean; } { const data = object(value); if (typeof data.revision !== "number") throw new Error("模型响应缺少 revision"); @@ -215,8 +269,15 @@ export function modelResponse(value: unknown): { return { revision: data.revision, models, + model_guard: typeof data.model_guard === "boolean" ? data.model_guard : undefined, + model_guard_locked: + typeof data.model_guard_locked === "boolean" ? data.model_guard_locked : undefined, model_capability_guard: typeof data.model_capability_guard === "boolean" ? data.model_capability_guard : undefined, + model_capability_guard_locked: + typeof data.model_capability_guard_locked === "boolean" + ? data.model_capability_guard_locked + : undefined, }; } export type Credential = RecordValue & { id: string; name: string | null }; diff --git a/web/src/appearance.tsx b/web/src/appearance.tsx index 79d43da..3ee8382 100644 --- a/web/src/appearance.tsx +++ b/web/src/appearance.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; import { Drawer, DrawerPresence, Icon } from "./components"; +import { chooseLanguage, languagePreference, useT, type LangPref } from "./i18n"; import s from "./ui.module.scss"; import "./theme.scss"; @@ -66,6 +67,7 @@ export function chooseAppearance(value: Partial<{ mode: Mode; palette: Palette } useAppearance.setState({ ...next, persisted }); } export function Appearance() { + const t = useT(); const [open, setOpen] = useState(false); const { mode, palette, dark, persisted } = useAppearance(); useEffect(initializeAppearance, []); @@ -135,6 +137,27 @@ export function Appearance() { : "配色只应用于浅色界面,不改变状态颜色。"}

{!persisted &&

浏览器无法保存偏好,本次会话内仍然生效。

} +
+ {t("appearance.language")} + {( + [ + ["system", t("appearance.lang.system")], + ["zh", "中文"], + ["en", "EN"], + ] as [LangPref, string][] + ).map(([value, label]) => ( + + ))} +
)} diff --git a/web/src/automation.test.tsx b/web/src/automation.test.tsx index 98008ee..f29398a 100644 --- a/web/src/automation.test.tsx +++ b/web/src/automation.test.tsx @@ -1,9 +1,18 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { Credentials } from "./pages/Credentials"; import { api, credentialResponse, useResource } from "./api"; import { TravelSummary } from "./Travel"; +function openRowMenu(credentialName: string) { + const row = screen + .getAllByText(credentialName) + .map((el) => el.closest("tr, article")) + .find((tr) => tr && within(tr).queryByRole("button", { name: /^更多/ })); + if (!row) throw new Error(`row for ${credentialName} not found`); + fireEvent.click(within(row).getByRole("button", { name: /^更多/ })); +} + vi.mock("./api", async (original) => ({ ...(await original()), useResource: vi.fn(), @@ -53,8 +62,15 @@ it("renders server preferences, allows international checkin opt-in, but never o }); const post = vi.spyOn(api, "post"); render(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 cn.info" })); expect(screen.getByRole("switch", { name: "自动签到 cn.info" })).toHaveProperty("checked", true); expect(screen.getByRole("switch", { name: "自动旅行 cn.info" })).toHaveProperty("checked", true); + // The daily turn is international-only, so the domestic switch is shown but unusable. + expect(screen.getByRole("switch", { name: "自动活跃打卡 cn.info" })).toHaveProperty( + "disabled", + true, + ); + fireEvent.click(screen.getByRole("button", { name: "任务配置 intl.info" })); expect(screen.getByRole("switch", { name: "自动签到 intl.info" })).toHaveProperty( "checked", false, @@ -64,11 +80,6 @@ it("renders server preferences, allows international checkin opt-in, but never o true, ); expect(screen.queryByRole("button", { name: "旅行领派 intl.info" })).toBeNull(); - // The daily turn is international-only, so the domestic switch is shown but unusable. - expect(screen.getByRole("switch", { name: "自动活跃打卡 cn.info" })).toHaveProperty( - "disabled", - true, - ); expect(screen.getByRole("switch", { name: "自动活跃打卡 intl.info" })).toHaveProperty( "checked", false, @@ -91,6 +102,7 @@ it("renders server preferences, allows international checkin opt-in, but never o ); expect(patch).toHaveBeenLastCalledWith("/credentials/intl", { auto_daily_chat: true }); expect(post).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 cn.info" })); await act(async () => fireEvent.click(screen.getByRole("switch", { name: "自动旅行 cn.info" }))); expect(patch).toHaveBeenLastCalledWith("/credentials/cn", { auto_travel: false }); expect(screen.getByRole("switch", { name: "自动签到 cn.info" })).toHaveProperty("checked", true); @@ -101,6 +113,7 @@ it("does not pretend a failed or malformed save succeeded", async () => { fixture(); const patch = vi.spyOn(api, "patch").mockResolvedValue({ data: { id: "intl", revision: 1 } }); render(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 intl.info" })); await act(async () => fireEvent.click(screen.getByRole("switch", { name: "自动签到 intl.info" })), ); @@ -110,6 +123,7 @@ it("does not pretend a failed or malformed save succeeded", async () => { false, ); patch.mockRejectedValueOnce(new Error("network unavailable")); + fireEvent.click(screen.getByRole("button", { name: "任务配置 cn.info" })); await act(async () => fireEvent.click(screen.getByRole("switch", { name: "自动旅行 cn.info" }))); expect(screen.getByRole("switch", { name: "自动旅行 cn.info" })).toHaveProperty("checked", true); }); @@ -135,6 +149,7 @@ it("keeps manual checkin and travel separate from the automation preference writ ["旅行领派 cn.info", "/credentials/cn/travel"], ["签到 intl.info", "/credentials/intl/checkin"], ]) { + openRowMenu(label.split(" ").pop()!); await act(async () => fireEvent.click(screen.getByRole("button", { name: label }))); expect(post).toHaveBeenLastCalledWith( path, @@ -143,6 +158,7 @@ it("keeps manual checkin and travel separate from the automation preference writ ); } expect(screen.getByText("部分完成")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 intl.info" })); expect(screen.getByText(/签到活动未开放或已结束/)).toBeTruthy(); }); @@ -164,6 +180,7 @@ it("shows partial completion when checkin is unavailable but departure succeeded }, }); render(); + openRowMenu("cn.info"); await act(async () => fireEvent.click(screen.getByRole("button", { name: "签到 cn.info" }))); expect(screen.getByText("部分完成")).toBeTruthy(); }); @@ -176,6 +193,7 @@ it("disables switches when an older backend omits them and rejects malformed typ error: null, }); render(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 old.info" })); expect(screen.getByRole("switch", { name: "自动签到 old.info" })).toHaveProperty( "disabled", true, @@ -243,6 +261,7 @@ it("keeps confirmed departure visible when its status read fails", async () => { }, }); render(); + openRowMenu("cn.info"); await act(async () => fireEvent.click(screen.getByRole("button", { name: "旅行领派 cn.info" }))); expect(screen.getByText("部分完成")).toBeTruthy(); expect(screen.getByText("派遣后核验")).toBeTruthy(); @@ -270,6 +289,7 @@ it("shows nested travel diagnostics independently from checkin", async () => { }, }); render(); + openRowMenu("cn.info"); await act(async () => fireEvent.click(screen.getByRole("button", { name: "签到 cn.info" }))); expect(screen.getByText("部分完成")).toBeTruthy(); expect(screen.getByText("地点配置")).toBeTruthy(); diff --git a/web/src/buddy.test.tsx b/web/src/buddy.test.tsx index 4f11ecb..1525321 100644 --- a/web/src/buddy.test.tsx +++ b/web/src/buddy.test.tsx @@ -1,9 +1,18 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { Buddy, buddyConfirmation } from "./Buddy"; import { Credentials } from "./pages/Credentials"; import { api, useResource } from "./api"; +function openRowMenu(credentialName: string) { + const row = screen + .getAllByText(credentialName) + .map((el) => el.closest("tr, article")) + .find((tr) => tr && within(tr).queryByRole("button", { name: /^更多/ })); + if (!row) throw new Error(`row for ${credentialName} not found`); + fireEvent.click(within(row).getByRole("button", { name: /^更多/ })); +} + vi.mock("./api", async (original) => ({ ...(await original()), useResource: vi.fn(), @@ -148,6 +157,7 @@ it("opens confirmation only from a scoped manual travel result", async () => { }, }); render(); + openRowMenu("cn.info"); await act(async () => fireEvent.click(screen.getByRole("button", { name: "旅行领派 cn.info" }))); expect(screen.getByRole("dialog")).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /我已阅读并同意/ })).toHaveProperty( @@ -203,6 +213,7 @@ it("shows partial completion when checkin succeeds but Buddy prerequisites block }, }); render(); + openRowMenu("cn.info"); await act(async () => fireEvent.click(screen.getByRole("button", { name: "签到 cn.info" }))); expect(screen.getByText("部分完成")).toBeTruthy(); expect(screen.queryByText("已完成")).toBeNull(); diff --git a/web/src/components.tsx b/web/src/components.tsx index 878ef9d..74daecd 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -17,7 +17,7 @@ export function Icon({ name = "grid" }: { name?: string }) { plus: "M12 5v14M5 12h14", collapse: "M4 4h16v16H4zM9 4v16m7-12-3 4 3 4", expand: "M4 4h16v16H4zM9 4v16m4-12 3 4-3 4", - refresh: "M20 7v5h-5M4 17v-5h5M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1", + refresh: "M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15", close: "m6 6 12 12M6 18 18 6", shield: "m12 2 8 3v6c0 5-8 11-8 11S4 16 4 11V5l8-3Zm-4 9 3 3 5-6", leaf: "M20 3C7 2 1 8 5 16s17 4 15-13ZM5 20 16 8", @@ -27,6 +27,12 @@ export function Icon({ name = "grid" }: { name?: string }) { sun: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5", moon: "M20 15A9 9 0 0 1 9 4a9 9 0 1 0 11 11Z", system: "M3 4h18v13H3zM8 21h8m-4-4v4", + down: "M12 3v12m0 0 4-4m-4 4-4-4M5 21h14", + up: "M12 21V9m0 0 4 4m-4-4-4 4M5 3h14", + cache: + "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm8 3v12c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6c0 1.7-3.6 3-8 3s-8-1.3-8-3", + database: + "M12 8c4.4 0 8-1.3 8-3s-3.6-3-8-3-8 1.3-8 3 3.6 3 8 3Zm-8 2v9c0 1.7 3.6 3 8 3s8-1.3 8-3v-9M4 14.5c0 1.7 3.6 3 8 3s8-1.3 8-3", }; return (
-

{title}

+

{title}

{description &&

{description}

}
{actions}
diff --git a/web/src/copy.test.tsx b/web/src/copy.test.tsx index a42721a..2ea1243 100644 --- a/web/src/copy.test.tsx +++ b/web/src/copy.test.tsx @@ -93,17 +93,17 @@ describe("concise user-facing copy", () => { it("keeps configuration source, locks, and pending effective values clear", () => { resource(settings); const { container } = render(); + expect(screen.getByText("当前生效:30")).toBeTruthy(); + expect(screen.getByText("已保存:60")).toBeTruthy(); + expect(screen.queryByText("当前生效:未知")).toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: /外部锁定/ })); expect(screen.getByText("环境变量")).toBeTruthy(); expect(screen.getByText("命令行")).toBeTruthy(); expect(screen.getByText("重启后生效")).toBeTruthy(); expect(screen.getAllByText("外部锁定")).toHaveLength(2); - expect(screen.getByText("当前生效:30")).toBeTruthy(); - expect(screen.getByText("已保存:60")).toBeTruthy(); - expect(screen.queryByText("当前生效:未知")).toBeNull(); expect(container.textContent).not.toMatch( /revision|schema|WebUI 不写入|服务端校验为准|内部常量/, ); - expect(screen.getByText("查看存储详情").closest("details")?.open).toBe(false); }); it("surfaces a session store that could not persist a revocation", () => { @@ -155,7 +155,7 @@ describe("concise user-facing copy", () => { expect(screen.getByText("人工停用")).toBeTruthy(); expect(screen.getByText("1 个模型冷却")).toBeTruthy(); expect(screen.getAllByText("—").length).toBeGreaterThan(0); - expect(screen.getByText("网关已知 Credit").closest("section")?.textContent).toContain("0"); + expect(screen.getAllByText("Credit 消耗")[0].closest("section")?.textContent).toContain("0"); expect(container.textContent).not.toMatch( /一个客户端请求只统计一次|未知用量不计为零|人工状态与熔断独立|未导入的旧文本日志/, ); diff --git a/web/src/dashboard.test.tsx b/web/src/dashboard.test.tsx index 344ae8a..ad5df23 100644 --- a/web/src/dashboard.test.tsx +++ b/web/src/dashboard.test.tsx @@ -59,15 +59,17 @@ describe("dashboard granularity", () => { }; }); render(); - await screen.findByRole("img", { name: "按日请求量趋势" }); + await screen.findByRole("img", { name: "按日用量趋势" }); fireEvent.change(screen.getByLabelText("统计时间范围"), { target: { value: "1" } }); - const hourly = await screen.findByRole("img", { name: "按小时请求量趋势" }); - expect(hourly.querySelectorAll("circle")).toHaveLength(24); + const hourly = await screen.findByRole("img", { name: "按小时用量趋势" }); + expect(hourly.querySelectorAll('[data-series="requests"] circle')).toHaveLength(24); expect(get).toHaveBeenCalledWith("/dashboard?days=1&granularity=auto", expect.anything()); fireEvent.change(screen.getByLabelText("统计粒度"), { target: { value: "day" } }); await waitFor(() => expect( - screen.getByRole("img", { name: "按日请求量趋势" }).querySelectorAll("circle"), + screen + .getByRole("img", { name: "按日用量趋势" }) + .querySelectorAll('[data-series="requests"] circle'), ).toHaveLength(1), ); expect(get).toHaveBeenCalledWith("/dashboard?days=1&granularity=day", expect.anything()); @@ -114,3 +116,65 @@ describe("official credits", () => { expect(screen.getByText("12.5")).toBeTruthy(); }); }); + +describe("overview cards and usage trend", () => { + it("renders KPI cards with prior-period deltas and a three-series trend", async () => { + vi.spyOn(api, "get").mockResolvedValue({ + data: { + summary: { + requests: 200, + success: 198, + error: 2, + success_rate: 0.99, + total_tokens: 1000000, + input_tokens: 800000, + output_tokens: 200000, + cache_read_tokens: 600000, + cache_creation_tokens: 1000, + reasoning_tokens: 5000, + credit: 42, + p95_duration_ms: 23900, + }, + previous: { requests: 100, credit: 40 }, + series: [ + { + bucket: 1789344000, + date: "2026-09-14", + requests: 120, + success: 119, + error: 1, + total_tokens: 600000, + credit: 25, + }, + { + bucket: 1789430400, + date: "2026-09-15", + requests: 80, + success: 79, + error: 1, + total_tokens: 400000, + credit: 17, + }, + ], + models: [], + profiles: [], + health: { credentials: [] }, + storage: {}, + range: { granularity: "day" }, + official_credits: {}, + generated_at: 1, + }, + }); + render(); + expect(await screen.findByText("较上周期 +100.0%")).toBeTruthy(); + expect(screen.getByText("较上周期 +5.0%")).toBeTruthy(); + expect(screen.getByText("P95 延迟 23.9s")).toBeTruthy(); + expect(screen.getByText("缓存命中率 75.0%")).toBeTruthy(); + expect(screen.getByText(/^推理 Token/)).toBeTruthy(); + const chart = screen.getByRole("img", { name: "按日用量趋势" }); + expect(chart.querySelectorAll("path[data-series]")).toHaveLength(3); + fireEvent.focus(chart); + expect(await screen.findByRole("tooltip", { name: /2026-09-14/ })).toBeTruthy(); + expect(screen.getByText("2026-09-14 · UTC")).toBeTruthy(); + }); +}); diff --git a/web/src/enhancements.test.tsx b/web/src/enhancements.test.tsx index f164759..e7b1ee8 100644 --- a/web/src/enhancements.test.tsx +++ b/web/src/enhancements.test.tsx @@ -7,6 +7,15 @@ import { Trend } from "./Trend"; import { Credentials } from "./pages/Credentials"; import { api, useResource } from "./api"; +function openRowMenu(credentialName: string) { + const row = screen + .getAllByText(credentialName) + .map((el) => el.closest("tr, article")) + .find((tr) => tr && within(tr).queryByRole("button", { name: /^更多/ })); + if (!row) throw new Error(`row for ${credentialName} not found`); + fireEvent.click(within(row).getByRole("button", { name: /^更多/ })); +} + vi.mock("./api", async (original) => ({ ...(await original()), useResource: vi.fn(), @@ -173,6 +182,13 @@ describe("scoped credential buttons", () => { ["批量签到", "/checkin"], ["同步全部余额", "/sync"], ]) { + if ( + label.startsWith("刷新凭证 ") || + label.startsWith("签到 ") || + label.startsWith("清除冷却 ") + ) + openRowMenu("one.info"); + if (label === "批量签到") fireEvent.click(screen.getByRole("button", { name: "批量操作" })); await act(async () => fireEvent.click(screen.getByRole("button", { name: label }))); expect(post).toHaveBeenLastCalledWith( path, @@ -209,6 +225,7 @@ describe("scoped credential buttons", () => { }, }); render(); + openRowMenu("one.info"); const button = screen.getByRole("button", { name: "清除冷却 one.info" }); expect((button as HTMLButtonElement).disabled).toBe(false); await act(async () => fireEvent.click(button)); diff --git a/web/src/i18n.test.tsx b/web/src/i18n.test.tsx index c789221..9aca297 100644 --- a/web/src/i18n.test.tsx +++ b/web/src/i18n.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { LanguageProvider, LanguageToggle, translate, useT } from "./i18n"; +import { chooseLanguage, LanguageProvider, translate, useT } from "./i18n"; beforeEach(() => { try { @@ -23,22 +23,44 @@ function Probe() { return ( - ); } describe("i18n", () => { - it("defaults to Chinese and switches to English with persistence", () => { + it("follows the browser language by default and persists explicit choices", () => { render(); + // jsdom reports en-US, so the system preference resolves to English. + expect(screen.getByTestId("probe").textContent).toBe("Overview"); + act(() => chooseLanguage("zh")); expect(screen.getByTestId("probe").textContent).toBe("概览"); - fireEvent.click(screen.getByRole("button")); - // eslint-disable-next-line no-console + expect(screen.getByTestId("interp").textContent).toBe("尝试 3"); + expect(localStorage.getItem("codebuddy2api.lang")).toBe("zh"); + act(() => chooseLanguage("en")); expect(screen.getByTestId("probe").textContent).toBe("Overview"); - expect(screen.getByTestId("interp").textContent).toBe("Attempt 3"); expect(localStorage.getItem("codebuddy2api.lang")).toBe("en"); + act(() => chooseLanguage("system")); + expect(screen.getByTestId("probe").textContent).toBe("Overview"); + expect(localStorage.getItem("codebuddy2api.lang")).toBe("system"); }); it("falls back to the key itself when an entry is missing", () => { expect(translate("missing.key.entirely")).toBe("missing.key.entirely"); }); + it("applies language choices made in the appearance drawer", async () => { + const { Appearance } = await import("./appearance"); + render( + + + + , + ); + expect(screen.getByTestId("probe").textContent).toBe("Overview"); + fireEvent.click(screen.getByRole("button", { name: "外观设置" })); + fireEvent.click(document.querySelector('input[name="ui-language"][value="zh"]')!); + expect(localStorage.getItem("codebuddy2api.lang")).toBe("zh"); + expect(screen.getByTestId("probe").textContent).toBe("概览"); + fireEvent.click(document.querySelector('input[name="ui-language"][value="system"]')!); + expect(localStorage.getItem("codebuddy2api.lang")).toBe("system"); + expect(screen.getByTestId("probe").textContent).toBe("Overview"); + }); }); diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index c8ed02f..6d187fe 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -1,11 +1,43 @@ 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 = { + "storage.title": "存储管理", + "storage.desc": "日志库体积、归档、清理与压缩指引。", + "storage.status": "存储状态", + "storage.stateDegraded": "存储异常", + "storage.stateUnknown": "状态未知", + "storage.dbFile": "数据库文件", + "storage.walFile": "WAL 文件", + "storage.logical": "日志逻辑占用", + "storage.reclaimable": "可回收空间", + "storage.counts": "请求 {requests} 条 · 事件 {events} 条 · 保留 {days} 天", + "storage.pendingCleanup": "有待清理数据", + "storage.cleanup": "清理", + "storage.cleanupDesc": "在线删除日志明细或全部日志与统计;全部清理需要确认文本与当前 API key。", + "storage.cleanupOpen": "打开清理", + "storage.archive": "归档旧日志", + "storage.archiveHint": "导出为 JSONL 后从库中删除,统计聚合不受影响", + "storage.archiveDays": "归档早于(天)", + "storage.archiveSubmit": "归档并删除", + "storage.archiveBusy": "正在归档…", + "storage.archiveEmpty": "没有早于 {days} 天的日志可归档", + "storage.archiveDone": "已归档 {count} 条到 {file}", + "storage.archiveResult": "归档结果", + "storage.archives": "归档文件", + "storage.archivesEmpty": "暂无归档", + "storage.vacuum": "压缩数据库", + "storage.vacuumDesc": + "删除与归档释放的空间留在 SQLite 空闲页中{reclaimable}。VACUUM 需要独占锁,不能在网关运行中执行,请按以下步骤操作:", + "storage.vacuumReclaim": "(当前约可回收 {size})", + "storage.vacuumNote": "压缩期间网关不可用;统计聚合保存在同一库中,VACUUM 不影响数据。", + "storage.step.stop": "# 1. 停止网关", + "storage.step.stopLocal": "# 本地部署则停止 converter 进程", + "storage.step.vacuum": "# 2. 压缩(在部署目录执行)", + "storage.step.restart": "# 3. 重启网关", "app.skip": "跳转到内容", "app.workspace": "工作空间", "app.primaryNav": "主导航", @@ -17,11 +49,13 @@ const zh: Record = { "app.signOut": "退出登录", "app.verifying": "正在验证登录状态…", "app.backToSignIn": "返回登录", - "app.language": "切换语言 / Switch language", + "appearance.language": "语言", + "appearance.lang.system": "系统语言", "nav.overview": "概览", "nav.models": "模型路由", "nav.credentials": "凭证管理", "nav.logs": "日志审计", + "nav.storage": "存储管理", "nav.settings": "系统设置", "login.subtitle": "网关管理", "login.title": "登录管理控制台", @@ -156,6 +190,27 @@ const zh: Record = { "dash.storage.shm": "SHM 文件", "dash.storage.credits": "网关已知 Credit", "dash.storage.creditsNote": "官方账号余额", + "dash.kpi.requests": "请求数", + "dash.kpi.successRate": "成功率", + "dash.kpi.failures": "失败数", + "dash.kpi.credit": "Credit 消耗", + "dash.kpi.totalTokens": "总 Token", + "dash.kpi.inputTokens": "输入 Token", + "dash.kpi.outputTokens": "输出 Token", + "dash.kpi.cacheTokens": "缓存 Token", + "dash.vsPrevious": "较上周期 {pct}", + "dash.p95Latency": "P95 延迟 {value}", + "dash.errorBuckets": "{n} 个异常时间点", + "dash.reasoningSub": "推理 Token {value}", + "dash.shareOf": "占比 {pct}", + "dash.cacheHitRate": "缓存命中率 {pct}", + "dash.usageTrend": "用量趋势总览", + "dash.usageTrendHint": "请求、Token 与 Credit 在同一时间范围内联动展示。", + "dash.usageTrend.daily": "按日用量趋势", + "dash.usageTrend.hourly": "按小时用量趋势", + "dash.trendData": "查看趋势数据表", + "dash.zoomReset": "复位缩放", + "dash.zoomHint": "滚轮缩放 · 双击复位", "dash.unlimited": "无限", "dash.storage.unknown": "未知", "dash.storage.collectDegraded": "采集降级", @@ -218,6 +273,42 @@ const zh: Record = { }; const en: Record = { + "storage.title": "Storage", + "storage.desc": "Log database size, archiving, cleanup and compaction guide.", + "storage.status": "Storage status", + "storage.stateDegraded": "Storage degraded", + "storage.stateUnknown": "Unknown", + "storage.dbFile": "Database file", + "storage.walFile": "WAL file", + "storage.logical": "Logical log size", + "storage.reclaimable": "Reclaimable space", + "storage.counts": "{requests} requests · {events} events · kept {days} days", + "storage.pendingCleanup": "cleanup pending", + "storage.cleanup": "Cleanup", + "storage.cleanupDesc": + "Delete log details or all logs and statistics online; a full cleanup needs the confirmation text and the current API key.", + "storage.cleanupOpen": "Open cleanup", + "storage.archive": "Archive old logs", + "storage.archiveHint": + "Exported as JSONL, then removed from the database; statistics are unaffected", + "storage.archiveDays": "Archive entries older than (days)", + "storage.archiveSubmit": "Archive and delete", + "storage.archiveBusy": "Archiving…", + "storage.archiveEmpty": "No logs older than {days} days to archive", + "storage.archiveDone": "Archived {count} entries to {file}", + "storage.archiveResult": "archive result", + "storage.archives": "Archive files", + "storage.archivesEmpty": "No archives yet", + "storage.vacuum": "Compact the database", + "storage.vacuumDesc": + "Space freed by deletion and archiving stays in SQLite free pages{reclaimable}. VACUUM needs an exclusive lock and cannot run while the gateway is up; follow these steps:", + "storage.vacuumReclaim": " (about {size} reclaimable now)", + "storage.vacuumNote": + "The gateway is unavailable during compaction; statistics live in the same database and are unaffected by VACUUM.", + "storage.step.stop": "# 1. Stop the gateway", + "storage.step.stopLocal": "# for a local deployment stop the converter process", + "storage.step.vacuum": "# 2. Compact (run in the deployment directory)", + "storage.step.restart": "# 3. Restart the gateway", "app.skip": "Skip to content", "app.workspace": "Workspace", "app.primaryNav": "Primary navigation", @@ -229,11 +320,13 @@ const en: Record = { "app.signOut": "Sign out", "app.verifying": "Verifying session…", "app.backToSignIn": "Back to sign-in", - "app.language": "切换语言 / Switch language", + "appearance.language": "Language", + "appearance.lang.system": "System", "nav.overview": "Overview", "nav.models": "Model routing", "nav.credentials": "Credentials", "nav.logs": "Audit logs", + "nav.storage": "Storage", "nav.settings": "Settings", "login.subtitle": "Gateway management", "login.title": "Sign in to the console", @@ -369,6 +462,27 @@ const en: Record = { "dash.storage.shm": "SHM file", "dash.storage.credits": "Gateway-known credits", "dash.storage.creditsNote": "Official account balance", + "dash.kpi.requests": "Requests", + "dash.kpi.successRate": "Success rate", + "dash.kpi.failures": "Failures", + "dash.kpi.credit": "Credit used", + "dash.kpi.totalTokens": "Total tokens", + "dash.kpi.inputTokens": "Input tokens", + "dash.kpi.outputTokens": "Output tokens", + "dash.kpi.cacheTokens": "Cached tokens", + "dash.vsPrevious": "{pct} vs prior period", + "dash.p95Latency": "P95 latency {value}", + "dash.errorBuckets": "{n} anomalous points", + "dash.reasoningSub": "Reasoning tokens {value}", + "dash.shareOf": "{pct} of total", + "dash.cacheHitRate": "Cache hit rate {pct}", + "dash.usageTrend": "Usage trend", + "dash.usageTrendHint": "Requests, tokens and Credit share one time range.", + "dash.usageTrend.daily": "Daily usage trend", + "dash.usageTrend.hourly": "Hourly usage trend", + "dash.trendData": "View trend data table", + "dash.zoomReset": "Reset zoom", + "dash.zoomHint": "Scroll to zoom · double-click reset", "dash.unlimited": "Unlimited", "dash.storage.unknown": "Unknown", "dash.storage.collectDegraded": "Collection degraded", @@ -435,6 +549,37 @@ const messages: Record> = { zh, en }; const STORAGE_KEY = "codebuddy2api.lang"; const LANG_EVENT = "codebuddy.lang"; +export type LangPref = "system" | Lang; + +function systemLang(): Lang { + try { + return navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + } catch { + return "zh"; + } +} +function savedPref(): LangPref { + try { + const value = localStorage.getItem(STORAGE_KEY); + return value === "zh" || value === "en" ? value : "system"; + } catch { + return "system"; + } +} +let currentPref: LangPref = "system"; +export function languagePreference(): LangPref { + return currentPref; +} +export function chooseLanguage(pref: LangPref) { + currentPref = pref; + try { + localStorage.setItem(STORAGE_KEY, pref); + } catch { + /* ignore */ + } + window.dispatchEvent(new CustomEvent(LANG_EVENT, { detail: pref })); +} + let currentLang: Lang = "zh"; const LangContext = createContext("zh"); @@ -460,47 +605,28 @@ export function useT() { } export function LanguageProvider({ children }: { children: ReactNode }) { - const [lang, setLang] = useState(() => { - try { - return localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "zh"; - } catch { - return "zh"; - } + const [pref, setPref] = useState(() => { + currentPref = savedPref(); + return currentPref; }); + const [system, setSystem] = useState(systemLang); + const lang = pref === "system" ? system : pref; 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); + if (detail === "zh" || detail === "en" || detail === "system") setPref(detail); }; window.addEventListener(LANG_EVENT, apply); return () => window.removeEventListener(LANG_EVENT, apply); }, []); + useEffect(() => { + const sync = () => setSystem(systemLang()); + window.addEventListener("languagechange", sync); + return () => window.removeEventListener("languagechange", sync); + }, []); return {children}; } - -export function LanguageToggle() { - return ( - - ); -} diff --git a/web/src/modelMetadata.test.tsx b/web/src/modelMetadata.test.tsx index 9d93018..d1f1d7d 100644 --- a/web/src/modelMetadata.test.tsx +++ b/web/src/modelMetadata.test.tsx @@ -13,7 +13,6 @@ import { specRange, } from "./modelMetadata"; import { Models } from "./pages/Models"; -import { Settings } from "./pages/Settings"; const model: ModelRule = { id: "upstream", @@ -228,33 +227,28 @@ describe("effort and search summaries", () => { }); }); -describe("capability guard settings", () => { - function settings(locked: boolean) { +describe("capability guard settings on model routing", () => { + function modelsPayload(locked: boolean) { return { revision: 1, - audit: {}, - items: [ - { - key: "model_capability_guard", - value: true, - stored: null, - source: locked ? "environment" : "default", - mode: "hot", - type: "boolean", - label: "模型能力预检", - locked, - }, - ], + models: [model], + model_guard: true, + model_guard_locked: locked, + model_capability_guard: true, + model_capability_guard_locked: locked, }; } it("saves an explicit false without changing other settings", async () => { - vi.spyOn(api, "get").mockResolvedValue({ data: settings(false) }); + vi.spyOn(api, "get").mockImplementation((url) => + Promise.resolve({ + data: url === "/models" ? modelsPayload(false) : { credentials: [] }, + }), + ); const save = vi.spyOn(api, "patch").mockResolvedValue({ data: { revision: 2 } }); - render(); - const toggle = await screen.findByRole("checkbox", { name: /模型能力预检/ }); + render(); + const toggle = await screen.findByRole("switch", { name: /模型能力预检/ }); fireEvent.click(toggle); - fireEvent.click(screen.getByRole("button", { name: "保存更改" })); await waitFor(() => expect(save).toHaveBeenCalledWith("/settings", { revision: 1, @@ -264,11 +258,16 @@ describe("capability guard settings", () => { expect(screen.getByText(/国际图片归并及其他安全限制不变/)).toBeTruthy(); }); - it("shows an environment-locked guard instead of an editable switch", async () => { - vi.spyOn(api, "get").mockResolvedValue({ data: settings(true) }); - render(); - await screen.findByText("外部锁定"); - expect(screen.queryByRole("checkbox", { name: /模型能力预检/ })).toBeNull(); - expect(screen.getByText("当前生效:开启")).toBeTruthy(); + it("disables the switches when locked by external configuration", async () => { + vi.spyOn(api, "get").mockImplementation((url) => + Promise.resolve({ + data: url === "/models" ? modelsPayload(true) : { credentials: [] }, + }), + ); + render(); + const capSwitch = await screen.findByRole("switch", { name: /模型能力预检/ }); + const guardSwitch = await screen.findByRole("switch", { name: /表外模型拦截/ }); + expect(capSwitch).toHaveProperty("disabled", true); + expect(guardSwitch).toHaveProperty("disabled", true); }); }); diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index 3fc3d55..4aea4d1 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { OAuth } from "../OAuth"; import { Trial } from "../Trial"; import { TravelSummary } from "../Travel"; import { Buddy, buddyConfirmation } from "../Buddy"; import { api, + compact, credentialResponse, errorMessage, list, @@ -32,21 +33,26 @@ import { import { prepareImports, type ImportResult } from "../imports"; import { downloadFilename } from "../downloads"; import s from "../ui.module.scss"; + function expiry(value: unknown, milliseconds = false) { return typeof value === "number" ? new Date(milliseconds ? value : value * 1000).toLocaleString("zh-CN") : text(value); } + const lastErrorLabels: Record = { http_401: "上游 401(登录态被拒)", http_403: "上游 403(无权限)", credential_error: "凭证同步失败(详见操作结果)", }; + function lastErrorLabel(value: unknown) { const key = text(value); return lastErrorLabels[key] ?? key; } + export { safeOAuthUrl } from "../OAuth"; + function boundModels(credential: Credential): string[] { return Array.isArray(credential.bindings) ? credential.bindings.filter((id): id is string => typeof id === "string") @@ -114,11 +120,97 @@ function ImportDrawer({ onClose, onDone }: { onClose: () => void; onDone: () => ); } + +type ChannelKey = "all" | "cn" | "intl" | "enterprise"; +type ViewMode = "cards" | "list"; +type StatusFilter = "all" | "ready" | "issue" | "disabled"; +type DetailTab = "overview" | "tasks" | "cooldowns" | "raw"; + +const PAGE_SIZES = [10, 20, 50, 100, 500, 1000, 2000] as const; +const PAGE_SIZE_KEY = "codebuddy2api.credPageSize"; +const VIEW_MODE_KEY = "codebuddy2api.credView"; + +function savedPageSize(): number { + try { + const raw = Number(localStorage.getItem(PAGE_SIZE_KEY)); + return (PAGE_SIZES as readonly number[]).includes(raw) ? raw : 20; + } catch { + return 20; + } +} + +function savedViewMode(): ViewMode { + try { + return localStorage.getItem(VIEW_MODE_KEY) === "list" ? "list" : "cards"; + } catch { + return "cards"; + } +} + +function isEnterprise(c: Credential): boolean { + if (c.enterprise === true || Boolean(c.enterpriseName)) return true; + if (c.credits && typeof c.credits === "object" && object(c.credits).enterprise === true) + return true; + return false; +} + +function channelOf(c: Credential): Exclude { + if (isEnterprise(c)) return "enterprise"; + if (text(c.profile).startsWith("intl") || c.region === "intl") return "intl"; + return "cn"; +} + +function MoreMenu({ + label, + title = "更多", + children, +}: { + label?: string; + title?: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + return ( +
+ + {open && ( +
setOpen(false)}> + {children} +
+ )} +
+ ); +} + export function Credentials() { const resource = useResource("/credentials", credentialResponse); const [selected, setSelected] = useState([]); const [drawer, setDrawer] = useState<"oauth" | "import" | "export" | null>(null); - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState<{ credential: Credential; tab: DetailTab } | null>(null); + const [detailLogs, setDetailLogs] = useState[] | null>(null); const [deleting, setDeleting] = useState(null); const [trialTarget, setTrialTarget] = useState(null); const [buddyTarget, setBuddyTarget] = useState<{ @@ -130,6 +222,30 @@ export function Credentials() { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [maintenance, setMaintenance] = useState[]>([]); + const openDetail = (credential: Credential, tab: DetailTab = "overview") => { + setDetail({ credential, tab }); + }; + const liveDetail = detail + ? (resource.data?.find((item) => item.id === detail.credential.id) ?? detail.credential) + : null; + useEffect(() => { + if (!liveDetail || detail?.tab !== "overview") return; + let cancelled = false; + const credKey = text(liveDetail.account_key ?? liveDetail.id); + void api + .get(`/logs?credential=${encodeURIComponent(credKey)}&limit=8`) + .then((res) => { + if (cancelled) return; + const items = object(res.data).items; + setDetailLogs(Array.isArray(items) ? list(items) : []); + }) + .catch(() => { + if (!cancelled) setDetailLogs([]); + }); + return () => { + cancelled = true; + }; + }, [liveDetail, detail?.tab]); const maintain = ( action: | "refresh" @@ -224,14 +340,379 @@ export function Credentials() { setNotice(`${label}已${enabled ? "开启" : "关闭"};保存不会立即执行,后续维护按新设置执行。`); }); }; - const liveSelected = selected.filter((id) => resource.data?.some((c) => c.id === id)); + const [channel, setChannel] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [view, setView] = useState(savedViewMode); + const [pageSize, setPageSize] = useState(savedPageSize); + const [page, setPage] = useState(1); + const chooseView = (next: ViewMode) => { + setView(next); + try { + localStorage.setItem(VIEW_MODE_KEY, next); + } catch { + /* ignore */ + } + }; + const choosePageSize = (next: number) => { + setPageSize(next); + setPage(1); + try { + localStorage.setItem(PAGE_SIZE_KEY, String(next)); + } catch { + /* ignore */ + } + }; + const allItems = resource.data ?? []; + const isReady = (c: Credential) => { + const until = number(c.fail_until); + const cooling = until !== null && Math.ceil(until - now / 1000) > 0; + return c.enabled !== false && !cooling && c.health === "ready"; + }; + const filtered = allItems.filter((c) => { + if (channel !== "all" && channelOf(c) !== channel) return false; + if (statusFilter === "ready" && !isReady(c)) return false; + if (statusFilter === "disabled" && c.enabled !== false) return false; + if (statusFilter === "issue" && (c.enabled === false || isReady(c))) return false; + const q = query.trim().toLowerCase(); + if (!q) return true; + return [c.name, c.nickname, c.uid, c.enterpriseName, profileLabel(text(c.profile))] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)); + }); + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const safePage = Math.min(page, totalPages); + const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize); + const liveSelected = selected.filter((id) => allItems.some((c) => c.id === id)); + const channelDefs: [ChannelKey, string, string][] = [ + ["all", "全部账号", "个人区域与企业账号汇总"], + ["cn", "国内区域", "国内 CodeBuddy · WorkBuddy"], + ["intl", "国际区域", "国际 CodeBuddy · WorkBuddy"], + ["enterprise", "企业账号", "独立归类 · 不按区域拆分"], + ]; + const renderSyncIcon = (c: Credential) => ( + + ); + const renderActions = (c: Credential) => { + const enabledTasks = [ + c.auto_checkin === true && "签到", + c.travel_supported === true && c.auto_travel === true && "旅行", + c.daily_chat_supported === true && c.auto_daily_chat === true && "打卡", + ].filter(Boolean); + return ( +
+ + + + + + {/* Not gated on displayed cooldowns or on enablement: a reset that failed + to persist must stay retryable, and a disabled account can still hold + a stale cooldown worth clearing. */} + + {c.trial_supported === true && ( + + )} + {c.daily_chat_supported === true && ( + + )} + {c.travel_supported === true && ( + <> + + + + )} + + +
+ ); + }; + const renderEnableSwitch = (c: Credential) => ( + + ); + const renderHealthBadge = (c: Credential) => { + const until = number(c.fail_until); + const remaining = until === null ? null : Math.max(0, Math.ceil(until - now / 1000)); + return ( + 0 ? "bad" : c.health === "ready" ? "good" : "warn"} + > + {remaining !== null && remaining > 0 + ? `认证熔断 ${remaining}s` + : c.health === "ready" + ? "认证正常" + : text(c.health)} + + ); + }; + const renderSyncAndCatalog = (c: Credential) => { + const cooldowns = Array.isArray(c.cooldowns) ? list(c.cooldowns) : null; + const trip = c.travel && typeof c.travel === "object" ? object(c.travel) : null; + return ( + <> + + {c.sync_pending === true + ? "目录同步中" + : c.catalog_ready === true + ? "目录已就绪" + : "目录状态待确认"} + +
+ {c.token_expired === true ? "Token 已过期" : "同步正常"} + {cooldowns ? ( + cooldowns.length ? ( + cooldowns.map((cooldown, i) => ( + + {text(cooldown.model)} ·{" "} + {number(cooldown.until) !== null + ? `${Math.max(0, Math.ceil(Number(cooldown.until) - now / 1000))}s` + : text(cooldown.remaining_seconds)} + + )) + ) : ( + 无模型冷却 + ) + ) : c.model_cooldowns ? ( + + ) : null} +
+ {c.last_error_code != null && ( + + 最近错误:{text(lastErrorLabel(c.last_error_code))} + {number(c.last_failure_at) !== null ? ` · ${expiry(c.last_failure_at)}` : ""} + + )} + {typeof c.sync_error === "string" && c.sync_error !== "" && ( + 失败原因:{text(c.sync_error)} + )} + {c.travel_supported === true && trip && } + + ); + }; + const renderUsageBlock = (c: Credential, compactTable = false) => { + const stats = c.usage_stats && typeof c.usage_stats === "object" ? object(c.usage_stats) : null; + const reqs = number(stats?.requests) ?? 0; + const rate = number(stats?.success_rate); + const totalTokens = number(stats?.total_tokens); + const cacheTokens = number(stats?.cache_read_tokens); + const credit = number(stats?.credit); + const avgMs = number(stats?.avg_duration_ms); + if (compactTable) { + return ( +
+
+ 请求 + {compact(reqs)} +
+
+ 成功率 + {rate !== null ? `${(rate * 100).toFixed(1)}%` : "—"} +
+
+ Token + {totalTokens !== null ? compact(totalTokens) : "—"} +
+
+ Credit + {credit !== null ? metric(credit) : "—"} +
+
+ ); + } + return ( +
+
+ 累计请求 + {compact(reqs)} + {rate !== null ? `成功率 ${(rate * 100).toFixed(1)}%` : "暂无请求记录"} +
+
+ 总 Token + {totalTokens !== null ? compact(totalTokens) : "0"} + 缓存 {cacheTokens !== null ? compact(cacheTokens) : "0"} +
+
+ Credit 消耗 + {credit !== null ? metric(credit) : "0"} + {avgMs !== null ? `平均耗时 ${(avgMs / 1000).toFixed(1)}s` : "耗时 —"} +
+
+ 最近调用 + {stats?.last_model ? text(stats.last_model) : "—"} + + {stats?.last_request_at !== undefined ? expiry(stats.last_request_at) : "尚无调用"} + +
+
+ ); + }; + const renderAutomationPanel = (c: Credential) => { + const checkin = c.checkin && typeof c.checkin === "object" ? object(c.checkin) : null; + const trip = c.travel && typeof c.travel === "object" ? object(c.travel) : null; + const dailyChat = + c.daily_chat && typeof c.daily_chat === "object" ? object(c.daily_chat) : null; + return ( +
+
+ + + 上次签到{checkin?.date ? `(${text(checkin.date)})` : ""}: + {checkin ? text(checkin.message) : "尚未查询"} + +
+
+ + + {c.travel_supported === true + ? `上次旅行:${trip ? text(trip.message) : "尚未查询"}` + : "旅行仅适用于国内账号"} + +
+
+ + + {c.daily_chat_supported === true + ? `今日打卡:${dailyChat ? text(dailyChat.message) : "尚未执行"}` + : "活跃打卡仅适用于国际 WorkBuddy 账号"} + +
+ {dailyChat?.acp_usage != null && ( + 本次会话成本:{text(dailyChat.acp_usage)} 积分 + )} + {trip?.stale === true && 状态可能已变化,请先查询核验} + {c.travel_supported === true && } + {c.enabled === false && 账号停用期间不执行自动任务} + {c.trial_supported === true && c.trial != null && ( + 体验积分:{text(object(c.trial).message)} + )} +
+ ); + }; return ( <> - + + + + + + + + ); + })} + + )} {maintenance.length > 0 && (
    @@ -291,337 +828,271 @@ export function Credentials() { )} -
    -
    - - + ))} +
    + {liveSelected.length > 0 && ( + + )} +
    +
    + - - {busy && 操作执行中,请勿重复提交…}
    - -

    - 自动任务按账号保存:国内默认签到后旅行,国际默认关闭。开关分别生效;余额同步不触发领取,关闭开关不撤回已发送的请求。 -

    {resource.data && - (resource.data.length ? ( -
    - - - - - - - - - - - - - - - {resource.data.map((c) => { - const until = number(c.fail_until); - const remaining = - until === null ? null : Math.max(0, Math.ceil(until - now / 1000)); - const cooldowns = Array.isArray(c.cooldowns) ? list(c.cooldowns) : null; + (filtered.length ? ( + <> + {view === "cards" ? ( +
    + {paged.map((c) => { const balance = c.credits && typeof c.credits === "object" ? object(c.credits) : null; - const checkin = - c.checkin && typeof c.checkin === "object" ? object(c.checkin) : null; - const trip = c.travel && typeof c.travel === "object" ? object(c.travel) : null; - const dailyChat = - c.daily_chat && typeof c.daily_chat === "object" - ? object(c.daily_chat) - : null; return ( -
    - - - - - - - - - +
    + 目录与同步状态 + {renderSyncAndCatalog(c)} +
    + + {renderUsageBlock(c)} +
    {renderActions(c)}
    + ); })} - -
    - 0 && liveSelected.length === resource.data.length - } - onChange={(e) => - setSelected(e.target.checked ? resource.data!.map((c) => c.id) : []) - } - /> - 凭证 / 产品人工状态账号任务 / 上次结果认证健康模型 429 冷却官方余额 / 到期操作
    - - setSelected( - e.target.checked - ? [...selected, c.id] - : selected.filter((id) => id !== c.id), - ) - } - /> - - {c.name ?? "安全文件名不可用"} - - {profileLabel(text(c.profile))} · {text(c.nickname ?? c.uid)} - - - {c.sync_pending === true - ? "目录同步中" - : c.catalog_ready === true - ? "目录已就绪" - : "目录状态待确认"} - - - - {c.enabled === true - ? "已启用" - : c.enabled === false - ? "人工停用" - : "未知"} - - - - 0 - ? "bad" - : c.health === "ready" - ? "good" - : "warn" - } - > - {remaining !== null && remaining > 0 - ? `认证熔断 ${remaining}s` - : c.health === "ready" - ? "认证正常" - : text(c.health)} - - {c.token_expired === true ? "Token 已过期" : ""} - {c.last_error_code != null && ( +
    + {c.name ?? "安全文件名不可用"} + + {isEnterprise(c) + ? `企业账号 · ${profileLabel(text(c.profile))}` + : profileLabel(text(c.profile))}{" "} + · {text(c.nickname ?? c.uid)} + +
    + +
    + {renderHealthBadge(c)} + {renderEnableSwitch(c)} +
    + +
    +
    +
    + 官方余额 / 到期 + {renderSyncIcon(c)} +
    + + {balance + ? balance.unlimited && balance.enterprise + ? "无限" + : metric(balance.credits ?? balance.remaining) + : "未知"} + - 最近错误:{text(lastErrorLabel(c.last_error_code))} - {number(c.last_failure_at) !== null - ? ` · ${expiry(c.last_failure_at)}` - : ""} + {c.token_expires_at !== undefined + ? expiry(c.token_expires_at, true) + : c.expiresAt !== undefined + ? expiry(c.expiresAt, true) + : expiry(c.expires_at ?? balance?.soonest_expiry)} - )} - {typeof c.sync_error === "string" && c.sync_error !== "" && ( - 失败原因:{text(c.sync_error)} - )} -
    - {cooldowns ? ( - cooldowns.length ? ( - cooldowns.map((cooldown, i) => ( - - {text(cooldown.model)} ·{" "} - {number(cooldown.until) !== null - ? `${Math.max(0, Math.ceil(Number(cooldown.until) - now / 1000))}s` - : text(cooldown.remaining_seconds)} - - )) - ) : ( - 无模型冷却 - ) - ) : c.model_cooldowns ? ( - - ) : ( - "未知" - )} - - {balance - ? balance.unlimited && balance.enterprise - ? "无限" - : metric(balance.credits ?? balance.remaining) - : "未知"} - - {c.token_expires_at !== undefined - ? expiry(c.token_expires_at, true) - : c.expiresAt !== undefined - ? expiry(c.expiresAt, true) - : expiry(c.expires_at ?? balance?.soonest_expiry)} - - -
    - - - - - {/* Not gated on displayed cooldowns or on enablement: a reset that failed - to persist must stay retryable, and a disabled account can still hold - a stale cooldown worth clearing. */} - - {c.trial_supported === true && ( - - )} - {c.daily_chat_supported === true && ( - - )} - {c.travel_supported === true && ( - <> - - - - )} - -
    -
    -
    + + ) : ( +
    + + + + + + + + + + + + + + {paged.map((c) => { + const balance = + c.credits && typeof c.credits === "object" ? object(c.credits) : null; + return ( + + + + + + + + + + ); + })} + +
    凭证 / 产品目录与同步状态官方余额 / 到期请求与用量认证与启用操作
    + + setSelected( + e.target.checked + ? [...selected, c.id] + : selected.filter((id) => id !== c.id), + ) + } + /> + + {c.name ?? "安全文件名不可用"} + + {isEnterprise(c) + ? `企业账号 · ${profileLabel(text(c.profile))}` + : profileLabel(text(c.profile))}{" "} + · {text(c.nickname ?? c.uid)} + + {renderSyncAndCatalog(c)} +
    + + {balance + ? balance.unlimited && balance.enterprise + ? "无限" + : metric(balance.credits ?? balance.remaining) + : "未知"} + + {renderSyncIcon(c)} +
    + + {c.token_expires_at !== undefined + ? expiry(c.token_expires_at, true) + : c.expiresAt !== undefined + ? expiry(c.expiresAt, true) + : expiry(c.expires_at ?? balance?.soonest_expiry)} + +
    {renderUsageBlock(c, true)} +
    + {renderHealthBadge(c)} + {renderEnableSwitch(c)} +
    +
    {renderActions(c)}
    +
    + )} +
    + + 第 {(safePage - 1) * pageSize + 1}– + {Math.min(filtered.length, safePage * pageSize)} 条 · 共 {filtered.length} 个账号 + +
    + + + + 第 {safePage} / {totalPages} 页 + + +
    +
    + ) : ( - 添加账号或导入 .info 文件。 + + {allItems.length ? "可切换上方区域或清除筛选条件。" : "添加账号或导入 .info 文件。"} + ))}
    @@ -657,9 +1128,167 @@ export function Credentials() { )} - {detail && ( - setDetail(null)}> - + {detail && liveDetail && ( + setDetail(null)} + > +
    +
    + {renderHealthBadge(liveDetail)} + {renderEnableSwitch(liveDetail)} +
    +
    + {renderSyncIcon(liveDetail)} + +
    +
    +
    + {( + [ + ["overview", "概览与请求"], + ["tasks", "自动化配置"], + ["cooldowns", "绑定与冷却"], + ["raw", "原始诊断"], + ] as [DetailTab, string][] + ).map(([key, label]) => ( + + ))} +
    + {detail.tab === "overview" && ( + <> + +
    {renderUsageBlock(liveDetail)}
    +
    + +
    {renderSyncAndCatalog(liveDetail)}
    +
    + +
    + {detailLogs === null ? ( + 正在加载请求记录… + ) : detailLogs.length ? ( +
    + + + + + + + + + + + + {detailLogs.map((log, i) => ( + + + + + + + + ))} + +
    时间模型状态TokenCredit
    {expiry(log.started_at)}{text(log.public_model ?? log.model)} + + {text(log.status_code ?? log.outcome)} + + {compact(log.total_tokens)}{metric(log.credit)}
    +
    + ) : ( + + )} +
    +
    + + )} + {detail.tab === "tasks" && ( + +
    +

    + 自动任务按账号保存:国内默认签到后旅行,国际默认关闭。开关分别生效;余额同步不触发领取,关闭开关不撤回已发送的请求。 +

    + {renderAutomationPanel(liveDetail)} +
    + + {liveDetail.travel_supported === true && ( + <> + + + + )} + {liveDetail.daily_chat_supported === true && ( + + )} + {liveDetail.trial_supported === true && ( + + )} +
    +
    +
    + )} + {detail.tab === "cooldowns" && ( + +
    +
    +
    + 绑定模型规则 + + {Array.isArray(liveDetail.bindings) && liveDetail.bindings.length + ? liveDetail.bindings.map(text).join("、") + : "无专属绑定(参与公共选路)"} + +
    +
    +
    + +
    +
    +
    + )} + {detail.tab === "raw" && }
    )}
    diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 08a4c6e..2a6aab3 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -13,6 +13,7 @@ import { import s from "../ui.module.scss"; function normalize(value: unknown): RecordValue & { summary: RecordValue; + previous: RecordValue | null; series: RecordValue[]; models: RecordValue[]; profiles: RecordValue[]; @@ -28,6 +29,8 @@ function normalize(value: unknown): RecordValue & { return { ...d, summary: object(d.summary, t("dash.summary")), + previous: + d.previous === null || d.previous === undefined ? null : object(d.previous, "上周期汇总"), series: list(d.series, "趋势"), models: list(d.models, "模型排行"), profiles: list(d.profiles, t("dash.profileUsage")), @@ -42,7 +45,7 @@ function Health({ data }: { data: RecordValue }) { const credentials = list(data.credentials); if (!credentials.length) return ; return ( -
    +
    {credentials.map((c, i) => (
    @@ -71,7 +74,7 @@ function OfficialCredits({ value }: { value: unknown }) { const rows = Object.entries(object(value)); if (!rows.length) return ; return ( -
    +
    @@ -111,14 +114,7 @@ function OfficialCredits({ value }: { value: unknown }) { ); } -function bytes(value: unknown) { - const n = number(value); - 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 { KpiCards, UsageTrend } from "../UsageOverview"; import { t, useT } from "../i18n"; export { Trend } from "../Trend"; export function Dashboard() { @@ -126,10 +122,11 @@ export function Dashboard() { const [days, setDays] = useState("7"); const [granularity, setGranularity] = useState("auto"); const resource = useResource(`/dashboard?days=${days}&granularity=${granularity}`, normalize); + // Hourly buckets power the trend's zoom-in granularity swap. + const hourly = useResource(`/dashboard?days=${days}&granularity=hour`, normalize); const d = resource.data; const period = d?.range ?? {}; const actualGrain = period.granularity === "hour" ? "hour" : "day"; - const rate = number(d?.summary.success_rate); return ( <> {t("dash.granularityHour")} - @@ -171,45 +173,22 @@ export function Dashboard() { {t("dash.storageDegraded")} )} -
    - {[ - [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]) => ( -
    -
    - {label} - - - -
    - {value} -
    - ))} -
    + + + {period.partial === true && ( +

    + {t("dash.storagePartial")} +

    + )} + +
    - - {period.partial === true && ( -

    - {t("dash.storagePartial")} -

    - )} - -
    {d.profiles.length ? (
    @@ -237,7 +216,7 @@ export function Dashboard() { {d.models.length ? ( -
    +
    @@ -272,49 +251,6 @@ export function Dashboard() { - -
    - - {d.storage.degraded === true - ? t("dash.storage.collectDegraded") - : d.storage.degraded === false - ? t("dash.status.normal") - : t("dash.status.unknown")} - - - {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 ? ( diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx index fbca209..ce51804 100644 --- a/web/src/pages/Models.tsx +++ b/web/src/pages/Models.tsx @@ -493,16 +493,39 @@ function matchesCatalog(model: ModelRule, query: string, filter: CatalogFilter): return true; } export function Models() { - const resource = useResource("/models", modelResponse); - const credentials = useResource("/credentials", credentialResponse); + const resource = useResource("/models", modelResponse, { cache: true }); + const credentials = useResource("/credentials", credentialResponse, { cache: true }); const [query, setQuery] = useState(""); const [detailsId, setDetailsId] = useState(null); const detailed = resource.data?.models.find((model) => model.id === detailsId); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); + const [editorRevision, setEditorRevision] = useState(null); const [deleting, setDeleting] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [guardNotice, setGuardNotice] = useState(null); + const toggleGuard = (key: "model_guard" | "model_capability_guard", next: boolean) => { + if (!resource.data || busy) return; + setBusy(true); + setError(null); + setGuardNotice(null); + void api + .patch("/settings", { + revision: resource.data.revision, + values: { [key]: next }, + }) + .then(() => { + setGuardNotice( + key === "model_capability_guard" && !next + ? "仅关闭新增能力预检;元数据展示、国际图片归并及其他安全限制不变,新请求生效。" + : `${key === "model_guard" ? "表外模型拦截" : "模型能力预检"}已${next ? "开启" : "关闭"}。`, + ); + resource.reload(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; const [filter, setFilter] = useState(emptyFilter); const [sort, setSort] = useState("catalog"); const filtering = Boolean( @@ -529,7 +552,10 @@ export function Models() {
    - + {m.custom && ( + ))} +
    - {!data.items.length && } - {data.items.map((item) => { + {!visible.length && } + {visible.map((item) => { const value = Object.hasOwn(values, item.key) ? values[item.key] : (item.stored ?? item.value); @@ -232,27 +264,6 @@ function SettingsForm({ - - - {data.audit.degraded === true - ? "存储异常" - : data.audit.degraded === false - ? "正常" - : "状态未知"} - -
    - 查看存储详情 - -
    -
    {data.session.degraded === true && ( 撤销未生效 diff --git a/web/src/pages/Storage.tsx b/web/src/pages/Storage.tsx new file mode 100644 index 0000000..af167ac --- /dev/null +++ b/web/src/pages/Storage.tsx @@ -0,0 +1,230 @@ +import { useState } from "react"; +import { api, errorMessage, number, object, text, useResource } from "../api"; +import { useT } from "../i18n"; +import { + Badge, + ClearLogs, + DrawerPresence, + Empty, + ErrorNotice, + Icon, + PageTitle, + Panel, + ResourceState, +} from "../components"; +import s from "../ui.module.scss"; + +function normalize(value: unknown) { + return object(value, "存储状态"); +} + +function bytes(value: unknown): string { + const n = number(value); + if (n === null) return "—"; + if (n < 1024) return `${n} B`; + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; + return `${(n / 1024 ** 3).toFixed(2)} GB`; +} + +function ArchivePanel({ archives, reload }: { archives: unknown[]; reload: () => void }) { + const t = useT(); + // Default shorter than the 30-day retention window so opening the page (which + // prunes expired rows) still leaves older in-retention logs to archive. + const [days, setDays] = useState(7); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + return ( + +
    +
    { + e.preventDefault(); + setBusy(true); + setError(null); + setResult(null); + void api + .post("/storage/archive", { days }) + .then((response) => { + const d = object(response.data, t("storage.archiveResult")); + const count = (number(d.archived_requests) ?? 0) + (number(d.archived_events) ?? 0); + setResult( + count === 0 + ? t("storage.archiveEmpty", { days }) + : t("storage.archiveDone", { count, file: text(d.file) }), + ); + reload(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }} + > + + +
    + {result &&

    {result}

    } + +
    +
    + {t("storage.archives")} + {archives.length} +
    + {archives.length ? ( +
      + {archives.map((name) => ( +
    • {text(name)}
    • + ))} +
    + ) : ( + + )} +
    +
    +
    + ); +} + +export function Storage() { + const t = useT(); + const resource = useResource("/storage", normalize); + const [clearing, setClearing] = useState(false); + const d = resource.data; + const reclaimable = number(d?.reclaimable_bytes); + const archives = Array.isArray(d?.archives) ? d.archives : []; + const stateLabel = + d?.degraded === true + ? t("storage.stateDegraded") + : d?.degraded === false + ? t("dash.status.normal") + : t("storage.stateUnknown"); + return ( + <> + + {d && ( + + {t("storage.status")} · {stateLabel} + + )} + + + } + /> + + {d && ( + <> +
    +
    +
    + {t("storage.dbFile")} + + + +
    + {bytes(d.db_bytes)} + + {t("storage.counts", { + requests: number(d.request_count) ?? "—", + events: number(d.event_count) ?? "—", + days: number(d.retention_days) ?? "—", + })} + +
    +
    +
    + {t("storage.walFile")} + + + +
    + {bytes(d.wal_bytes)} + SHM {bytes(d.shm_bytes)} +
    +
    +
    + {t("storage.logical")} + + + +
    + {bytes(d.logical_bytes)} + {d.pending_cleanup === true ? t("storage.pendingCleanup") : stateLabel} +
    +
    +
    + {t("storage.reclaimable")} + + + +
    + {bytes(d.reclaimable_bytes)} + + {number(d.freelist_pages) ?? 0} / {number(d.page_count) ?? 0} pages + +
    +
    +
    + + +
    +

    + {t("storage.vacuumDesc", { + reclaimable: + reclaimable !== null && reclaimable > 0 + ? t("storage.vacuumReclaim", { size: bytes(reclaimable) }) + : "", + })} +

    +
    +                  {`${t("storage.step.stop")}
    +docker compose down        ${t("storage.step.stopLocal")}
    +
    +${t("storage.step.vacuum")}
    +sqlite3 "\${CODEBUDDY_AUTH_DIR:-auth}/logs.sqlite3" \\
    +  "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
    +
    +${t("storage.step.restart")}
    +docker compose up -d`}
    +                
    + {t("storage.vacuumNote")} +
    +
    + {t("storage.cleanup")} +

    {t("storage.cleanupDesc")}

    +
    + +
    +
    +
    +
    + + )} + + {clearing && ( + setClearing(false)} onDone={() => resource.reload()} /> + )} + + + ); +} diff --git a/web/src/storage.test.tsx b/web/src/storage.test.tsx new file mode 100644 index 0000000..fb39657 --- /dev/null +++ b/web/src/storage.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource } from "./api"; +import { Storage } from "./pages/Storage"; + +const storagePayload = { + degraded: false, + db_bytes: 5 * 1024 * 1024, + wal_bytes: 1024, + shm_bytes: 32, + logical_bytes: 2 * 1024 * 1024, + request_count: 120, + event_count: 8, + retention_days: 30, + page_count: 1300, + freelist_pages: 512, + reclaimable_bytes: 512 * 4096, + pending_cleanup: false, + archives: ["audit-20260901-000000-abcdef12.jsonl"], +}; + +afterEach(() => vi.restoreAllMocks()); + +describe("storage page", () => { + it("shows sizes with reclaimable estimate and archives old logs on demand", async () => { + const get = vi.spyOn(api, "get").mockResolvedValue({ data: storagePayload }); + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { ok: true, archived_requests: 4, archived_events: 2, file: "audit-x.jsonl" }, + }); + render(); + expect(await screen.findByText("可回收空间")).toBeTruthy(); + expect(screen.getAllByText("2.0 MB").length).toBeGreaterThan(0); + expect(screen.getByText(/约可回收 2.0 MB/)).toBeTruthy(); + expect(screen.getByText("audit-20260901-000000-abcdef12.jsonl")).toBeTruthy(); + expect(get).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "归档并删除" })); + await waitFor(() => expect(post).toHaveBeenCalledWith("/storage/archive", { days: 7 })); + expect(await screen.findByText(/已归档 6 条/)).toBeTruthy(); + await waitFor(() => expect(get).toHaveBeenCalledTimes(2)); + }); +}); + +const asMarker = (value: unknown) => value as { marker: string }; +const CACHE_OPTS = { cache: true }; + +describe("cached resources", () => { + function Probe({ path }: { path: string }) { + const { data, loading } = useResource(path, asMarker, CACHE_OPTS); + if (loading || !data) return 加载中; + return {data.marker}; + } + + it("renders the cached snapshot immediately on remount while refreshing", async () => { + let call = 0; + const get = vi + .spyOn(api, "get") + .mockImplementation( + (path: string) => + new Promise((resolve) => + setTimeout(() => resolve({ data: { marker: `fresh-${path}-${++call}` } }), 5), + ), + ); + const first = render(); + expect(await screen.findByText("fresh-/cache-probe-1")).toBeTruthy(); + first.unmount(); + // Second mount: cached value shows synchronously, then the fresh GET replaces it. + render(); + expect(screen.queryByText("加载中")).toBeNull(); + expect(screen.getByText("fresh-/cache-probe-1")).toBeTruthy(); + expect(await screen.findByText("fresh-/cache-probe-2")).toBeTruthy(); + expect(get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/theme.scss b/web/src/theme.scss index fd5e340..6a4b604 100644 --- a/web/src/theme.scss +++ b/web/src/theme.scss @@ -25,6 +25,9 @@ --good-bg: #e6f2e5; --glow: hsl(var(--hue) 43% 78% / 0.72); --glow-alt: hsl(calc(var(--hue) + 38) 35% 79% / 0.65); + --series-requests: #3f7fb8; + --series-tokens: #4e9e5f; + --series-credit: #cf8a2e; } :root[data-theme="light"][data-palette="blue"] { --hue: 213; @@ -63,6 +66,9 @@ --good-bg: #263e2f; --glow: #35634b55; --glow-alt: #284e4955; + --series-requests: #7fb2e0; + --series-tokens: #8fd0a0; + --series-credit: #e3b96f; } :root[data-theme="dark"] { @include dark; diff --git a/web/src/trial.test.tsx b/web/src/trial.test.tsx index 7d61d8d..30958f8 100644 --- a/web/src/trial.test.tsx +++ b/web/src/trial.test.tsx @@ -1,9 +1,18 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { Trial } from "./Trial"; import { Credentials } from "./pages/Credentials"; import { api, useResource } from "./api"; +function openRowMenu(credentialName: string) { + const row = screen + .getAllByText(credentialName) + .map((el) => el.closest("tr, article")) + .find((tr) => tr && within(tr).queryByRole("button", { name: /^更多/ })); + if (!row) throw new Error(`row for ${credentialName} not found`); + fireEvent.click(within(row).getByRole("button", { name: /^更多/ })); +} + vi.mock("./api", async (original) => ({ ...(await original()), useResource: vi.fn(), @@ -33,7 +42,9 @@ it("only opens a confirmation drawer for supported accounts and sends no claim o }); const post = vi.spyOn(api, "post"); render(); + openRowMenu("cn.info"); expect(screen.queryByRole("button", { name: "领取体验积分 cn.info" })).toBeNull(); + openRowMenu("intl.info"); fireEvent.click(screen.getByRole("button", { name: "领取体验积分 intl.info" })); expect(screen.getByRole("dialog")).toBeTruthy(); expect(screen.getByRole("button", { name: "确认领取" })).toHaveProperty("disabled", false); diff --git a/web/src/ui.module.scss b/web/src/ui.module.scss index 4f6f56b..5c95670 100644 --- a/web/src/ui.module.scss +++ b/web/src/ui.module.scss @@ -430,6 +430,733 @@ $white: var(--page); font-size: 10px; color: #96a08e; } +.kpiGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 23px; +} +.kpiCard { + @include glass(0.48, 18px); + border-radius: 20px; + padding: 16px 18px 12px; + min-width: 0; +} +.kpiCard > div { + display: flex; + align-items: center; + justify-content: space-between; + color: #798771; + font-size: 12px; +} +.kpiCard > div > span:last-child { + width: 28px; + height: 28px; + display: grid; + place-items: center; + background: #f1f6e9; + color: $green; + border-radius: 8px; +} +.kpiCard strong { + display: block; + font-size: 22px; + letter-spacing: -0.4px; + font-weight: 600; + margin: 6px 0 2px; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} +.kpiCard small { + display: block; + min-height: 13px; + font-size: 10px; + color: #96a08e; +} +.spark { + width: 100%; + height: 26px; + margin-top: 6px; +} +.kpiValue { + position: relative; + cursor: default; +} +.kpiTip { + position: absolute; + left: 0; + bottom: calc(100% + 8px); + z-index: 20; + display: none; + flex-direction: column; + gap: 2px; + min-width: 120px; + padding: 8px 12px; + background: var(--primary); + color: var(--on-primary); + border-radius: 12px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + box-shadow: 0 8px 22px var(--shadow); + pointer-events: none; + white-space: nowrap; +} +.kpiTip b { + font-size: 10px; + opacity: 0.75; + font-weight: 500; +} +.kpiValue:hover .kpiTip, +.kpiValue:focus-visible .kpiTip { + display: flex; +} +.zoomReset { + flex-shrink: 0; + font-size: 11px; + padding: 5px 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; +} +.zoomReset:hover { + color: var(--accent); + border-color: var(--accent); + box-shadow: 0 2px 10px var(--shadow); +} +.moreMenu { + position: relative; + display: inline-block; +} +.moreMenuList { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 40; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 128px; + padding: 6px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: 0 10px 26px var(--shadow); +} +.moreMenuList button { + width: 100%; + text-align: left; + white-space: nowrap; +} +.channelGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 20px; +} +@media (max-width: 1200px) { + .channelGrid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } +} +.channelCard { + @include glass(0.48, 18px); + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px 12px; + border-radius: 18px; + text-align: left; + cursor: pointer; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; +} +.channelCard:hover { + border-color: var(--accent); + transform: translateY(-1px); +} +.channelActive { + border-color: var(--accent); + box-shadow: 0 6px 20px var(--shadow); +} +.channelHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + font-size: 12px; + color: var(--muted); +} +.channelHead span:last-child { + font-size: 10.5px; + padding: 2px 8px; + border-radius: 999px; + background: var(--card); + border: 1px solid var(--line); + font-variant-numeric: tabular-nums; +} +.channelCard strong { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.3px; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.channelCard small { + font-size: 10.5px; + color: var(--muted); +} +.channelBar { + width: 100%; + height: 4px; + margin-top: 2px; + border-radius: 999px; + background: var(--line); + overflow: hidden; +} +.channelBar > i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 0.2s ease; +} +.credFilterBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 4px 22px 14px; +} +.credFilterLeft { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: 1 1 320px; +} +.credSearch { + flex: 1 1 200px; + max-width: 280px; + min-width: 150px; +} +.statusPills { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: var(--card); + border: 1px solid var(--line); +} +.statusPills button, +.viewSwitch button { + padding: 4px 11px; + font-size: 11px; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); +} +.statusPills button.pillActive, +.viewSwitch button.viewActive { + background: var(--surface); + color: var(--text); + border-color: var(--line); + box-shadow: 0 2px 8px var(--shadow); +} +.viewSwitch { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: var(--card); + border: 1px solid var(--line); +} +.credCardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; + padding: 4px 22px 22px; +} +@media (max-width: 600px) { + .credCardGrid { + grid-template-columns: 1fr; + padding: 4px 14px 18px; + } +} +.credCard { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 18px; + min-width: 0; +} +.credCardHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.credCardTitle { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} +.credCardTitle input { + margin-top: 3px; +} +.credCardTitle > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.credCardTitle strong { + font-size: 14px; + word-break: break-all; +} +.credCardTitle small { + font-size: 11px; + color: var(--muted); +} +.credCardBadges { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; +} +.credCardStats { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.25fr); + gap: 8px; +} +.credCardStats > div { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--line); + min-width: 0; +} +.credStatHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.credStatHead > span, +.credCardStats > div > span { + font-size: 10px; + color: var(--muted); +} +.credCardStats > div > strong { + font-size: 15px; + font-variant-numeric: tabular-nums; +} +.credCardStats > div > small { + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; +} +.credSyncTags { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 2px; +} +.iconBtn { + width: 26px; + height: 26px; + padding: 0; + display: inline-grid; + place-items: center; + border-radius: 50%; + border: 1px solid var(--line); + background: var(--card); + color: var(--text); + flex-shrink: 0; +} +.iconBtn svg { + width: 13px; + height: 13px; +} +.credUsageBox { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding: 11px 12px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--line); +} +.credUsageBox > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.credUsageBox span { + font-size: 10px; + color: var(--muted); +} +.credUsageBox strong { + font-size: 13.5px; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.credUsageBox small { + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tableUsage { + display: grid; + grid-template-columns: repeat(2, minmax(92px, 1fr)); + gap: 4px 12px; +} +.tableUsage > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6px; + font-size: 11px; +} +.tableUsage span { + font-size: 10px; + color: var(--muted); +} +.tableUsage strong { + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.credPager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 12px 22px 18px; + border-top: 1px solid var(--line); + font-size: 11.5px; + color: var(--muted); +} +.credPagerRight { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.detailBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.detailKpis { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.detailKpis > div { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 14px; + border-radius: 14px; + background: var(--card); + border: 1px solid var(--line); +} +.detailKpis span { + font-size: 10.5px; + color: var(--muted); +} +.detailKpis strong { + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.detailKpis small { + font-size: 10.5px; + color: var(--muted); +} +.credCardTasks { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--line); +} +.credTaskRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 11.5px; +} +.credTaskRow .check { + margin: 0; + flex-shrink: 0; +} +.enableSwitch { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 5px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface); + font-size: 11px; + color: var(--text); + cursor: pointer; + user-select: none; +} +.enableSwitch input { + appearance: none; + width: 26px; + height: 15px; + margin: 0; + border-radius: 999px; + background: var(--line); + position: relative; + cursor: pointer; + transition: background 0.15s ease; +} +.enableSwitch input::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s ease; +} +.enableSwitch input:checked { + background: var(--accent); +} +.enableSwitch input:checked::after { + transform: translateX(11px); +} +.enableSwitch input:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.credTaskRow small { + font-size: 10.5px; + color: var(--muted); + text-align: right; +} +.credCardFoot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + margin-top: auto; + padding-top: 4px; +} +.zoomHint { + margin-left: auto; + font-size: 10px; + color: var(--muted); + opacity: 0.8; +} +.storageSplit { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px; + align-items: stretch; +} +@media (max-width: 980px) { + .storageSplit { + grid-template-columns: 1fr; + } +} +.panelBody { + display: flex; + flex-direction: column; + gap: 14px; + padding: 2px 22px 22px; +} +.storageLead { + margin: 0; + font-size: 12px; + line-height: 1.65; + color: var(--text); +} +.storageSubtle { + font-size: 11px; + line-height: 1.5; + color: var(--muted); +} +.archiveForm { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} +.archiveForm .field { + flex: 1 1 160px; + max-width: 220px; + margin: 0; +} +.storageSection { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.storageSectionHead { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; +} +.storageSectionHead span { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-variant-numeric: tabular-nums; + background: var(--card); + border: 1px solid var(--line); + color: var(--muted); +} +.archiveList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 210px; + overflow-y: auto; +} +.archiveList li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--text); +} +.cleanupBox { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + margin-top: auto; + background: var(--card); + border: 1px solid var(--line); + border-radius: 16px; +} +.cleanupBox strong { + display: block; + font-size: 12px; + margin-bottom: 4px; +} +.cleanupBox p { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: var(--muted); +} +.cleanupBox button { + flex-shrink: 0; +} +.resultNote { + font-size: 12px; + color: var(--accent); + margin: 0; +} +.codeBlock { + font-size: 11.5px; + line-height: 1.55; + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px 14px; + overflow-x: auto; + white-space: pre; + margin: 0; +} +.chartHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: -4px 15px 2px; +} +.chartHead .zoomHint { + margin-left: 0; +} +.chartLegend { + display: flex; + gap: 18px; + justify-content: center; + padding: 4px 0 8px; + font-size: 11px; + color: $muted; +} +.legendDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; +} +.panelScroll { + max-height: 340px; + overflow-y: auto; +} +.panelScroll thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--surface); + box-shadow: 0 1px 0 var(--line); +} +@media (max-width: 1100px) { + .kpiGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} .dashboardGrid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); @@ -454,8 +1181,8 @@ $white: var(--page); } .chart { width: 100%; - min-height: 190px; - padding: 0 15px; + height: 210px; + display: block; } .chart text { font-size: 11px; @@ -768,9 +1495,13 @@ $white: var(--page); padding: 26px 28px 42px; } .drawerBody > .panel, -.drawerBody form + .panel { +.drawerBody form + .panel, +.drawerBody section + section { margin-top: 22px; } +.drawerBody .panel + .panel { + margin-top: 14px; +} .field { display: flex; flex-direction: column; @@ -828,7 +1559,7 @@ $white: var(--page); } .details > div { display: grid; - grid-template-columns: minmax(100px, 0.85fr) minmax(0, 1.3fr); + grid-template-columns: 140px minmax(0, 1fr); gap: 15px; border-bottom: 1px solid #edf0e6; padding: 10px 0; @@ -1778,7 +2509,8 @@ $white: var(--page); padding: 0 24px 20px; } .appearanceChoices, -.paletteChoices { +.paletteChoices, +.langChoices { border: 0; padding: 0; margin: 18px 0 24px; @@ -1787,7 +2519,8 @@ $white: var(--page); grid-template-columns: repeat(3, minmax(0, 1fr)); } .appearanceChoices legend, -.paletteChoices legend { +.paletteChoices legend, +.langChoices legend { margin-bottom: 12px; font-size: 12px; color: var(--muted); @@ -1806,7 +2539,8 @@ $white: var(--page); font-size: 11px; } .appearanceChoices input, -.paletteChoices input { +.paletteChoices input, +.langChoices input { position: absolute; opacity: 0; inset: 0; @@ -1943,17 +2677,24 @@ $white: var(--page); font-size: 11px; } .chartTooltip dl { - display: grid; - grid-template-columns: repeat(3, 1fr); + display: flex; + flex-direction: column; margin: 8px 0 0; - gap: 12px; + gap: 6px; +} +.chartTooltip dl > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; } .chartTooltip dt { color: var(--muted); + white-space: nowrap; } .chartTooltip dd { margin: 0; - font-size: 15px; + font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; } @@ -2219,3 +2960,23 @@ $white: var(--page); backdrop-filter: none; } } + +.langChoices label { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin: 0 auto; + border: 1px solid var(--line); + border-radius: 50%; + cursor: pointer; + font-size: 12px; + position: relative; + color: var(--muted); +} +.langChoices label:has(:checked) { + border-color: var(--accent); + color: var(--accent); + background: var(--soft); +}