platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from api_client import (
|
||||
CmdResult,
|
||||
EgressIdentity,
|
||||
EgressIdentityRefreshResult,
|
||||
LoginSessionAction,
|
||||
LoginSessionStart,
|
||||
LoginSessionState,
|
||||
LoginState,
|
||||
VpnLocation,
|
||||
VpnLocationsState,
|
||||
VpnStatus,
|
||||
)
|
||||
|
||||
from .views import ActionView, LoginAction, LoginFlowView, VpnAutoconnectView, VpnStatusView
|
||||
|
||||
|
||||
class VpnControllerMixin:
|
||||
def vpn_locations_view(self) -> List[VpnLocation]:
|
||||
return self.client.vpn_locations()
|
||||
|
||||
def vpn_locations_state_view(self) -> VpnLocationsState:
|
||||
return self.client.vpn_locations_state()
|
||||
|
||||
def vpn_locations_refresh_trigger(self) -> None:
|
||||
self.client.vpn_locations_refresh_trigger()
|
||||
|
||||
def vpn_status_view(self) -> VpnStatusView:
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_status_model(self) -> VpnStatus:
|
||||
return self.client.vpn_status()
|
||||
|
||||
# --- autoconnect / autoloop ---
|
||||
|
||||
def _autoconnect_from_auto(self, auto) -> bool:
|
||||
"""
|
||||
Вытаскиваем True/False из ответа /vpn/autoloop/status.
|
||||
|
||||
Приоритет:
|
||||
1) явное поле auto.enabled (bool)
|
||||
2) эвристика по status_word / raw_text
|
||||
"""
|
||||
enabled_field = getattr(auto, "enabled", None)
|
||||
if isinstance(enabled_field, bool):
|
||||
return enabled_field
|
||||
|
||||
word = (getattr(auto, "status_word", "") or "").strip().lower()
|
||||
raw = (getattr(auto, "raw_text", "") or "").lower()
|
||||
|
||||
# приоритет — явные статусы
|
||||
if word in (
|
||||
"active",
|
||||
"running",
|
||||
"enabled",
|
||||
"on",
|
||||
"up",
|
||||
"started",
|
||||
"ok",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return True
|
||||
if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"):
|
||||
return False
|
||||
|
||||
# фоллбек — по raw_text
|
||||
if "inactive" in raw or "disabled" in raw or "failed" in raw:
|
||||
return False
|
||||
if "active" in raw or "running" in raw or "enabled" in raw:
|
||||
return True
|
||||
return False
|
||||
|
||||
def vpn_autoconnect_view(self) -> VpnAutoconnectView:
|
||||
try:
|
||||
auto = self.client.vpn_autoloop_status()
|
||||
except Exception as e:
|
||||
return VpnAutoconnectView(
|
||||
enabled=False,
|
||||
unit_text=f"unit: ERROR ({e})",
|
||||
color="red",
|
||||
)
|
||||
|
||||
enabled = self._autoconnect_from_auto(auto)
|
||||
|
||||
unit_state = (
|
||||
getattr(auto, "unit_state", "") # если backend так отдаёт
|
||||
or (auto.status_word or "")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
text = f"unit: {unit_state}"
|
||||
|
||||
low = f"{unit_state} {(auto.raw_text or '')}".lower()
|
||||
if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")):
|
||||
color = "red"
|
||||
elif "active" in low or "running" in low or "enabled" in low:
|
||||
color = "green"
|
||||
else:
|
||||
color = "orange"
|
||||
|
||||
return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color)
|
||||
|
||||
def vpn_autoconnect_enabled(self) -> bool:
|
||||
"""Старый интерфейс — оставляем для кнопки toggle."""
|
||||
return self.vpn_autoconnect_view().enabled
|
||||
|
||||
def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView:
|
||||
res = self.client.vpn_autoconnect(enable)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_cmd_then_status(res, st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> VpnStatusView:
|
||||
self.client.vpn_set_location(target=target, iso=iso, label=label)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def egress_identity(self, scope: str, *, refresh: bool = False) -> EgressIdentity:
|
||||
return self.client.egress_identity_get(scope, refresh=refresh)
|
||||
|
||||
def egress_identity_refresh(
|
||||
self,
|
||||
*,
|
||||
scopes: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> EgressIdentityRefreshResult:
|
||||
return self.client.egress_identity_refresh(scopes=scopes, force=force)
|
||||
|
||||
def _pretty_vpn_status(self, st: VpnStatus) -> str:
|
||||
lines = [
|
||||
f"unit_state: {st.unit_state}",
|
||||
f"desired_location: {st.desired_location or '—'}",
|
||||
f"status: {st.status_word}",
|
||||
]
|
||||
if st.raw_text:
|
||||
lines.append("")
|
||||
lines.append(st.raw_text.strip())
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
# -------- Login Flow (interactive) --------
|
||||
|
||||
def login_flow_start(self) -> LoginFlowView:
|
||||
s: LoginSessionStart = self.client.vpn_login_session_start()
|
||||
|
||||
dot = self._level_to_color(s.level)
|
||||
|
||||
if not s.ok:
|
||||
txt = s.error or "Failed to start login session"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "failed",
|
||||
level=s.level or "red",
|
||||
dot_color="red",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
if (s.phase or "").lower() == "already_logged":
|
||||
txt = (
|
||||
f"Already logged in as {s.email}"
|
||||
if s.email
|
||||
else "Already logged in"
|
||||
)
|
||||
return LoginFlowView(
|
||||
phase="already_logged",
|
||||
level="green",
|
||||
dot_color="green",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email=s.email or "",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
txt = f"Login started (pid={s.pid})" if s.pid else "Login started"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "starting",
|
||||
level=s.level or "yellow",
|
||||
dot_color=dot,
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=True,
|
||||
cursor=0,
|
||||
lines=[],
|
||||
can_open=True,
|
||||
can_check=True,
|
||||
can_cancel=True,
|
||||
)
|
||||
|
||||
def login_flow_poll(self, since: int) -> LoginFlowView:
|
||||
st: LoginSessionState = self.client.vpn_login_session_state(since=since)
|
||||
|
||||
dot = self._level_to_color(st.level)
|
||||
|
||||
phase = (st.phase or "").lower()
|
||||
if phase == "waiting_browser":
|
||||
status_txt = "Waiting for browser authorization…"
|
||||
elif phase == "checking":
|
||||
status_txt = "Checking…"
|
||||
elif phase == "success":
|
||||
status_txt = "✅ Logged in"
|
||||
elif phase == "failed":
|
||||
status_txt = "❌ Login failed"
|
||||
elif phase == "cancelled":
|
||||
status_txt = "Cancelled"
|
||||
elif phase == "already_logged":
|
||||
status_txt = (
|
||||
f"Already logged in as {st.email}"
|
||||
if st.email
|
||||
else "Already logged in"
|
||||
)
|
||||
else:
|
||||
status_txt = st.phase or "…"
|
||||
|
||||
clean_lines = self._clean_login_lines(st.lines)
|
||||
|
||||
return LoginFlowView(
|
||||
phase=st.phase,
|
||||
level=st.level,
|
||||
dot_color=dot,
|
||||
status_text=status_txt,
|
||||
url=st.url,
|
||||
email=st.email,
|
||||
alive=st.alive,
|
||||
cursor=st.cursor,
|
||||
can_open=st.can_open,
|
||||
can_check=st.can_cancel,
|
||||
can_cancel=st.can_cancel,
|
||||
lines=clean_lines,
|
||||
)
|
||||
|
||||
def login_flow_action(self, action: str) -> ActionView:
|
||||
act = action.strip().lower()
|
||||
if act not in ("open", "check", "cancel"):
|
||||
raise ValueError(f"Invalid login action: {action}")
|
||||
|
||||
res: LoginSessionAction = self.client.vpn_login_session_action(
|
||||
cast(LoginAction, act)
|
||||
)
|
||||
|
||||
if not res.ok:
|
||||
txt = res.error or "Login action failed"
|
||||
return ActionView(ok=False, pretty_text=txt + "\n")
|
||||
|
||||
txt = f"OK: {act} → phase={res.phase} level={res.level}"
|
||||
return ActionView(ok=True, pretty_text=txt + "\n")
|
||||
|
||||
def login_flow_stop(self) -> ActionView:
|
||||
res = self.client.vpn_login_session_stop()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def vpn_logout(self) -> ActionView:
|
||||
res = self.client.vpn_logout()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
# Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI
|
||||
def login_banner_cli_text(self) -> str:
|
||||
try:
|
||||
st: LoginState = self.client.get_login_state()
|
||||
except Exception as e:
|
||||
return f"Failed to query login state: {e}"
|
||||
|
||||
# backend может не иметь поля error, поэтому через getattr
|
||||
err = getattr(st, "error", None) or getattr(st, "message", None)
|
||||
if err:
|
||||
return str(err)
|
||||
|
||||
if st.email:
|
||||
return f"You are already logged in.\nCurrent user is {st.email}"
|
||||
|
||||
if st.state:
|
||||
return f"Login state: {st.state}"
|
||||
|
||||
return "No login information available."
|
||||
|
||||
# -------- Routes --------
|
||||
|
||||
Reference in New Issue
Block a user