From 98410b0fe04ff6c512ce525152b3feb65c4d6051 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Tue, 9 Jun 2026 16:09:26 +0800 Subject: [PATCH] feat: show monitor availability column --- README.md | 5 ++++- pyproject.toml | 2 +- sub2api_quota_tui.py | 25 +++++++++++++++++++--- tests/test_payload.py | 49 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b6e4a79..5452829 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ access, API keys, OAuth credentials, or plaintext env files. Columns are ordered for scanning inside zellij: ```text -Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status +Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability ``` `Provider` distinguishes `openai` and `anthropic` accounts from the public @@ -85,3 +85,6 @@ health, and the selected account detail shows the matching monitor status when one exists. Monitor binding first uses the shared `base_url_hash` emitted by the account API and `sub2api-status`; name-token matching remains only as a fallback for older status payloads. + +`Availability` is derived from the bound channel monitor. Accounts without a +matching monitor show `-`. diff --git a/pyproject.toml b/pyproject.toml index 70e0aca..1b3e4dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.1.3" +version = "0.1.4" description = "Terminal UI for Sub2API account quota and daily usage" readme = "README.md" requires-python = ">=3.11" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index 8533e94..b7f15f7 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -263,6 +263,23 @@ def monitor_detail(row: dict[str, Any], status_payload: dict[str, Any]) -> str: return detail +def monitor_availability(row: dict[str, Any], status_payload: dict[str, Any]) -> str: + item = matching_monitor(row, status_payload) + if not item: + return "-" + if item.get("enabled") is False: + return "disabled" + raw_status = str(item.get("latest_status") or "").strip().lower() + if not raw_status: + return "unknown" + kind = status_kind(raw_status) + if kind == "ok": + return "ok" + if kind == "failed": + return "failed" + return raw_status + + def window_for(account: dict[str, Any], window_id: str) -> dict[str, Any]: for window in account.get("windows") or []: if isinstance(window, dict) and window.get("id") == window_id: @@ -388,7 +405,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d print(summary_line(payload)) if status_payload or status_error: print(monitor_summary(status_payload or {}, status_error)) - print("name provider group daily today tokens req kind 5h 7d reset status") + print("name provider group daily today tokens req kind 5h 7d reset status availability") for row in normalize_account_rows(payload, filter_text): print( f"{row['name'][:30]:<30} " @@ -402,7 +419,8 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d f"{row['five_hour']:<9} " f"{row['weekly']:<9} " f"{row['reset']:<11} " - f"{row['status']}" + f"{row['status']:<10} " + f"{monitor_availability(row, status_payload or {})}" ) @@ -449,7 +467,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in table = self.query_one("#accounts", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status") + table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability") self.refresh_data(refresh=True) self.set_interval(refresh_seconds, self.refresh_data) @@ -497,6 +515,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in row["weekly"], row["reset"], row["status"], + monitor_availability(row, self.status_payload), key=key, ) self.query_one("#summary", Static).update(summary_line(self.payload)) diff --git a/tests/test_payload.py b/tests/test_payload.py index dfa9ba9..103b1db 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -121,6 +121,55 @@ class Sub2APIQuotaTUITests(unittest.TestCase): self.assertEqual(rows[0]["daily_quota_cell"], "96.6/300") self.assertTrue(rows[0]["reset"].endswith("00:00") or rows[0]["reset"] != "-") + def test_monitor_availability_column_uses_bound_monitor_status(self) -> None: + mod = load_module() + payload = { + "generated_at": "2026-06-09T12:00:00+08:00", + "totals": {"total_accounts": 1, "usable_accounts": 1, "today_cost_usd": 1, "today_tokens": 100, "today_requests": 2}, + "accounts": [ + { + "id": 2, + "name": "input 300", + "base_url_hash": "input-hash", + "routing_group": "fast", + "provider": "openai", + "kind": "daily_limited", + "status": "ok", + "account_type": "apikey", + "today_cost_usd": 1, + "today_tokens": 100, + "today_requests": 2, + "windows": [], + } + ], + } + status_payload = { + "channel_monitors": { + "items": [ + { + "name": "other responses", + "base_url_hash": "input-hash", + "enabled": True, + "latest_status": "operational", + "latency_ms": 2606, + } + ] + } + } + rows = mod.normalize_account_rows(payload) + out = io.StringIO() + + with contextlib.redirect_stdout(out): + mod.print_once(payload, status_payload=status_payload) + + self.assertEqual(mod.monitor_availability(rows[0], status_payload), "ok") + self.assertIn("availability", out.getvalue()) + self.assertIn(" ok\n", out.getvalue()) + + def test_monitor_availability_is_dash_without_bound_monitor(self) -> None: + mod = load_module() + self.assertEqual(mod.monitor_availability({"name": "alpha"}, {}), "-") + def test_routing_group_sort_shows_sfast_first(self) -> None: mod = load_module() payload = { diff --git a/uv.lock b/uv.lock index 8ce2daf..3320880 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "textual" },