first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-04 15:21:03 +01:00
commit 5429f176c9
53 changed files with 3808 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# Szkielet do dopisania.
# Zalecane: librouteros (synch) w executorze lub biblioteka async jeśli użyjesz.
from typing import Any, Dict, List
from app.services.mikrotik.client_base import MikroTikClient
class MikroTikAPIClient(MikroTikClient):
def __init__(self, host: str, port: int, username: str, password: str, use_ssl: bool = False):
self.host = host
self.port = port
self.username = username
self.password = password
self.use_ssl = use_ssl
async def list_interfaces(self) -> List[Dict[str, Any]]:
raise NotImplementedError("API list_interfaces not implemented")
async def monitor_traffic_once(self, iface: str) -> Dict[str, Any]:
raise NotImplementedError("API monitor_traffic_once not implemented")

View File

@@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, List
class MikroTikClient(ABC):
@abstractmethod
async def list_interfaces(self) -> List[Dict[str, Any]]:
...
@abstractmethod
async def monitor_traffic_once(self, iface: str) -> Dict[str, Any]:
...

View File

@@ -0,0 +1,62 @@
import json
from typing import Any, Dict, List
import httpx
from app.services.mikrotik.client_base import MikroTikClient
def parse_bps(value: Any) -> float:
if value is None:
return 0.0
s = str(value).strip().lower().replace(" ", "")
if s == "":
return 0.0
try:
return float(s)
except ValueError:
pass
multipliers = {"bps": 1.0, "kbps": 1_000.0, "mbps": 1_000_000.0, "gbps": 1_000_000_000.0}
for unit, mul in multipliers.items():
if s.endswith(unit):
num = s[: -len(unit)]
try:
return float(num) * mul
except ValueError:
return 0.0
return 0.0
class MikroTikRESTClient(MikroTikClient):
def __init__(self, base_url: str, username: str, password: str, verify_ssl: bool):
self.base = base_url.rstrip("/") + "/rest"
self.auth = (username, password)
self.verify = verify_ssl
async def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base,
auth=self.auth,
verify=self.verify,
timeout=httpx.Timeout(10.0),
headers={"Content-Type": "application/json"},
)
async def list_interfaces(self) -> List[Dict[str, Any]]:
params = {".proplist": "name,type,disabled,running"}
async with await self._client() as c:
r = await c.get("/interface", params=params)
r.raise_for_status()
data = r.json()
if isinstance(data, dict):
return [data]
return data
async def monitor_traffic_once(self, iface: str) -> Dict[str, Any]:
payload = {"interface": iface, "once": ""}
async with await self._client() as c:
r = await c.post("/interface/monitor-traffic", content=json.dumps(payload))
r.raise_for_status()
data = r.json()
if isinstance(data, list) and data:
return data[0]
if isinstance(data, dict):
return data
return {}

View File

@@ -0,0 +1,19 @@
# Szkielet do dopisania.
# Zalecane: asyncssh + komenda:
# /interface/monitor-traffic interface=ether1 once
# i parsowanie wyjścia.
from typing import Any, Dict, List
from app.services.mikrotik.client_base import MikroTikClient
class MikroTikSSHClient(MikroTikClient):
def __init__(self, host: str, port: int, username: str, password: str):
self.host = host
self.port = port
self.username = username
self.password = password
async def list_interfaces(self) -> List[Dict[str, Any]]:
raise NotImplementedError("SSH list_interfaces not implemented")
async def monitor_traffic_once(self, iface: str) -> Dict[str, Any]:
raise NotImplementedError("SSH monitor_traffic_once not implemented")

View File

@@ -0,0 +1,54 @@
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decrypt_secret
from app.models.router import Router, RouterCredential, CredentialMethod, RouterMethod
from app.services.mikrotik.client_rest import MikroTikRESTClient
from app.services.mikrotik.client_ssh import MikroTikSSHClient
from app.services.mikrotik.client_api import MikroTikAPIClient
async def build_client(session: AsyncSession, router_id: int):
rres = await session.execute(select(Router).where(Router.id == router_id))
router = rres.scalar_one_or_none()
if not router:
return None
cres = await session.execute(select(RouterCredential).where(RouterCredential.router_id == router_id))
creds = cres.scalars().all()
# wybór: preferowana metoda albo auto: REST -> API -> SSH
order = []
pref = router.preferred_method
if pref == RouterMethod.REST:
order = [CredentialMethod.REST]
elif pref == RouterMethod.API:
order = [CredentialMethod.API]
elif pref == RouterMethod.SSH:
order = [CredentialMethod.SSH]
else:
order = [CredentialMethod.REST, CredentialMethod.API, CredentialMethod.SSH]
cred_by_method = {c.method: c for c in creds}
for m in order:
c = cred_by_method.get(m)
if not c:
continue
secret = decrypt_secret(c.secret_encrypted)
extra = {}
try:
extra = json.loads(c.extra_json or "{}")
except Exception:
extra = {}
if m == CredentialMethod.REST:
base_url = f"https://{router.host}:{router.port_rest}"
if extra.get("scheme") in ("http", "https"):
base_url = f"{extra['scheme']}://{router.host}:{router.port_rest}"
return MikroTikRESTClient(base_url, c.username, secret, router.verify_ssl)
if m == CredentialMethod.SSH:
return MikroTikSSHClient(router.host, router.port_ssh, c.username, secret)
if m == CredentialMethod.API:
return MikroTikAPIClient(router.host, router.port_api, c.username, secret, use_ssl=bool(extra.get("ssl", False)))
return None

View File

@@ -0,0 +1,25 @@
import asyncio
from typing import Dict, Set, Any
class Hub:
def __init__(self):
self._lock = asyncio.Lock()
self._subs: Dict[int, Set[Any]] = {} # panel_id -> set[WebSocket]
async def subscribe(self, panel_id: int, ws):
async with self._lock:
self._subs.setdefault(panel_id, set()).add(ws)
async def unsubscribe(self, panel_id: int, ws):
async with self._lock:
s = self._subs.get(panel_id)
if s and ws in s:
s.remove(ws)
if s and len(s) == 0:
self._subs.pop(panel_id, None)
async def connections(self, panel_id: int):
async with self._lock:
return list(self._subs.get(panel_id, set()))
hub = Hub()

View File

@@ -0,0 +1,98 @@
import asyncio
import json
import logging
from typing import Dict, Any, Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.dashboard import DashboardPanel
from app.services.mikrotik.factory import build_client
from app.services.mikrotik.client_rest import parse_bps
from app.services.streaming.hub import hub
log = logging.getLogger("poller")
class PanelPoller:
def __init__(self):
self._tasks: Dict[int, asyncio.Task] = {}
self._lock = asyncio.Lock()
async def ensure_running(self, panel_id: int, session: AsyncSession):
async with self._lock:
t = self._tasks.get(panel_id)
if t and not t.done():
return
self._tasks[panel_id] = asyncio.create_task(self._run(panel_id, session))
async def _run(self, panel_id: int, session: AsyncSession):
# UWAGA: session jest z zewnątrz; do prostoty używamy jednego session per WS.
# W prod lepiej robić osobne sesje w pętli / użyć SessionLocal.
while True:
conns = await hub.connections(panel_id)
if not conns:
# nikt nie subskrybuje -> zakończ
async with self._lock:
self._tasks.pop(panel_id, None)
return
pres = await session.execute(select(DashboardPanel).where(DashboardPanel.id == panel_id))
panel = pres.scalar_one_or_none()
if not panel:
await asyncio.sleep(1)
continue
cfg = {}
try:
cfg = json.loads(panel.config_json or "{}")
except Exception:
cfg = {}
interfaces: List[str] = [str(x) for x in (cfg.get("interfaces") or [])]
metrics: List[str] = [str(x) for x in (cfg.get("metrics") or ["rx_bps","tx_bps"])]
interval_ms = int(cfg.get("interval_ms") or settings.DEFAULT_POLL_INTERVAL_MS)
interval_ms = max(250, min(interval_ms, 5000))
client = await build_client(session, panel.router_id)
if not client:
payload = {"type":"error","message":"No client/credentials for router"}
for ws in conns:
try: await ws.send_text(json.dumps(payload))
except Exception: pass
await asyncio.sleep(interval_ms/1000)
continue
# jeśli brak interfaces, pobierz i ogranicz (żeby nie zabić routera)
if not interfaces:
try:
ifs = await client.list_interfaces()
interfaces = [i.get("name") for i in ifs if i.get("name")][:10]
except Exception:
interfaces = []
ts = int(asyncio.get_event_loop().time() * 1000)
rows = []
for iface in interfaces:
try:
raw = await client.monitor_traffic_once(iface)
row = {"iface": iface, "ts": ts}
# RouterOS REST typowo ma rx-bits-per-second / tx-bits-per-second
rx = parse_bps(raw.get("rx-bits-per-second"))
tx = parse_bps(raw.get("tx-bits-per-second"))
row["rx_bps"] = rx
row["tx_bps"] = tx
rows.append(row)
except Exception as e:
log.info("poll error panel=%s iface=%s err=%s", panel_id, iface, e)
msg = {"type":"traffic","panelId": panel_id, "data": rows, "metrics": metrics}
for ws in conns:
try:
await ws.send_text(json.dumps(msg))
except Exception:
pass
await asyncio.sleep(interval_ms / 1000)
panel_poller = PanelPoller()