fix: show Pricing Monitor source balances
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
- 包名:`shusub2`
|
||||
- 命令名:`shusub2`
|
||||
- 本地状态路径:`~/.config/shusub2/`
|
||||
- 目标 API:`cliproxy-codex-quota` 的 `/api/tui/accounts`、可选 `sub2api-status` `/api/status`,以及可选 Sub2API `/api/v1/admin/usage` 和 `/api/v1/admin/ops/errors`。
|
||||
- 目标 API:`cliproxy-codex-quota` 的 `/api/tui/accounts`、可选 `sub2api-status` `/api/status`、可选 Pricing Monitor `/api/ui-data?view=accounts`,以及可选 Sub2API `/api/v1/admin/usage` 和 `/api/v1/admin/ops/errors`。
|
||||
- 备注:账号数据无 secret,不需要 SSH、数据库访问、API key、OAuth credentials 或 plaintext env;请求与错误日志可选持有 Sub2API admin API key,仅存本机 `~/.config/shusub2/logs-token`(0600)或 `SHUSUB2_LOGS_TOKEN`。
|
||||
|
||||
Single-page terminal dashboard for the token-safe Sub2API account feed,
|
||||
@@ -62,6 +62,8 @@ Environment variables override the config file:
|
||||
- `SHUSUB2_API_URL_FILE`
|
||||
- `SHUSUB2_STATUS_URL`
|
||||
- `SHUSUB2_STATUS_URL_FILE`
|
||||
- `SHUSUB2_PRICING_URL`
|
||||
- `SHUSUB2_PRICING_URL_FILE`
|
||||
- `SHUSUB2_LOGS_URL`
|
||||
- `SHUSUB2_LOGS_URL_FILE`
|
||||
- `SHUSUB2_LOGS_TOKEN`
|
||||
@@ -81,10 +83,12 @@ Environment variables override the config file:
|
||||
|
||||
## Unified Dashboard
|
||||
|
||||
`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 immediately. Automatic Accounts,
|
||||
Keys/Logs, and Errors refresh defaults to every five minutes. The client requests
|
||||
`shusub2` opens a compact dashboard for Accounts, Keys, Logs, and Errors. Its
|
||||
summary also reports Pricing Monitor upstream health and CNY total. Press `a`,
|
||||
`k`, `l`, or `e` to focus a dashboard table; press `p` to open the full
|
||||
Upstreams source-balance page. `/` filters the active table and `r` refreshes
|
||||
all data immediately. Automatic Accounts and Upstreams, Keys/Logs, and Errors refresh defaults
|
||||
to every five minutes. The client requests
|
||||
gzip-compressed JSON and transparently decodes it when the upstream supports it.
|
||||
The two-line detail area follows the selected row. Accounts, Keys, Logs, and
|
||||
Errors use green, yellow, magenta, and red section styling respectively.
|
||||
@@ -94,7 +98,8 @@ complete keys, accounts, and models. On a narrow terminal, a table scrolls
|
||||
horizontally instead of truncating a field:
|
||||
|
||||
```text
|
||||
ACCOUNT | Group | Today | Daily | 5h | 5h Bal | 7d | 7d Bal | Avail
|
||||
ACCOUNT | Group | Today | Daily | 5h | 7d | Avail
|
||||
UPSTREAMS (p) | Source | Kind | Balance | CNY | Unit | Status | Updated | Age
|
||||
KEY | Today | Tokens | Req
|
||||
LOG KEY | Account | Model | First | Duration | Tok/s | Input | Output | Cache | Tokens | Cost | Time | Age
|
||||
ERR | Status | Key | Account | Model | Time | Age
|
||||
@@ -104,8 +109,8 @@ 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.
|
||||
Use `--accounts`, `--pricing`, `--logs`, or `--errors` to start a dedicated
|
||||
full-table view. The corresponding `--once` form still prints only that data set.
|
||||
|
||||
## Request Logs Page
|
||||
|
||||
@@ -204,6 +209,7 @@ Configuration:
|
||||
|
||||
- `--api-url` / `SUB2API_QUOTA_TUI_API_URL`
|
||||
- `--status-url` / `SHUSUB2_STATUS_URL`
|
||||
- `--pricing-url` / `SHUSUB2_PRICING_URL`
|
||||
- `--logs-url` / `SHUSUB2_LOGS_URL`
|
||||
- `--logs-token` / `SHUSUB2_LOGS_TOKEN`
|
||||
- `--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`
|
||||
@@ -211,14 +217,19 @@ Configuration:
|
||||
- `--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`
|
||||
- `--errors-limit` / `SHUSUB2_ERRORS_LIMIT`
|
||||
- `--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`
|
||||
- `--accounts` / `--logs` / `--errors`
|
||||
- `--accounts` / `--pricing` / `--logs` / `--errors`
|
||||
- `--save-config`
|
||||
- `--install`
|
||||
- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS`
|
||||
- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT`
|
||||
|
||||
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,
|
||||
The dashboard reads `/api/tui/accounts`, can optionally read `sub2api-status`
|
||||
`/api/status` for channel monitor health, and reads Pricing Monitor's
|
||||
`/api/ui-data?view=accounts` for upstream source balances. The default pricing URL
|
||||
is the Tailscale HTTPS service; `--pricing-url`, `SHUSUB2_PRICING_URL`, or
|
||||
`~/.config/shusub2/pricing-url` can override it. This is a cached, read-only
|
||||
projection collected by Pricing Monitor on its own cadence: `shusub2` neither
|
||||
contacts upstream providers directly nor asks Pricing Monitor to refresh. The Accounts table does not need SSH,
|
||||
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
|
||||
@@ -241,7 +252,7 @@ panel after the accounts table when a token is configured.
|
||||
The dedicated Accounts page keeps the full columns for scanning inside zellij:
|
||||
|
||||
```text
|
||||
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 5h Bal | 7d | 7d Bal | Reset | Status | Availability
|
||||
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability
|
||||
```
|
||||
|
||||
`Provider` distinguishes `openai` and `anthropic` accounts from the public
|
||||
@@ -250,10 +261,14 @@ Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 5h Bal | 7d
|
||||
`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in
|
||||
`accounts.extra`; otherwise it is `-`.
|
||||
|
||||
`5h` / `7d` retain the used/remaining percentages, while `5h Bal` / `7d Bal` show
|
||||
that same window's derived remaining USD balance from the token-safe account
|
||||
feed. A missing window stays `-`; the client does not invent a cross-window
|
||||
total or currency conversion.
|
||||
`5h` / `7d` are only the account feed's used/remaining quota percentages. They
|
||||
are not upstream provider balances and therefore never claim to be a cash or
|
||||
source-account balance.
|
||||
|
||||
The dedicated `--pricing` page shows Pricing Monitor source rows such as
|
||||
`code-plan`, `codexapis`, `testvideo`, `kedaya`, `ycy`, and `mdkj`: raw available
|
||||
balance with its source unit, derived CNY when supplied, health state, and last
|
||||
successful collection time. It reads only the existing sanitized source projection.
|
||||
|
||||
`Group` is the derived Sub2API tier alias group. Higher tiers win when multiple
|
||||
aliases exist: `id < slow < fast < sfast`. The table shows `sfast` first, then
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "shusub2"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+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,
|
||||
|
||||
+133
-5
@@ -39,8 +39,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
"today_tokens": 2000,
|
||||
"today_requests": 4,
|
||||
"windows": [
|
||||
{"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "remaining_balance_usd": 4, "reset": "2026-06-09T15:00:00+08:00"},
|
||||
{"id": "weekly", "used_percent": 10, "remaining_percent": 90, "remaining_balance_usd": 108, "reset": "2026-06-10T15:00:00+08:00"},
|
||||
{"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "reset": "2026-06-09T15:00:00+08:00"},
|
||||
{"id": "weekly", "used_percent": 10, "remaining_percent": 90, "reset": "2026-06-10T15:00:00+08:00"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -72,8 +72,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
self.assertEqual(rows[0]["kind_label"], "usage")
|
||||
self.assertEqual(rows[1]["five_hour"], "80%/20%")
|
||||
self.assertEqual(rows[1]["weekly"], "10%/90%")
|
||||
self.assertEqual(rows[1]["five_hour_balance"], "$4")
|
||||
self.assertEqual(rows[1]["weekly_balance"], "$108")
|
||||
self.assertNotIn("five_hour_balance", rows[1])
|
||||
self.assertNotIn("weekly_balance", rows[1])
|
||||
self.assertIn("today $0.75", mod.summary_line(payload))
|
||||
|
||||
def test_filter_matches_kind_and_name(self) -> None:
|
||||
@@ -90,6 +90,89 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "sfast")], ["beta"])
|
||||
self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "anthropic")], ["alpha"])
|
||||
|
||||
def test_normalize_pricing_rows_uses_pricing_monitor_source_balances(self) -> None:
|
||||
mod = load_module()
|
||||
payload = {
|
||||
"generated_at": "2026-08-02T12:00:00Z",
|
||||
"sources": [
|
||||
{
|
||||
"name": "code-plan",
|
||||
"source_kind": "newapi",
|
||||
"health_state": "healthy",
|
||||
"last_success_at": "2026-08-02T11:59:00Z",
|
||||
"balance_available": True,
|
||||
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
|
||||
},
|
||||
{
|
||||
"name": "kedaya",
|
||||
"source_kind": "remote_monitor",
|
||||
"health_state": "error",
|
||||
"last_error": "upstream request failed",
|
||||
"balance_available": False,
|
||||
"balance": {"available": 99, "available_cny": 99, "unit": "balance"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rows = mod.normalize_pricing_rows(payload)
|
||||
|
||||
self.assertEqual([row["name"] for row in rows], ["code-plan", "kedaya"])
|
||||
self.assertEqual(rows[0]["balance"], 5000000)
|
||||
self.assertEqual(rows[0]["balance_cny"], 10)
|
||||
self.assertEqual(rows[0]["unit"], "quota")
|
||||
self.assertEqual(rows[0]["status"], "healthy")
|
||||
self.assertEqual(rows[1]["status"], "error")
|
||||
self.assertEqual([row["name"] for row in mod.normalize_pricing_rows(payload, "keday")], ["kedaya"])
|
||||
self.assertEqual(mod.pricing_summary(payload, rows), "upstreams 1/2 healthy | CNY ¥10")
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
mod.print_pricing_once(payload)
|
||||
self.assertIn("code-plan", out.getvalue())
|
||||
self.assertIn("5,000,000", out.getvalue())
|
||||
self.assertIn("¥10", out.getvalue())
|
||||
|
||||
def test_default_pricing_url_reads_env_or_config_file(self) -> None:
|
||||
mod = load_module()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_file = Path(tmp) / "shusub2" / "pricing-url"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("https://pricing.example.test/api/ui-data?view=accounts\n", encoding="utf-8")
|
||||
old_url = os.environ.get("SHUSUB2_PRICING_URL")
|
||||
old_file = os.environ.get("SHUSUB2_PRICING_URL_FILE")
|
||||
os.environ["SHUSUB2_PRICING_URL_FILE"] = str(config_file)
|
||||
os.environ.pop("SHUSUB2_PRICING_URL", None)
|
||||
try:
|
||||
self.assertEqual(
|
||||
mod.default_pricing_url(),
|
||||
"https://pricing.example.test/api/ui-data?view=accounts",
|
||||
)
|
||||
os.environ["SHUSUB2_PRICING_URL"] = "https://env.example.test/api/ui-data?view=accounts"
|
||||
self.assertEqual(
|
||||
mod.default_pricing_url(),
|
||||
"https://env.example.test/api/ui-data?view=accounts",
|
||||
)
|
||||
finally:
|
||||
if old_url is None:
|
||||
os.environ.pop("SHUSUB2_PRICING_URL", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_PRICING_URL"] = old_url
|
||||
if old_file is None:
|
||||
os.environ.pop("SHUSUB2_PRICING_URL_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_PRICING_URL_FILE"] = old_file
|
||||
|
||||
def test_fetch_pricing_payload_requires_sources_list(self) -> None:
|
||||
mod = load_module()
|
||||
original_fetch = mod.fetch_payload
|
||||
mod.fetch_payload = lambda *args, **kwargs: {"view": "accounts", "sources": []}
|
||||
try:
|
||||
self.assertEqual(mod.fetch_pricing_payload("https://price.example/api/ui-data?view=accounts", 1)["sources"], [])
|
||||
mod.fetch_payload = lambda *args, **kwargs: {"view": "accounts"}
|
||||
with self.assertRaisesRegex(RuntimeError, "source balances"):
|
||||
mod.fetch_pricing_payload("https://price.example/api/ui-data?view=accounts", 1)
|
||||
finally:
|
||||
mod.fetch_payload = original_fetch
|
||||
|
||||
def test_infers_public_status_url_from_accounts_url(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
@@ -252,7 +335,7 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(mod.monitor_availability(rows[0], status_payload), "ok")
|
||||
self.assertIn("availability", out.getvalue())
|
||||
self.assertIn("5h bal", out.getvalue())
|
||||
self.assertNotIn("5h bal", out.getvalue())
|
||||
self.assertIn(" ok\n", out.getvalue())
|
||||
|
||||
def test_monitor_availability_is_dash_without_bound_monitor(self) -> None:
|
||||
@@ -756,6 +839,19 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
|
||||
],
|
||||
}
|
||||
mod.fetch_optional_payload = lambda *args, **kwargs: ({}, "")
|
||||
mod.fetch_pricing_payload = lambda *args, **kwargs: {
|
||||
"generated_at": "2026-07-24T12:00:00+08:00",
|
||||
"sources": [
|
||||
{
|
||||
"name": "code-plan",
|
||||
"source_kind": "newapi",
|
||||
"health_state": "healthy",
|
||||
"last_success_at": "2026-07-24T12:00:00+08:00",
|
||||
"balance_available": True,
|
||||
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
|
||||
}
|
||||
],
|
||||
}
|
||||
mod.fetch_key_usage_payload = lambda *args, **kwargs: {
|
||||
"date": "2026-07-24",
|
||||
"trend": [{"api_key_id": 1, "key_name": "wmy", "requests": 3, "tokens": 300}],
|
||||
@@ -805,6 +901,7 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
|
||||
rc = mod.run_textual(
|
||||
"https://example/accounts",
|
||||
"",
|
||||
"https://example/pricing?view=accounts",
|
||||
"https://example/usage",
|
||||
"token",
|
||||
"https://example/cn",
|
||||
@@ -846,9 +943,40 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.press(key)
|
||||
await pilot.pause()
|
||||
self.assertEqual(screen.focused.id, expected_id)
|
||||
await pilot.press("p")
|
||||
await pilot.pause()
|
||||
self.assertEqual(type(app.screen).__name__, "PricingScreen")
|
||||
self.assertEqual(app.screen.focused.id, "pricing")
|
||||
self.assertEqual(app.screen.rows[0]["name"], "code-plan")
|
||||
|
||||
|
||||
class PageSelectionTests(unittest.TestCase):
|
||||
def test_once_pricing_prints_pricing_monitor_sources(self) -> None:
|
||||
mod = load_module()
|
||||
original_fetch = mod.fetch_pricing_payload
|
||||
mod.fetch_pricing_payload = lambda *args, **kwargs: {
|
||||
"generated_at": "2026-08-02T12:00:00Z",
|
||||
"sources": [
|
||||
{
|
||||
"name": "code-plan",
|
||||
"source_kind": "newapi",
|
||||
"health_state": "healthy",
|
||||
"last_success_at": "2026-08-02T11:59:00Z",
|
||||
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
|
||||
}
|
||||
],
|
||||
}
|
||||
out = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out):
|
||||
rc = mod.main(["--once", "--pricing", "--no-version-check"])
|
||||
finally:
|
||||
mod.fetch_pricing_payload = original_fetch
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("code-plan", out.getvalue())
|
||||
self.assertIn("¥10", out.getvalue())
|
||||
|
||||
def test_dedicated_page_flags_are_mutually_exclusive(self) -> None:
|
||||
mod = load_module()
|
||||
old_token = os.environ.pop("SHUSUB2_LOGS_TOKEN", None)
|
||||
|
||||
Reference in New Issue
Block a user