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

0
.dockerignore Normal file
View File

44
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View File

28
backend/app/api/deps.py Normal file
View 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")

View 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)

View 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)

View 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}

View 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

View 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
View 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

View File

@@ -0,0 +1,7 @@
import logging
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

View 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
View 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"])

View File

@@ -0,0 +1 @@
from app.core.db import Base

View 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")

View 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")

View 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)

View 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

View 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 = {}

View 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

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()

12
backend/requirements.txt Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE=http://localhost:8000
NEXT_PUBLIC_WS_BASE=ws://localhost:8000

24
frontend/Dockerfile Normal file
View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };

View File

View 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
View 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
View 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
)