feat: show channel monitor health

This commit is contained in:
2026-06-09 15:28:10 +08:00
parent ffde69aee6
commit a59ae55783
5 changed files with 213 additions and 15 deletions
+125 -10
View File
@@ -15,8 +15,12 @@ from typing import Any
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_REFRESH_SECONDS = 60
DEFAULT_TIMEOUT_SECONDS = 10
MONITOR_OK_STATUSES = {"operational", "ok", "success"}
MONITOR_FAILED_STATUSES = {"error", "failed", "failure"}
MONITOR_STOPWORDS = {"response", "responses", "monitor"}
def env_int(name: str, default: int, *, minimum: int = 1) -> int:
@@ -26,12 +30,12 @@ def env_int(name: str, default: int, *, minimum: int = 1) -> int:
return default
def default_api_url() -> str:
for name in ("SHUSUB2_API_URL", "SUB2API_QUOTA_TUI_API_URL"):
def configured_url(env_names: tuple[str, ...], config_path: str, default: str = "") -> str:
for name in env_names:
value = os.environ.get(name, "").strip()
if value:
return value
config_file = Path(os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser()
config_file = Path(config_path).expanduser()
try:
for line in config_file.read_text(encoding="utf-8").splitlines():
value = line.strip()
@@ -39,7 +43,22 @@ def default_api_url() -> str:
return value
except OSError:
pass
return DEFAULT_API_URL
return default
def default_api_url() -> str:
return configured_url(
("SHUSUB2_API_URL", "SUB2API_QUOTA_TUI_API_URL"),
os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE),
DEFAULT_API_URL,
)
def default_status_url() -> str:
return configured_url(
("SHUSUB2_STATUS_URL",),
os.environ.get("SHUSUB2_STATUS_URL_FILE", DEFAULT_STATUS_CONFIG_FILE),
)
def as_float(value: Any) -> float:
@@ -127,6 +146,15 @@ def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[
return data
def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]:
if not str(url or "").strip():
return {}, ""
try:
return fetch_payload(url, timeout), ""
except Exception as exc:
return {}, str(exc)
def kind_label(kind: Any) -> str:
return {
"quota_limited": "quota",
@@ -152,6 +180,84 @@ def routing_group_sort_rank(value: Any) -> int:
return {"sfast": 0, "fast": 1, "slow": 2, "id": 3, "": 4, "-": 4}.get(str(value or "").strip().lower(), 4)
def status_kind(value: Any) -> str:
text = str(value or "").strip().lower()
if text in MONITOR_OK_STATUSES:
return "ok"
if text in MONITOR_FAILED_STATUSES:
return "failed"
return "unknown"
def format_latency(value: Any) -> str:
number = as_int(value)
return "-" if number <= 0 else f"{number}ms"
def monitor_items(status_payload: dict[str, Any]) -> list[dict[str, Any]]:
monitors = status_payload.get("channel_monitors") if isinstance(status_payload.get("channel_monitors"), dict) else {}
items = []
for item in monitors.get("items") or []:
if isinstance(item, dict):
items.append(item)
return items
def monitor_summary(status_payload: dict[str, Any], error: str = "") -> str:
if error:
return f"monitors error: {error}"
monitors = status_payload.get("channel_monitors") if isinstance(status_payload.get("channel_monitors"), dict) else {}
if not monitors:
return "monitors -"
enabled = as_int(monitors.get("enabled"))
ok = as_int(monitors.get("latest_ok"))
failed = as_int(monitors.get("latest_failed"))
unknown = as_int(monitors.get("latest_unknown"))
checked = short_time(monitors.get("latest_checked_max"))
return f"monitors {ok} ok / {failed} failed / {unknown} unknown ({enabled} enabled, {checked})"
def text_tokens(value: Any) -> set[str]:
tokens = set()
for token in "".join(ch.lower() if ch.isalnum() else " " for ch in str(value or "")).split():
if token and token not in MONITOR_STOPWORDS and not token.isdigit():
tokens.add(token)
return tokens
def matching_monitor(row: dict[str, Any], status_payload: dict[str, Any]) -> dict[str, Any] | None:
account_tokens = text_tokens(row.get("name"))
if not account_tokens:
return None
best: tuple[int, dict[str, Any]] | None = None
for item in monitor_items(status_payload):
monitor_tokens = text_tokens(item.get("name"))
if not monitor_tokens:
continue
overlap = account_tokens & monitor_tokens
if not overlap:
continue
subset_bonus = 10 if account_tokens <= monitor_tokens or monitor_tokens <= account_tokens else 0
score = subset_bonus + len(overlap)
if best is None or score > best[0]:
best = (score, item)
return None if best is None else best[1]
def monitor_detail(row: dict[str, Any], status_payload: dict[str, Any]) -> str:
item = matching_monitor(row, status_payload)
if not item:
return "monitor -"
status = str(item.get("latest_status") or "unknown")
latency = format_latency(item.get("latency_ms"))
checked = short_time(item.get("checked_at"))
message = str(item.get("message") or "").strip()
detail = f"monitor {status} {latency} checked {checked}"
if message:
detail += f" {message}"
return detail
def window_for(account: dict[str, Any], window_id: str) -> dict[str, Any]:
for window in account.get("windows") or []:
if isinstance(window, dict) and window.get("id") == window_id:
@@ -272,8 +378,10 @@ def summary_line(payload: dict[str, Any]) -> str:
)
def print_once(payload: dict[str, Any], filter_text: str = "") -> None:
def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: dict[str, Any] | None = None, status_error: str = "") -> None:
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 7d reset status")
for row in normalize_account_rows(payload, filter_text):
print(
@@ -292,7 +400,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "") -> None:
)
def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: int) -> int:
try:
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header, Input, Static
@@ -317,6 +425,8 @@ def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
def __init__(self) -> None:
super().__init__()
self.payload: dict[str, Any] = {}
self.status_payload: dict[str, Any] = {}
self.status_error = ""
self.rows: list[dict[str, Any]] = []
self.row_by_key: dict[str, dict[str, Any]] = {}
@@ -352,8 +462,10 @@ def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
status.update("refreshing...")
try:
self.payload = fetch_payload(api_url, timeout, refresh=refresh)
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
self.render_payload()
status.update(f"source {self.payload.get('source_name') or '-'} | {api_url}")
status_bits = [monitor_summary(self.status_payload, self.status_error), f"source {self.payload.get('source_name') or '-'}"]
status.update(" | ".join(bit for bit in status_bits if bit) + f" | {api_url}")
except Exception as exc:
status.update(f"error: {exc}")
@@ -401,7 +513,8 @@ def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
f"{row['daily_quota_remaining'] if row['daily_quota_remaining'] is not None else '-'} left) | "
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 '-'}"
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
f"{monitor_detail(row, self.status_payload)}"
)
error = str(raw.get("error") or "").strip()
if error:
@@ -415,6 +528,7 @@ def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
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(
"--refresh-seconds",
type=int,
@@ -429,9 +543,10 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.once:
print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter)
status_payload, status_error = fetch_optional_payload(args.status_url, args.timeout)
print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter, status_payload, status_error)
return 0
return run_textual(args.api_url, max(1, args.refresh_seconds), max(1, args.timeout))
return run_textual(args.api_url, args.status_url, max(1, args.refresh_seconds), max(1, args.timeout))
if __name__ == "__main__":