857 lines
32 KiB
Python
857 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""Textual client for proxy-port-monitor and mihomo selector groups."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import Any, Iterator
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
|
|
APP_NAME = "proxy-port-monitor-tui"
|
|
DEFAULT_API_URL = "https://proxy.tailbeb9ad.ts.net"
|
|
DEFAULT_CONFIG_FILE = "~/.config/proxy-port-monitor-tui/api-url"
|
|
DEFAULT_REFRESH_SECONDS = 15
|
|
DEFAULT_TIMEOUT_SECONDS = 10
|
|
DEFAULT_TEST_URL = "https://www.gstatic.com/generate_204"
|
|
DEFAULT_TEST_TIMEOUT_MS = 5000
|
|
DEFAULT_DELAY_CONCURRENCY = 4
|
|
MAX_JSON_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
|
|
|
|
class ApiError(RuntimeError):
|
|
"""A bounded, user-facing API failure."""
|
|
|
|
|
|
def env_int(name: str, default: int, *, minimum: int = 1, maximum: int | None = None) -> int:
|
|
try:
|
|
value = int(os.environ.get(name, default))
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
value = max(minimum, value)
|
|
return min(value, maximum) if maximum is not None else value
|
|
|
|
|
|
def configured_url(env_names: tuple[str, ...], config_path: str, default: str) -> str:
|
|
for env_name in env_names:
|
|
value = os.environ.get(env_name, "").strip()
|
|
if value:
|
|
return value
|
|
path = Path(config_path).expanduser()
|
|
try:
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
value = line.strip()
|
|
if value and not value.startswith("#"):
|
|
return value
|
|
except OSError:
|
|
pass
|
|
return default
|
|
|
|
|
|
def default_api_url() -> str:
|
|
return configured_url(
|
|
("PROXY_MONITOR_TUI_API_URL",),
|
|
os.environ.get("PROXY_MONITOR_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE),
|
|
DEFAULT_API_URL,
|
|
)
|
|
|
|
|
|
def config_file_path() -> Path:
|
|
return Path(os.environ.get("PROXY_MONITOR_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser()
|
|
|
|
|
|
def write_api_url_config(api_url: str) -> Path:
|
|
value = normalize_base_url(api_url)
|
|
path = config_file_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
path.parent.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
path.write_text(value + "\n", encoding="utf-8")
|
|
try:
|
|
path.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
def normalize_base_url(value: str) -> str:
|
|
candidate = str(value or "").strip()
|
|
parsed = urllib.parse.urlparse(candidate)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("api url must be an absolute http(s) URL")
|
|
if parsed.params or parsed.query or parsed.fragment:
|
|
raise ValueError("api url must not include params, query, or fragment")
|
|
return urllib.parse.urlunparse(parsed._replace(path=parsed.path.rstrip("/")))
|
|
|
|
|
|
def validate_test_url(value: str) -> str:
|
|
candidate = str(value or "").strip()
|
|
parsed = urllib.parse.urlparse(candidate)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("test url must be an absolute http(s) URL")
|
|
return candidate
|
|
|
|
|
|
def as_mapping(value: object) -> dict[str, Any]:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def as_list(value: object) -> list[Any]:
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
def as_int(value: object) -> int | None:
|
|
if isinstance(value, bool):
|
|
return None
|
|
try:
|
|
return int(value) if value is not None and str(value).strip() else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def quote_path_component(value: object) -> str:
|
|
return urllib.parse.quote(str(value), safe="")
|
|
|
|
|
|
class ProxyMonitorClient:
|
|
"""Narrow client for the existing proxy-port-monitor API surface."""
|
|
|
|
def __init__(self, base_url: str, timeout_seconds: int) -> None:
|
|
self.base_url = normalize_base_url(base_url)
|
|
self.timeout_seconds = max(1, int(timeout_seconds))
|
|
self._base = urllib.parse.urlparse(self.base_url)
|
|
|
|
def endpoint(self, path: str, query: dict[str, object] | None = None) -> str:
|
|
normalized_path = "/" + str(path).lstrip("/")
|
|
base_path = self._base.path.rstrip("/")
|
|
encoded_query = urllib.parse.urlencode(query or {}, doseq=True)
|
|
return urllib.parse.urlunparse(
|
|
self._base._replace(path=base_path + normalized_path, query=encoded_query)
|
|
)
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
query: dict[str, object] | None = None,
|
|
payload: dict[str, object] | None = None,
|
|
expect_json: bool = True,
|
|
) -> Any:
|
|
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
self.endpoint(path, query),
|
|
data=body,
|
|
method=method,
|
|
headers={"Accept": "application/json", **({"Content-Type": "application/json"} if body else {})},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
|
try:
|
|
content_length = int(response.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
content_length = 0
|
|
if content_length > MAX_JSON_RESPONSE_BYTES:
|
|
raise ApiError("response exceeds 8 MiB limit")
|
|
raw = response.read(MAX_JSON_RESPONSE_BYTES + 1)
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
detail = exc.read(1024).decode("utf-8", errors="replace").strip()
|
|
except Exception:
|
|
detail = ""
|
|
suffix = f": {detail}" if detail else ""
|
|
raise ApiError(f"HTTP {exc.code} {exc.reason}{suffix}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise ApiError(f"request failed: {exc.reason}") from exc
|
|
except OSError as exc:
|
|
raise ApiError(f"request failed: {exc}") from exc
|
|
|
|
if not expect_json:
|
|
return None
|
|
if len(raw) > MAX_JSON_RESPONSE_BYTES:
|
|
raise ApiError("response exceeds 8 MiB limit")
|
|
if not raw:
|
|
raise ApiError("response did not contain JSON")
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ApiError("response did not contain valid JSON") from exc
|
|
|
|
def fetch_status(self) -> dict[str, Any]:
|
|
payload = self._request("GET", "/status.json")
|
|
if not isinstance(payload, dict):
|
|
raise ApiError("status response must be an object")
|
|
return payload
|
|
|
|
def fetch_proxies(self, target_name: str) -> dict[str, Any]:
|
|
payload = self._request(
|
|
"GET",
|
|
f"/admin/{quote_path_component(target_name)}/api/proxies",
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise ApiError("proxies response must be an object")
|
|
return payload
|
|
|
|
def measure_delay(
|
|
self,
|
|
target_name: str,
|
|
proxy_name: str,
|
|
*,
|
|
test_url: str,
|
|
timeout_ms: int,
|
|
) -> int | None:
|
|
payload = self._request(
|
|
"GET",
|
|
f"/admin/{quote_path_component(target_name)}/api/proxies/{quote_path_component(proxy_name)}/delay",
|
|
query={"url": test_url, "timeout": max(100, int(timeout_ms))},
|
|
)
|
|
delay = as_int(as_mapping(payload).get("delay"))
|
|
return delay if delay is not None and delay > 0 else None
|
|
|
|
def select_proxy(self, target_name: str, group_name: str, proxy_name: str) -> None:
|
|
self._request(
|
|
"PUT",
|
|
f"/admin/{quote_path_component(target_name)}/api/proxies/{quote_path_component(group_name)}",
|
|
payload={"name": proxy_name},
|
|
expect_json=False,
|
|
)
|
|
|
|
|
|
def normalize_machine_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for item in as_list(payload.get("targets")):
|
|
target = as_mapping(item)
|
|
name = str(target.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
ok = target.get("ok")
|
|
if ok is True:
|
|
state = "online"
|
|
elif ok is False:
|
|
state = "offline"
|
|
else:
|
|
state = "unknown"
|
|
rows.append(
|
|
{
|
|
"name": name,
|
|
"state": state,
|
|
"ok": ok is True,
|
|
"detail": str(target.get("detail") or "-"),
|
|
"checked_at": str(target.get("checked_at") or "-"),
|
|
"consecutive_failures": as_int(target.get("consecutive_failures")) or 0,
|
|
"exit_country": str(target.get("exit_country") or "-"),
|
|
"exit_country_code": str(target.get("exit_country_code") or ""),
|
|
"note": str(target.get("note") or ""),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def normalize_selector_groups(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
groups: list[dict[str, Any]] = []
|
|
for raw_name, raw_proxy in as_mapping(payload.get("proxies")).items():
|
|
proxy = as_mapping(raw_proxy)
|
|
if str(proxy.get("type") or "").casefold() != "selector":
|
|
continue
|
|
candidates: list[str] = []
|
|
for candidate in as_list(proxy.get("all")):
|
|
value = str(candidate or "").strip()
|
|
if value and value not in candidates:
|
|
candidates.append(value)
|
|
if not candidates:
|
|
continue
|
|
groups.append(
|
|
{
|
|
"name": str(raw_name),
|
|
"now": str(proxy.get("now") or ""),
|
|
"candidates": candidates,
|
|
}
|
|
)
|
|
return sorted(groups, key=lambda item: str(item["name"]).casefold())
|
|
|
|
|
|
def recommend_fastest(delay_results: dict[str, tuple[int | None, str]]) -> str | None:
|
|
successful = [
|
|
(delay, name)
|
|
for name, (delay, _detail) in delay_results.items()
|
|
if delay is not None and delay > 0
|
|
]
|
|
return min(successful)[1] if successful else None
|
|
|
|
|
|
def iter_measure_candidates(
|
|
client: ProxyMonitorClient,
|
|
target_name: str,
|
|
candidates: list[str],
|
|
*,
|
|
test_url: str,
|
|
timeout_ms: int,
|
|
concurrency: int,
|
|
) -> Iterator[tuple[str, tuple[int | None, str]]]:
|
|
"""Yield each candidate result as soon as its controller request finishes."""
|
|
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
|
|
|
|
def measure(candidate: str) -> tuple[int | None, str]:
|
|
try:
|
|
delay = client.measure_delay(
|
|
target_name,
|
|
candidate,
|
|
test_url=test_url,
|
|
timeout_ms=timeout_ms,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return None, str(exc)
|
|
return (delay, f"{delay} ms") if delay is not None else (None, "no delay result")
|
|
|
|
with ThreadPoolExecutor(max_workers=max(1, min(int(concurrency), len(unique_candidates) or 1))) as executor:
|
|
futures = {executor.submit(measure, candidate): candidate for candidate in unique_candidates}
|
|
for future in as_completed(futures):
|
|
candidate = futures[future]
|
|
try:
|
|
yield candidate, future.result()
|
|
except Exception as exc: # noqa: BLE001
|
|
yield candidate, (None, str(exc))
|
|
|
|
|
|
def measure_candidates(
|
|
client: ProxyMonitorClient,
|
|
target_name: str,
|
|
candidates: list[str],
|
|
*,
|
|
test_url: str,
|
|
timeout_ms: int,
|
|
concurrency: int,
|
|
) -> dict[str, tuple[int | None, str]]:
|
|
"""Collect all candidate results for non-interactive callers."""
|
|
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
|
|
results = dict(
|
|
iter_measure_candidates(
|
|
client,
|
|
target_name,
|
|
unique_candidates,
|
|
test_url=test_url,
|
|
timeout_ms=timeout_ms,
|
|
concurrency=concurrency,
|
|
)
|
|
)
|
|
|
|
return {candidate: results.get(candidate, (None, "not run")) for candidate in unique_candidates}
|
|
|
|
|
|
def format_machine_exit(row: dict[str, Any]) -> str:
|
|
code = str(row.get("exit_country_code") or "").strip()
|
|
country = str(row.get("exit_country") or "-").strip()
|
|
return f"{country} {code}".strip()
|
|
|
|
|
|
def print_once(payload: dict[str, Any]) -> None:
|
|
rows = normalize_machine_rows(payload)
|
|
up = as_int(payload.get("targets_up"))
|
|
total = as_int(payload.get("targets_total"))
|
|
print(f"proxy targets: {up if up is not None else '-'} / {total if total is not None else len(rows)}")
|
|
print("name state detail exit checked failures")
|
|
for row in rows:
|
|
print(
|
|
f"{row['name'][:15]:<15} "
|
|
f"{row['state']:<8} "
|
|
f"{row['detail'][:22]:<22} "
|
|
f"{format_machine_exit(row)[:13]:<13} "
|
|
f"{row['checked_at'][:20]:<20} "
|
|
f"{row['consecutive_failures']}"
|
|
)
|
|
|
|
|
|
def run_textual(
|
|
client: ProxyMonitorClient,
|
|
*,
|
|
refresh_seconds: int,
|
|
test_url: str,
|
|
test_timeout_ms: int,
|
|
delay_concurrency: int,
|
|
) -> int:
|
|
try:
|
|
from textual import work
|
|
from textual.app import App, ComposeResult
|
|
from textual.screen import ModalScreen, Screen
|
|
from textual.widgets import DataTable, Footer, Header, Static
|
|
except ImportError:
|
|
print(
|
|
"Textual is required. Run with: uv run --with textual python proxy_monitor_tui.py",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
class SwitchConfirmation(ModalScreen):
|
|
CSS = """
|
|
SwitchConfirmation {
|
|
align: center middle;
|
|
}
|
|
#switch-confirmation {
|
|
width: 72;
|
|
height: auto;
|
|
padding: 2 3;
|
|
border: tall $warning;
|
|
background: $surface;
|
|
}
|
|
"""
|
|
BINDINGS = [
|
|
("y", "confirm", "Confirm"),
|
|
("n", "cancel", "Cancel"),
|
|
("escape", "cancel", "Cancel"),
|
|
]
|
|
|
|
def __init__(self, target_name: str, group_name: str, current_name: str, next_name: str) -> None:
|
|
super().__init__()
|
|
self.message = (
|
|
"Runtime-only mihomo selector change\n\n"
|
|
f"machine: {target_name}\n"
|
|
f"group: {group_name}\n"
|
|
f"current: {current_name or '-'}\n"
|
|
f"next: {next_name}\n\n"
|
|
"Press y to switch, or n / Esc to cancel."
|
|
)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Static(self.message, id="switch-confirmation")
|
|
|
|
def action_confirm(self) -> None:
|
|
self.dismiss(True)
|
|
|
|
def action_cancel(self) -> None:
|
|
self.dismiss(False)
|
|
|
|
class MachineScreen(Screen):
|
|
CSS = """
|
|
#machine-summary, #machine-operation {
|
|
height: 2;
|
|
padding: 0 1;
|
|
}
|
|
#groups {
|
|
height: 1fr;
|
|
}
|
|
#nodes {
|
|
height: 1fr;
|
|
}
|
|
"""
|
|
BINDINGS = [
|
|
("escape", "back", "Back"),
|
|
("r", "reload_groups", "Reload"),
|
|
("t", "test_group", "Test delays"),
|
|
("s", "switch_node", "Switch node"),
|
|
("q", "quit", "Quit"),
|
|
]
|
|
|
|
def __init__(self, target: dict[str, Any]) -> None:
|
|
super().__init__()
|
|
self.target = target
|
|
self.groups_by_name: dict[str, dict[str, Any]] = {}
|
|
self.delay_results: dict[str, dict[str, tuple[int | None, str]]] = {}
|
|
self.delay_test_runs: dict[str, int] = {}
|
|
self.selected_group = ""
|
|
self.selected_node = ""
|
|
self.pending_switch: tuple[str, str] | None = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header(show_clock=True)
|
|
yield Static("", id="machine-summary")
|
|
yield DataTable(id="groups")
|
|
yield DataTable(id="nodes")
|
|
yield Static("", id="machine-operation")
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
groups = self.query_one("#groups", DataTable)
|
|
groups.cursor_type = "row"
|
|
groups.zebra_stripes = True
|
|
groups.add_columns("Selector group", "Current", "Candidates")
|
|
nodes = self.query_one("#nodes", DataTable)
|
|
nodes.cursor_type = "row"
|
|
nodes.zebra_stripes = True
|
|
nodes.add_columns("Candidate", "Current", "Delay", "Recommendation")
|
|
self.query_one("#machine-summary", Static).update(
|
|
f"{self.target['name']} | {self.target['state']} | "
|
|
f"{self.target['detail']} | exit {format_machine_exit(self.target)}"
|
|
)
|
|
self.load_groups()
|
|
|
|
def _set_operation(self, message: str) -> None:
|
|
self.query_one("#machine-operation", Static).update(message)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def load_groups(self) -> None:
|
|
try:
|
|
groups = normalize_selector_groups(client.fetch_proxies(str(self.target["name"])))
|
|
except Exception as exc: # noqa: BLE001
|
|
self.app.call_from_thread(self._set_operation, f"controller error: {exc}")
|
|
return
|
|
self.app.call_from_thread(self._finish_groups, groups, self.selected_group)
|
|
|
|
def _finish_groups(self, groups: list[dict[str, Any]], preferred_group: str = "") -> None:
|
|
self.groups_by_name = {str(group["name"]): group for group in groups}
|
|
table = self.query_one("#groups", DataTable)
|
|
table.clear()
|
|
for group in groups:
|
|
table.add_row(
|
|
str(group["name"]),
|
|
str(group.get("now") or "-"),
|
|
str(len(as_list(group.get("candidates")))),
|
|
key=str(group["name"]),
|
|
)
|
|
if not groups:
|
|
self.selected_group = ""
|
|
self.selected_node = ""
|
|
self.query_one("#nodes", DataTable).clear()
|
|
self._set_operation("no Selector groups returned by this controller")
|
|
return
|
|
selected = preferred_group if preferred_group in self.groups_by_name else str(groups[0]["name"])
|
|
self._show_group(selected)
|
|
self._set_operation(
|
|
"Select a group and candidate. t tests delay; s opens a runtime-only switch confirmation."
|
|
)
|
|
|
|
def _show_group(self, group_name: str) -> None:
|
|
group = self.groups_by_name.get(group_name)
|
|
if not group:
|
|
return
|
|
previous_node = self.selected_node if self.selected_group == group_name else ""
|
|
self.selected_group = group_name
|
|
table = self.query_one("#nodes", DataTable)
|
|
table.clear()
|
|
results = self.delay_results.get(group_name, {})
|
|
recommended = recommend_fastest(results)
|
|
candidates = as_list(group.get("candidates"))
|
|
self.selected_node = (
|
|
previous_node
|
|
if previous_node in candidates
|
|
else (str(candidates[0]) if candidates else "")
|
|
)
|
|
for candidate in candidates:
|
|
node = str(candidate)
|
|
delay, detail = results.get(node, (None, "-"))
|
|
table.add_row(
|
|
node,
|
|
"yes" if node == str(group.get("now") or "") else "",
|
|
f"{delay} ms" if delay is not None else detail,
|
|
"recommended" if node == recommended else "",
|
|
key=node,
|
|
)
|
|
|
|
def on_data_table_row_highlighted(self, event: Any) -> None:
|
|
table_id = str(event.data_table.id or "")
|
|
key = str(event.row_key.value)
|
|
if table_id == "groups":
|
|
self._show_group(key)
|
|
elif table_id == "nodes":
|
|
self.selected_node = key
|
|
|
|
def action_reload_groups(self) -> None:
|
|
self._set_operation("loading selector groups...")
|
|
self.load_groups()
|
|
|
|
def action_test_group(self) -> None:
|
|
group = self.groups_by_name.get(self.selected_group)
|
|
if not group:
|
|
self._set_operation("choose a Selector group first")
|
|
return
|
|
group_name = str(group["name"])
|
|
candidates = list(dict.fromkeys(str(candidate) for candidate in as_list(group.get("candidates")) if candidate))
|
|
run_id = self.delay_test_runs.get(group_name, 0) + 1
|
|
self.delay_test_runs[group_name] = run_id
|
|
self.delay_results[group_name] = {}
|
|
self._show_group(group_name)
|
|
self._set_operation(
|
|
f"testing {len(candidates)} candidates; each completed delay appears immediately..."
|
|
)
|
|
self.test_selected_group(group_name, candidates, run_id)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def test_selected_group(self, group_name: str, candidates: list[str], run_id: int) -> None:
|
|
total = len(candidates)
|
|
for candidate, result in iter_measure_candidates(
|
|
client,
|
|
str(self.target["name"]),
|
|
candidates,
|
|
test_url=test_url,
|
|
timeout_ms=test_timeout_ms,
|
|
concurrency=delay_concurrency,
|
|
):
|
|
self.app.call_from_thread(
|
|
self._record_delay_result,
|
|
group_name,
|
|
run_id,
|
|
candidate,
|
|
result,
|
|
total,
|
|
)
|
|
self.app.call_from_thread(self._finish_delay_test, group_name, run_id)
|
|
|
|
def _record_delay_result(
|
|
self,
|
|
group_name: str,
|
|
run_id: int,
|
|
candidate: str,
|
|
result: tuple[int | None, str],
|
|
total: int,
|
|
) -> None:
|
|
if self.delay_test_runs.get(group_name) != run_id:
|
|
return
|
|
results = self.delay_results.setdefault(group_name, {})
|
|
results[candidate] = result
|
|
completed = len(results)
|
|
recommended = recommend_fastest(results)
|
|
if group_name == self.selected_group:
|
|
self._show_group(group_name)
|
|
delay, detail = result
|
|
latest = f"{delay} ms" if delay is not None else detail
|
|
self._set_operation(
|
|
f"testing {completed}/{total}; {candidate}: {latest}; "
|
|
f"best so far: {recommended or 'none'}"
|
|
)
|
|
|
|
def _finish_delay_test(
|
|
self,
|
|
group_name: str,
|
|
run_id: int,
|
|
) -> None:
|
|
if self.delay_test_runs.get(group_name) != run_id:
|
|
return
|
|
results = self.delay_results.get(group_name, {})
|
|
recommended = recommend_fastest(results)
|
|
if group_name == self.selected_group:
|
|
self._show_group(group_name)
|
|
self._set_operation(
|
|
f"delay test complete; recommendation: {recommended or 'no reachable candidate'}; "
|
|
"no node was switched."
|
|
)
|
|
|
|
def action_switch_node(self) -> None:
|
|
group = self.groups_by_name.get(self.selected_group)
|
|
node = self.selected_node
|
|
if not group or not node:
|
|
self._set_operation("choose a Selector group and candidate first")
|
|
return
|
|
current = str(group.get("now") or "")
|
|
if node == current:
|
|
self._set_operation("selected node is already active")
|
|
return
|
|
self.pending_switch = (str(group["name"]), node)
|
|
self.app.push_screen(
|
|
SwitchConfirmation(str(self.target["name"]), str(group["name"]), current, node),
|
|
self._handle_switch_confirmation,
|
|
)
|
|
|
|
def _handle_switch_confirmation(self, confirmed: object) -> None:
|
|
pending = self.pending_switch
|
|
self.pending_switch = None
|
|
if not confirmed or not pending:
|
|
self._set_operation("switch cancelled")
|
|
return
|
|
group_name, node_name = pending
|
|
self._set_operation(f"switching {group_name} to {node_name}...")
|
|
self.switch_selected_node(group_name, node_name)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def switch_selected_node(self, group_name: str, node_name: str) -> None:
|
|
try:
|
|
client.select_proxy(str(self.target["name"]), group_name, node_name)
|
|
groups = normalize_selector_groups(client.fetch_proxies(str(self.target["name"])))
|
|
except Exception as exc: # noqa: BLE001
|
|
self.app.call_from_thread(self._set_operation, f"switch failed: {exc}")
|
|
return
|
|
self.app.call_from_thread(self._finish_switch, group_name, node_name, groups)
|
|
|
|
def _finish_switch(
|
|
self,
|
|
group_name: str,
|
|
node_name: str,
|
|
groups: list[dict[str, Any]],
|
|
) -> None:
|
|
selected = next((group for group in groups if group["name"] == group_name), {})
|
|
if str(as_mapping(selected).get("now") or "") != node_name:
|
|
self._set_operation(
|
|
f"switch request completed but controller reports {as_mapping(selected).get('now') or '-'}"
|
|
)
|
|
self._finish_groups(groups, group_name)
|
|
return
|
|
self._finish_groups(groups, group_name)
|
|
self._set_operation(f"switched {group_name} to {node_name}; verified by controller readback")
|
|
|
|
def action_back(self) -> None:
|
|
self.app.pop_screen()
|
|
|
|
class ProxyMonitorApp(App):
|
|
CSS = """
|
|
#summary, #status {
|
|
height: 2;
|
|
padding: 0 1;
|
|
}
|
|
#machines {
|
|
height: 1fr;
|
|
}
|
|
"""
|
|
BINDINGS = [
|
|
("q", "quit", "Quit"),
|
|
("r", "refresh", "Refresh"),
|
|
]
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.rows_by_name: dict[str, dict[str, Any]] = {}
|
|
self.selected_machine = ""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header(show_clock=True)
|
|
yield Static("", id="summary")
|
|
yield DataTable(id="machines")
|
|
yield Static("", id="status")
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
table = self.query_one("#machines", DataTable)
|
|
table.cursor_type = "row"
|
|
table.zebra_stripes = True
|
|
table.add_columns("Machine", "State", "17890 check", "Exit", "Checked", "Failures")
|
|
self.refresh_status()
|
|
self.set_interval(max(1, refresh_seconds), self.refresh_status)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def refresh_status(self) -> None:
|
|
try:
|
|
payload = client.fetch_status()
|
|
rows = normalize_machine_rows(payload)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.app.call_from_thread(self._set_status, f"status refresh failed: {exc}")
|
|
return
|
|
self.app.call_from_thread(self._finish_status, payload, rows)
|
|
|
|
def _set_status(self, message: str) -> None:
|
|
self.query_one("#status", Static).update(message)
|
|
|
|
def _finish_status(self, payload: dict[str, Any], rows: list[dict[str, Any]]) -> None:
|
|
self.rows_by_name = {str(row["name"]): row for row in rows}
|
|
table = self.query_one("#machines", DataTable)
|
|
table.clear()
|
|
for row in rows:
|
|
table.add_row(
|
|
str(row["name"]),
|
|
str(row["state"]),
|
|
str(row["detail"]),
|
|
format_machine_exit(row),
|
|
str(row["checked_at"]),
|
|
str(row["consecutive_failures"]),
|
|
key=str(row["name"]),
|
|
)
|
|
if rows and self.selected_machine not in self.rows_by_name:
|
|
self.selected_machine = str(rows[0]["name"])
|
|
up = payload.get("targets_up", "-")
|
|
total = payload.get("targets_total", len(rows))
|
|
self.query_one("#summary", Static).update(
|
|
f"proxy-port-monitor | reachable {up}/{total} | "
|
|
"Enter opens a reachable machine; r refreshes."
|
|
)
|
|
self.query_one("#status", Static).update(
|
|
f"source {client.base_url} | last summary: {payload.get('last_summary') or '-'}"
|
|
)
|
|
|
|
def on_data_table_row_highlighted(self, event: Any) -> None:
|
|
if str(event.data_table.id or "") == "machines":
|
|
self.selected_machine = str(event.row_key.value)
|
|
|
|
def on_data_table_row_selected(self, event: Any) -> None:
|
|
if str(event.data_table.id or "") == "machines":
|
|
self.selected_machine = str(event.row_key.value)
|
|
self.action_open_machine()
|
|
|
|
def action_refresh(self) -> None:
|
|
self.query_one("#status", Static).update("refreshing...")
|
|
self.refresh_status()
|
|
|
|
def action_open_machine(self) -> None:
|
|
target = self.rows_by_name.get(self.selected_machine)
|
|
if not target:
|
|
self.query_one("#status", Static).update("choose a machine first")
|
|
return
|
|
if not target.get("ok"):
|
|
self.query_one("#status", Static).update(
|
|
f"{target['name']} is not reachable; controller actions are disabled"
|
|
)
|
|
return
|
|
self.push_screen(MachineScreen(target))
|
|
|
|
ProxyMonitorApp().run()
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Proxy Port Monitor Textual TUI")
|
|
parser.add_argument("--api-url", default=default_api_url(), help="proxy-port-monitor base URL")
|
|
parser.add_argument("--save-config", action="store_true", help="save --api-url to local config before starting")
|
|
parser.add_argument(
|
|
"--refresh-seconds",
|
|
type=int,
|
|
default=env_int("PROXY_MONITOR_TUI_REFRESH_SECONDS", DEFAULT_REFRESH_SECONDS),
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=env_int("PROXY_MONITOR_TUI_TIMEOUT", DEFAULT_TIMEOUT_SECONDS),
|
|
help="HTTP API timeout in seconds",
|
|
)
|
|
parser.add_argument(
|
|
"--test-url",
|
|
default=os.environ.get("PROXY_MONITOR_TUI_TEST_URL", DEFAULT_TEST_URL),
|
|
help="mihomo delay probe URL",
|
|
)
|
|
parser.add_argument(
|
|
"--test-timeout-ms",
|
|
type=int,
|
|
default=env_int(
|
|
"PROXY_MONITOR_TUI_TEST_TIMEOUT_MS",
|
|
DEFAULT_TEST_TIMEOUT_MS,
|
|
minimum=100,
|
|
),
|
|
help="mihomo delay timeout in milliseconds",
|
|
)
|
|
parser.add_argument(
|
|
"--delay-concurrency",
|
|
type=int,
|
|
default=env_int(
|
|
"PROXY_MONITOR_TUI_DELAY_CONCURRENCY",
|
|
DEFAULT_DELAY_CONCURRENCY,
|
|
maximum=16,
|
|
),
|
|
)
|
|
parser.add_argument("--once", action="store_true", help="print the connection snapshot and exit")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
api_url = normalize_base_url(args.api_url)
|
|
test_url = validate_test_url(args.test_url)
|
|
if args.save_config:
|
|
print(f"saved api url to {write_api_url_config(api_url)}")
|
|
client = ProxyMonitorClient(api_url, max(1, args.timeout))
|
|
if args.once:
|
|
print_once(client.fetch_status())
|
|
return 0
|
|
return run_textual(
|
|
client,
|
|
refresh_seconds=max(1, args.refresh_seconds),
|
|
test_url=test_url,
|
|
test_timeout_ms=max(100, args.test_timeout_ms),
|
|
delay_concurrency=max(1, min(16, args.delay_concurrency)),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|