feat: show daily key usage in dashboard

This commit is contained in:
2026-07-27 03:39:10 +08:00
parent 69f72c09b5
commit 42b2e63a97
5 changed files with 99 additions and 16 deletions
+11 -6
View File
@@ -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`.
+1 -1
View File
@@ -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"
+77 -5
View File
@@ -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; }
+9 -3
View File
@@ -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)
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "shusub2"
version = "0.2.5"
version = "0.2.6"
source = { editable = "." }
dependencies = [
{ name = "textual" },