diff --git a/README.md b/README.md index fe1493e..63b13af 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,9 @@ Workspace 当前提供以下有界 projection: - `accounts`:账号摘要、quota window、当日用量和 provider/group/status 字段。 - `status`:channel monitor 摘要。 - `sources`:Pricing Monitor 已有的脱敏 source 余额与健康状态。 -- `traffic.requests`:仅 server6 的最近有界请求样本。 +- `traffic.requests`:仅 server6 的最新有界请求样本。 - `traffic.errors`:server6/server4 的有界逐条错误事件,保留节点、时间、key/account、结构化错误类别和状态链路,不做错误分组或 per-row count。 -- `traffic.keys`:仅 server6 按 `Asia/Shanghai` 自然日聚合的 key 使用量,payload 同时携带统计日期和时区。 +- `traffic.keys`:仅 server6 按 `Asia/Shanghai` 自然日完整聚合的 key 使用量;事件样本上限不会截断其 request/token/cost totals,payload 同时携带统计日期和时区。 投影允许展示 account/key 名称、稳定 ID、model、instance 和运维状态,但不包含 API key 原文、access/refresh token、cookie、密码、数据库凭据或请求/响应正文。ID 始终按 @@ -117,10 +117,10 @@ Accounts 只在 canonical 名称 `{family}-quota-{source}` 或 provider、URL、display name 或模糊文本推断;source 为 error/stale 时保留名称和状态, 但不伪造 CNY 数值。 -Requests 展示 server6 的 key、account、model、token bucket、actual cost、first-token latency、 +Requests 展示 server6 的最新有界明细:key、account、model、token bucket、actual cost、first-token latency、 duration 和 decode throughput。Errors 展示 server6/server4 的逐条 instance、事件时间、status path、key、 -account、model、phase/type/owner,不显示聚合次数。Key usage、Requests 和对应 account 当天用量只采用 -server6;Errors 保留双节点。所有 workspace Traffic 都使用服务器声明的 `Asia/Shanghai` 自然日,不依赖客户端本地日期或滚动 +account、model、phase/type/owner,不显示聚合次数。Key usage 和对应 Traffic Account usage 是 server6 +完整当天 rollup;Errors 与 Requests 仍是事件样本。所有 workspace Traffic 都使用服务器声明的 `Asia/Shanghai` 自然日,不依赖客户端本地日期或滚动 24 小时窗口;每个 Traffic projection 显式携带日期、时区和 `[00:00, 次日 00:00)` 边界。 ## Legacy Direct diff --git a/pyproject.toml b/pyproject.toml index bce1c49..9a10d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.3.2" +version = "0.3.3" description = "Aggregated operations TUI for Sub2API" readme = "README.md" requires-python = ">=3.11" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index 1286429..3fde0b8 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -26,7 +26,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError APP_NAME = "shusub2" -FALLBACK_VERSION = "0.3.2" +FALLBACK_VERSION = "0.3.3" DEFAULT_WORKSPACE_URL = "https://price.tailbeb9ad.ts.net/api/ui-data?view=workspace" DEFAULT_WORKSPACE_URL_CONFIG_FILE = "~/.config/shusub2/workspace-url" DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" @@ -607,6 +607,8 @@ def fetch_workspace_payload(workspace_url: str, timeout: int) -> dict[str, Any]: raise RuntimeError("Pricing Monitor returned an invalid workspace projection") if workspace_traffic_period(traffic) is None: raise RuntimeError("Pricing Monitor returned an invalid workspace traffic period") + if traffic.get("usage_rollups_full_day") is not True: + raise RuntimeError("Pricing Monitor returned incomplete workspace traffic usage") channel_monitors = payload["status"].get("channel_monitors") bounded_fields = ( ("accounts", payload["accounts"].get("accounts"), MAX_WORKSPACE_ACCOUNTS), @@ -697,6 +699,7 @@ def workspace_keys_payload(payload: dict[str, Any]) -> dict[str, Any]: "period_kind": period.get("period_kind", ""), "date": period.get("date", ""), "timezone": period.get("timezone", ""), + "usage_rollups_full_day": traffic.get("usage_rollups_full_day") is True, "workspace": True, } @@ -1983,7 +1986,7 @@ def run_textual( ) self.configure_table( self.query_one("#keys", DataTable), - ("Node", "KEY", "Today", "Tokens", "Req"), + ("Node", "KEY", "Day total" if not legacy_direct else "Today", "Tokens", "Req"), ) self.configure_table( self.query_one("#logs", DataTable), @@ -2251,11 +2254,14 @@ def run_textual( else: summary.append(pricing_summary(self.pricing_payload), style="bright_blue") summary.append(" | ", style="dim") - summary.append(f"keys {len(self.key_rows)} ({key_summary.removeprefix('keys ')})", style="yellow") + key_scope = "keys day total" if not legacy_direct else "keys" + request_scope = "request sample" if not legacy_direct else "requests" + error_scope = "error sample" if not legacy_direct else "errors" + summary.append(f"{key_scope} {len(self.key_rows)} ({key_summary.removeprefix('keys ')})", style="yellow") summary.append(" | ", style="dim") - summary.append(f"requests {len(self.log_rows)}/{logs_total}", style="magenta") + summary.append(f"{request_scope} {len(self.log_rows)}/{logs_total}", style="magenta") summary.append(" | ", style="dim") - summary.append(f"errors {len(self.error_rows)}/{self.errors_total()}", style="red") + summary.append(f"{error_scope} {len(self.error_rows)}/{self.errors_total()}", style="red") self.query_one("#summary", Static).update(summary) status_bits = [version_message] @@ -2328,8 +2334,11 @@ def run_textual( if error: detail += f" | {error}" elif table_id == "keys": + period_label = usage_period_label(self.keys_payload) + if self.keys_payload.get("usage_rollups_full_day") is True: + period_label += " full-day" detail = ( - f"{row['node']} | {row['name']} | {usage_period_label(self.keys_payload)} {format_cost(row['cost'])} | " + f"{row['node']} | {row['name']} | {period_label} {format_cost(row['cost'])} | " f"{format_count(row['tokens'])} tokens | {format_count(row['requests'])} req" ) elif table_id == "logs": @@ -2398,7 +2407,7 @@ def run_textual( keys_table = self.query_one("#keys", DataTable) keys_table.cursor_type = "row" keys_table.zebra_stripes = True - keys_table.add_columns("Node", "Key", "Today", "Tokens", "Req") + keys_table.add_columns("Node", "Key", "Day total" if not legacy_direct else "Today", "Tokens", "Req") self.refresh_data(refresh=legacy_direct) self.set_interval(refresh_seconds, self.refresh_data) diff --git a/tests/test_payload.py b/tests/test_payload.py index 2d3c356..2c665a9 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -89,6 +89,7 @@ def workspace_payload_fixture() -> dict[str, object]: "ends_at": local_end.isoformat(), "sample_limit": 1000, "sample_limited": False, + "usage_rollups_full_day": True, "partial": False, "instances": [ {"instance": "server6", "ok": True, "error_total": 4}, @@ -810,6 +811,7 @@ class WorkspaceTests(unittest.TestCase): self.assertEqual(keys["period_kind"], "calendar_day") self.assertEqual(keys["date"], payload["traffic"]["date"]) self.assertEqual(keys["timezone"], "Asia/Shanghai") + self.assertTrue(keys["usage_rollups_full_day"]) self.assertIn(f"today {keys['date']} (Asia/Shanghai)", mod.usage_period_label(keys)) self.assertEqual(errors["time_range"], f"today {keys['date']} (Asia/Shanghai)") self.assertEqual({item["_node"] for item in errors["items"]}, {"server6", "server4"}) @@ -876,6 +878,12 @@ class WorkspaceTests(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "invalid workspace traffic period"): mod.fetch_workspace_payload("https://workspace.example.test/data", 3) + incomplete_usage = workspace_payload_fixture() + incomplete_usage["traffic"]["usage_rollups_full_day"] = False + with mock.patch.object(mod, "fetch_payload", return_value=incomplete_usage): + with self.assertRaisesRegex(RuntimeError, "incomplete workspace traffic usage"): + mod.fetch_workspace_payload("https://workspace.example.test/data", 3) + noncanonical_request = workspace_payload_fixture() noncanonical_request["traffic"]["requests"][0]["instance"] = "server4" with mock.patch.object(mod, "fetch_payload", return_value=noncanonical_request): diff --git a/uv.lock b/uv.lock index 77bcdc9..37389b2 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.3.2" +version = "0.3.3" source = { editable = "." } dependencies = [ { name = "textual" },