From 42b2e63a979a56291bac0d219942ca5a02f4f061 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Mon, 27 Jul 2026 03:39:10 +0800 Subject: [PATCH] feat: show daily key usage in dashboard --- README.md | 17 +++++---- pyproject.toml | 2 +- sub2api_quota_tui.py | 82 ++++++++++++++++++++++++++++++++++++++++--- tests/test_payload.py | 12 +++++-- uv.lock | 2 +- 5 files changed, 99 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f624551..c19a964 100644 --- a/README.md +++ b/README.md @@ -81,20 +81,25 @@ Environment variables override the config file: ## Unified Dashboard -`shusub2` opens a single dashboard with Accounts, Logs, and Errors tables -stacked vertically. Press `a`, `l`, or `e` to focus a table, `/` to filter all -three tables, and `r` to refresh all data. The two-line detail area follows the -selected row. +`shusub2` opens a single dashboard with Accounts, Keys, Logs, and Errors +tables stacked vertically. Press `a`, `k`, `l`, or `e` to focus a table, `/` to +filter all four tables, and `r` to refresh all data. The two-line detail area +follows the selected row. The dashboard uses compact columns sized to fit an 80-column terminal without horizontal scrolling: ```text ACCOUNT | Group | Today | Daily | 5h | 7d | Avail +KEY | Today | Tokens | Req LOG KEY | Account | Model | Cost | Latency | Time ERR | Status | Account | Model | Phase | Type | Time ``` +The Keys table shows today's usage sorted by actual cost, including key name, +tokens, and request count. It uses the same admin API key as Logs and Errors; +without that token the table is hidden and the summary remains compact. + Use `--accounts`, `--logs`, or `--errors` to start a dedicated full-table view. The corresponding `--once` form still prints only that data set. @@ -205,8 +210,8 @@ Configuration: The dashboard reads `/api/tui/accounts` and can optionally read `sub2api-status` `/api/status` for channel monitor health. The Accounts table does not need SSH, -database access, API keys, OAuth credentials, or plaintext env files. The -Logs and Errors tables authenticate to the Sub2API admin APIs with +database access, API keys, OAuth credentials, or plaintext env files. The Keys, +Logs, and Errors tables authenticate to the Sub2API admin APIs with an admin API key stored only in `~/.config/shusub2/logs-token` (0600) or `SHUSUB2_LOGS_TOKEN`. diff --git a/pyproject.toml b/pyproject.toml index e0a8c4d..9ecbb0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.2.5" +version = "0.2.6" 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 4dbd489..b0d3bcb 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -19,7 +19,7 @@ from typing import Any APP_NAME = "shusub2" -FALLBACK_VERSION = "0.2.5" +FALLBACK_VERSION = "0.2.6" DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url" @@ -483,7 +483,8 @@ def fetch_key_usage_payload(logs_url: str, token: str, timeout: int, limit: int return {"date": today, "trend": points, "stats": stats} -def normalize_key_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: +def normalize_key_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]: + needle = filter_text.strip().lower() stats = payload.get("stats") if isinstance(payload.get("stats"), dict) else {} merged: dict[Any, dict[str, Any]] = {} for point in payload.get("trend") or []: @@ -491,6 +492,8 @@ def normalize_key_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: continue key_id = as_int(point.get("api_key_id")) name = str(point.get("key_name") or "").strip() or (f"#{key_id}" if key_id else "-") + if needle and needle not in name.lower(): + continue row = merged.setdefault(key_id or name, {"id": key_id, "name": name, "requests": 0, "tokens": 0, "cost": 0.0}) row["requests"] += as_int(point.get("requests")) row["tokens"] += as_int(point.get("tokens")) @@ -1189,12 +1192,13 @@ def run_textual( ("r", "refresh", "Refresh"), ("/", "focus_filter", "Filter"), ("a", "focus_accounts", "Accounts"), + ("k", "focus_keys", "Keys"), ("l", "focus_logs", "Logs"), ("e", "focus_errors", "Errors"), ] def __init__(self) -> None: - super().__init__() + super().__init__(classes="dashboard") self.accounts_payload: dict[str, Any] = {} self.status_payload: dict[str, Any] = {} self.monitor_error = "" @@ -1203,10 +1207,14 @@ def run_textual( self.logs_error = "" self.errors_payload: dict[str, Any] = {} self.errors_error = "" + self.keys_payload: dict[str, Any] = {} + self.keys_error = "" self.account_rows: list[dict[str, Any]] = [] + self.key_rows: list[dict[str, Any]] = [] self.log_rows: list[dict[str, Any]] = [] self.error_rows: list[dict[str, Any]] = [] self.account_by_key: dict[str, dict[str, Any]] = {} + self.key_by_key: dict[str, dict[str, Any]] = {} self.log_by_key: dict[str, dict[str, Any]] = {} self.error_by_key: dict[str, dict[str, Any]] = {} self.active_table = "accounts" @@ -1216,6 +1224,7 @@ def run_textual( yield Static("", id="summary") yield Input(placeholder="filter", id="filter") yield DataTable(id="accounts") + yield DataTable(id="keys") yield DataTable(id="logs") yield DataTable(id="errors") yield Static("", id="detail") @@ -1242,6 +1251,15 @@ def run_textual( ("Avail", 7), ), ) + self.configure_table( + self.query_one("#keys", DataTable), + ( + ("KEY", 16), + ("Today", 9), + ("Tokens", 8), + ("Req", 6), + ), + ) self.configure_table( self.query_one("#logs", DataTable), ( @@ -1280,6 +1298,11 @@ def run_textual( def action_focus_accounts(self) -> None: self.focus_table("accounts") + def action_focus_keys(self) -> None: + table = self.query_one("#keys", DataTable) + if table.display: + self.focus_table("keys") + def action_focus_logs(self) -> None: self.focus_table("logs") @@ -1300,7 +1323,7 @@ def run_textual( self.focus_table(self.active_table) def refresh_all(self, refresh: bool = False) -> None: - self.query_one("#status", Static).update("refreshing accounts, logs, and errors...") + self.query_one("#status", Static).update("refreshing accounts, keys, logs, and errors...") self.refresh_accounts(refresh=refresh) self.refresh_logs() self.refresh_errors() @@ -1326,8 +1349,22 @@ def run_textual( except Exception as exc: self.logs_error = str(exc) self.render_logs() + self.refresh_keys() self.render_meta() + def refresh_keys(self) -> None: + self.keys_error = "" + if not str(logs_token or "").strip(): + self.keys_payload = {} + self.keys_error = "admin token not configured" + else: + try: + self.keys_payload = fetch_key_usage_payload(logs_url, logs_token, timeout) + except Exception as exc: + self.keys_payload = {} + self.keys_error = str(exc) + self.render_keys() + def refresh_errors(self) -> None: self.errors_error = "" if not str(logs_token or "").strip(): @@ -1349,6 +1386,7 @@ def run_textual( def render_tables(self) -> None: self.render_accounts() + self.render_keys() self.render_logs() self.render_errors() self.render_meta() @@ -1374,6 +1412,25 @@ def run_textual( key=key, ) + def render_keys(self) -> None: + filter_text = self.query_one("#filter", Input).value + self.key_rows = normalize_key_rows(self.keys_payload, filter_text) + table = self.query_one("#keys", DataTable) + table.clear() + self.key_by_key = {} + for index, row in enumerate(self.key_rows): + key = f"key-{row['id']}-{index}" + self.key_by_key[key] = row + color = key_color(row["name"]) + table.add_row( + Text(str(row["name"]), style=color) if color else str(row["name"]), + format_cost(row["cost"]), + format_count(row["tokens"]), + format_count(row["requests"]), + key=key, + ) + table.display = bool(self.key_rows) and self.app.size.height >= 20 + def render_logs(self) -> None: filter_text = self.query_one("#filter", Input).value self.log_rows = normalize_log_rows(self.logs_payload, filter_text) @@ -1430,9 +1487,14 @@ def run_textual( logs_total = as_int(logs_envelope(self.logs_payload).get("total")) if not logs_total: logs_total = len(log_items(self.logs_payload)) + key_summary = "keys -" + if self.key_rows: + top_key = self.key_rows[0] + key_summary = f"keys {top_key['name']} {format_cost(top_key['cost'])}" summary = ( f"accounts {len(self.account_rows)}/{account_total} ({usable} usable, " f"{format_cost(totals.get('today_cost_usd'))}) | " + f"keys {len(self.key_rows)} ({key_summary.removeprefix('keys ')}) | " f"logs {len(self.log_rows)}/{logs_total} | " f"errors {len(self.error_rows)}/{self.errors_total()}" ) @@ -1456,6 +1518,8 @@ def run_textual( status_bits.append(f"logs: {self.logs_error}") if self.errors_error: status_bits.append(f"errors: {self.errors_error}") + if self.keys_error and self.keys_error != self.logs_error: + status_bits.append(f"keys: {self.keys_error}") if not self.errors_error and self.errors_payload: sources = self.errors_payload.get("sources") if isinstance(self.errors_payload.get("sources"), dict) else {} source_bits = [] @@ -1470,6 +1534,7 @@ def run_textual( def render_active_detail(self) -> None: rows_by_table = { "accounts": self.account_rows, + "keys": self.key_rows, "logs": self.log_rows, "errors": self.error_rows, } @@ -1490,6 +1555,11 @@ def run_textual( error = str(row.get("error") or "").strip() if error: detail += f" | {error}" + elif table_id == "keys": + detail = ( + f"{row['name']} | today {format_cost(row['cost'])} | " + f"{format_count(row['tokens'])} tokens | {format_count(row['requests'])} req" + ) elif table_id == "logs": detail = log_detail_line(row) else: @@ -1498,7 +1568,7 @@ def run_textual( def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: table_id = str(event.control.id or "") - if table_id not in {"accounts", "logs", "errors"}: + if table_id not in {"accounts", "keys", "logs", "errors"}: return if event.control.has_focus: self.active_table = table_id @@ -1506,6 +1576,7 @@ def run_textual( return row_maps = { "accounts": self.account_by_key, + "keys": self.key_by_key, "logs": self.log_by_key, "errors": self.error_by_key, } @@ -1892,6 +1963,7 @@ def run_textual( #filter:focus { border: none; } #accounts { height: 1fr; min-height: 3; } #keys { height: auto; max-height: 6; } + .dashboard #keys { height: 1fr; min-height: 0; max-height: 100%; } #logs { height: 1fr; min-height: 3; } #errors { height: 1fr; min-height: 3; } #detail { height: 2; padding: 0 1; background: $surface-lighten-1; } diff --git a/tests/test_payload.py b/tests/test_payload.py index 4dc4c17..645ff4c 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -688,6 +688,11 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase): ], } mod.fetch_optional_payload = lambda *args, **kwargs: ({}, "") + mod.fetch_key_usage_payload = lambda *args, **kwargs: { + "date": "2026-07-24", + "trend": [{"api_key_id": 1, "key_name": "wmy", "requests": 3, "tokens": 300}], + "stats": {"1": {"today_actual_cost": 0.42}}, + } mod.fetch_logs_payload = lambda *args, **kwargs: { "data": { "total": 1, @@ -750,11 +755,12 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(type(screen).__name__, "DashboardScreen") self.assertEqual(screen.query_one("#filter").region.height, 1) self.assertEqual(screen.query_one("#detail").region.height, 2) - for selector in ("#accounts", "#logs", "#errors"): + for selector in ("#accounts", "#keys", "#logs", "#errors"): table = screen.query_one(selector) - self.assertGreaterEqual(table.region.height, 5) + self.assertGreaterEqual(table.region.height, 4) self.assertLessEqual(table.virtual_size.width, table.region.width) - for key, expected_id in (("l", "logs"), ("e", "errors"), ("a", "accounts")): + self.assertIn("wmy", str(screen.query_one("#summary").render())) + for key, expected_id in (("k", "keys"), ("l", "logs"), ("e", "errors"), ("a", "accounts")): await pilot.press(key) await pilot.pause() self.assertEqual(screen.focused.id, expected_id) diff --git a/uv.lock b/uv.lock index 3992d83..a8fd0ac 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.5" +version = "0.2.6" source = { editable = "." } dependencies = [ { name = "textual" },