From 0c7e437b7be2188f491a2780924d250852477e22 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Mon, 27 Jul 2026 04:12:27 +0800 Subject: [PATCH] feat: show error keys and relative ages --- README.md | 12 ++++---- pyproject.toml | 2 +- sub2api_quota_tui.py | 64 +++++++++++++++++++++++++++++++------------ tests/test_payload.py | 9 ++++++ uv.lock | 2 +- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c19a964..da76752 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ 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 +LOG KEY | Account | Model | Cost | Latency | Time | Age +ERR | Status | Key | Account | Model | Time | Age ``` The Keys table shows today's usage sorted by actual cost, including key name, @@ -114,7 +114,7 @@ Sub2API admin usage API and refreshes every 60 seconds by default (`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns: ```text -Key | Account | Model | Effort | Type | Tokens | Cost | First | Latency | Tok/s | Time +Key | Account | Model | Effort | Type | Tokens | Cost | First | Latency | Tok/s | Time | Age ``` `Type` is the Sub2API `request_type` (`sync` / `stream` / `ws_v2` / `cyber`). @@ -126,7 +126,8 @@ decode speed, upstream model mapping, user, and request id. first-token latency and `Latency` the total duration, both shown in seconds. `Tok/s` is the decode throughput computed as `output_tokens / (latency - first_token)`; it shows -`-` when there is no output or no positive decode window. In the TUI each +`-` when there is no output or no positive decode window. `Age` is relative to +local current time (`now`, `5m ago`, `2h ago`, etc.). In the TUI each API key name is rendered in a stable per-key color so rows from the same key are easy to group visually (`--once --logs` output stays plain text). @@ -172,9 +173,10 @@ defaults to `24h` (`--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`); each so Columns: ```text -Node | Status | Key | Account | Model | Phase | Type | Owner | Time +Node | Status | Key | Account | Model | Phase | Type | Owner | Time | Age ``` +`Age` is relative to local current time (`now`, `5m ago`, `2h ago`, etc.). Auth reuses the same admin API key as the logs page. ## Local Development diff --git a/pyproject.toml b/pyproject.toml index 9487ee7..222d917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.2.7" +version = "0.2.8" 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 93ed4c4..0147481 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.7" +FALLBACK_VERSION = "0.2.8" 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" @@ -300,6 +300,28 @@ def short_time(value: Any) -> str: return parsed.astimezone().strftime("%m-%d %H:%M") +def relative_age(value: Any, now: dt.datetime | None = None) -> str: + parsed = parse_time(value) + if not parsed: + return "-" + current = now or dt.datetime.now(dt.timezone.utc) + if current.tzinfo is None: + current = current.replace(tzinfo=dt.timezone.utc) + elapsed = (current.astimezone(dt.timezone.utc) - parsed.astimezone(dt.timezone.utc)).total_seconds() + if elapsed < -30: + return f"in {max(1, int((-elapsed) / 60))}m" + seconds = max(0, int(elapsed)) + if seconds < 60: + return "now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + return f"{hours // 24}d ago" + + def add_refresh_param(api_url: str, refresh: bool) -> str: if not refresh: return api_url @@ -568,6 +590,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d "tokens_per_second": log_tokens_per_second(item), "created_at": str(item.get("created_at") or ""), "time": short_time(item.get("created_at")), + "age": relative_age(item.get("created_at")), "request_id": str(item.get("request_id") or ""), "raw": item, } @@ -617,7 +640,7 @@ def log_detail_line(row: dict[str, Any]) -> str: def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None: rows = normalize_log_rows(payload, filter_text) print(logs_summary_line(payload, len(rows))) - print("key account model effort type tokens cost first latency tok/s time") + print("key account model effort type tokens cost first latency tok/s time age") for row in rows: print( f"{row['key'][:20]:<21} " @@ -630,7 +653,8 @@ def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None: f"{format_seconds(row['first_token_ms']):<8} " f"{format_seconds(row['duration_ms']):<8} " f"{format_rate(row['tokens_per_second']):<8} " - f"{row['time']}" + f"{row['time']:<11} " + f"{row['age']}" ) @@ -801,6 +825,7 @@ def normalize_error_rows(payload: dict[str, Any], filter_text: str = "") -> list "client_request_id": client_request_id, "created_at": str(item.get("created_at") or ""), "time": short_time(item.get("created_at")), + "age": relative_age(item.get("created_at")), "resolved": bool(item.get("resolved")), "raw": item, } @@ -878,7 +903,7 @@ def print_errors_once(payload: dict[str, Any], filter_text: str = "") -> None: print(f"{node}: ok fetched {as_int(info.get('fetched'))} total {as_int(info.get('total'))} | {info.get('url') or '-'}") else: print(f"{node}: error {info.get('error') or 'unknown'} | {info.get('url') or '-'}") - print("node status key account model phase type owner time") + print("node status key account model phase type owner time age") for row in rows: print( f"{row['node']:<5} " @@ -889,7 +914,8 @@ def print_errors_once(payload: dict[str, Any], filter_text: str = "") -> None: f"{row['phase'][:9]:<10} " f"{row['type'][:13]:<14} " f"{row['owner'][:8]:<9} " - f"{row['time']}" + f"{row['time']:<11} " + f"{row['age']}" ) @@ -1263,12 +1289,13 @@ def run_textual( self.configure_table( self.query_one("#logs", DataTable), ( - ("LOG KEY", 12), - ("Account", 12), - ("Model", 16), - ("Cost", 8), + ("LOG KEY", 10), + ("Account", 10), + ("Model", 12), + ("Cost", 7), ("Latency", 7), ("Time", 11), + ("Age", 7), ), ) self.configure_table( @@ -1276,11 +1303,11 @@ def run_textual( ( ("ERR", 4), ("Status", 6), - ("Account", 12), - ("Model", 13), - ("Phase", 8), - ("Type", 10), + ("Key", 12), + ("Account", 11), + ("Model", 12), ("Time", 11), + ("Age", 8), ), ) self.refresh_all(refresh=True) @@ -1448,6 +1475,7 @@ def run_textual( format_cost(row["cost"]), format_seconds(row["duration_ms"]), row["time"], + row["age"], key=key, ) @@ -1463,11 +1491,11 @@ def run_textual( table.add_row( row["node"], str(row["status_code"]), + row["key"], row["account"], row["model"], - row["phase"], - row["type"], row["time"], + row["age"], key=key, ) @@ -1771,7 +1799,7 @@ def run_textual( table = self.query_one("#logs", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Key", "Account", "Model", "Effort", "Type", "Tokens", "Cost", "First", "Latency", "Tok/s", "Time") + table.add_columns("Key", "Account", "Model", "Effort", "Type", "Tokens", "Cost", "First", "Latency", "Tok/s", "Time", "Age") self.refresh_data() self.set_interval(logs_refresh_seconds, self.refresh_data) @@ -1831,6 +1859,7 @@ def run_textual( format_seconds(row["duration_ms"]), format_rate(row["tokens_per_second"]), row["time"], + row["age"], key=key, ) self.query_one("#summary", Static).update(logs_summary_line(self.payload, len(self.rows))) @@ -1873,7 +1902,7 @@ def run_textual( table = self.query_one("#errors", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Node", "Status", "Key", "Account", "Model", "Phase", "Type", "Owner", "Time") + table.add_columns("Node", "Status", "Key", "Account", "Model", "Phase", "Type", "Owner", "Time", "Age") self.refresh_data() self.set_interval(errors_refresh_seconds, self.refresh_data) @@ -1946,6 +1975,7 @@ def run_textual( row["type"], row["owner"], row["time"], + row["age"], key=key, ) self.query_one("#summary", Static).update(errors_summary_line(self.payload, len(self.rows))) diff --git a/tests/test_payload.py b/tests/test_payload.py index 715a343..e1491f9 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -394,6 +394,15 @@ class Sub2APILogsTests(unittest.TestCase): self.assertEqual(oldest["duration_ms"], 5321) self.assertTrue(oldest["time"].endswith("10:00") or oldest["time"] != "-") + def test_relative_age_uses_compact_minutes_and_hours(self) -> None: + mod = load_module() + now = mod.dt.datetime(2026, 7, 24, 12, 5, tzinfo=mod.dt.timezone.utc) + + self.assertEqual(mod.relative_age("2026-07-24T12:05:00Z", now), "now") + self.assertEqual(mod.relative_age("2026-07-24T12:00:00Z", now), "5m ago") + self.assertEqual(mod.relative_age("2026-07-24T10:05:00Z", now), "2h ago") + self.assertEqual(mod.relative_age("bad", now), "-") + def test_normalize_log_rows_filter_matches_key_account_model(self) -> None: mod = load_module() payload = sample_logs_payload() diff --git a/uv.lock b/uv.lock index c7a21fd..bd9a178 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.7" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "textual" },