feat: add compact unified dashboard
This commit is contained in:
+356
-17
@@ -19,7 +19,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_NAME = "shusub2"
|
||||
FALLBACK_VERSION = "0.2.4"
|
||||
FALLBACK_VERSION = "0.2.5"
|
||||
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"
|
||||
@@ -1172,7 +1172,7 @@ def run_textual(
|
||||
errors_limit: int,
|
||||
errors_time_range: str,
|
||||
version_message: str = "",
|
||||
start_page: str = "accounts",
|
||||
start_page: str = "dashboard",
|
||||
) -> int:
|
||||
try:
|
||||
from rich.text import Text
|
||||
@@ -1183,6 +1183,336 @@ def run_textual(
|
||||
print("Textual is required. Run with: uv run --with textual python sub2api_quota_tui.py", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
class DashboardScreen(Screen[None]):
|
||||
AUTO_FOCUS = "#accounts"
|
||||
BINDINGS = [
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("a", "focus_accounts", "Accounts"),
|
||||
("l", "focus_logs", "Logs"),
|
||||
("e", "focus_errors", "Errors"),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.accounts_payload: dict[str, Any] = {}
|
||||
self.status_payload: dict[str, Any] = {}
|
||||
self.monitor_error = ""
|
||||
self.accounts_error = ""
|
||||
self.logs_payload: dict[str, Any] = {}
|
||||
self.logs_error = ""
|
||||
self.errors_payload: dict[str, Any] = {}
|
||||
self.errors_error = ""
|
||||
self.account_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.log_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.error_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.active_table = "accounts"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
yield Static("", id="summary")
|
||||
yield Input(placeholder="filter", id="filter")
|
||||
yield DataTable(id="accounts")
|
||||
yield DataTable(id="logs")
|
||||
yield DataTable(id="errors")
|
||||
yield Static("", id="detail")
|
||||
yield Static("", id="status")
|
||||
yield Footer()
|
||||
|
||||
@staticmethod
|
||||
def configure_table(table: DataTable, columns: tuple[tuple[str, int], ...]) -> None:
|
||||
table.cursor_type = "row"
|
||||
table.zebra_stripes = True
|
||||
for label, width in columns:
|
||||
table.add_column(label, width=width)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.configure_table(
|
||||
self.query_one("#accounts", DataTable),
|
||||
(
|
||||
("ACCOUNT", 16),
|
||||
("Group", 5),
|
||||
("Today", 8),
|
||||
("Daily", 10),
|
||||
("5h", 9),
|
||||
("7d", 9),
|
||||
("Avail", 7),
|
||||
),
|
||||
)
|
||||
self.configure_table(
|
||||
self.query_one("#logs", DataTable),
|
||||
(
|
||||
("LOG KEY", 12),
|
||||
("Account", 12),
|
||||
("Model", 16),
|
||||
("Cost", 8),
|
||||
("Latency", 7),
|
||||
("Time", 11),
|
||||
),
|
||||
)
|
||||
self.configure_table(
|
||||
self.query_one("#errors", DataTable),
|
||||
(
|
||||
("ERR", 4),
|
||||
("Status", 6),
|
||||
("Account", 12),
|
||||
("Model", 13),
|
||||
("Phase", 8),
|
||||
("Type", 10),
|
||||
("Time", 11),
|
||||
),
|
||||
)
|
||||
self.refresh_all(refresh=True)
|
||||
self.set_interval(refresh_seconds, self.refresh_accounts)
|
||||
self.set_interval(logs_refresh_seconds, self.refresh_logs)
|
||||
self.set_interval(errors_refresh_seconds, self.refresh_errors)
|
||||
self.focus_table("accounts")
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
self.refresh_all(refresh=True)
|
||||
|
||||
def action_focus_filter(self) -> None:
|
||||
self.query_one("#filter", Input).focus()
|
||||
|
||||
def action_focus_accounts(self) -> None:
|
||||
self.focus_table("accounts")
|
||||
|
||||
def action_focus_logs(self) -> None:
|
||||
self.focus_table("logs")
|
||||
|
||||
def action_focus_errors(self) -> None:
|
||||
self.focus_table("errors")
|
||||
|
||||
def focus_table(self, table_id: str) -> None:
|
||||
self.active_table = table_id
|
||||
self.query_one(f"#{table_id}", DataTable).focus()
|
||||
self.render_active_detail()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "filter":
|
||||
self.render_tables()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "filter":
|
||||
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.refresh_accounts(refresh=refresh)
|
||||
self.refresh_logs()
|
||||
self.refresh_errors()
|
||||
|
||||
def refresh_accounts(self, refresh: bool = False) -> None:
|
||||
self.accounts_error = ""
|
||||
try:
|
||||
self.accounts_payload = fetch_payload(api_url, timeout, refresh=refresh)
|
||||
self.status_payload, self.monitor_error = fetch_optional_payload(status_url, timeout)
|
||||
except Exception as exc:
|
||||
self.accounts_error = str(exc)
|
||||
self.render_accounts()
|
||||
self.render_meta()
|
||||
|
||||
def refresh_logs(self) -> None:
|
||||
self.logs_error = ""
|
||||
if not str(logs_token or "").strip():
|
||||
self.logs_payload = {}
|
||||
self.logs_error = "admin token not configured"
|
||||
else:
|
||||
try:
|
||||
self.logs_payload = fetch_logs_payload(logs_url, logs_token, timeout, logs_limit)
|
||||
except Exception as exc:
|
||||
self.logs_error = str(exc)
|
||||
self.render_logs()
|
||||
self.render_meta()
|
||||
|
||||
def refresh_errors(self) -> None:
|
||||
self.errors_error = ""
|
||||
if not str(logs_token or "").strip():
|
||||
self.errors_payload = {}
|
||||
self.errors_error = "admin token not configured"
|
||||
else:
|
||||
try:
|
||||
self.errors_payload = fetch_merged_errors(
|
||||
default_error_sources(errors_cn_url, errors_us_url),
|
||||
logs_token,
|
||||
timeout,
|
||||
errors_limit,
|
||||
errors_time_range,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.errors_error = str(exc)
|
||||
self.render_errors()
|
||||
self.render_meta()
|
||||
|
||||
def render_tables(self) -> None:
|
||||
self.render_accounts()
|
||||
self.render_logs()
|
||||
self.render_errors()
|
||||
self.render_meta()
|
||||
self.render_active_detail()
|
||||
|
||||
def render_accounts(self) -> None:
|
||||
filter_text = self.query_one("#filter", Input).value
|
||||
self.account_rows = normalize_account_rows(self.accounts_payload, filter_text)
|
||||
table = self.query_one("#accounts", DataTable)
|
||||
table.clear()
|
||||
self.account_by_key = {}
|
||||
for index, row in enumerate(self.account_rows):
|
||||
key = f"account-{row['id']}-{index}"
|
||||
self.account_by_key[key] = row
|
||||
table.add_row(
|
||||
row["name"],
|
||||
row["routing_group"],
|
||||
format_cost(row["today_cost_usd"]),
|
||||
row["daily_quota_cell"],
|
||||
row["five_hour"],
|
||||
row["weekly"],
|
||||
monitor_availability(row, self.status_payload),
|
||||
key=key,
|
||||
)
|
||||
|
||||
def render_logs(self) -> None:
|
||||
filter_text = self.query_one("#filter", Input).value
|
||||
self.log_rows = normalize_log_rows(self.logs_payload, filter_text)
|
||||
table = self.query_one("#logs", DataTable)
|
||||
table.clear()
|
||||
self.log_by_key = {}
|
||||
for index, row in enumerate(self.log_rows):
|
||||
key = f"log-{row['id']}-{index}"
|
||||
self.log_by_key[key] = row
|
||||
color = key_color(row["key"])
|
||||
table.add_row(
|
||||
Text(row["key"], style=color) if color else row["key"],
|
||||
row["account"],
|
||||
row["model"],
|
||||
format_cost(row["cost"]),
|
||||
format_seconds(row["duration_ms"]),
|
||||
row["time"],
|
||||
key=key,
|
||||
)
|
||||
|
||||
def render_errors(self) -> None:
|
||||
filter_text = self.query_one("#filter", Input).value
|
||||
self.error_rows = normalize_error_rows(self.errors_payload, filter_text)
|
||||
table = self.query_one("#errors", DataTable)
|
||||
table.clear()
|
||||
self.error_by_key = {}
|
||||
for index, row in enumerate(self.error_rows):
|
||||
key = f"error-{row['node']}-{row['id']}-{index}"
|
||||
self.error_by_key[key] = row
|
||||
table.add_row(
|
||||
row["node"],
|
||||
str(row["status_code"]),
|
||||
row["account"],
|
||||
row["model"],
|
||||
row["phase"],
|
||||
row["type"],
|
||||
row["time"],
|
||||
key=key,
|
||||
)
|
||||
|
||||
def errors_total(self) -> int:
|
||||
sources = self.errors_payload.get("sources") if isinstance(self.errors_payload.get("sources"), dict) else {}
|
||||
totals = [
|
||||
as_int(info.get("total"))
|
||||
for info in sources.values()
|
||||
if isinstance(info, dict) and info.get("ok")
|
||||
]
|
||||
return sum(totals) if totals else len(self.errors_payload.get("items") or [])
|
||||
|
||||
def render_meta(self) -> None:
|
||||
totals = self.accounts_payload.get("totals") if isinstance(self.accounts_payload.get("totals"), dict) else {}
|
||||
account_total = as_int(totals.get("total_accounts")) or len(self.accounts_payload.get("accounts") or [])
|
||||
usable = as_int(totals.get("usable_accounts"))
|
||||
logs_total = as_int(logs_envelope(self.logs_payload).get("total"))
|
||||
if not logs_total:
|
||||
logs_total = len(log_items(self.logs_payload))
|
||||
summary = (
|
||||
f"accounts {len(self.account_rows)}/{account_total} ({usable} usable, "
|
||||
f"{format_cost(totals.get('today_cost_usd'))}) | "
|
||||
f"logs {len(self.log_rows)}/{logs_total} | "
|
||||
f"errors {len(self.error_rows)}/{self.errors_total()}"
|
||||
)
|
||||
self.query_one("#summary", Static).update(summary)
|
||||
|
||||
status_bits = [version_message]
|
||||
if self.accounts_error:
|
||||
status_bits.append(f"accounts: {self.accounts_error}")
|
||||
else:
|
||||
status_bits.extend(
|
||||
(
|
||||
monitor_summary(self.status_payload, self.monitor_error),
|
||||
f"source {self.accounts_payload.get('source_name') or '-'}",
|
||||
)
|
||||
)
|
||||
shared_admin_error = self.logs_error and self.logs_error == self.errors_error
|
||||
if shared_admin_error:
|
||||
status_bits.append(f"logs/errors: {self.logs_error}")
|
||||
else:
|
||||
if self.logs_error:
|
||||
status_bits.append(f"logs: {self.logs_error}")
|
||||
if self.errors_error:
|
||||
status_bits.append(f"errors: {self.errors_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 = []
|
||||
for node in ("cn", "us"):
|
||||
info = sources.get(node) if isinstance(sources.get(node), dict) else {}
|
||||
if info:
|
||||
source_bits.append(f"{node} {'ok' if info.get('ok') else 'err'}")
|
||||
if source_bits:
|
||||
status_bits.append("errors " + "/".join(source_bits))
|
||||
self.query_one("#status", Static).update(" | ".join(bit for bit in status_bits if bit) or "ready")
|
||||
|
||||
def render_active_detail(self) -> None:
|
||||
rows_by_table = {
|
||||
"accounts": self.account_rows,
|
||||
"logs": self.log_rows,
|
||||
"errors": self.error_rows,
|
||||
}
|
||||
rows = rows_by_table[self.active_table]
|
||||
if not rows:
|
||||
self.query_one("#detail", Static).update(f"no {self.active_table}")
|
||||
return
|
||||
self.render_detail(self.active_table, rows[0])
|
||||
|
||||
def render_detail(self, table_id: str, row: dict[str, Any]) -> None:
|
||||
if table_id == "accounts":
|
||||
detail = (
|
||||
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | "
|
||||
f"daily {row['daily_quota_cell']} | today {format_cost(row['today_cost_usd'])}, "
|
||||
f"{format_count(row['today_tokens'])} tokens, {format_count(row['today_requests'])} req | "
|
||||
f"{monitor_detail(row, self.status_payload)}"
|
||||
)
|
||||
error = str(row.get("error") or "").strip()
|
||||
if error:
|
||||
detail += f" | {error}"
|
||||
elif table_id == "logs":
|
||||
detail = log_detail_line(row)
|
||||
else:
|
||||
detail = error_detail_line(row)
|
||||
self.query_one("#detail", Static).update(detail)
|
||||
|
||||
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"}:
|
||||
return
|
||||
if event.control.has_focus:
|
||||
self.active_table = table_id
|
||||
if table_id != self.active_table:
|
||||
return
|
||||
row_maps = {
|
||||
"accounts": self.account_by_key,
|
||||
"logs": self.log_by_key,
|
||||
"errors": self.error_by_key,
|
||||
}
|
||||
row = row_maps[table_id].get(str(event.row_key.value))
|
||||
if row:
|
||||
self.render_detail(table_id, row)
|
||||
|
||||
class AccountsScreen(Screen[None]):
|
||||
AUTO_FOCUS = "#accounts"
|
||||
BINDINGS = [
|
||||
@@ -1274,7 +1604,9 @@ def run_textual(
|
||||
def render_keys(self) -> None:
|
||||
table = self.query_one("#keys", DataTable)
|
||||
table.clear()
|
||||
for row in normalize_key_rows(self.keys_payload):
|
||||
rows = normalize_key_rows(self.keys_payload)
|
||||
table.display = bool(rows)
|
||||
for row in rows:
|
||||
color = key_color(row["name"])
|
||||
table.add_row(
|
||||
Text(str(row["name"]), style=color) if color else str(row["name"]),
|
||||
@@ -1556,12 +1888,13 @@ def run_textual(
|
||||
class Sub2APIQuotaApp(App[None]):
|
||||
CSS = """
|
||||
#summary { height: 1; padding: 0 1; color: $accent; }
|
||||
#filter { height: 3; }
|
||||
#accounts { height: 2fr; }
|
||||
#keys { height: 1fr; border-top: solid $panel; }
|
||||
#logs { height: 1fr; }
|
||||
#errors { height: 1fr; }
|
||||
#detail { height: 3; padding: 0 1; border-top: solid $panel; }
|
||||
#filter { height: 1; border: none; padding: 0 1; }
|
||||
#filter:focus { border: none; }
|
||||
#accounts { height: 1fr; min-height: 3; }
|
||||
#keys { height: auto; max-height: 6; }
|
||||
#logs { height: 1fr; min-height: 3; }
|
||||
#errors { height: 1fr; min-height: 3; }
|
||||
#detail { height: 2; padding: 0 1; background: $surface-lighten-1; }
|
||||
#status { height: 1; padding: 0 1; color: $text-muted; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
@@ -1569,12 +1902,14 @@ def run_textual(
|
||||
]
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if start_page == "logs":
|
||||
if start_page == "accounts":
|
||||
self.push_screen(AccountsScreen())
|
||||
elif start_page == "logs":
|
||||
self.push_screen(LogsScreen())
|
||||
elif start_page == "errors":
|
||||
self.push_screen(ErrorsScreen())
|
||||
else:
|
||||
self.push_screen(AccountsScreen())
|
||||
self.push_screen(DashboardScreen())
|
||||
|
||||
Sub2APIQuotaApp().run()
|
||||
return 0
|
||||
@@ -1640,8 +1975,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument("--timeout", type=int, default=env_int("SUB2API_QUOTA_TUI_TIMEOUT", DEFAULT_TIMEOUT_SECONDS))
|
||||
parser.add_argument("--once", action="store_true", help="print one snapshot and exit")
|
||||
parser.add_argument("--logs", action="store_true", help="start on the request logs page (with --once: print a logs snapshot)")
|
||||
parser.add_argument("--errors", action="store_true", help="start on the merged cn+us errors page (with --once: print an errors snapshot)")
|
||||
parser.add_argument("--accounts", action="store_true", help="start on the dedicated accounts page (with --once: print the accounts snapshot)")
|
||||
parser.add_argument("--logs", action="store_true", help="start on the dedicated request logs page (with --once: print a logs snapshot)")
|
||||
parser.add_argument("--errors", action="store_true", help="start on the dedicated merged cn+us errors page (with --once: print an errors snapshot)")
|
||||
parser.add_argument("--filter", default="", help="initial filter for --once output")
|
||||
return parser
|
||||
|
||||
@@ -1652,8 +1988,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
logs_limit = max(1, min(args.logs_limit, 1000))
|
||||
errors_limit = max(1, min(args.errors_limit, 500))
|
||||
errors_time_range = str(args.errors_time_range or DEFAULT_ERRORS_TIME_RANGE).strip() or DEFAULT_ERRORS_TIME_RANGE
|
||||
if args.logs and args.errors:
|
||||
print("choose only one of --logs or --errors", file=sys.stderr)
|
||||
selected_pages = sum(bool(value) for value in (args.accounts, args.logs, args.errors))
|
||||
if selected_pages > 1:
|
||||
print("choose only one of --accounts, --logs, or --errors", file=sys.stderr)
|
||||
return 2
|
||||
if args.save_config or args.install:
|
||||
config_path = write_api_url_config(args.api_url)
|
||||
@@ -1711,8 +2048,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
except Exception as exc:
|
||||
print(f"keys error: {exc}", file=sys.stderr)
|
||||
return 0
|
||||
start_page = "accounts"
|
||||
if args.logs:
|
||||
start_page = "dashboard"
|
||||
if args.accounts:
|
||||
start_page = "accounts"
|
||||
elif args.logs:
|
||||
start_page = "logs"
|
||||
elif args.errors:
|
||||
start_page = "errors"
|
||||
|
||||
Reference in New Issue
Block a user