feat: show daily key usage in dashboard
This commit is contained in:
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user