feat: show dual-node account test status

This commit is contained in:
2026-08-07 03:09:23 +08:00
parent 492e6c882f
commit 68d37a1584
5 changed files with 208 additions and 22 deletions
+9 -6
View File
@@ -69,7 +69,7 @@ refresh。组件失败时服务端保留对应 last-good 数据,并在 `compon
Workspace 当前提供以下有界 projection
- `accounts`:账号摘要、quota window当日用量和 provider/group/status 字段;如能按当前 server6 upstream key-group binding 关联,还包含 `account_rate_multiplier` 原始有效倍率和 `account_rate_multiplier_cny` 展示倍率。
- `accounts`按完整 `(account_id, account_name)` 对齐 server6/server4 fresh binding 和 scheduled-test plan 的账号摘要、quota window当日用量`active_state``schedulable_state``yes` / `partial` / `no``last_test_state` 另可为 `NA`。每个节点先选择自身最新的有效测试结果,聚合 `last_test_time` / `last_test_model` 取两者中较早的一次;详情保留脱敏 `node_status` 用于定位 partial。当前 server6 upstream key-group binding 精确关联时仍包含 `account_rate_multiplier` 原始有效倍率和 `account_rate_multiplier_cny` 展示倍率。
- `status`channel monitor 摘要。
- `sources`Pricing Monitor 已有的脱敏 source 余额与健康状态。
- `traffic.requests`:仅 server6 的最新有界请求样本;每行带对应 account 的当前 `account_rate_multiplier` / `account_rate_multiplier_cny`,无法稳定关联时为 `null`
@@ -131,7 +131,7 @@ account、model、phase/type/owner,不显示聚合次数。Key usage 和对应
`--legacy-direct` 仅用于迁移对账和故障诊断。它恢复旧拓扑:
- Accounts helper `/api/tui/accounts`
- `sub2api-status` `/api/status`
- `sub2api-status` `/api/status`(历史接口,已归档下线;仅作为旧客户端参数的兼容说明)
- Pricing Monitor `view=accounts`
- Sub2API admin usage/key/error API
@@ -202,12 +202,15 @@ uv lock --check
## Maintenance Notes
Workspace adapter 只负责把 server projection 映射为现有渲染模型,不得在普通刷新中
重新引入 legacy HTTP 请求。新增 projection 字段时应同时验证:字段 allowlist、字符串 ID、
重新引入 legacy HTTP 请求。当前 `sub2api-status` 与旧 Accounts helper 已归档下线,Pricing Monitor
受管模型中的 Workspace 输入已成对清空。由于含用户未提交 Rule Control 改动的 companion 不在本次部署范围,
远端 collector 暂保留旧环境并会显示 stale / partial;下一次安全的 selected deployment 或重启后,默认
`shusub2` Workspace 入口将返回 `503 workspace data unavailable`。新增替代 projection 字段时应同时验证:字段 allowlist、字符串 ID、
finite number、payload 上限、单请求 cache、错误聚合次数和 legacy fallback。
`cliproxy-codex-quota` `sub2api-status` 当前仍可作为 Aggregation Hub 的过渡采集输入
双读对照,但不再是默认 TUI 客户端直连依赖。移除这些过渡输入前,必须先完成 server2
聚合字段对账和 AstrBot `view=alerts` 连续性验证。
`cliproxy-codex-quota`继续作为 Aggregation Hub 的过渡采集输入`sub2api-status` 已归档下线,
不得再作为采集输入或双读对照。恢复 Workspace 前,必须先完成新的服务端 projection、server2 聚合字段
对账和 AstrBot `view=alerts` 连续性验证。
## Tests And Release
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "shusub2"
version = "0.3.5"
version = "0.3.6"
description = "Aggregated operations TUI for Sub2API"
readme = "README.md"
requires-python = ">=3.11"
+123 -10
View File
@@ -27,7 +27,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
APP_NAME = "shusub2"
FALLBACK_VERSION = "0.3.5"
FALLBACK_VERSION = "0.3.6"
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"
@@ -664,6 +664,38 @@ def fetch_workspace_response(
return payload, etag
def workspace_account_contract_is_valid(rows: list[dict[str, Any]]) -> bool:
for row in rows:
if str(row.get("active_state") or "") not in {"yes", "partial", "no"}:
return False
if str(row.get("schedulable_state") or "") not in {"yes", "partial", "no"}:
return False
if str(row.get("last_test_state") or "") not in {"yes", "partial", "no", "NA"}:
return False
if not isinstance(row.get("last_test_time"), str) or not isinstance(
row.get("last_test_model"), str
):
return False
node_status = row.get("node_status")
if not isinstance(node_status, dict):
return False
for node_name in ("server6", "server4"):
node = node_status.get(node_name)
if not isinstance(node, dict):
return False
if node.get("active") is not None and not isinstance(node.get("active"), bool):
return False
if node.get("schedulable") is not None and not isinstance(node.get("schedulable"), bool):
return False
if str(node.get("last_test") or "") not in {"yes", "no", "NA"}:
return False
if not isinstance(node.get("last_test_time"), str) or not isinstance(
node.get("last_test_model"), str
):
return False
return True
def fetch_workspace_payload(
workspace_url: str,
timeout: int,
@@ -709,6 +741,8 @@ def fetch_workspace_payload(
not isinstance(row, dict) for row in rows
):
raise RuntimeError(f"Pricing Monitor returned invalid workspace {label}")
if not workspace_account_contract_is_valid(payload["accounts"]["accounts"]):
raise RuntimeError("Pricing Monitor returned invalid workspace account contract")
for label in ("requests", "keys"):
for row in traffic.get(label) or []:
if str(row.get("instance") or row.get("node") or "").strip() != "server6":
@@ -1708,6 +1742,36 @@ def reset_cell(account: dict[str, Any]) -> str:
return "-"
def workspace_account_state(value: Any, *, last_test: bool = False) -> str:
if isinstance(value, bool):
return "yes" if value else "no"
allowed = {"yes", "partial", "no"}
if last_test:
allowed.add("NA")
state = str(value or "").strip()
return state if state in allowed else "-"
def workspace_account_node_detail(row: dict[str, Any]) -> str:
node_status = row.get("node_status")
if not isinstance(node_status, dict):
return "two-node -"
details: list[str] = []
for node_name in ("server6", "server4"):
node = node_status.get(node_name)
if not isinstance(node, dict):
details.append(f"{node_name} unavailable")
continue
details.append(
f"{node_name} active {workspace_account_state(node.get('active'))} "
f"schedulable {workspace_account_state(node.get('schedulable'))} "
f"test {workspace_account_state(node.get('last_test'), last_test=True)} "
f"{short_time(node.get('last_test_time'))} "
f"{str(node.get('last_test_model') or '-')}"
)
return " | ".join(details) if details else "two-node -"
def normalize_account_rows(
payload: dict[str, Any],
filter_text: str = "",
@@ -1762,6 +1826,12 @@ def normalize_account_rows(
"account_type": str(account.get("account_type") or ""),
"plan": str(account.get("plan") or ""),
"priority": account.get("priority"),
"active": workspace_account_state(account.get("active_state")),
"schedulable_state": workspace_account_state(account.get("schedulable_state")),
"last_test": workspace_account_state(account.get("last_test_state"), last_test=True),
"last_test_time": short_time(account.get("last_test_time")),
"last_test_model": str(account.get("last_test_model") or "-"),
"node_status": account.get("node_status") if isinstance(account.get("node_status"), dict) else {},
"today_cost_usd": as_float(account.get("today_cost_usd")),
"today_actual_cost_usd": as_float(account.get("today_actual_cost_usd")),
"today_tokens": as_int(account.get("today_tokens")),
@@ -1941,7 +2011,7 @@ def print_once(
print(f"sources: {pricing_error}")
elif pricing_payload:
print(pricing_summary(pricing_payload))
print("name provider group source multiplier src cny src state daily today tokens req kind 5h 7d reset status availability")
print("name provider group source multiplier src cny src state daily today tokens req kind 5h 7d reset status active schedulable last-test last-test-time last-test-model availability")
for row in normalize_account_rows(payload, filter_text, pricing_payload):
print(
f"{row['name'][:30]:<30} "
@@ -1960,6 +2030,11 @@ def print_once(
f"{row['weekly']:<9} "
f"{row['reset']:<11} "
f"{row['status']:<10} "
f"{row['active']:<9} "
f"{row['schedulable_state']:<9} "
f"{row['last_test']:<10} "
f"{row['last_test_time']:<13} "
f"{row['last_test_model'][:23]:<23} "
f"{monitor_availability(row, status_payload or {})}"
)
@@ -2096,9 +2171,15 @@ def run_textual(
def on_mount(self) -> None:
self.app.sub_title = "Dashboard"
account_columns = (
"ACCOUNT", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Today", "Daily", "5h", "7d"
)
if not legacy_direct:
account_columns += ("Active", "Schedulable", "Last test", "Last test time", "Last test model")
account_columns += ("Avail",)
self.configure_table(
self.query_one("#accounts", DataTable),
("ACCOUNT", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Today", "Daily", "5h", "7d", "Avail"),
account_columns,
)
self.configure_table(
self.query_one("#keys", DataTable),
@@ -2258,7 +2339,7 @@ def run_textual(
for index, row in enumerate(self.account_rows):
key = f"account-{row['id']}-{index}"
self.account_by_key[key] = row
table.add_row(
cells: list[Any] = [
row["name"],
row["routing_group"],
row["pricing_source"],
@@ -2269,9 +2350,19 @@ def run_textual(
row["daily_quota_cell"],
row["five_hour"],
row["weekly"],
monitor_availability(row, self.status_payload),
key=key,
]
if not legacy_direct:
cells.extend(
(
row["active"],
row["schedulable_state"],
row["last_test"],
row["last_test_time"],
row["last_test_model"],
)
)
cells.append(monitor_availability(row, self.status_payload))
table.add_row(*cells, key=key)
def render_keys(self) -> None:
filter_text = self.query_one("#filter", Input).value
@@ -2447,6 +2538,9 @@ def run_textual(
f"7d {row['weekly']} | "
f"today {format_cost(row['today_cost_usd'])}, "
f"{format_count(row['today_tokens'])} tokens, {format_count(row['today_requests'])} req | "
f"active {row['active']} | schedulable {row['schedulable_state']} | "
f"last test {row['last_test']} {row['last_test_time']} {row['last_test_model']} | "
f"{workspace_account_node_detail(row)} | "
f"{account_pricing_detail(row)} | "
f"{monitor_detail(row, self.status_payload)}"
)
@@ -2523,7 +2617,13 @@ def run_textual(
table = self.query_one("#accounts", DataTable)
table.cursor_type = "row"
table.zebra_stripes = True
table.add_columns("Name", "Provider", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability")
account_columns = [
"Name", "Provider", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status"
]
if not legacy_direct:
account_columns.extend(("Active", "Schedulable", "Last test", "Last test time", "Last test model"))
account_columns.append("Availability")
table.add_columns(*account_columns)
keys_table = self.query_one("#keys", DataTable)
keys_table.cursor_type = "row"
keys_table.zebra_stripes = True
@@ -2616,7 +2716,7 @@ def run_textual(
for row in self.rows:
key = str(row["id"])
self.row_by_key[key] = row
table.add_row(
cells: list[Any] = [
row["name"],
row["provider"],
row["routing_group"],
@@ -2633,9 +2733,19 @@ def run_textual(
row["weekly"],
row["reset"],
row["status"],
monitor_availability(row, self.status_payload),
key=key,
]
if not legacy_direct:
cells.extend(
(
row["active"],
row["schedulable_state"],
row["last_test"],
row["last_test_time"],
row["last_test_model"],
)
)
cells.append(monitor_availability(row, self.status_payload))
table.add_row(*cells, key=key)
self.query_one("#summary", Static).update(summary_line(self.payload))
if self.rows:
self.render_detail(self.rows[0])
@@ -2660,6 +2770,9 @@ def run_textual(
f"today {format_cost(row['today_cost_usd'])}, {format_count(row['today_tokens'])} tokens, "
f"{format_count(row['today_requests'])} req | latest {row['latest_usage_at']} | "
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
f"active {row['active']} | schedulable {row['schedulable_state']} | "
f"last test {row['last_test']} {row['last_test_time']} {row['last_test_model']} | "
f"{workspace_account_node_detail(row)} | "
f"{monitor_detail(row, self.status_payload)}"
)
error = str(raw.get("error") or "").strip()
+71 -1
View File
@@ -67,6 +67,27 @@ def workspace_payload_fixture() -> dict[str, object]:
"provider": "openai",
"kind": "quota_limited",
"usable": True,
"active_state": "yes",
"schedulable_state": "partial",
"last_test_state": "partial",
"last_test_time": "2026-08-03T10:00:00Z",
"last_test_model": "gpt-5.6-luna",
"node_status": {
"server6": {
"active": True,
"schedulable": True,
"last_test": "yes",
"last_test_time": "2026-08-03T10:00:00Z",
"last_test_model": "gpt-5.6-luna",
},
"server4": {
"active": True,
"schedulable": False,
"last_test": "no",
"last_test_time": "2026-08-03T10:02:00Z",
"last_test_model": "gpt-5.6-mini",
},
},
"account_rate_multiplier": 0.06,
"account_rate_multiplier_cny": 0.003,
}
@@ -730,6 +751,8 @@ def sample_logs_payload() -> dict:
"api_key": {"id": 3, "name": "codex-main"},
"account_id": 7,
"account": {"id": 7, "name": "oai-sub-1"},
"account_rate_multiplier": 0.06,
"account_rate_multiplier_cny": 0.003,
"user": {"id": 1, "name": "shujakuin"},
"model": "gpt-5.5",
"upstream_model": "gpt-5.5-codex",
@@ -807,10 +830,45 @@ class WorkspaceTests(unittest.TestCase):
normalized_accounts = mod.normalize_account_rows(accounts, pricing_payload=pricing)
normalized_logs = mod.normalize_log_rows(logs)
self.assertEqual(normalized_accounts[0]["id"], 9007199254740993)
self.assertEqual(normalized_accounts[0]["account_multiplier"], 0.003)
self.assertEqual(normalized_accounts[0]["active"], "yes")
self.assertEqual(normalized_accounts[0]["schedulable_state"], "partial")
self.assertEqual(normalized_accounts[0]["last_test"], "partial")
self.assertEqual(normalized_accounts[0]["last_test_model"], "gpt-5.6-luna")
self.assertIn("server6 active yes", mod.workspace_account_node_detail(normalized_accounts[0]))
once_output = io.StringIO()
with contextlib.redirect_stdout(once_output):
mod.print_once(accounts, pricing_payload=pricing)
self.assertIn("last-test", once_output.getvalue())
self.assertIn("gpt-5.6-luna", once_output.getvalue())
self.assertEqual(normalized_logs[0]["id"], 9007199254740997)
self.assertEqual(normalized_logs[0]["account_multiplier"], 0.003)
self.assertEqual(normalized_logs[0]["cost"], 0.25)
self.assertEqual(mod.account_rate_value({"account_rate_multiplier": 0.06}), 0.06)
self.assertEqual(
mod.account_rate_value(
{
"account_rate_multiplier": 0.06,
"account_rate_multiplier_cny": 0.003,
}
),
0.003,
)
self.assertEqual(
mod.account_rate_value(
{
"account_rate_multiplier": 0.06,
"account_rate_multiplier_cny": "not-a-number",
}
),
0.06,
)
self.assertEqual(mod.account_rate_value({"account_rate_multiplier_cny": 0}), 0)
self.assertIsNone(
mod.account_rate_value({"account_rate_multiplier": float("nan")})
)
self.assertEqual(mod.format_multiplier(0.003), "0.003x")
self.assertEqual(mod.format_multiplier(0), "0x")
self.assertEqual(mod.format_multiplier(float("inf")), "-")
self.assertEqual(mod.as_int("9007199254740999"), 9007199254740999)
self.assertEqual(len(keys["items"]), 1)
self.assertEqual(mod.normalize_key_rows(keys)[0]["name"], "wmy")
@@ -863,6 +921,12 @@ class WorkspaceTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "invalid workspace requests"):
mod.fetch_workspace_payload("https://workspace.example.test/data", 3)
invalid_contract = workspace_payload_fixture()
invalid_contract["accounts"]["accounts"][0]["node_status"].pop("server4")
with mock.patch.object(mod, "fetch_workspace_response", return_value=(invalid_contract, "")):
with self.assertRaisesRegex(RuntimeError, "account contract"):
mod.fetch_workspace_payload("https://workspace.example.test/data", 3)
invalid_period = workspace_payload_fixture()
invalid_period["traffic"].pop("ends_at")
with mock.patch.object(mod, "fetch_workspace_response", return_value=(invalid_period, "")):
@@ -1323,6 +1387,7 @@ class Sub2APILogsTests(unittest.TestCase):
self.assertEqual(oldest["input_tokens"], 1200)
self.assertEqual(oldest["output_tokens"], 340)
self.assertEqual(oldest["cache_tokens"], 50 + 4100)
self.assertEqual(oldest["account_multiplier"], 0.003)
self.assertEqual(oldest["first_token_ms"], 800)
self.assertGreater(oldest["tokens_per_second"], 0)
self.assertEqual(oldest["cost"], 0.0123)
@@ -1399,6 +1464,11 @@ class Sub2APILogsTests(unittest.TestCase):
self.assertIn("$0.012", text)
self.assertIn("5.3s", text)
self.assertIn("total 2.3K records", text)
lines = text.splitlines()
header = next(line for line in lines if line.startswith("node"))
multiplier_row = next(line for line in lines if "codex-main" in line)
self.assertEqual(header.index("key"), multiplier_row.index("codex-main"))
self.assertEqual(header.index("multiplier"), multiplier_row.index("0.003x"))
def test_default_logs_token_reads_config_file(self) -> None:
mod = load_module()
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "shusub2"
version = "0.3.5"
version = "0.3.6"
source = { editable = "." }
dependencies = [
{ name = "textual" },