feat: show error keys and relative ages

This commit is contained in:
2026-07-27 04:12:27 +08:00
parent 082c8f22fe
commit 0c7e437b7b
5 changed files with 65 additions and 24 deletions
+7 -5
View File
@@ -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
+1 -1
View File
@@ -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"
+47 -17
View File
@@ -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)))
+9
View File
@@ -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()
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "shusub2"
version = "0.2.7"
version = "0.2.8"
source = { editable = "." }
dependencies = [
{ name = "textual" },