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 ## Unified Dashboard
`shusub2` opens a single dashboard with Accounts, Logs, and Errors tables `shusub2` opens a single dashboard with Accounts, Keys, Logs, and Errors
stacked vertically. Press `a`, `l`, or `e` to focus a table, `/` to filter all tables stacked vertically. Press `a`, `k`, `l`, or `e` to focus a table, `/` to
three tables, and `r` to refresh all data. The two-line detail area follows the filter all four tables, and `r` to refresh all data. The two-line detail area
selected row. follows the selected row.
The dashboard uses compact columns sized to fit an 80-column terminal without The dashboard uses compact columns sized to fit an 80-column terminal without
horizontal scrolling: horizontal scrolling:
```text ```text
ACCOUNT | Group | Today | Daily | 5h | 7d | Avail ACCOUNT | Group | Today | Daily | 5h | 7d | Avail
KEY | Today | Tokens | Req
LOG KEY | Account | Model | Cost | Latency | Time LOG KEY | Account | Model | Cost | Latency | Time
ERR | Status | Account | Model | Phase | Type | 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. Use `--accounts`, `--logs`, or `--errors` to start a dedicated full-table view.
The corresponding `--once` form still prints only that data set. 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` 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, `/api/status` for channel monitor health. The Accounts table does not need SSH,
database access, API keys, OAuth credentials, or plaintext env files. The database access, API keys, OAuth credentials, or plaintext env files. The Keys,
Logs and Errors tables authenticate to the Sub2API admin APIs with Logs, and Errors tables authenticate to the Sub2API admin APIs with
an admin API key stored only in `~/.config/shusub2/logs-token` (0600) or an admin API key stored only in `~/.config/shusub2/logs-token` (0600) or
`SHUSUB2_LOGS_TOKEN`. `SHUSUB2_LOGS_TOKEN`.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "shusub2" name = "shusub2"
version = "0.2.5" version = "0.2.6"
description = "Terminal UI for Sub2API account quota and daily usage" description = "Terminal UI for Sub2API account quota and daily usage"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+77 -5
View File
@@ -19,7 +19,7 @@ from typing import Any
APP_NAME = "shusub2" 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_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-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} 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 {} stats = payload.get("stats") if isinstance(payload.get("stats"), dict) else {}
merged: dict[Any, dict[str, Any]] = {} merged: dict[Any, dict[str, Any]] = {}
for point in payload.get("trend") or []: for point in payload.get("trend") or []:
@@ -491,6 +492,8 @@ def normalize_key_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
continue continue
key_id = as_int(point.get("api_key_id")) 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 "-") 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 = 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["requests"] += as_int(point.get("requests"))
row["tokens"] += as_int(point.get("tokens")) row["tokens"] += as_int(point.get("tokens"))
@@ -1189,12 +1192,13 @@ def run_textual(
("r", "refresh", "Refresh"), ("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"), ("/", "focus_filter", "Filter"),
("a", "focus_accounts", "Accounts"), ("a", "focus_accounts", "Accounts"),
("k", "focus_keys", "Keys"),
("l", "focus_logs", "Logs"), ("l", "focus_logs", "Logs"),
("e", "focus_errors", "Errors"), ("e", "focus_errors", "Errors"),
] ]
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__(classes="dashboard")
self.accounts_payload: dict[str, Any] = {} self.accounts_payload: dict[str, Any] = {}
self.status_payload: dict[str, Any] = {} self.status_payload: dict[str, Any] = {}
self.monitor_error = "" self.monitor_error = ""
@@ -1203,10 +1207,14 @@ def run_textual(
self.logs_error = "" self.logs_error = ""
self.errors_payload: dict[str, Any] = {} self.errors_payload: dict[str, Any] = {}
self.errors_error = "" self.errors_error = ""
self.keys_payload: dict[str, Any] = {}
self.keys_error = ""
self.account_rows: list[dict[str, Any]] = [] self.account_rows: list[dict[str, Any]] = []
self.key_rows: list[dict[str, Any]] = []
self.log_rows: list[dict[str, Any]] = [] self.log_rows: list[dict[str, Any]] = []
self.error_rows: list[dict[str, Any]] = [] self.error_rows: list[dict[str, Any]] = []
self.account_by_key: dict[str, 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.log_by_key: dict[str, dict[str, Any]] = {}
self.error_by_key: dict[str, dict[str, Any]] = {} self.error_by_key: dict[str, dict[str, Any]] = {}
self.active_table = "accounts" self.active_table = "accounts"
@@ -1216,6 +1224,7 @@ def run_textual(
yield Static("", id="summary") yield Static("", id="summary")
yield Input(placeholder="filter", id="filter") yield Input(placeholder="filter", id="filter")
yield DataTable(id="accounts") yield DataTable(id="accounts")
yield DataTable(id="keys")
yield DataTable(id="logs") yield DataTable(id="logs")
yield DataTable(id="errors") yield DataTable(id="errors")
yield Static("", id="detail") yield Static("", id="detail")
@@ -1242,6 +1251,15 @@ def run_textual(
("Avail", 7), ("Avail", 7),
), ),
) )
self.configure_table(
self.query_one("#keys", DataTable),
(
("KEY", 16),
("Today", 9),
("Tokens", 8),
("Req", 6),
),
)
self.configure_table( self.configure_table(
self.query_one("#logs", DataTable), self.query_one("#logs", DataTable),
( (
@@ -1280,6 +1298,11 @@ def run_textual(
def action_focus_accounts(self) -> None: def action_focus_accounts(self) -> None:
self.focus_table("accounts") 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: def action_focus_logs(self) -> None:
self.focus_table("logs") self.focus_table("logs")
@@ -1300,7 +1323,7 @@ def run_textual(
self.focus_table(self.active_table) self.focus_table(self.active_table)
def refresh_all(self, refresh: bool = False) -> None: 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_accounts(refresh=refresh)
self.refresh_logs() self.refresh_logs()
self.refresh_errors() self.refresh_errors()
@@ -1326,8 +1349,22 @@ def run_textual(
except Exception as exc: except Exception as exc:
self.logs_error = str(exc) self.logs_error = str(exc)
self.render_logs() self.render_logs()
self.refresh_keys()
self.render_meta() 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: def refresh_errors(self) -> None:
self.errors_error = "" self.errors_error = ""
if not str(logs_token or "").strip(): if not str(logs_token or "").strip():
@@ -1349,6 +1386,7 @@ def run_textual(
def render_tables(self) -> None: def render_tables(self) -> None:
self.render_accounts() self.render_accounts()
self.render_keys()
self.render_logs() self.render_logs()
self.render_errors() self.render_errors()
self.render_meta() self.render_meta()
@@ -1374,6 +1412,25 @@ def run_textual(
key=key, 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: def render_logs(self) -> None:
filter_text = self.query_one("#filter", Input).value filter_text = self.query_one("#filter", Input).value
self.log_rows = normalize_log_rows(self.logs_payload, filter_text) 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")) logs_total = as_int(logs_envelope(self.logs_payload).get("total"))
if not logs_total: if not logs_total:
logs_total = len(log_items(self.logs_payload)) 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 = ( summary = (
f"accounts {len(self.account_rows)}/{account_total} ({usable} usable, " f"accounts {len(self.account_rows)}/{account_total} ({usable} usable, "
f"{format_cost(totals.get('today_cost_usd'))}) | " 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"logs {len(self.log_rows)}/{logs_total} | "
f"errors {len(self.error_rows)}/{self.errors_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}") status_bits.append(f"logs: {self.logs_error}")
if self.errors_error: if self.errors_error:
status_bits.append(f"errors: {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: if not self.errors_error and self.errors_payload:
sources = self.errors_payload.get("sources") if isinstance(self.errors_payload.get("sources"), dict) else {} sources = self.errors_payload.get("sources") if isinstance(self.errors_payload.get("sources"), dict) else {}
source_bits = [] source_bits = []
@@ -1470,6 +1534,7 @@ def run_textual(
def render_active_detail(self) -> None: def render_active_detail(self) -> None:
rows_by_table = { rows_by_table = {
"accounts": self.account_rows, "accounts": self.account_rows,
"keys": self.key_rows,
"logs": self.log_rows, "logs": self.log_rows,
"errors": self.error_rows, "errors": self.error_rows,
} }
@@ -1490,6 +1555,11 @@ def run_textual(
error = str(row.get("error") or "").strip() error = str(row.get("error") or "").strip()
if error: if error:
detail += f" | {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": elif table_id == "logs":
detail = log_detail_line(row) detail = log_detail_line(row)
else: else:
@@ -1498,7 +1568,7 @@ def run_textual(
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
table_id = str(event.control.id or "") 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 return
if event.control.has_focus: if event.control.has_focus:
self.active_table = table_id self.active_table = table_id
@@ -1506,6 +1576,7 @@ def run_textual(
return return
row_maps = { row_maps = {
"accounts": self.account_by_key, "accounts": self.account_by_key,
"keys": self.key_by_key,
"logs": self.log_by_key, "logs": self.log_by_key,
"errors": self.error_by_key, "errors": self.error_by_key,
} }
@@ -1892,6 +1963,7 @@ def run_textual(
#filter:focus { border: none; } #filter:focus { border: none; }
#accounts { height: 1fr; min-height: 3; } #accounts { height: 1fr; min-height: 3; }
#keys { height: auto; max-height: 6; } #keys { height: auto; max-height: 6; }
.dashboard #keys { height: 1fr; min-height: 0; max-height: 100%; }
#logs { height: 1fr; min-height: 3; } #logs { height: 1fr; min-height: 3; }
#errors { height: 1fr; min-height: 3; } #errors { height: 1fr; min-height: 3; }
#detail { height: 2; padding: 0 1; background: $surface-lighten-1; } #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_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: { mod.fetch_logs_payload = lambda *args, **kwargs: {
"data": { "data": {
"total": 1, "total": 1,
@@ -750,11 +755,12 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(type(screen).__name__, "DashboardScreen") self.assertEqual(type(screen).__name__, "DashboardScreen")
self.assertEqual(screen.query_one("#filter").region.height, 1) self.assertEqual(screen.query_one("#filter").region.height, 1)
self.assertEqual(screen.query_one("#detail").region.height, 2) 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) 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) 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.press(key)
await pilot.pause() await pilot.pause()
self.assertEqual(screen.focused.id, expected_id) self.assertEqual(screen.focused.id, expected_id)
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]] [[package]]
name = "shusub2" name = "shusub2"
version = "0.2.5" version = "0.2.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },