From 5429f176c998e6566409c301f65a420f1364cdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 4 Mar 2026 15:21:03 +0100 Subject: [PATCH] first commit --- .dockerignore | 0 .gitignore | 44 + README.md | 61 + backend/.env.example | 25 + backend/Dockerfile | 21 + backend/app/__init__.py | 0 backend/app/api/deps.py | 28 + backend/app/api/routes_auth.py | 57 + backend/app/api/routes_dashboards.py | 70 + backend/app/api/routes_routers.py | 93 + backend/app/api/routes_stream.py | 75 + backend/app/core/config.py | 27 + backend/app/core/db.py | 24 + backend/app/core/logging.py | 7 + backend/app/core/security.py | 68 + backend/app/main.py | 45 + backend/app/models/__init__.py | 1 + backend/app/models/dashboard.py | 31 + backend/app/models/router.py | 41 + backend/app/models/user.py | 18 + backend/app/schemas/dashboard.py | 22 + backend/app/schemas/router.py | 28 + backend/app/schemas/user.py | 10 + backend/app/services/mikrotik/client_api.py | 18 + backend/app/services/mikrotik/client_base.py | 11 + backend/app/services/mikrotik/client_rest.py | 62 + backend/app/services/mikrotik/client_ssh.py | 19 + backend/app/services/mikrotik/factory.py | 54 + backend/app/services/streaming/hub.py | 25 + backend/app/services/streaming/poller.py | 98 + backend/requirements.txt | 12 + docker-compose.yml | 28 + frontend/.env.example | 2 + frontend/Dockerfile | 24 + frontend/app/admin/page.tsx | 118 ++ frontend/app/dashboards/[id]/page.tsx | 179 ++ frontend/app/dashboards/page.tsx | 68 + frontend/app/globals.css | 8 + frontend/app/layout.tsx | 19 + frontend/app/login/page.tsx | 40 + frontend/app/page.tsx | 14 + frontend/components/Shell.tsx | 36 + frontend/components/TrafficChart.tsx | 31 + frontend/lib/api.ts | 26 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 17 + frontend/package-lock.json | 1999 ++++++++++++++++++ frontend/package.json | 24 + frontend/postcss.config.js | 1 + frontend/public/favicon.ico | 0 frontend/tailwind.config.ts | 9 + frontend/tsconfig.json | 35 + scripts/dev.sh | 30 + 53 files changed, 3808 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routes_auth.py create mode 100644 backend/app/api/routes_dashboards.py create mode 100644 backend/app/api/routes_routers.py create mode 100644 backend/app/api/routes_stream.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/db.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/dashboard.py create mode 100644 backend/app/models/router.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/dashboard.py create mode 100644 backend/app/schemas/router.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/mikrotik/client_api.py create mode 100644 backend/app/services/mikrotik/client_base.py create mode 100644 backend/app/services/mikrotik/client_rest.py create mode 100644 backend/app/services/mikrotik/client_ssh.py create mode 100644 backend/app/services/mikrotik/factory.py create mode 100644 backend/app/services/streaming/hub.py create mode 100644 backend/app/services/streaming/poller.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/admin/page.tsx create mode 100644 frontend/app/dashboards/[id]/page.tsx create mode 100644 frontend/app/dashboards/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/Shell.tsx create mode 100644 frontend/components/TrafficChart.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100755 scripts/dev.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ecaf12 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf9e72f --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..798b6b9 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1e4579b --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..0a70c16 --- /dev/null +++ b/backend/app/api/deps.py @@ -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") diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py new file mode 100644 index 0000000..8215aaf --- /dev/null +++ b/backend/app/api/routes_auth.py @@ -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) diff --git a/backend/app/api/routes_dashboards.py b/backend/app/api/routes_dashboards.py new file mode 100644 index 0000000..2021a27 --- /dev/null +++ b/backend/app/api/routes_dashboards.py @@ -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) diff --git a/backend/app/api/routes_routers.py b/backend/app/api/routes_routers.py new file mode 100644 index 0000000..6521b17 --- /dev/null +++ b/backend/app/api/routes_routers.py @@ -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} diff --git a/backend/app/api/routes_stream.py b/backend/app/api/routes_stream.py new file mode 100644 index 0000000..90fe516 --- /dev/null +++ b/backend/app/api/routes_stream.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..36cd7a8 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..3782504 --- /dev/null +++ b/backend/app/core/db.py @@ -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 diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..4943775 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,7 @@ +import logging + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..f04e680 --- /dev/null +++ b/backend/app/core/security.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9273993 --- /dev/null +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..996db5b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +from app.core.db import Base diff --git a/backend/app/models/dashboard.py b/backend/app/models/dashboard.py new file mode 100644 index 0000000..ebefa3a --- /dev/null +++ b/backend/app/models/dashboard.py @@ -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") diff --git a/backend/app/models/router.py b/backend/app/models/router.py new file mode 100644 index 0000000..f7e6cfa --- /dev/null +++ b/backend/app/models/router.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..0bbb1bd --- /dev/null +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..0a6d746 --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -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 diff --git a/backend/app/schemas/router.py b/backend/app/schemas/router.py new file mode 100644 index 0000000..adc4b3a --- /dev/null +++ b/backend/app/schemas/router.py @@ -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 = {} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..400ee1e --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/mikrotik/client_api.py b/backend/app/services/mikrotik/client_api.py new file mode 100644 index 0000000..39ab7b7 --- /dev/null +++ b/backend/app/services/mikrotik/client_api.py @@ -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") diff --git a/backend/app/services/mikrotik/client_base.py b/backend/app/services/mikrotik/client_base.py new file mode 100644 index 0000000..3e0da73 --- /dev/null +++ b/backend/app/services/mikrotik/client_base.py @@ -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]: + ... diff --git a/backend/app/services/mikrotik/client_rest.py b/backend/app/services/mikrotik/client_rest.py new file mode 100644 index 0000000..91785a4 --- /dev/null +++ b/backend/app/services/mikrotik/client_rest.py @@ -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 {} diff --git a/backend/app/services/mikrotik/client_ssh.py b/backend/app/services/mikrotik/client_ssh.py new file mode 100644 index 0000000..153a812 --- /dev/null +++ b/backend/app/services/mikrotik/client_ssh.py @@ -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") diff --git a/backend/app/services/mikrotik/factory.py b/backend/app/services/mikrotik/factory.py new file mode 100644 index 0000000..dd370ef --- /dev/null +++ b/backend/app/services/mikrotik/factory.py @@ -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 diff --git a/backend/app/services/streaming/hub.py b/backend/app/services/streaming/hub.py new file mode 100644 index 0000000..d1bcef9 --- /dev/null +++ b/backend/app/services/streaming/hub.py @@ -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() diff --git a/backend/app/services/streaming/poller.py b/backend/app/services/streaming/poller.py new file mode 100644 index 0000000..d53e201 --- /dev/null +++ b/backend/app/services/streaming/poller.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f97ba0a --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b663232 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..9f1d258 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_BASE=http://localhost:8000 +NEXT_PUBLIC_WS_BASE=ws://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2e85651 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..2504531 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -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([]); + const [err, setErr] = useState(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(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 ( + +
Admin
+
Routery i poświadczenia (wymaga roli admin)
+ {err &&
{err}
} + +
+
+
Add router
+
+ setName(e.target.value)} placeholder="name" /> + setHost(e.target.value)} placeholder="host/ip" /> + + +
+
+ +
+
Add credentials
+
+ + + setUsername(e.target.value)} placeholder="username" /> + setSecret(e.target.value)} placeholder="password/token" /> + +
REST: ustaw scheme w extra_json (domyślnie https). SSH/API to szkielety.
+
+
+
+ +
+
Routers
+
+ {routers.map(r => ( +
+
+
{r.name}
+
{r.host} • pref: {r.preferred_method}
+
+
id: {r.id}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/app/dashboards/[id]/page.tsx b/frontend/app/dashboards/[id]/page.tsx new file mode 100644 index 0000000..c0f4245 --- /dev/null +++ b/frontend/app/dashboards/[id]/page.tsx @@ -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([]); + const [dashName, setDashName] = useState(""); + const [err, setErr] = useState(null); + + // create panel form + const [title, setTitle] = useState("WAN"); + const [routerId, setRouterId] = useState(1); + const [interfaces, setInterfaces] = useState("ether1"); + const [metrics, setMetrics] = useState("rx_bps,tx_bps"); + const [intervalMs, setIntervalMs] = useState(1000); + const [windowPts, setWindowPts] = useState(120); + + const [routers, setRouters] = useState([]); + const dataMap = useRef>(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 ( + +
+
+
{dashName || "Dashboard"}
+
Live charts
+
+
+ + {err &&
{err}
} + +
+
Add panel
+
+ setTitle(e.target.value)} placeholder="title"/> + + setInterfaces(e.target.value)} placeholder="interfaces: ether1,ether2"/> + setMetrics(e.target.value)} placeholder="metrics: rx_bps,tx_bps"/> + +
+
+
+ interval ms + setIntervalMs(Number(e.target.value))}/> +
+
+ window pts + setWindowPts(Number(e.target.value))}/> +
+
+
+ +
+ {panels.map(p => ( + + ))} +
+
+ ); +} + +function PanelCard({ panel, dataMapRef }: { panel: Panel; dataMapRef: any }) { + const [data, setData] = useState([]); + const wsRef = useRef(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 ( +
+
+
+
{panel.title}
+
panelId: {panel.id} • routerId: {panel.router_id}
+
+
+
+ s !== "*:rx_bps" && s !== "*:tx_bps")} /> +
+
+ ); +} diff --git a/frontend/app/dashboards/page.tsx b/frontend/app/dashboards/page.tsx new file mode 100644 index 0000000..2da541a --- /dev/null +++ b/frontend/app/dashboards/page.tsx @@ -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([]); + const [name, setName] = useState("Main"); + const [err, setErr] = useState(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 ( + +
+
+
Dashboards
+
Twoje dashboardy
+
+
+ setName(e.target.value)} /> + +
+
+ + {err &&
{err}
} + +
+ {items.map(d => ( + +
{d.name}
+
id: {d.id}
+ + ))} +
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..deffe83 --- /dev/null +++ b/frontend/app/globals.css @@ -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; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..4492c6e --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + +
+ {children} +
+ + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..2e1cee1 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -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(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 ( +
+
Login
+
+ setEmail(e.target.value)} placeholder="email" /> + setPassword(e.target.value)} placeholder="password" /> + {err &&
{err}
} + +
+
+ Domyślny admin: admin@example.com / admin1234 +
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..e7f6695 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+
mt-traffic
+

Live monitoring MikroTik z dashboardami.

+
+ Login + Dashboards +
+
+ ); +} diff --git a/frontend/components/Shell.tsx b/frontend/components/Shell.tsx new file mode 100644 index 0000000..63eb2df --- /dev/null +++ b/frontend/components/Shell.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/components/TrafficChart.tsx b/frontend/components/TrafficChart.tsx new file mode 100644 index 0000000..709ac1d --- /dev/null +++ b/frontend/components/TrafficChart.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts"; + +type Point = { t: string } & Record; + +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 ( +
+ + + + fmtBps(Number(v))} /> + fmtBps(Number(v))} /> + + {series.map((s) => ( + + ))} + + +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..343a869 --- /dev/null +++ b/frontend/lib/api.ts @@ -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; +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..325addc --- /dev/null +++ b/frontend/next.config.js @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..410c79e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "mt-traffic-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mt-traffic-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz", + "integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz", + "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", + "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", + "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz", + "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", + "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", + "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", + "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", + "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", + "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz", + "integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.15", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.15", + "@next/swc-darwin-x64": "14.2.15", + "@next/swc-linux-arm64-gnu": "14.2.15", + "@next/swc-linux-arm64-musl": "14.2.15", + "@next/swc-linux-x64-gnu": "14.2.15", + "@next/swc-linux-x64-musl": "14.2.15", + "@next/swc-win32-arm64-msvc": "14.2.15", + "@next/swc-win32-ia32-msvc": "14.2.15", + "@next/swc-win32-x64-msvc": "14.2.15" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.7.tgz", + "integrity": "sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^16.10.2", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..235ff7d --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..cce4985 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1 @@ +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..0adbb1d --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..87859bd --- /dev/null +++ b/frontend/tsconfig.json @@ -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" + ] +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..834211e --- /dev/null +++ b/scripts/dev.sh @@ -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 +)