fix: show Pricing Monitor source balances
This commit is contained in:
+294
-26
@@ -7,6 +7,7 @@ import datetime as dt
|
||||
import gzip
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -20,10 +21,12 @@ from typing import Any
|
||||
|
||||
|
||||
APP_NAME = "shusub2"
|
||||
FALLBACK_VERSION = "0.2.13"
|
||||
FALLBACK_VERSION = "0.2.14"
|
||||
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"
|
||||
DEFAULT_PRICING_URL = "https://price.tailbeb9ad.ts.net/api/ui-data?view=accounts"
|
||||
DEFAULT_PRICING_URL_CONFIG_FILE = "~/.config/shusub2/pricing-url"
|
||||
DEFAULT_LOGS_API_URL = "https://sub2apicn.shujk.top/api/v1/admin/usage"
|
||||
DEFAULT_LOGS_URL_CONFIG_FILE = "~/.config/shusub2/logs-url"
|
||||
DEFAULT_LOGS_TOKEN_CONFIG_FILE = "~/.config/shusub2/logs-token"
|
||||
@@ -86,6 +89,14 @@ def default_status_url() -> str:
|
||||
)
|
||||
|
||||
|
||||
def default_pricing_url() -> str:
|
||||
return configured_url(
|
||||
("SHUSUB2_PRICING_URL",),
|
||||
os.environ.get("SHUSUB2_PRICING_URL_FILE", DEFAULT_PRICING_URL_CONFIG_FILE),
|
||||
DEFAULT_PRICING_URL,
|
||||
)
|
||||
|
||||
|
||||
def default_logs_url() -> str:
|
||||
return configured_url(
|
||||
("SHUSUB2_LOGS_URL",),
|
||||
@@ -109,6 +120,12 @@ def logs_url_config_file_path() -> Path:
|
||||
return Path(os.environ.get("SHUSUB2_LOGS_URL_FILE", DEFAULT_LOGS_URL_CONFIG_FILE)).expanduser()
|
||||
|
||||
|
||||
def pricing_url_config_file_path() -> Path:
|
||||
return Path(
|
||||
os.environ.get("SHUSUB2_PRICING_URL_FILE", DEFAULT_PRICING_URL_CONFIG_FILE)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def logs_token_config_file_path() -> Path:
|
||||
return Path(os.environ.get("SHUSUB2_LOGS_TOKEN_FILE", DEFAULT_LOGS_TOKEN_CONFIG_FILE)).expanduser()
|
||||
|
||||
@@ -263,6 +280,30 @@ def format_cost(value: Any) -> str:
|
||||
return f"${amount:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def optional_number(value: Any) -> float | None:
|
||||
if value is None or str(value).strip() == "":
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def format_amount(value: Any) -> str:
|
||||
amount = optional_number(value)
|
||||
if amount is None:
|
||||
return "-"
|
||||
if amount.is_integer():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.6f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def format_cny(value: Any) -> str:
|
||||
amount = format_amount(value)
|
||||
return "-" if amount == "-" else f"¥{amount}"
|
||||
|
||||
|
||||
def format_count(value: Any) -> str:
|
||||
number = as_int(value)
|
||||
if abs(number) >= 1_000_000:
|
||||
@@ -361,6 +402,13 @@ def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]
|
||||
return {}, str(exc)
|
||||
|
||||
|
||||
def fetch_pricing_payload(pricing_url: str, timeout: int) -> dict[str, Any]:
|
||||
payload = fetch_payload(pricing_url, timeout)
|
||||
if not isinstance(payload.get("sources"), list):
|
||||
raise RuntimeError("Pricing Monitor did not return source balances")
|
||||
return payload
|
||||
|
||||
|
||||
def logs_request_url(logs_url: str, limit: int) -> str:
|
||||
parsed = urllib.parse.urlparse(logs_url)
|
||||
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
@@ -1063,13 +1111,6 @@ def window_cell(account: dict[str, Any], window_id: str) -> str:
|
||||
return f"{format_percent(window.get('used_percent'))}/{format_percent(window.get('remaining_percent'))}"
|
||||
|
||||
|
||||
def window_balance_cell(account: dict[str, Any], window_id: str) -> str:
|
||||
window = window_for(account, window_id)
|
||||
if not window or window.get("remaining_balance_usd") is None:
|
||||
return "-"
|
||||
return format_cost(window.get("remaining_balance_usd"))
|
||||
|
||||
|
||||
def daily_quota_cell(account: dict[str, Any]) -> str:
|
||||
limit = account.get("daily_quota_limit")
|
||||
used = account.get("daily_quota_used")
|
||||
@@ -1142,8 +1183,6 @@ def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"quota_used_percent_max": account.get("quota_used_percent_max"),
|
||||
"five_hour": window_cell(account, "five-hour"),
|
||||
"weekly": window_cell(account, "weekly"),
|
||||
"five_hour_balance": window_balance_cell(account, "five-hour"),
|
||||
"weekly_balance": window_balance_cell(account, "weekly"),
|
||||
"reset": reset_cell(account),
|
||||
"latest_usage_at": short_time(account.get("latest_usage_at") or account.get("last_used_at")),
|
||||
"error": str(account.get("error") or ""),
|
||||
@@ -1164,6 +1203,84 @@ def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
return rows
|
||||
|
||||
|
||||
def pricing_status(source: dict[str, Any]) -> str:
|
||||
if str(source.get("last_error") or "").strip():
|
||||
return "error"
|
||||
state = str(source.get("health_state") or "").strip().lower()
|
||||
return state or ("refreshing" if source.get("refreshing") else "unknown")
|
||||
|
||||
|
||||
def normalize_pricing_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
||||
needle = filter_text.strip().lower()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for source in payload.get("sources") or []:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
balance = source.get("balance") if isinstance(source.get("balance"), dict) else {}
|
||||
row = {
|
||||
"name": str(source.get("name") or "-"),
|
||||
"kind": str(source.get("source_kind") or "-"),
|
||||
"balance": optional_number(balance.get("available")),
|
||||
"balance_cny": optional_number(balance.get("available_cny")),
|
||||
"unit": str(balance.get("unit") or "-"),
|
||||
"status": pricing_status(source),
|
||||
"updated_at": source.get("last_success_at"),
|
||||
"updated": short_time(source.get("last_success_at")),
|
||||
"age": relative_age(source.get("last_success_at")),
|
||||
"error": str(source.get("last_error") or "").strip(),
|
||||
"raw": source,
|
||||
}
|
||||
haystack = " ".join(
|
||||
str(row[key] or "") for key in ("name", "kind", "unit", "status", "error")
|
||||
).lower()
|
||||
if needle and needle not in haystack:
|
||||
continue
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda row: (row["status"] != "healthy", row["name"].lower()))
|
||||
return rows
|
||||
|
||||
|
||||
def pricing_summary(payload: dict[str, Any], rows: list[dict[str, Any]] | None = None) -> str:
|
||||
source_rows = rows if rows is not None else normalize_pricing_rows(payload)
|
||||
healthy = sum(row["status"] == "healthy" for row in source_rows)
|
||||
cny_values = [
|
||||
row["balance_cny"]
|
||||
for row in source_rows
|
||||
if row["status"] == "healthy" and row["balance_cny"] is not None
|
||||
]
|
||||
total_cny = sum(cny_values) if cny_values else None
|
||||
total_label = f" | CNY {format_cny(total_cny)}" if total_cny is not None else ""
|
||||
return f"upstreams {healthy}/{len(source_rows)} healthy{total_label}"
|
||||
|
||||
|
||||
def pricing_detail_line(row: dict[str, Any]) -> str:
|
||||
detail = (
|
||||
f"{row['name']} | {row['kind']} | balance {format_amount(row['balance'])} {row['unit']} | "
|
||||
f"CNY {format_cny(row['balance_cny'])} | {row['status']} | "
|
||||
f"updated {row['updated']} ({row['age']})"
|
||||
)
|
||||
if row["error"]:
|
||||
detail += f" | {row['error']}"
|
||||
return detail
|
||||
|
||||
|
||||
def print_pricing_once(payload: dict[str, Any], filter_text: str = "") -> None:
|
||||
rows = normalize_pricing_rows(payload, filter_text)
|
||||
print(f"{short_time(payload.get('generated_at'))} | {pricing_summary(payload, rows)}")
|
||||
print("source kind balance cny unit status updated age")
|
||||
for row in rows:
|
||||
print(
|
||||
f"{row['name']:<30} "
|
||||
f"{row['kind']:<15} "
|
||||
f"{format_amount(row['balance']):<19} "
|
||||
f"{format_cny(row['balance_cny']):<19} "
|
||||
f"{row['unit']:<9} "
|
||||
f"{row['status']:<11} "
|
||||
f"{row['updated']:<12} "
|
||||
f"{row['age']}"
|
||||
)
|
||||
|
||||
|
||||
def summary_line(payload: dict[str, Any]) -> str:
|
||||
totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
|
||||
generated = short_time(payload.get("generated_local") or payload.get("generated_at"))
|
||||
@@ -1181,7 +1298,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
||||
print(summary_line(payload))
|
||||
if status_payload or status_error:
|
||||
print(monitor_summary(status_payload or {}, status_error))
|
||||
print("name provider group daily today tokens req kind 5h 5h bal 7d 7d bal reset status availability")
|
||||
print("name provider group daily today tokens req kind 5h 7d reset status availability")
|
||||
for row in normalize_account_rows(payload, filter_text):
|
||||
print(
|
||||
f"{row['name'][:30]:<30} "
|
||||
@@ -1193,9 +1310,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
||||
f"{format_count(row['today_requests']):<5} "
|
||||
f"{row['kind_label']:<9} "
|
||||
f"{row['five_hour']:<9} "
|
||||
f"{row['five_hour_balance']:<9} "
|
||||
f"{row['weekly']:<9} "
|
||||
f"{row['weekly_balance']:<9} "
|
||||
f"{row['reset']:<11} "
|
||||
f"{row['status']:<10} "
|
||||
f"{monitor_availability(row, status_payload or {})}"
|
||||
@@ -1205,6 +1320,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
||||
def run_textual(
|
||||
api_url: str,
|
||||
status_url: str,
|
||||
pricing_url: str,
|
||||
logs_url: str,
|
||||
logs_token: str,
|
||||
errors_cn_url: str,
|
||||
@@ -1234,6 +1350,7 @@ def run_textual(
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("a", "focus_accounts", "Accounts"),
|
||||
("p", "show_pricing", "Upstreams"),
|
||||
("k", "focus_keys", "Keys"),
|
||||
("l", "focus_logs", "Logs"),
|
||||
("e", "focus_errors", "Errors"),
|
||||
@@ -1243,6 +1360,8 @@ def run_textual(
|
||||
super().__init__(classes="dashboard")
|
||||
self.accounts_payload: dict[str, Any] = {}
|
||||
self.status_payload: dict[str, Any] = {}
|
||||
self.pricing_payload: dict[str, Any] = {}
|
||||
self.pricing_error = ""
|
||||
self.monitor_error = ""
|
||||
self.accounts_error = ""
|
||||
self.logs_payload: dict[str, Any] = {}
|
||||
@@ -1285,7 +1404,7 @@ def run_textual(
|
||||
def on_mount(self) -> None:
|
||||
self.configure_table(
|
||||
self.query_one("#accounts", DataTable),
|
||||
("ACCOUNT", "Group", "Today", "Daily", "5h", "5h Bal", "7d", "7d Bal", "Avail"),
|
||||
("ACCOUNT", "Group", "Today", "Daily", "5h", "7d", "Avail"),
|
||||
)
|
||||
self.configure_table(
|
||||
self.query_one("#keys", DataTable),
|
||||
@@ -1315,6 +1434,7 @@ def run_textual(
|
||||
)
|
||||
self.refresh_all(refresh=True)
|
||||
self.set_interval(refresh_seconds, self.refresh_accounts)
|
||||
self.set_interval(refresh_seconds, self.refresh_pricing)
|
||||
self.set_interval(logs_refresh_seconds, self.refresh_logs)
|
||||
self.set_interval(errors_refresh_seconds, self.refresh_errors)
|
||||
self.focus_table("accounts")
|
||||
@@ -1328,6 +1448,9 @@ def run_textual(
|
||||
def action_focus_accounts(self) -> None:
|
||||
self.focus_table("accounts")
|
||||
|
||||
def action_show_pricing(self) -> None:
|
||||
self.app.switch_screen(PricingScreen())
|
||||
|
||||
def action_focus_keys(self) -> None:
|
||||
table = self.query_one("#keys", DataTable)
|
||||
if table.display:
|
||||
@@ -1353,8 +1476,9 @@ 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, keys, logs, and errors...")
|
||||
self.query_one("#status", Static).update("refreshing accounts, upstream balances, keys, logs, and errors...")
|
||||
self.refresh_accounts(refresh=refresh)
|
||||
self.refresh_pricing()
|
||||
self.refresh_logs()
|
||||
self.refresh_errors()
|
||||
|
||||
@@ -1368,6 +1492,15 @@ def run_textual(
|
||||
self.render_accounts()
|
||||
self.render_meta()
|
||||
|
||||
def refresh_pricing(self) -> None:
|
||||
self.pricing_error = ""
|
||||
try:
|
||||
self.pricing_payload = fetch_pricing_payload(pricing_url, timeout)
|
||||
except Exception as exc:
|
||||
self.pricing_payload = {}
|
||||
self.pricing_error = str(exc)
|
||||
self.render_meta()
|
||||
|
||||
def refresh_logs(self) -> None:
|
||||
self.logs_error = ""
|
||||
if not str(logs_token or "").strip():
|
||||
@@ -1437,9 +1570,7 @@ def run_textual(
|
||||
format_cost(row["today_cost_usd"]),
|
||||
row["daily_quota_cell"],
|
||||
row["five_hour"],
|
||||
row["five_hour_balance"],
|
||||
row["weekly"],
|
||||
row["weekly_balance"],
|
||||
monitor_availability(row, self.status_payload),
|
||||
key=key,
|
||||
)
|
||||
@@ -1536,6 +1667,11 @@ def run_textual(
|
||||
style="green",
|
||||
)
|
||||
summary.append(" | ", style="dim")
|
||||
if self.pricing_error:
|
||||
summary.append("upstreams unavailable", style="bright_blue")
|
||||
else:
|
||||
summary.append(pricing_summary(self.pricing_payload), style="bright_blue")
|
||||
summary.append(" | ", style="dim")
|
||||
summary.append(f"keys {len(self.key_rows)} ({key_summary.removeprefix('keys ')})", style="yellow")
|
||||
summary.append(" | ", style="dim")
|
||||
summary.append(f"logs {len(self.log_rows)}/{logs_total}", style="magenta")
|
||||
@@ -1553,6 +1689,10 @@ def run_textual(
|
||||
f"source {self.accounts_payload.get('source_name') or '-'}",
|
||||
)
|
||||
)
|
||||
if self.pricing_error:
|
||||
status_bits.append(f"upstreams: {self.pricing_error}")
|
||||
elif self.pricing_payload:
|
||||
status_bits.append(pricing_summary(self.pricing_payload) + f" | {pricing_url}")
|
||||
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}")
|
||||
@@ -1592,8 +1732,8 @@ def run_textual(
|
||||
detail = (
|
||||
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | "
|
||||
f"daily {row['daily_quota_cell']} | "
|
||||
f"5h {row['five_hour_balance']} ({row['five_hour']}) | "
|
||||
f"7d {row['weekly_balance']} ({row['weekly']}) | "
|
||||
f"5h {row['five_hour']} | "
|
||||
f"7d {row['weekly']} | "
|
||||
f"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)}"
|
||||
@@ -1635,6 +1775,7 @@ def run_textual(
|
||||
BINDINGS = [
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("p", "show_pricing", "Upstreams"),
|
||||
("l", "show_logs", "Logs"),
|
||||
("e", "show_errors", "Errors"),
|
||||
]
|
||||
@@ -1663,7 +1804,7 @@ def run_textual(
|
||||
table = self.query_one("#accounts", DataTable)
|
||||
table.cursor_type = "row"
|
||||
table.zebra_stripes = True
|
||||
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "5h Bal", "7d", "7d Bal", "Reset", "Status", "Availability")
|
||||
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability")
|
||||
keys_table = self.query_one("#keys", DataTable)
|
||||
keys_table.cursor_type = "row"
|
||||
keys_table.zebra_stripes = True
|
||||
@@ -1677,6 +1818,9 @@ def run_textual(
|
||||
def action_focus_filter(self) -> None:
|
||||
self.query_one("#filter", Input).focus()
|
||||
|
||||
def action_show_pricing(self) -> None:
|
||||
self.app.switch_screen(PricingScreen())
|
||||
|
||||
def action_show_logs(self) -> None:
|
||||
self.app.switch_screen(LogsScreen())
|
||||
|
||||
@@ -1751,9 +1895,7 @@ def run_textual(
|
||||
format_count(row["today_requests"]),
|
||||
row["kind_label"],
|
||||
row["five_hour"],
|
||||
row["five_hour_balance"],
|
||||
row["weekly"],
|
||||
row["weekly_balance"],
|
||||
row["reset"],
|
||||
row["status"],
|
||||
monitor_availability(row, self.status_payload),
|
||||
@@ -1777,7 +1919,7 @@ def run_textual(
|
||||
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | {row['account_type']} | "
|
||||
f"daily {row['daily_quota_cell']} ({format_percent(row['daily_quota_used_percent'])} used, "
|
||||
f"{row['daily_quota_remaining'] if row['daily_quota_remaining'] is not None else '-'} left) | "
|
||||
f"5h {row['five_hour_balance']} ({row['five_hour']}) | 7d {row['weekly_balance']} ({row['weekly']}) | "
|
||||
f"5h {row['five_hour']} | 7d {row['weekly']} | "
|
||||
f"today {format_cost(row['today_cost_usd'])}, {format_count(row['today_tokens'])} tokens, "
|
||||
f"{format_count(row['today_requests'])} req | latest {row['latest_usage_at']} | "
|
||||
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
|
||||
@@ -1788,12 +1930,117 @@ def run_textual(
|
||||
detail += f" | {error}"
|
||||
self.query_one("#detail", Static).update(detail)
|
||||
|
||||
class PricingScreen(Screen[None]):
|
||||
AUTO_FOCUS = "#pricing"
|
||||
BINDINGS = [
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("a", "show_accounts", "Accounts"),
|
||||
("l", "show_logs", "Logs"),
|
||||
("e", "show_errors", "Errors"),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.payload: dict[str, Any] = {}
|
||||
self.rows: list[dict[str, Any]] = []
|
||||
self.row_by_key: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
yield Static("", id="summary")
|
||||
yield Input(placeholder="filter upstream sources", id="filter")
|
||||
yield DataTable(id="pricing")
|
||||
yield Static("", id="detail")
|
||||
yield Static("", id="status")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#pricing", DataTable)
|
||||
table.cursor_type = "row"
|
||||
table.zebra_stripes = True
|
||||
table.add_columns("Source", "Kind", "Balance", "CNY", "Unit", "Status", "Updated", "Age")
|
||||
self.refresh_data()
|
||||
self.set_interval(refresh_seconds, self.refresh_data)
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
self.refresh_data()
|
||||
|
||||
def action_focus_filter(self) -> None:
|
||||
self.query_one("#filter", Input).focus()
|
||||
|
||||
def action_show_accounts(self) -> None:
|
||||
self.app.switch_screen(AccountsScreen())
|
||||
|
||||
def action_show_logs(self) -> None:
|
||||
self.app.switch_screen(LogsScreen())
|
||||
|
||||
def action_show_errors(self) -> None:
|
||||
self.app.switch_screen(ErrorsScreen())
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "filter":
|
||||
self.render_payload()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "filter":
|
||||
self.query_one("#pricing", DataTable).focus()
|
||||
|
||||
def refresh_data(self) -> None:
|
||||
status = self.query_one("#status", Static)
|
||||
status.update("refreshing upstream balances from Pricing Monitor...")
|
||||
try:
|
||||
self.payload = fetch_pricing_payload(pricing_url, timeout)
|
||||
self.render_payload()
|
||||
status_bits = [version_message, pricing_summary(self.payload), pricing_url]
|
||||
status.update(" | ".join(bit for bit in status_bits if bit))
|
||||
except Exception as exc:
|
||||
self.payload = {}
|
||||
self.rows = []
|
||||
self.row_by_key = {}
|
||||
self.query_one("#pricing", DataTable).clear()
|
||||
self.query_one("#summary", Static).update("upstreams unavailable")
|
||||
self.query_one("#detail", Static).update("no upstream balances")
|
||||
status.update(f"upstreams error: {exc}")
|
||||
|
||||
def render_payload(self) -> None:
|
||||
filter_text = self.query_one("#filter", Input).value
|
||||
self.rows = normalize_pricing_rows(self.payload, filter_text)
|
||||
table = self.query_one("#pricing", DataTable)
|
||||
table.clear()
|
||||
self.row_by_key = {}
|
||||
for index, row in enumerate(self.rows):
|
||||
key = f"pricing-{row['name']}-{index}"
|
||||
self.row_by_key[key] = row
|
||||
table.add_row(
|
||||
row["name"],
|
||||
row["kind"],
|
||||
format_amount(row["balance"]),
|
||||
format_cny(row["balance_cny"]),
|
||||
row["unit"],
|
||||
row["status"],
|
||||
row["updated"],
|
||||
row["age"],
|
||||
key=key,
|
||||
)
|
||||
self.query_one("#summary", Static).update(pricing_summary(self.payload, self.rows))
|
||||
if self.rows:
|
||||
self.query_one("#detail", Static).update(pricing_detail_line(self.rows[0]))
|
||||
else:
|
||||
self.query_one("#detail", Static).update("no upstream balances")
|
||||
|
||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
row = self.row_by_key.get(str(event.row_key.value))
|
||||
if row:
|
||||
self.query_one("#detail", Static).update(pricing_detail_line(row))
|
||||
|
||||
class LogsScreen(Screen[None]):
|
||||
AUTO_FOCUS = "#logs"
|
||||
BINDINGS = [
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("a", "show_accounts", "Accounts"),
|
||||
("p", "show_pricing", "Upstreams"),
|
||||
("e", "show_errors", "Errors"),
|
||||
]
|
||||
|
||||
@@ -1845,6 +2092,9 @@ def run_textual(
|
||||
def action_show_accounts(self) -> None:
|
||||
self.app.switch_screen(AccountsScreen())
|
||||
|
||||
def action_show_pricing(self) -> None:
|
||||
self.app.switch_screen(PricingScreen())
|
||||
|
||||
def action_show_errors(self) -> None:
|
||||
self.app.switch_screen(ErrorsScreen())
|
||||
|
||||
@@ -1916,6 +2166,7 @@ def run_textual(
|
||||
("r", "refresh", "Refresh"),
|
||||
("/", "focus_filter", "Filter"),
|
||||
("a", "show_accounts", "Accounts"),
|
||||
("p", "show_pricing", "Upstreams"),
|
||||
("l", "show_logs", "Logs"),
|
||||
]
|
||||
|
||||
@@ -1951,6 +2202,9 @@ def run_textual(
|
||||
def action_show_accounts(self) -> None:
|
||||
self.app.switch_screen(AccountsScreen())
|
||||
|
||||
def action_show_pricing(self) -> None:
|
||||
self.app.switch_screen(PricingScreen())
|
||||
|
||||
def action_show_logs(self) -> None:
|
||||
self.app.switch_screen(LogsScreen())
|
||||
|
||||
@@ -2032,6 +2286,7 @@ def run_textual(
|
||||
#filter { height: 1; border: none; padding: 0 1; }
|
||||
#filter:focus { border: none; }
|
||||
#accounts { height: 1fr; min-height: 3; }
|
||||
#pricing { height: 1fr; min-height: 3; }
|
||||
#keys { height: auto; max-height: 6; }
|
||||
.dashboard #accounts { background: $success 5%; }
|
||||
.dashboard #accounts > .datatable--header { background: $success 35%; color: $foreground; }
|
||||
@@ -2053,6 +2308,8 @@ def run_textual(
|
||||
def on_mount(self) -> None:
|
||||
if start_page == "accounts":
|
||||
self.push_screen(AccountsScreen())
|
||||
elif start_page == "pricing":
|
||||
self.push_screen(PricingScreen())
|
||||
elif start_page == "logs":
|
||||
self.push_screen(LogsScreen())
|
||||
elif start_page == "errors":
|
||||
@@ -2075,6 +2332,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI")
|
||||
parser.add_argument("--api-url", default=default_api_url())
|
||||
parser.add_argument("--status-url", default=default_status_url(), help="optional sub2api-status /api/status URL for channel monitor health")
|
||||
parser.add_argument("--pricing-url", default=default_pricing_url(), help="Pricing Monitor /api/ui-data?view=accounts URL for upstream source balances")
|
||||
parser.add_argument("--logs-url", default=default_logs_url(), help="Sub2API admin usage logs URL (default: sub2apicn /api/v1/admin/usage)")
|
||||
parser.add_argument("--logs-token", default=default_logs_token(), help="Sub2API admin API key for logs/errors pages (prefer SHUSUB2_LOGS_TOKEN or ~/.config/shusub2/logs-token)")
|
||||
parser.add_argument("--errors-cn-url", default=default_errors_cn_url(), help="cn Sub2API admin ops errors URL (default: sub2apicn /api/v1/admin/ops/errors)")
|
||||
@@ -2083,7 +2341,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=default_errors_us_url(),
|
||||
help="us Sub2API admin ops errors URL (default: DNS-only us.sub2 /api/v1/admin/ops/errors; CF sub2apius often blocks non-browser clients)",
|
||||
)
|
||||
parser.add_argument("--save-config", action="store_true", help="persist --api-url/--logs-url/--logs-token/--errors-*-url to ~/.config/shusub2/ before running")
|
||||
parser.add_argument("--save-config", action="store_true", help="persist --api-url/--pricing-url/--logs-url/--logs-token/--errors-*-url to ~/.config/shusub2/ before running")
|
||||
parser.add_argument("--install", action="store_true", help="persist config, install shusub2 as a uv tool, then exit")
|
||||
parser.add_argument("--version-check-url", default=os.environ.get("SHUSUB2_VERSION_CHECK_URL", DEFAULT_VERSION_CHECK_URL))
|
||||
parser.add_argument(
|
||||
@@ -2125,6 +2383,7 @@ 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("--accounts", action="store_true", help="start on the dedicated accounts page (with --once: print the accounts snapshot)")
|
||||
parser.add_argument("--pricing", action="store_true", help="start on the Pricing Monitor upstream balances page (with --once: print source balances)")
|
||||
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")
|
||||
@@ -2137,13 +2396,16 @@ 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
|
||||
selected_pages = sum(bool(value) for value in (args.accounts, args.logs, args.errors))
|
||||
selected_pages = sum(bool(value) for value in (args.accounts, args.pricing, args.logs, args.errors))
|
||||
if selected_pages > 1:
|
||||
print("choose only one of --accounts, --logs, or --errors", file=sys.stderr)
|
||||
print("choose only one of --accounts, --pricing, --logs, or --errors", file=sys.stderr)
|
||||
return 2
|
||||
if args.save_config or args.install:
|
||||
config_path = write_api_url_config(args.api_url)
|
||||
print(f"saved api url to {config_path}")
|
||||
if str(args.pricing_url or "").strip():
|
||||
pricing_url_path = write_config_value(pricing_url_config_file_path(), args.pricing_url)
|
||||
print(f"saved pricing url to {pricing_url_path}")
|
||||
if str(args.logs_url or "").strip():
|
||||
logs_url_path = write_config_value(logs_url_config_file_path(), args.logs_url)
|
||||
print(f"saved logs url to {logs_url_path}")
|
||||
@@ -2167,6 +2429,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.once:
|
||||
if version_message:
|
||||
print(version_message)
|
||||
if args.pricing:
|
||||
print_pricing_once(fetch_pricing_payload(args.pricing_url, args.timeout), args.filter)
|
||||
return 0
|
||||
if args.logs:
|
||||
if not str(args.logs_token or "").strip():
|
||||
print(logs_token_hint(), file=sys.stderr)
|
||||
@@ -2200,6 +2465,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
start_page = "dashboard"
|
||||
if args.accounts:
|
||||
start_page = "accounts"
|
||||
elif args.pricing:
|
||||
start_page = "pricing"
|
||||
elif args.logs:
|
||||
start_page = "logs"
|
||||
elif args.errors:
|
||||
@@ -2207,6 +2474,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return run_textual(
|
||||
args.api_url,
|
||||
status_url,
|
||||
args.pricing_url,
|
||||
args.logs_url,
|
||||
args.logs_token,
|
||||
args.errors_cn_url,
|
||||
|
||||
Reference in New Issue
Block a user