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
+13 -3
View File
@@ -19,13 +19,15 @@ uv tool install git+https://gitea.shujk.top/shujakuin/shusub2.git
shusub2 --api-url https://example.com/api/tui/accounts shusub2 --api-url https://example.com/api/tui/accounts
``` ```
Configure a default API URL for `shusub2`: Configure default API and optional status URLs for `shusub2`:
```bash ```bash
mkdir -p ~/.config/shusub2 mkdir -p ~/.config/shusub2
chmod 700 ~/.config/shusub2 chmod 700 ~/.config/shusub2
printf '%s\n' 'https://example.com/api/tui/accounts' > ~/.config/shusub2/api-url printf '%s\n' 'https://example.com/api/tui/accounts' > ~/.config/shusub2/api-url
printf '%s\n' 'https://example.com/api/status' > ~/.config/shusub2/status-url
chmod 600 ~/.config/shusub2/api-url chmod 600 ~/.config/shusub2/api-url
chmod 600 ~/.config/shusub2/status-url
shusub2 shusub2
``` ```
@@ -34,6 +36,8 @@ Environment variables override the config file:
- `SHUSUB2_API_URL` - `SHUSUB2_API_URL`
- `SUB2API_QUOTA_TUI_API_URL` - `SUB2API_QUOTA_TUI_API_URL`
- `SHUSUB2_API_URL_FILE` - `SHUSUB2_API_URL_FILE`
- `SHUSUB2_STATUS_URL`
- `SHUSUB2_STATUS_URL_FILE`
## Local Development ## Local Development
@@ -52,11 +56,13 @@ zellij action new-pane --name sub2api-quota -- \
Configuration: Configuration:
- `--api-url` / `SUB2API_QUOTA_TUI_API_URL` - `--api-url` / `SUB2API_QUOTA_TUI_API_URL`
- `--status-url` / `SHUSUB2_STATUS_URL`
- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS` - `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS`
- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT` - `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT`
The TUI reads only `/api/tui/accounts`; it does not need SSH, database access, The TUI reads `/api/tui/accounts` and can optionally read `sub2api-status`
API keys, OAuth credentials, or plaintext env files. `/api/status` for channel monitor health. It does not need SSH, database
access, API keys, OAuth credentials, or plaintext env files.
Columns are ordered for scanning inside zellij: Columns are ordered for scanning inside zellij:
@@ -73,3 +79,7 @@ Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset
`Group` is the derived Sub2API tier alias group. Higher tiers win when multiple `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 aliases exist: `id < slow < fast < sfast`. The table shows `sfast` first, then
`fast`, `slow`, `id`, and ungrouped accounts. `fast`, `slow`, `id`, and ungrouped accounts.
When `--status-url` is configured, the status line shows channel monitor
health, and the selected account detail shows the matching monitor status when
one exists.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "shusub2" name = "shusub2"
version = "0.1.1" version = "0.1.2"
description = "Terminal UI for Sub2API account quota and daily usage" description = "Terminal UI for Sub2API account quota and daily usage"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+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_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
DEFAULT_REFRESH_SECONDS = 60 DEFAULT_REFRESH_SECONDS = 60
DEFAULT_TIMEOUT_SECONDS = 10 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: 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 return default
def default_api_url() -> str: def configured_url(env_names: tuple[str, ...], config_path: str, default: str = "") -> str:
for name in ("SHUSUB2_API_URL", "SUB2API_QUOTA_TUI_API_URL"): for name in env_names:
value = os.environ.get(name, "").strip() value = os.environ.get(name, "").strip()
if value: if value:
return value return value
config_file = Path(os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser() config_file = Path(config_path).expanduser()
try: try:
for line in config_file.read_text(encoding="utf-8").splitlines(): for line in config_file.read_text(encoding="utf-8").splitlines():
value = line.strip() value = line.strip()
@@ -39,7 +43,22 @@ def default_api_url() -> str:
return value return value
except OSError: except OSError:
pass 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: def as_float(value: Any) -> float:
@@ -127,6 +146,15 @@ def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[
return data 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: def kind_label(kind: Any) -> str:
return { return {
"quota_limited": "quota", "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) 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]: def window_for(account: dict[str, Any], window_id: str) -> dict[str, Any]:
for window in account.get("windows") or []: for window in account.get("windows") or []:
if isinstance(window, dict) and window.get("id") == window_id: 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)) 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") print("name provider group daily today tokens req kind 5h 7d reset status")
for row in normalize_account_rows(payload, filter_text): for row in normalize_account_rows(payload, filter_text):
print( 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: try:
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header, Input, Static 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: def __init__(self) -> None:
super().__init__() super().__init__()
self.payload: dict[str, Any] = {} self.payload: dict[str, Any] = {}
self.status_payload: dict[str, Any] = {}
self.status_error = ""
self.rows: list[dict[str, Any]] = [] self.rows: list[dict[str, Any]] = []
self.row_by_key: dict[str, 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...") status.update("refreshing...")
try: try:
self.payload = fetch_payload(api_url, timeout, refresh=refresh) self.payload = fetch_payload(api_url, timeout, refresh=refresh)
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
self.render_payload() 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: except Exception as exc:
status.update(f"error: {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"{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"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"{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() error = str(raw.get("error") or "").strip()
if error: if error:
@@ -415,6 +528,7 @@ def run_textual(api_url: str, refresh_seconds: int, timeout: int) -> int:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI") parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI")
parser.add_argument("--api-url", default=default_api_url()) 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( parser.add_argument(
"--refresh-seconds", "--refresh-seconds",
type=int, type=int,
@@ -429,9 +543,10 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
if args.once: 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 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__": if __name__ == "__main__":
+73
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import io
from pathlib import Path from pathlib import Path
import importlib.util import importlib.util
import sys import sys
@@ -134,6 +136,77 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
self.assertEqual([row["name"] for row in rows], ["sfast", "fast", "slow", "manual"]) self.assertEqual([row["name"] for row in rows], ["sfast", "fast", "slow", "manual"])
def test_monitor_summary_and_account_match(self) -> None:
mod = load_module()
status_payload = {
"channel_monitors": {
"enabled": 6,
"latest_ok": 5,
"latest_failed": 0,
"latest_unknown": 1,
"latest_checked_max": "2026-06-09 15:07:40+08:00",
"items": [
{
"name": "input responses",
"provider": "openai",
"enabled": True,
"primary_model": "gpt-5.5",
"latest_status": "operational",
"latency_ms": 2606,
"checked_at": "2026-06-09 15:03:28+08:00",
"message": "",
},
{
"name": "kedaya responses",
"provider": "openai",
"enabled": True,
"primary_model": "gpt-5.5",
"latest_status": "degraded",
"latency_ms": 25923,
"checked_at": "2026-06-09 15:07:40+08:00",
"message": "slow response: 25923ms",
},
],
}
}
row = {"name": "input 300"}
self.assertIn("monitors 5 ok / 0 failed / 1 unknown", mod.monitor_summary(status_payload))
self.assertIn("monitor operational 2606ms", mod.monitor_detail(row, status_payload))
def test_monitor_error_is_non_blocking_in_once_output(self) -> None:
mod = load_module()
payload = {
"generated_at": "2026-06-09T12:00:00+08:00",
"totals": {"total_accounts": 1, "usable_accounts": 1, "today_cost_usd": 0, "today_tokens": 0, "today_requests": 0},
"accounts": [
{"id": 1, "name": "alpha", "routing_group": "slow", "provider": "openai", "kind": "pay_as_you_go", "status": "ok"}
],
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
mod.print_once(payload, status_error="timeout")
text = out.getvalue()
self.assertIn("monitors error: timeout", text)
self.assertIn("alpha", text)
def test_once_output_includes_monitor_summary(self) -> None:
mod = load_module()
payload = {
"generated_at": "2026-06-09T12:00:00+08:00",
"totals": {"total_accounts": 0, "usable_accounts": 0, "today_cost_usd": 0, "today_tokens": 0, "today_requests": 0},
"accounts": [],
}
status_payload = {"channel_monitors": {"enabled": 1, "latest_ok": 1, "latest_failed": 0, "latest_unknown": 0}}
out = io.StringIO()
with contextlib.redirect_stdout(out):
mod.print_once(payload, status_payload=status_payload)
self.assertIn("monitors 1 ok / 0 failed / 0 unknown", out.getvalue())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]] [[package]]
name = "shusub2" name = "shusub2"
version = "0.1.1" version = "0.1.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },