feat: show error keys and relative ages
This commit is contained in:
@@ -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
@@ -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
@@ -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)))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user