Initial proxy port monitor TUI
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
#!/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
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raw = response.read(1_048_576)
|
||||
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 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 measure_candidates(
|
||||
client: ProxyMonitorClient,
|
||||
target_name: str,
|
||||
candidates: list[str],
|
||||
*,
|
||||
test_url: str,
|
||||
timeout_ms: int,
|
||||
concurrency: int,
|
||||
) -> dict[str, tuple[int | None, str]]:
|
||||
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
|
||||
results: dict[str, tuple[int | None, str]] = {}
|
||||
|
||||
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:
|
||||
results[candidate] = future.result()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results[candidate] = (None, str(exc))
|
||||
|
||||
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.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
|
||||
self._set_operation(f"testing {len(as_list(group.get('candidates')))} candidates...")
|
||||
self.test_selected_group(str(group["name"]), list(as_list(group.get("candidates"))))
|
||||
|
||||
@work(thread=True, exclusive=True)
|
||||
def test_selected_group(self, group_name: str, candidates: list[str]) -> None:
|
||||
results = 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._finish_delay_test, group_name, results)
|
||||
|
||||
def _finish_delay_test(
|
||||
self,
|
||||
group_name: str,
|
||||
results: dict[str, tuple[int | None, str]],
|
||||
) -> None:
|
||||
self.delay_results[group_name] = results
|
||||
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())
|
||||
Reference in New Issue
Block a user