feat: show channel monitor health
This commit is contained in:
+125
-10
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user