first commit
This commit is contained in:
0
.dockerignore
Normal file
0
.dockerignore
Normal file
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# ---- OS / IDE ----
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# ---- Env ----
|
||||
**/.env
|
||||
**/.env.local
|
||||
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyd
|
||||
*.so
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.log
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
mt_traffic.db
|
||||
|
||||
# ---- Node / Next ----
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.cache/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# ---- Docker ----
|
||||
docker-compose.override.yml
|
||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# mt-traffic (MikroTik Live Traffic)
|
||||
|
||||
Nowoczesna aplikacja webowa do monitoringu ruchu na interfejsach MikroTik (RouterOS v7) z dashboardami i wykresami live.
|
||||
|
||||
## Szybki start (dev, bez Dockera)
|
||||
|
||||
Wymagania:
|
||||
- Python 3.11+
|
||||
- Node 18+ / 20+
|
||||
- npm
|
||||
|
||||
1) Backend
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
2) Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Front: http://localhost:3000
|
||||
- API: http://localhost:8000
|
||||
|
||||
## Dev script (uruchamia oba)
|
||||
```bash
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
## Produkcja (Docker Compose)
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.example frontend/.env.local
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
- Front: http://localhost:3000
|
||||
- API: http://localhost:8000
|
||||
|
||||
## Logowanie
|
||||
Domyślny admin jest tworzony przy starcie, jeśli nie ma użytkowników:
|
||||
- email: admin@example.com
|
||||
- hasło: admin1234
|
||||
|
||||
Zmień to od razu w .env (ADMIN_BOOTSTRAP_*).
|
||||
|
||||
## MikroTik
|
||||
Aplikacja wspiera metody połączeń:
|
||||
- REST (/rest) – zaimplementowane
|
||||
- SSH – szkielet (do dopisania)
|
||||
- API (8728/8729) – szkielet (do dopisania)
|
||||
|
||||
W panelu dodaj router i poświadczenia (admin), nadaj uprawnienia userom.
|
||||
25
backend/.env.example
Normal file
25
backend/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
APP_ENV=dev
|
||||
APP_NAME=mt-traffic
|
||||
BASE_URL=http://localhost:8000
|
||||
|
||||
# SQLite: w dev może być w repo, w dockerze zapisujemy do /data
|
||||
DATABASE_URL=sqlite+aiosqlite:///./mt_traffic.db
|
||||
DOCKER_DATABASE_URL=sqlite+aiosqlite:////data/mt_traffic.db
|
||||
|
||||
# Security
|
||||
SESSION_SECRET=change_me_long_random
|
||||
CREDENTIALS_MASTER_KEY=change_me_32bytes_base64_fernet
|
||||
PASSWORD_HASH_ALG=argon2
|
||||
|
||||
# Cookies
|
||||
COOKIE_SECURE=false
|
||||
COOKIE_SAMESITE=lax
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# Polling
|
||||
DEFAULT_POLL_INTERVAL_MS=1000
|
||||
MAX_WS_SUBSCRIPTIONS_PER_USER=10
|
||||
|
||||
# Bootstrap admin
|
||||
ADMIN_BOOTSTRAP_EMAIL=admin@example.com
|
||||
ADMIN_BOOTSTRAP_PASSWORD=admin1234
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
# Data dir for sqlite
|
||||
RUN mkdir -p /data
|
||||
ENV DATABASE_URL=sqlite+aiosqlite:////data/mt_traffic.db
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
28
backend/app/api/deps.py
Normal file
28
backend/app/api/deps.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.core.security import read_session_token, SESSION_COOKIE
|
||||
from app.models.user import User
|
||||
|
||||
async def db_session() -> AsyncSession:
|
||||
async for s in get_session():
|
||||
yield s
|
||||
|
||||
async def get_current_user(request: Request, session: AsyncSession = Depends(db_session)) -> User:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = read_session_token(token)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
res = await session.execute(select(User).where(User.id == uid))
|
||||
user = res.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
return user
|
||||
|
||||
def require_admin(user: User) -> None:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
57
backend/app/api/routes_auth.py
Normal file
57
backend/app/api/routes_auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends, Response, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import db_session, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
verify_password, create_session_token, new_csrf_token,
|
||||
SESSION_COOKIE, CSRF_COOKIE
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.user import LoginIn, UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, response: Response, session: AsyncSession = Depends(db_session)):
|
||||
res = await session.execute(select(User).where(User.email == payload.email))
|
||||
user = res.scalar_one_or_none()
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Bad credentials")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Inactive")
|
||||
|
||||
token = create_session_token(user.id)
|
||||
csrf = new_csrf_token()
|
||||
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=settings.COOKIE_SECURE,
|
||||
samesite=settings.COOKIE_SAMESITE,
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
# CSRF token czytelny dla JS (nie httponly)
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
httponly=False,
|
||||
secure=settings.COOKIE_SECURE,
|
||||
samesite=settings.COOKIE_SAMESITE,
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return UserOut(id=user.id, email=user.email, role=user.role)
|
||||
70
backend/app/api/routes_dashboards.py
Normal file
70
backend/app/api/routes_dashboards.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import db_session, get_current_user
|
||||
from app.api.routes_routers import require_csrf
|
||||
from app.models.user import User
|
||||
from app.models.dashboard import Dashboard, DashboardPanel, Permission
|
||||
from app.models.router import Router
|
||||
from app.schemas.dashboard import DashboardCreate, DashboardOut, PanelCreate, PanelOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=list[DashboardOut])
|
||||
async def list_dashboards(user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
res = await session.execute(select(Dashboard).where(Dashboard.owner_user_id == user.id))
|
||||
rows = res.scalars().all()
|
||||
return [DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared) for d in rows]
|
||||
|
||||
@router.post("", response_model=DashboardOut)
|
||||
async def create_dashboard(payload: DashboardCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
require_csrf(request)
|
||||
d = Dashboard(owner_user_id=user.id, name=payload.name, is_shared=payload.is_shared)
|
||||
session.add(d)
|
||||
await session.commit()
|
||||
await session.refresh(d)
|
||||
return DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared)
|
||||
|
||||
@router.get("/{dashboard_id}")
|
||||
async def get_dashboard(dashboard_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
res = await session.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
|
||||
d = res.scalar_one_or_none()
|
||||
if not d or d.owner_user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
pres = await session.execute(select(DashboardPanel).where(DashboardPanel.dashboard_id == d.id))
|
||||
panels = pres.scalars().all()
|
||||
out_panels = []
|
||||
for p in panels:
|
||||
out_panels.append(PanelOut(
|
||||
id=p.id, dashboard_id=p.dashboard_id, title=p.title,
|
||||
router_id=p.router_id, config=json.loads(p.config_json or "{}")
|
||||
).model_dump())
|
||||
return {"dashboard": DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared).model_dump(), "panels": out_panels}
|
||||
|
||||
@router.post("/{dashboard_id}/panels", response_model=PanelOut)
|
||||
async def create_panel(dashboard_id: int, payload: PanelCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
require_csrf(request)
|
||||
# dashboard ownership
|
||||
res = await session.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
|
||||
d = res.scalar_one_or_none()
|
||||
if not d or d.owner_user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# check permission to router
|
||||
if user.role != "admin":
|
||||
pres = await session.execute(select(Permission).where(Permission.user_id == user.id, Permission.router_id == payload.router_id, Permission.can_view == True)) # noqa
|
||||
if not pres.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="No access to router")
|
||||
|
||||
p = DashboardPanel(
|
||||
dashboard_id=dashboard_id,
|
||||
title=payload.title,
|
||||
router_id=payload.router_id,
|
||||
config_json=json.dumps(payload.config or {}),
|
||||
)
|
||||
session.add(p)
|
||||
await session.commit()
|
||||
await session.refresh(p)
|
||||
return PanelOut(id=p.id, dashboard_id=p.dashboard_id, title=p.title, router_id=p.router_id, config=payload.config)
|
||||
93
backend/app/api/routes_routers.py
Normal file
93
backend/app/api/routes_routers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.api.deps import db_session, get_current_user, require_admin
|
||||
from app.core.security import encrypt_secret, CSRF_COOKIE
|
||||
from app.models.user import User
|
||||
from app.models.router import Router, RouterCredential, CredentialMethod
|
||||
from app.models.dashboard import Permission
|
||||
from app.schemas.router import RouterCreate, RouterOut, CredentialCreate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def require_csrf(request: Request):
|
||||
# minimalny CSRF: nagłówek X-CSRF-Token musi równać się cookie mt_csrf
|
||||
cookie = request.cookies.get(CSRF_COOKIE)
|
||||
header = request.headers.get("X-CSRF-Token")
|
||||
if not cookie or not header or cookie != header:
|
||||
raise HTTPException(status_code=403, detail="CSRF check failed")
|
||||
|
||||
@router.get("", response_model=list[RouterOut])
|
||||
async def list_routers(user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
if user.role == "admin":
|
||||
res = await session.execute(select(Router))
|
||||
rows = res.scalars().all()
|
||||
else:
|
||||
res = await session.execute(
|
||||
select(Router).join(Permission, Permission.router_id == Router.id)
|
||||
.where(Permission.user_id == user.id, Permission.can_view == True) # noqa
|
||||
)
|
||||
rows = res.scalars().all()
|
||||
return [RouterOut(
|
||||
id=r.id, name=r.name, host=r.host,
|
||||
port_rest=r.port_rest, port_ssh=r.port_ssh, port_api=r.port_api,
|
||||
verify_ssl=r.verify_ssl, preferred_method=r.preferred_method,
|
||||
tags=r.tags
|
||||
) for r in rows]
|
||||
|
||||
@router.post("", response_model=RouterOut)
|
||||
async def create_router(payload: RouterCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
require_admin(user)
|
||||
require_csrf(request)
|
||||
r = Router(**payload.model_dump())
|
||||
session.add(r)
|
||||
await session.commit()
|
||||
await session.refresh(r)
|
||||
return RouterOut(
|
||||
id=r.id, name=r.name, host=r.host,
|
||||
port_rest=r.port_rest, port_ssh=r.port_ssh, port_api=r.port_api,
|
||||
verify_ssl=r.verify_ssl, preferred_method=r.preferred_method, tags=r.tags
|
||||
)
|
||||
|
||||
@router.post("/{router_id}/credentials")
|
||||
async def add_credential(router_id: int, payload: CredentialCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
require_admin(user)
|
||||
require_csrf(request)
|
||||
res = await session.execute(select(Router).where(Router.id == router_id))
|
||||
r = res.scalar_one_or_none()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Router not found")
|
||||
|
||||
method = payload.method.lower()
|
||||
if method not in ("rest", "ssh", "api"):
|
||||
raise HTTPException(status_code=400, detail="Bad method")
|
||||
|
||||
c = RouterCredential(
|
||||
router_id=router_id,
|
||||
method=CredentialMethod(method),
|
||||
username=payload.username,
|
||||
secret_encrypted=encrypt_secret(payload.secret),
|
||||
extra_json=json.dumps(payload.extra_json or {}),
|
||||
)
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/{router_id}/grant")
|
||||
async def grant_router(router_id: int, target_user_id: int, can_edit: bool = False, request: Request = None,
|
||||
user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
|
||||
require_admin(user)
|
||||
require_csrf(request)
|
||||
# upsert permission
|
||||
res = await session.execute(select(Permission).where(Permission.user_id == target_user_id, Permission.router_id == router_id))
|
||||
p = res.scalar_one_or_none()
|
||||
if not p:
|
||||
p = Permission(user_id=target_user_id, router_id=router_id, can_view=True, can_edit=can_edit)
|
||||
session.add(p)
|
||||
else:
|
||||
p.can_view = True
|
||||
p.can_edit = bool(can_edit)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
75
backend/app/api/routes_stream.py
Normal file
75
backend/app/api/routes_stream.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import db_session
|
||||
from app.core.security import read_session_token, SESSION_COOKIE
|
||||
from app.models.user import User
|
||||
from app.models.dashboard import DashboardPanel, Permission, Dashboard
|
||||
from app.services.streaming.hub import hub
|
||||
from app.services.streaming.poller import panel_poller
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def ws_current_user(ws: WebSocket, session: AsyncSession) -> User | None:
|
||||
token = ws.cookies.get(SESSION_COOKIE)
|
||||
uid = read_session_token(token) if token else None
|
||||
if not uid:
|
||||
return None
|
||||
res = await session.execute(select(User).where(User.id == uid))
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
@router.websocket("/ws/stream")
|
||||
async def ws_stream(websocket: WebSocket, session: AsyncSession = Depends(db_session)):
|
||||
await websocket.accept()
|
||||
user = await ws_current_user(websocket, session)
|
||||
if not user or not user.is_active:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
subscribed_panel_id = None
|
||||
try:
|
||||
# pierwszy msg: {"panelId": 123}
|
||||
first = await websocket.receive_text()
|
||||
msg = json.loads(first)
|
||||
panel_id = int(msg.get("panelId"))
|
||||
subscribed_panel_id = panel_id
|
||||
|
||||
pres = await session.execute(select(DashboardPanel).where(DashboardPanel.id == panel_id))
|
||||
panel = pres.scalar_one_or_none()
|
||||
if not panel:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
# dashboard ownership lub (w przyszłości) shared
|
||||
dres = await session.execute(select(Dashboard).where(Dashboard.id == panel.dashboard_id))
|
||||
dash = dres.scalar_one_or_none()
|
||||
if not dash or dash.owner_user_id != user.id:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
# permission do routera
|
||||
if user.role != "admin":
|
||||
pr = await session.execute(select(Permission).where(Permission.user_id == user.id, Permission.router_id == panel.router_id, Permission.can_view == True)) # noqa
|
||||
if not pr.scalar_one_or_none():
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await hub.subscribe(panel_id, websocket)
|
||||
await panel_poller.ensure_running(panel_id, session)
|
||||
|
||||
# keepalive loop: klient może wysyłać ping
|
||||
while True:
|
||||
_ = await websocket.receive_text()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if subscribed_panel_id is not None:
|
||||
try:
|
||||
await hub.unsubscribe(subscribed_panel_id, websocket)
|
||||
except Exception:
|
||||
pass
|
||||
27
backend/app/core/config.py
Normal file
27
backend/app/core/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
APP_ENV: str = "dev"
|
||||
APP_NAME: str = "mt-traffic"
|
||||
BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./mt_traffic.db"
|
||||
DOCKER_DATABASE_URL: str = "sqlite+aiosqlite:////data/mt_traffic.db"
|
||||
|
||||
SESSION_SECRET: str = "change_me"
|
||||
CREDENTIALS_MASTER_KEY: str = "change_me_32bytes_base64_fernet"
|
||||
PASSWORD_HASH_ALG: str = "argon2"
|
||||
|
||||
COOKIE_SECURE: bool = False
|
||||
COOKIE_SAMESITE: str = "lax"
|
||||
CORS_ORIGINS: str = "http://localhost:3000"
|
||||
|
||||
DEFAULT_POLL_INTERVAL_MS: int = 1000
|
||||
MAX_WS_SUBSCRIPTIONS_PER_USER: int = 10
|
||||
|
||||
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
|
||||
ADMIN_BOOTSTRAP_PASSWORD: str = "admin1234"
|
||||
|
||||
settings = Settings()
|
||||
24
backend/app/core/db.py
Normal file
24
backend/app/core/db.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
def _db_url() -> str:
|
||||
# w dockerze zwykle ustawiamy DATABASE_URL przez env, więc preferujemy settings.DATABASE_URL
|
||||
return settings.DATABASE_URL
|
||||
|
||||
engine = create_async_engine(_db_url(), future=True, echo=False)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
async def init_db() -> None:
|
||||
from app.models import user, router, dashboard # noqa
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
7
backend/app/core/logging.py
Normal file
7
backend/app/core/logging.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
68
backend/app/core/security.py
Normal file
68
backend/app/core/security.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
def session_serializer() -> URLSafeTimedSerializer:
|
||||
return URLSafeTimedSerializer(settings.SESSION_SECRET, salt="session")
|
||||
|
||||
SESSION_COOKIE = "mt_session"
|
||||
CSRF_COOKIE = "mt_csrf"
|
||||
|
||||
def create_session_token(user_id: int) -> str:
|
||||
s = session_serializer()
|
||||
return s.dumps({"uid": user_id})
|
||||
|
||||
def read_session_token(token: str, max_age_seconds: int = 60 * 60 * 24 * 7) -> Optional[int]:
|
||||
s = session_serializer()
|
||||
try:
|
||||
data = s.loads(token, max_age=max_age_seconds)
|
||||
uid = int(data.get("uid"))
|
||||
return uid
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
|
||||
def new_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def fernet() -> Fernet:
|
||||
# CREDENTIALS_MASTER_KEY powinien być base64 urlsafe 32 bytes
|
||||
key = settings.CREDENTIALS_MASTER_KEY.encode("utf-8")
|
||||
return Fernet(key)
|
||||
|
||||
def encrypt_secret(plain: str) -> str:
|
||||
return fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
|
||||
|
||||
def decrypt_secret(enc: str) -> str:
|
||||
return fernet().decrypt(enc.encode("utf-8")).decode("utf-8")
|
||||
|
||||
async def bootstrap_admin_if_needed(session: AsyncSession) -> None:
|
||||
res = await session.execute(select(User).limit(1))
|
||||
first = res.scalar_one_or_none()
|
||||
if first:
|
||||
return
|
||||
admin = User(
|
||||
email=settings.ADMIN_BOOTSTRAP_EMAIL,
|
||||
password_hash=hash_password(settings.ADMIN_BOOTSTRAP_PASSWORD),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
45
backend/app/main.py
Normal file
45
backend/app/main.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.db import init_db, get_session
|
||||
from app.core.security import bootstrap_admin_if_needed
|
||||
from app.api.routes_auth import router as auth_router
|
||||
from app.api.routes_routers import router as routers_router
|
||||
from app.api.routes_dashboards import router as dashboards_router
|
||||
from app.api.routes_stream import router as stream_router
|
||||
|
||||
setup_logging()
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await init_db()
|
||||
async for session in get_session():
|
||||
await bootstrap_admin_if_needed(session)
|
||||
break
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"ok": True}
|
||||
|
||||
# 204 na favicon (ucisza spam)
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
return Response(status_code=204)
|
||||
|
||||
app.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||
app.include_router(routers_router, prefix="/api/routers", tags=["routers"])
|
||||
app.include_router(dashboards_router, prefix="/api/dashboards", tags=["dashboards"])
|
||||
app.include_router(stream_router, tags=["stream"])
|
||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.core.db import Base
|
||||
31
backend/app/models/dashboard.py
Normal file
31
backend/app/models/dashboard.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
router_id: Mapped[int] = mapped_column(ForeignKey("routers.id", ondelete="CASCADE"), primary_key=True)
|
||||
can_view: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
can_edit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class Dashboard(Base):
|
||||
__tablename__ = "dashboards"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class DashboardPanel(Base):
|
||||
__tablename__ = "dashboard_panels"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
dashboard_id: Mapped[int] = mapped_column(ForeignKey("dashboards.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
router_id: Mapped[int] = mapped_column(ForeignKey("routers.id", ondelete="CASCADE"), index=True)
|
||||
# config_json: {"interfaces":["ether1"],"metrics":["rx_bps","tx_bps"],"interval_ms":1000,"window":120}
|
||||
config_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
||||
dashboard = relationship("Dashboard")
|
||||
41
backend/app/models/router.py
Normal file
41
backend/app/models/router.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, Enum as SAEnum, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
class RouterMethod(str, Enum):
|
||||
AUTO = "auto"
|
||||
REST = "rest"
|
||||
SSH = "ssh"
|
||||
API = "api"
|
||||
|
||||
class Router(Base):
|
||||
__tablename__ = "routers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(200), unique=True, index=True)
|
||||
host: Mapped[str] = mapped_column(String(255))
|
||||
port_rest: Mapped[int] = mapped_column(Integer, default=443)
|
||||
port_ssh: Mapped[int] = mapped_column(Integer, default=22)
|
||||
port_api: Mapped[int] = mapped_column(Integer, default=8728)
|
||||
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
preferred_method: Mapped[RouterMethod] = mapped_column(SAEnum(RouterMethod), default=RouterMethod.AUTO)
|
||||
tags: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
class CredentialMethod(str, Enum):
|
||||
REST = "rest"
|
||||
SSH = "ssh"
|
||||
API = "api"
|
||||
|
||||
class RouterCredential(Base):
|
||||
__tablename__ = "router_credentials"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
router_id: Mapped[int] = mapped_column(ForeignKey("routers.id", ondelete="CASCADE"), index=True)
|
||||
method: Mapped[CredentialMethod] = mapped_column(SAEnum(CredentialMethod))
|
||||
username: Mapped[str] = mapped_column(String(255))
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text)
|
||||
extra_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
||||
router = relationship("Router")
|
||||
18
backend/app/models/user.py
Normal file
18
backend/app/models/user.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Boolean, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
class UserRole(str, Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.USER)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
22
backend/app/schemas/dashboard.py
Normal file
22
backend/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class DashboardCreate(BaseModel):
|
||||
name: str
|
||||
is_shared: bool = False
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
is_shared: bool
|
||||
|
||||
class PanelCreate(BaseModel):
|
||||
title: str
|
||||
router_id: int
|
||||
config: dict # {"interfaces":[...],"metrics":[...],"interval_ms":1000,"window":120}
|
||||
|
||||
class PanelOut(BaseModel):
|
||||
id: int
|
||||
dashboard_id: int
|
||||
title: str
|
||||
router_id: int
|
||||
config: dict
|
||||
28
backend/app/schemas/router.py
Normal file
28
backend/app/schemas/router.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class RouterCreate(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port_rest: int = 443
|
||||
port_ssh: int = 22
|
||||
port_api: int = 8728
|
||||
verify_ssl: bool = False
|
||||
preferred_method: str = "auto"
|
||||
tags: str = ""
|
||||
|
||||
class RouterOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
host: str
|
||||
port_rest: int
|
||||
port_ssh: int
|
||||
port_api: int
|
||||
verify_ssl: bool
|
||||
preferred_method: str
|
||||
tags: str
|
||||
|
||||
class CredentialCreate(BaseModel):
|
||||
method: str # rest|ssh|api
|
||||
username: str
|
||||
secret: str
|
||||
extra_json: dict = {}
|
||||
10
backend/app/schemas/user.py
Normal file
10
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
role: str
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
18
backend/app/services/mikrotik/client_api.py
Normal file
18
backend/app/services/mikrotik/client_api.py
Normal 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")
|
||||
11
backend/app/services/mikrotik/client_base.py
Normal file
11
backend/app/services/mikrotik/client_base.py
Normal 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]:
|
||||
...
|
||||
62
backend/app/services/mikrotik/client_rest.py
Normal file
62
backend/app/services/mikrotik/client_rest.py
Normal 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 {}
|
||||
19
backend/app/services/mikrotik/client_ssh.py
Normal file
19
backend/app/services/mikrotik/client_ssh.py
Normal 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")
|
||||
54
backend/app/services/mikrotik/factory.py
Normal file
54
backend/app/services/mikrotik/factory.py
Normal 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
|
||||
25
backend/app/services/streaming/hub.py
Normal file
25
backend/app/services/streaming/hub.py
Normal 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()
|
||||
98
backend/app/services/streaming/poller.py
Normal file
98
backend/app/services/streaming/poller.py
Normal 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()
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
httpx==0.27.2
|
||||
SQLAlchemy==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.6.1
|
||||
python-dotenv==1.0.1
|
||||
passlib[argon2]==1.7.4
|
||||
cryptography==43.0.3
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.12
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- mt_traffic_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||
- NEXT_PUBLIC_WS_BASE=ws://localhost:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mt_traffic_data:
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||
NEXT_PUBLIC_WS_BASE=ws://localhost:8000
|
||||
24
frontend/Dockerfile
Normal file
24
frontend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json /app/
|
||||
RUN npm install
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules /app/node_modules
|
||||
COPY . /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/.next /app/.next
|
||||
COPY --from=builder /app/public /app/public
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm","run","start"]
|
||||
118
frontend/app/admin/page.tsx
Normal file
118
frontend/app/admin/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import { apiFetch, getCsrfFromCookie } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const [routers, setRouters] = useState<any[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// add router
|
||||
const [name, setName] = useState("r1");
|
||||
const [host, setHost] = useState("192.168.88.1");
|
||||
const [verify, setVerify] = useState(false);
|
||||
|
||||
// add credential
|
||||
const [routerId, setRouterId] = useState<number>(1);
|
||||
const [method, setMethod] = useState("rest");
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [secret, setSecret] = useState("");
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await apiFetch("/api/routers");
|
||||
setRouters(r);
|
||||
if (r?.length) setRouterId(r[0].id);
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function createRouter() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch("/api/routers", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({
|
||||
name, host, verify_ssl: verify, preferred_method: "auto",
|
||||
port_rest: 443, port_ssh: 22, port_api: 8728, tags: ""
|
||||
})
|
||||
});
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
async function addCred() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch(`/api/routers/${routerId}/credentials`, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ method, username, secret, extra_json: { scheme: "https" } })
|
||||
});
|
||||
setSecret("");
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="text-2xl font-semibold">Admin</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">Routery i poświadczenia (wymaga roli admin)</div>
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add router</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={name} onChange={e=>setName(e.target.value)} placeholder="name" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={host} onChange={e=>setHost(e.target.value)} placeholder="host/ip" />
|
||||
<label className="text-sm text-zinc-300 flex items-center gap-2">
|
||||
<input type="checkbox" checked={verify} onChange={e=>setVerify(e.target.checked)} />
|
||||
verify SSL
|
||||
</label>
|
||||
<button onClick={createRouter} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create router</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add credentials</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<select className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={routerId} onChange={e=>setRouterId(Number(e.target.value))}>
|
||||
{routers.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={method} onChange={e=>setMethod(e.target.value)}>
|
||||
<option value="rest">rest</option>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={username} onChange={e=>setUsername(e.target.value)} placeholder="username" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={secret} onChange={e=>setSecret(e.target.value)} placeholder="password/token" />
|
||||
<button onClick={addCred} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Save credentials</button>
|
||||
<div className="text-xs text-zinc-400">REST: ustaw scheme w extra_json (domyślnie https). SSH/API to szkielety.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Routers</div>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{routers.map(r => (
|
||||
<div key={r.id} className="flex items-center justify-between border border-zinc-800 rounded-lg p-3">
|
||||
<div>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<div className="text-xs text-zinc-400">{r.host} • pref: {r.preferred_method}</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">id: {r.id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
179
frontend/app/dashboards/[id]/page.tsx
Normal file
179
frontend/app/dashboards/[id]/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import TrafficChart from "@/components/TrafficChart";
|
||||
import { apiFetch, getCsrfFromCookie, WS_BASE } from "@/lib/api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Panel = { id:number; title:string; router_id:number; config:any };
|
||||
|
||||
export default function DashboardView({ params }: { params: { id: string } }) {
|
||||
const dashboardId = Number(params.id);
|
||||
const [panels, setPanels] = useState<Panel[]>([]);
|
||||
const [dashName, setDashName] = useState<string>("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// create panel form
|
||||
const [title, setTitle] = useState("WAN");
|
||||
const [routerId, setRouterId] = useState<number>(1);
|
||||
const [interfaces, setInterfaces] = useState<string>("ether1");
|
||||
const [metrics, setMetrics] = useState<string>("rx_bps,tx_bps");
|
||||
const [intervalMs, setIntervalMs] = useState<number>(1000);
|
||||
const [windowPts, setWindowPts] = useState<number>(120);
|
||||
|
||||
const [routers, setRouters] = useState<any[]>([]);
|
||||
const dataMap = useRef<Map<number, any[]>>(new Map()); // panelId -> points
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await apiFetch(`/api/dashboards/${dashboardId}`);
|
||||
setDashName(d.dashboard.name);
|
||||
setPanels(d.panels);
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
async function loadRouters() {
|
||||
try {
|
||||
const r = await apiFetch("/api/routers");
|
||||
setRouters(r);
|
||||
if (r?.length) setRouterId(r[0].id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); loadRouters(); }, [dashboardId]);
|
||||
|
||||
async function createPanel() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
const cfg = {
|
||||
interfaces: interfaces.split(",").map(s=>s.trim()).filter(Boolean),
|
||||
metrics: metrics.split(",").map(s=>s.trim()).filter(Boolean),
|
||||
interval_ms: intervalMs,
|
||||
window: windowPts
|
||||
};
|
||||
await apiFetch(`/api/dashboards/${dashboardId}/panels`, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ title, router_id: routerId, config: cfg })
|
||||
});
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">{dashName || "Dashboard"}</div>
|
||||
<div className="text-sm text-zinc-400">Live charts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add panel</div>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={title} onChange={e=>setTitle(e.target.value)} placeholder="title"/>
|
||||
<select className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={routerId} onChange={e=>setRouterId(Number(e.target.value))}>
|
||||
{routers.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={interfaces} onChange={e=>setInterfaces(e.target.value)} placeholder="interfaces: ether1,ether2"/>
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={metrics} onChange={e=>setMetrics(e.target.value)} placeholder="metrics: rx_bps,tx_bps"/>
|
||||
<button onClick={createPanel} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 md:grid-cols-5 gap-2 text-sm text-zinc-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">interval ms</span>
|
||||
<input className="w-28 px-2 py-1 rounded bg-zinc-900 border border-zinc-700" type="number" value={intervalMs} onChange={e=>setIntervalMs(Number(e.target.value))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">window pts</span>
|
||||
<input className="w-28 px-2 py-1 rounded bg-zinc-900 border border-zinc-700" type="number" value={windowPts} onChange={e=>setWindowPts(Number(e.target.value))}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-3">
|
||||
{panels.map(p => (
|
||||
<PanelCard key={p.id} panel={p} dataMapRef={dataMap} />
|
||||
))}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelCard({ panel, dataMapRef }: { panel: Panel; dataMapRef: any }) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const series = useMemo(() => {
|
||||
const cfg = panel.config || {};
|
||||
const ifs: string[] = cfg.interfaces || [];
|
||||
const ms: string[] = cfg.metrics || ["rx_bps","tx_bps"];
|
||||
const out: string[] = [];
|
||||
for (const i of (ifs.length ? ifs : ["*"])) {
|
||||
for (const m of ms) out.push(`${i}:${m}`);
|
||||
}
|
||||
return out;
|
||||
}, [panel]);
|
||||
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
const windowPts = Number(panel.config?.window || 120);
|
||||
|
||||
const ws = new WebSocket(`${WS_BASE.replace("http","ws")}/ws/stream`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ panelId: panel.id }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "traffic" && msg.panelId === panel.id) {
|
||||
const rows = msg.data || [];
|
||||
const now = new Date();
|
||||
const t = now.toLocaleTimeString();
|
||||
const points = (dataMapRef.current.get(panel.id) || []) as any[];
|
||||
|
||||
// multi-series: iface:metric
|
||||
const pt: any = { t };
|
||||
for (const r of rows) {
|
||||
const iface = r.iface;
|
||||
pt[`${iface}:rx_bps`] = Number(r.rx_bps || 0);
|
||||
pt[`${iface}:tx_bps`] = Number(r.tx_bps || 0);
|
||||
}
|
||||
points.push(pt);
|
||||
while (points.length > windowPts) points.shift();
|
||||
dataMapRef.current.set(panel.id, points);
|
||||
if (!stop) setData([...points]);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const ping = setInterval(() => {
|
||||
try { ws.send("ping"); } catch {}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
stop = true;
|
||||
clearInterval(ping);
|
||||
try { ws.close(); } catch {}
|
||||
};
|
||||
}, [panel.id]);
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{panel.title}</div>
|
||||
<div className="text-xs text-zinc-400">panelId: {panel.id} • routerId: {panel.router_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TrafficChart data={data} series={series.filter(s => s !== "*:rx_bps" && s !== "*:tx_bps")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/app/dashboards/page.tsx
Normal file
68
frontend/app/dashboards/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import { apiFetch, getCsrfFromCookie } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Dashboard = { id: number; name: string; is_shared: boolean };
|
||||
|
||||
export default function DashboardsPage() {
|
||||
const [items, setItems] = useState<Dashboard[]>([]);
|
||||
const [name, setName] = useState("Main");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await apiFetch("/api/dashboards");
|
||||
setItems(data);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function create() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch("/api/dashboards", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ name, is_shared: false })
|
||||
});
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">Dashboards</div>
|
||||
<div className="text-sm text-zinc-400">Twoje dashboardy</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
value={name} onChange={e=>setName(e.target.value)} />
|
||||
<button onClick={create} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{items.map(d => (
|
||||
<Link key={d.id} href={`/dashboards/${d.id}`} className="block p-4 rounded-xl border border-zinc-800 bg-zinc-950 hover:bg-zinc-900">
|
||||
<div className="font-medium">{d.name}</div>
|
||||
<div className="text-xs text-zinc-400 mt-1">id: {d.id}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
8
frontend/app/globals.css
Normal file
8
frontend/app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root { color-scheme: dark; }
|
||||
html, body { height: 100%; }
|
||||
body { @apply bg-zinc-950 text-zinc-100; }
|
||||
a { @apply text-zinc-100; }
|
||||
19
frontend/app/layout.tsx
Normal file
19
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "mt-traffic",
|
||||
description: "MikroTik live traffic dashboards"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pl" className="dark">
|
||||
<body>
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
frontend/app/login/page.tsx
Normal file
40
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const r = useRouter();
|
||||
const [email, setEmail] = useState("admin@example.com");
|
||||
const [password, setPassword] = useState("admin1234");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
try {
|
||||
await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) });
|
||||
r.push("/dashboards");
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-20">
|
||||
<div className="text-2xl font-semibold">Login</div>
|
||||
<form onSubmit={onSubmit} className="mt-6 space-y-3">
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
value={email} onChange={e=>setEmail(e.target.value)} placeholder="email" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="password" />
|
||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
||||
<button className="w-full px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Sign in</button>
|
||||
</form>
|
||||
<div className="mt-3 text-xs text-zinc-400">
|
||||
Domyślny admin: admin@example.com / admin1234
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/app/page.tsx
Normal file
14
frontend/app/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto pt-20">
|
||||
<div className="text-3xl font-bold">mt-traffic</div>
|
||||
<p className="mt-2 text-zinc-300">Live monitoring MikroTik z dashboardami.</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href="/login" className="px-4 py-2 rounded-lg bg-zinc-100 text-zinc-900">Login</Link>
|
||||
<Link href="/dashboards" className="px-4 py-2 rounded-lg border border-zinc-700">Dashboards</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/components/Shell.tsx
Normal file
36
frontend/components/Shell.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<{email:string, role:string} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch("/auth/me").then(setMe).catch(() => setMe(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<aside className="w-64 shrink-0 border-r border-zinc-800 min-h-screen p-4">
|
||||
<div className="text-lg font-semibold">mt-traffic</div>
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<Link className="block hover:text-white text-zinc-300" href="/dashboards">Dashboards</Link>
|
||||
<Link className="block hover:text-white text-zinc-300" href="/admin">Admin</Link>
|
||||
</div>
|
||||
<div className="mt-6 text-xs text-zinc-400">
|
||||
{me ? (
|
||||
<div>
|
||||
<div>{me.email}</div>
|
||||
<div className="uppercase">{me.role}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Not logged in</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/components/TrafficChart.tsx
Normal file
31
frontend/components/TrafficChart.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
type Point = { t: string } & Record<string, number>;
|
||||
|
||||
function fmtBps(v: number) {
|
||||
const units = ["bps","Kbps","Mbps","Gbps"];
|
||||
let val = v, i = 0;
|
||||
while (val >= 1000 && i < units.length-1) { val /= 1000; i++; }
|
||||
const digits = val < 10 && i > 0 ? 2 : 0;
|
||||
return `${val.toFixed(digits)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export default function TrafficChart({ data, series }: { data: Point[]; series: string[] }) {
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} tickFormatter={(v)=>fmtBps(Number(v))} />
|
||||
<Tooltip formatter={(v:any)=>fmtBps(Number(v))} />
|
||||
<Legend />
|
||||
{series.map((s) => (
|
||||
<Line key={s} type="monotone" dataKey={s} dot={false} strokeWidth={2} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/lib/api.ts
Normal file
26
frontend/lib/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000";
|
||||
export const WS_BASE = process.env.NEXT_PUBLIC_WS_BASE || "ws://localhost:8000";
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...opts,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
...(opts.headers || {}),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `HTTP ${res.status}`);
|
||||
}
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("application/json")) return res.json();
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export function getCsrfFromCookie(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const m = document.cookie.match(/(?:^|; )mt_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
17
frontend/next.config.js
Normal file
17
frontend/next.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
module.exports = nextConfig;
|
||||
1999
frontend/package-lock.json
generated
Normal file
1999
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mt-traffic-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.15",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"recharts": "2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.3.3",
|
||||
"@types/react": "19.2.14",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.4.47",
|
||||
"tailwindcss": "3.4.14",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
}
|
||||
1
frontend/postcss.config.js
Normal file
1
frontend/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
0
frontend/public/favicon.ico
Normal file
0
frontend/public/favicon.ico
Normal file
9
frontend/tailwind.config.ts
Normal file
9
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config;
|
||||
35
frontend/tsconfig.json
Normal file
35
frontend/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
30
scripts/dev.sh
Executable file
30
scripts/dev.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
echo "[dev] starting backend..."
|
||||
(
|
||||
cd "$ROOT_DIR/backend"
|
||||
if [ ! -f ".env" ]; then cp .env.example .env; fi
|
||||
python -m venv .venv >/dev/null 2>&1 || true
|
||||
source .venv/bin/activate
|
||||
pip install -q -r requirements.txt
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
) &
|
||||
BACK_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "[dev] stopping..."
|
||||
kill $BACK_PID >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[dev] starting frontend..."
|
||||
(
|
||||
cd "$ROOT_DIR/frontend"
|
||||
if [ ! -f ".env.local" ]; then cp .env.example .env.local; fi
|
||||
npm install >/dev/null
|
||||
npm run dev
|
||||
)
|
||||
Reference in New Issue
Block a user