From 20d5e53220bb2d05b6d7e49c0870c5e3253b1780 Mon Sep 17 00:00:00 2001
From: kilyabin <65072190+kilyabin@users.noreply.github.com>
Date: Thu, 28 May 2026 14:19:39 +0400
Subject: [PATCH] =?UTF-8?q?feat:=20initial=20EDR=20system=20=E2=80=94=20ag?=
=?UTF-8?q?ent=20+=20backend=20+=20frontend=20+=20ensemble=20ML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 22 +
README.md | 222 +++
agent/.dockerignore | 4 +
agent/Dockerfile | 11 +
agent/main.py | 213 ++
agent/model.py | 224 +++
agent/requirements.txt | 7 +
agent/train.py | 32 +
backend/.dockerignore | 4 +
backend/Dockerfile | 13 +
backend/main.py | 349 ++++
backend/requirements.txt | 4 +
data/.gitkeep | 0
docker-compose.yml | 49 +
docs/diploma_practical_section.md | 485 +++++
frontend/.dockerignore | 4 +
frontend/Dockerfile | 20 +
frontend/index.html | 18 +
frontend/nginx.conf | 29 +
frontend/package.json | 35 +
frontend/pnpm-lock.yaml | 2405 +++++++++++++++++++++++
frontend/pnpm-workspace.yaml | 2 +
frontend/postcss.config.js | 6 +
frontend/src/App.tsx | 143 ++
frontend/src/components/Alerts.tsx | 102 +
frontend/src/components/FileEvents.tsx | 87 +
frontend/src/components/Overview.tsx | 106 +
frontend/src/components/Processes.tsx | 108 +
frontend/src/components/SystemChart.tsx | 85 +
frontend/src/components/ui/badge.tsx | 29 +
frontend/src/components/ui/card.tsx | 41 +
frontend/src/components/ui/table.tsx | 64 +
frontend/src/components/ui/toaster.tsx | 69 +
frontend/src/hooks/use-toast.ts | 27 +
frontend/src/index.css | 63 +
frontend/src/lib/api.ts | 72 +
frontend/src/lib/utils.ts | 18 +
frontend/src/main.tsx | 19 +
frontend/tailwind.config.ts | 34 +
frontend/tsconfig.app.json | 22 +
frontend/tsconfig.json | 7 +
frontend/tsconfig.node.json | 12 +
frontend/vite.config.js | 16 +
frontend/vite.config.ts | 17 +
44 files changed, 5299 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 agent/.dockerignore
create mode 100644 agent/Dockerfile
create mode 100644 agent/main.py
create mode 100644 agent/model.py
create mode 100644 agent/requirements.txt
create mode 100644 agent/train.py
create mode 100644 backend/.dockerignore
create mode 100644 backend/Dockerfile
create mode 100644 backend/main.py
create mode 100644 backend/requirements.txt
create mode 100644 data/.gitkeep
create mode 100644 docker-compose.yml
create mode 100644 docs/diploma_practical_section.md
create mode 100644 frontend/.dockerignore
create mode 100644 frontend/Dockerfile
create mode 100644 frontend/index.html
create mode 100644 frontend/nginx.conf
create mode 100644 frontend/package.json
create mode 100644 frontend/pnpm-lock.yaml
create mode 100644 frontend/pnpm-workspace.yaml
create mode 100644 frontend/postcss.config.js
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/components/Alerts.tsx
create mode 100644 frontend/src/components/FileEvents.tsx
create mode 100644 frontend/src/components/Overview.tsx
create mode 100644 frontend/src/components/Processes.tsx
create mode 100644 frontend/src/components/SystemChart.tsx
create mode 100644 frontend/src/components/ui/badge.tsx
create mode 100644 frontend/src/components/ui/card.tsx
create mode 100644 frontend/src/components/ui/table.tsx
create mode 100644 frontend/src/components/ui/toaster.tsx
create mode 100644 frontend/src/hooks/use-toast.ts
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/lib/api.ts
create mode 100644 frontend/src/lib/utils.ts
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/tailwind.config.ts
create mode 100644 frontend/tsconfig.app.json
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.js
create mode 100644 frontend/vite.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..84c045f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+.venv/
+venv/
+.env
+*.log
+
+# ML artefacts (regenerated on first run)
+data/*.pkl
+data/*.db
+data/UNSW_NB15_training-set.csv
+
+# Frontend
+frontend/node_modules/
+frontend/dist/
+frontend/*.tsbuildinfo
+
+# Docker / OS
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..486e3c6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,222 @@
+# EDR Monitor — Behavioral Malware Detection System
+
+A university diploma project demonstrating real-time **Endpoint Detection & Response (EDR)**
+using behavioral analysis and machine learning on a Linux host.
+
+---
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Linux Host / VM │
+│ │
+│ ┌──────────────┐ HTTP POST /api/metrics ┌──────────────┐ │
+│ │ │ ─────────────────────────▶ │ │ │
+│ │ agent/ │ │ backend/ │ │
+│ │ (psutil + │ │ (FastAPI + │ │
+│ │ watchdog + │ │ SQLite + │ │
+│ │ IsoForest) │ │ WebSocket) │ │
+│ │ │ │ │ │
+│ └──────────────┘ └──────┬───────┘ │
+│ │ │ │
+│ Filesystem watcher WS /ws │ │
+│ /etc /tmp /var/log │ │
+│ ┌───────▼───────┐ │
+│ │ frontend/ │ │
+│ │ (React + │ │
+│ │ Recharts + │ │
+│ │ shadcn/ui) │ │
+│ │ :3000 │ │
+│ └───────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**Components:**
+
+| Directory | Tech | Purpose |
+|-----------|------|---------|
+| `agent/` | Python 3.11, psutil, watchdog, scikit-learn | Collects process metrics, runs ML inference |
+| `backend/` | FastAPI, aiosqlite, uvicorn | REST API, SQLite storage, WebSocket broadcast |
+| `frontend/`| React 18, Vite, shadcn/ui, Recharts | Real-time SOC dashboard |
+
+---
+
+## Quick Start (local, no Docker)
+
+### 1. Backend
+
+```bash
+cd backend
+python -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8000
+```
+
+### 2. Agent
+
+```bash
+cd agent
+python -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+
+# Train the ML model (runs once, saves data/model.pkl)
+python train.py
+
+# Start the agent
+python main.py
+```
+
+> **Note:** The agent reads process metadata — run with sufficient privileges
+> (or as root) to see all processes and network connections.
+
+### 3. Frontend
+
+```bash
+cd frontend
+npm install
+npm run dev # http://localhost:3000
+```
+
+---
+
+## Quick Start (Docker Compose)
+
+```bash
+docker compose up --build
+```
+
+Services:
+- Backend → http://localhost:8000 (API docs: http://localhost:8000/docs)
+- Dashboard → http://localhost:3000
+
+---
+
+## ML Model
+
+The agent uses **Isolation Forest** (scikit-learn) for unsupervised anomaly detection.
+
+| Feature | Description |
+|---------|-------------|
+| `cpu_percent` | Process CPU utilisation |
+| `memory_mb` | RSS memory in megabytes |
+| `open_files` | Number of open file descriptors |
+| `connections` | Number of open TCP connections |
+
+Training data priority:
+1. `data/UNSW_NB15_training-set.csv` — real network intrusion dataset (download separately)
+2. Synthetic normal-behaviour data (fallback, auto-generated if CSV absent)
+
+Contamination factor: **5%** (5 % of training data treated as outliers).
+
+---
+
+## REST API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/api/stats` | Dashboard statistics |
+| `GET` | `/api/metrics?limit=200` | Recent process metrics |
+| `GET` | `/api/alerts?limit=50` | Security alerts |
+| `GET` | `/api/file-events?limit=100` | Filesystem events |
+| `POST` | `/api/metrics` | Ingest batch from agent |
+| `WS` | `/ws` | Real-time alert stream |
+
+Interactive docs: http://localhost:8000/docs
+
+---
+
+## Simulating Anomalies (demo)
+
+### CPU spike (Linux)
+```bash
+# Requires stress-ng
+stress-ng --cpu 4 --timeout 30s
+
+# Alternative — pure Python
+python3 -c "
+import multiprocessing, time
+def spin():
+ while True: pass
+procs = [multiprocessing.Process(target=spin) for _ in range(4)]
+[p.start() for p in procs]
+time.sleep(30)
+[p.terminate() for p in procs]
+"
+```
+
+### Memory spike
+```bash
+python3 -c "
+x = bytearray(2 * 1024 * 1024 * 1024) # allocate 2 GB
+import time; time.sleep(30)
+"
+```
+
+### Filesystem trigger (write to /tmp)
+```bash
+for i in $(seq 1 20); do echo "test" > /tmp/edr_test_$i.txt; sleep 1; done
+```
+
+Within ~10 seconds the dashboard will show the anomalous process highlighted in red
+and a toast notification will appear bottom-right.
+
+---
+
+## Screenshots
+
+> *Add screenshots here after first run*
+
+| Overview | Processes | Alerts |
+|----------|-----------|--------|
+|  |  |  |
+
+---
+
+## Dataset
+
+The system optionally uses the **UNSW-NB15** network intrusion dataset for training.
+
+Download: https://research.unsw.edu.au/projects/unsw-nb15-dataset
+
+Place the file at: `data/UNSW_NB15_training-set.csv`
+
+Then retrain:
+```bash
+cd agent && python train.py
+```
+
+---
+
+## Project Structure
+
+```
+dipl-edr/
+├── agent/
+│ ├── main.py # monitoring loop + fs watcher
+│ ├── model.py # IsolationForest wrapper
+│ ├── train.py # standalone training script
+│ └── requirements.txt
+├── backend/
+│ ├── main.py # FastAPI app + WebSocket
+│ └── requirements.txt
+├── frontend/
+│ ├── src/
+│ │ ├── App.tsx # root layout + WebSocket client
+│ │ ├── components/
+│ │ │ ├── Overview.tsx # stats cards + chart
+│ │ │ ├── Processes.tsx # process table
+│ │ │ ├── Alerts.tsx # alerts table
+│ │ │ ├── FileEvents.tsx # fs events table
+│ │ │ ├── SystemChart.tsx # Recharts line chart
+│ │ │ └── ui/ # shadcn-style primitives
+│ │ ├── hooks/use-toast.ts
+│ │ └── lib/
+│ │ ├── api.ts # typed fetch helpers
+│ │ └── utils.ts
+│ ├── package.json
+│ └── vite.config.ts
+├── data/ # .gitignored — holds model.pkl + edr.db
+├── docker-compose.yml
+└── README.md
+```
diff --git a/agent/.dockerignore b/agent/.dockerignore
new file mode 100644
index 0000000..e63523d
--- /dev/null
+++ b/agent/.dockerignore
@@ -0,0 +1,4 @@
+__pycache__
+*.py[cod]
+.venv
+*.log
diff --git a/agent/Dockerfile b/agent/Dockerfile
new file mode 100644
index 0000000..14b05d0
--- /dev/null
+++ b/agent/Dockerfile
@@ -0,0 +1,11 @@
+FROM python:3.11-slim
+
+WORKDIR /app/agent
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# train.py создаёт model.pkl при первом запуске, затем стартует агент
+CMD ["sh", "-c", "python train.py && python main.py"]
diff --git a/agent/main.py b/agent/main.py
new file mode 100644
index 0000000..5178775
--- /dev/null
+++ b/agent/main.py
@@ -0,0 +1,213 @@
+"""EDR monitoring agent.
+
+Collects process and system metrics every 5 seconds using psutil,
+monitors filesystem paths with watchdog, and POSTs batches to the backend.
+"""
+
+import logging
+import os
+import sys
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+import psutil
+import requests
+
+# Normalise per-process cpu_percent to 0-100% of *total* machine capacity.
+# psutil returns cumulative core-time (can exceed 100% on multi-core).
+_CPU_COUNT: int = psutil.cpu_count(logical=True) or 1
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+
+sys.path.insert(0, str(Path(__file__).parent))
+from model import AnomalyDetector
+
+# ── configuration ─────────────────────────────────────────────────────────────
+
+BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
+COLLECT_INTERVAL = int(os.getenv("COLLECT_INTERVAL", "5"))
+WATCH_PATHS = [p for p in ["/etc", "/tmp", "/var/log"] if Path(p).exists()]
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+# ── shared fs-event buffer ────────────────────────────────────────────────────
+
+_fs_events: list[dict] = []
+_fs_lock = threading.Lock()
+
+
+class _FSHandler(FileSystemEventHandler):
+ """Records create/modify/delete events into the shared buffer."""
+
+ def on_created(self, event) -> None:
+ if not event.is_directory:
+ self._push(event.src_path, "create")
+
+ def on_modified(self, event) -> None:
+ if not event.is_directory:
+ self._push(event.src_path, "modify")
+
+ def on_deleted(self, event) -> None:
+ self._push(event.src_path, "delete")
+
+ @staticmethod
+ def _push(path: str, event_type: str) -> None:
+ with _fs_lock:
+ _fs_events.append(
+ {
+ "path": path,
+ "event_type": event_type,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ # Bound buffer to avoid unbounded growth between flushes
+ if len(_fs_events) > 500:
+ _fs_events.pop(0)
+
+
+def _drain_fs_events() -> list[dict]:
+ """Return accumulated events and clear the buffer."""
+ with _fs_lock:
+ out = list(_fs_events)
+ _fs_events.clear()
+ return out
+
+
+# ── metric collection ─────────────────────────────────────────────────────────
+
+
+def _proc_connections(proc: psutil.Process) -> int:
+ """Return TCP connection count, compatible with all psutil versions."""
+ for method in ("net_connections", "connections"):
+ fn = getattr(proc, method, None)
+ if fn is None:
+ continue
+ try:
+ return len(fn())
+ except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
+ return 0
+ return 0
+
+
+def collect_processes() -> list[dict]:
+ """Return per-process metrics for all running processes."""
+ result = []
+ # 'connections' was removed from process_iter attrs in psutil 6.x
+ attrs = ["pid", "name", "cpu_percent", "memory_info", "open_files"]
+ for proc in psutil.process_iter(attrs):
+ try:
+ info = proc.info
+ mem_mb = (info["memory_info"].rss / 1024 / 1024
+ if info["memory_info"] else 0.0)
+ open_files = len(info["open_files"]) if info["open_files"] else 0
+ connections = _proc_connections(proc)
+ result.append(
+ {
+ "pid": info["pid"],
+ "name": info["name"] or "unknown",
+ "cpu_percent": round(float(info["cpu_percent"] or 0.0) / _CPU_COUNT, 2),
+ "memory_mb": round(mem_mb, 2),
+ "open_files": open_files,
+ "connections": connections,
+ }
+ )
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
+ pass
+ return result
+
+
+def collect_system() -> dict:
+ """Return system-wide CPU, RAM, and network connection totals."""
+ return {
+ "cpu_percent": psutil.cpu_percent(interval=None),
+ "ram_percent": psutil.virtual_memory().percent,
+ "network_connections": len(psutil.net_connections()),
+ }
+
+
+# ── backend communication ─────────────────────────────────────────────────────
+
+
+def send_batch(detector: AnomalyDetector) -> None:
+ """Collect metrics, annotate anomalies, and POST to backend."""
+ processes = collect_processes()
+ system = collect_system()
+ fs_events = _drain_fs_events()
+
+ processes = detector.predict(processes)
+
+ anomaly_count = sum(1 for p in processes if p.get("is_anomaly"))
+
+ payload = {
+ "processes": processes,
+ "system": system,
+ "file_events": fs_events,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ try:
+ resp = requests.post(
+ f"{BACKEND_URL}/api/metrics",
+ json=payload,
+ timeout=10,
+ )
+ resp.raise_for_status()
+ logger.info(
+ "Sent %d procs | anomalies=%d | fs_events=%d",
+ len(processes),
+ anomaly_count,
+ len(fs_events),
+ )
+ except requests.exceptions.ConnectionError:
+ logger.warning("Backend unreachable — skipping batch")
+ except requests.exceptions.RequestException as exc:
+ logger.error("POST /api/metrics failed: %s", exc)
+
+
+# ── entry point ───────────────────────────────────────────────────────────────
+
+
+def main() -> None:
+ """Start filesystem watcher and run the collection loop."""
+ logger.info("EDR Agent starting (backend=%s, interval=%ds)", BACKEND_URL, COLLECT_INTERVAL)
+
+ # Load or train the anomaly model
+ detector = AnomalyDetector()
+ detector.load_or_train()
+
+ # Start filesystem observer
+ handler = _FSHandler()
+ observer = Observer()
+ for path in WATCH_PATHS:
+ observer.schedule(handler, path, recursive=True)
+ logger.info("Watching path: %s", path)
+ observer.start()
+
+ # Warm up cpu_percent (first call always returns 0.0)
+ psutil.cpu_percent(interval=None)
+ for proc in psutil.process_iter(["cpu_percent"]):
+ try:
+ proc.cpu_percent(interval=None)
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ time.sleep(1)
+
+ try:
+ while True:
+ send_batch(detector)
+ time.sleep(COLLECT_INTERVAL)
+ except KeyboardInterrupt:
+ logger.info("Agent stopped by user")
+ finally:
+ observer.stop()
+ observer.join()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/agent/model.py b/agent/model.py
new file mode 100644
index 0000000..8ae1d8f
--- /dev/null
+++ b/agent/model.py
@@ -0,0 +1,224 @@
+"""Anomaly detection — voting ensemble of three unsupervised models.
+
+Pipeline:
+ IsolationForest + Local Outlier Factor + One-Class SVM
+ A process is flagged anomalous when at least 2 of 3 models agree.
+
+Training data (in order of preference):
+ 1. UNSW-NB15 dataset CSV (data/UNSW_NB15_training-set.csv)
+ 2. Synthetic normal-behaviour data (fallback, 2000 rows)
+"""
+
+import logging
+import os
+from pathlib import Path
+
+import numpy as np
+import joblib
+
+logger = logging.getLogger(__name__)
+
+MODEL_PATH = Path(os.getenv("MODEL_PATH", "data/model.pkl"))
+DATASET_PATH = Path(os.getenv("DATASET_PATH", "data/UNSW_NB15_training-set.csv"))
+
+# Trusted desktop applications — ML classification skipped.
+# Threshold alerts (CPU > 80 %, RAM > 3 GB) still fire from the backend.
+TRUSTED_PROCESSES: frozenset[str] = frozenset({
+ "firefox", "firefox-bin", "Web Content", "Isolated Web Co",
+ "chrome", "chromium", "chromium-browser",
+ "brave", "brave-browser", "opera",
+ "electron", "Electron",
+ "telegram", "Telegram", "telegram-desktop",
+ "slack", "discord", "discord-bin",
+ "code", "code-oss", "vscodium", "cursor",
+ "zoom", "zoom-bin", "zoomus",
+ "obsidian",
+ "spotify", "vlc", "mpv",
+ "libreoffice", "soffice",
+ "thunderbird",
+ "gnome-shell", "plasmashell", "kwin_wayland", "kwin_x11",
+ "Xorg", "Xwayland",
+})
+
+# Feature vector order: [cpu_percent, memory_mb, open_files, connections]
+FEATURE_COLS = ["cpu_percent", "memory_mb", "open_files", "connections"]
+
+
+class EnsembleDetector:
+ """Majority-vote ensemble: IsolationForest + LOF + One-Class SVM.
+
+ Each model casts a vote (+1 normal, -1 anomaly).
+ A process is anomalous when the vote sum is ≤ -1 (i.e. 2 or 3 models agree).
+ """
+
+ def __init__(self) -> None:
+ self.iforest = None
+ self.lof = None
+ self.ocsvm = None
+
+ # ── training ─────────────────────────────────────────────────────────────
+
+ def train(self, X: np.ndarray) -> None:
+ """Fit all three models on X and persist the ensemble to disk."""
+ from sklearn.ensemble import IsolationForest
+ from sklearn.neighbors import LocalOutlierFactor
+ from sklearn.svm import OneClassSVM
+ from sklearn.preprocessing import RobustScaler
+
+ logger.info("Training ensemble on %d samples …", X.shape[0])
+
+ # RobustScaler is median/IQR-based — less sensitive to extreme outliers
+ # than StandardScaler, which matters when training data may contain
+ # occasional noisy spikes.
+ scaler = RobustScaler()
+ X_scaled = scaler.fit_transform(X)
+
+ self.iforest = IsolationForest(
+ n_estimators=100,
+ contamination=0.01,
+ random_state=42,
+ n_jobs=-1,
+ )
+ self.iforest.fit(X_scaled)
+ logger.info(" IsolationForest trained")
+
+ # LOF in novelty=True mode so it can predict on new samples (not just
+ # the training set). n_neighbors=20 is a robust default for datasets
+ # of ~1000–5000 rows.
+ self.lof = LocalOutlierFactor(
+ n_neighbors=20,
+ contamination=0.01,
+ novelty=True,
+ n_jobs=-1,
+ )
+ self.lof.fit(X_scaled)
+ logger.info(" LocalOutlierFactor trained")
+
+ # One-Class SVM with RBF kernel. nu=0.01 ≈ expected outlier fraction.
+ # gamma="scale" adapts to feature variance automatically.
+ self.ocsvm = OneClassSVM(kernel="rbf", nu=0.01, gamma="scale")
+ self.ocsvm.fit(X_scaled)
+ logger.info(" OneClassSVM trained")
+
+ MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
+ joblib.dump({"iforest": self.iforest, "lof": self.lof,
+ "ocsvm": self.ocsvm, "scaler": scaler}, MODEL_PATH)
+ logger.info("Ensemble saved → %s", MODEL_PATH)
+
+ # Keep the scaler attached for inference
+ self._scaler = scaler
+
+ def load_or_train(self) -> None:
+ """Load persisted ensemble or train a new one."""
+ if MODEL_PATH.exists():
+ bundle = joblib.load(MODEL_PATH)
+ # Support legacy single-model pkl (no "scaler" key)
+ if not isinstance(bundle, dict):
+ logger.info("Legacy single-model pkl detected — retraining ensemble")
+ MODEL_PATH.unlink()
+ self.load_or_train()
+ return
+ self.iforest = bundle["iforest"]
+ self.lof = bundle["lof"]
+ self.ocsvm = bundle["ocsvm"]
+ self._scaler = bundle["scaler"]
+ logger.info("Ensemble loaded from %s", MODEL_PATH)
+ return
+ logger.info("No saved model found — training now")
+ X = self._load_dataset()
+ self.train(X)
+
+ # ── data helpers ──────────────────────────────────────────────────────────
+
+ def _load_dataset(self) -> np.ndarray:
+ if DATASET_PATH.exists():
+ return self._load_unsw()
+ logger.warning("Dataset not found — using synthetic training data")
+ return self._synthetic_data()
+
+ def _load_unsw(self) -> np.ndarray:
+ import pandas as pd
+ df = pd.read_csv(DATASET_PATH)
+ numeric = df.select_dtypes(include=[np.number]).dropna()
+ X = numeric.iloc[:, :4].values.astype(np.float32)
+ logger.info("Loaded UNSW-NB15: %d rows", X.shape[0])
+ return X
+
+ def _synthetic_data(self) -> np.ndarray:
+ """2000 rows of realistic normal Linux process behaviour.
+
+ Distribution:
+ 75 % idle/background (CPU ≈ 0 %, low RAM)
+ 20 % light workers (moderate CPU, moderate RAM)
+ 5 % heavier services (higher RAM, some connections)
+ """
+ rng = np.random.default_rng(42)
+ n = 2000
+
+ cpu_idle = rng.exponential(0.3, int(n * 0.75))
+ cpu_light = rng.uniform(0.5, 15, int(n * 0.20))
+ cpu_heavy = rng.uniform(15, 60, int(n * 0.05))
+ cpu = np.concatenate([cpu_idle, cpu_light, cpu_heavy])
+
+ mem_small = rng.uniform(0.5, 50, int(n * 0.50))
+ mem_medium = rng.uniform(50, 300, int(n * 0.30))
+ mem_large = rng.uniform(300, 800, int(n * 0.12))
+ mem_desktop = rng.uniform(800, 2000, int(n * 0.08))
+ mem = np.concatenate([mem_small, mem_medium, mem_large, mem_desktop])
+
+ files = np.concatenate([
+ rng.integers(0, 20, int(n * 0.70)).astype(float),
+ rng.integers(20, 80, int(n * 0.30)).astype(float),
+ ])
+
+ conns = np.concatenate([
+ rng.integers(0, 2, int(n * 0.70)).astype(float),
+ rng.integers(2, 20, int(n * 0.30)).astype(float),
+ ])
+
+ idx = rng.permutation(n)
+ return np.column_stack([cpu[idx], mem[idx], files[idx], conns[idx]])
+
+ # ── inference ─────────────────────────────────────────────────────────────
+
+ def predict(self, processes: list[dict]) -> list[dict]:
+ """Annotate each process dict with is_anomaly=True/False.
+
+ Trusted processes skip ML — they can only be flagged by the
+ backend's threshold checks (CPU > 80 %, RAM > 3 GB).
+ """
+ if not processes or self.iforest is None:
+ for p in processes:
+ p["is_anomaly"] = False
+ return processes
+
+ trusted_idx = {i for i, p in enumerate(processes)
+ if p["name"] in TRUSTED_PROCESSES}
+ candidates = [p for i, p in enumerate(processes) if i not in trusted_idx]
+
+ if candidates:
+ X_raw = np.array(
+ [[p["cpu_percent"], p["memory_mb"], p["open_files"], p["connections"]]
+ for p in candidates],
+ dtype=np.float32,
+ )
+ X = self._scaler.transform(X_raw)
+
+ # Each model returns +1 (normal) or -1 (anomaly)
+ v_if = self.iforest.predict(X) # IsolationForest
+ v_lof = self.lof.predict(X) # Local Outlier Factor
+ v_ocsvm = self.ocsvm.predict(X) # One-Class SVM
+
+ # Majority vote: flag anomaly when vote_sum ≤ -1 (≥ 2 of 3 agree)
+ vote_sum = v_if + v_lof + v_ocsvm
+ for proc, vs in zip(candidates, vote_sum):
+ proc["is_anomaly"] = bool(vs <= -1)
+
+ for i in trusted_idx:
+ processes[i]["is_anomaly"] = False
+ return processes
+
+
+# Module-level alias so existing import `from model import AnomalyDetector`
+# continues to work without changes in main.py.
+AnomalyDetector = EnsembleDetector
diff --git a/agent/requirements.txt b/agent/requirements.txt
new file mode 100644
index 0000000..996f016
--- /dev/null
+++ b/agent/requirements.txt
@@ -0,0 +1,7 @@
+psutil>=5.9.0
+watchdog>=3.0.0
+scikit-learn>=1.3.0
+joblib>=1.3.0
+requests>=2.31.0
+pandas>=2.0.0
+numpy>=1.24.0
diff --git a/agent/train.py b/agent/train.py
new file mode 100644
index 0000000..9a7a12a
--- /dev/null
+++ b/agent/train.py
@@ -0,0 +1,32 @@
+"""Standalone training script — trains IsolationForest and saves model.pkl.
+
+Usage:
+ cd agent
+ python train.py
+"""
+
+import logging
+import sys
+from pathlib import Path
+
+# Allow running from project root or agent/ directory
+sys.path.insert(0, str(Path(__file__).parent))
+
+from model import AnomalyDetector
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+)
+
+
+def main() -> None:
+ """Entry point: load data, train, save."""
+ detector = AnomalyDetector()
+ X = detector._load_dataset()
+ detector.train(X)
+ print(f"Done. Model saved to data/model.pkl ({X.shape[0]} training samples)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..e63523d
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,4 @@
+__pycache__
+*.py[cod]
+.venv
+*.log
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..3c1fc0c
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Зависимости отдельным слоем для кеширования
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..a5f3f91
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,349 @@
+"""EDR Backend — FastAPI REST API + WebSocket broadcast.
+
+Receives metric batches from the agent, stores them in SQLite,
+and pushes real-time anomaly alerts to connected dashboard clients.
+"""
+
+import logging
+import os
+from contextlib import asynccontextmanager
+from datetime import date, datetime, timezone
+from typing import Any
+
+import aiosqlite
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+
+logger = logging.getLogger(__name__)
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+)
+
+DB_PATH = os.getenv("DB_PATH", "../data/edr.db")
+
+
+# ── Database ──────────────────────────────────────────────────────────────────
+
+
+async def _init_db() -> None:
+ """Create all tables on first startup."""
+ async with aiosqlite.connect(DB_PATH) as db:
+ await db.executescript("""
+ CREATE TABLE IF NOT EXISTS metrics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ pid INTEGER,
+ name TEXT,
+ cpu_percent REAL,
+ memory_mb REAL,
+ open_files INTEGER,
+ connections INTEGER,
+ is_anomaly INTEGER DEFAULT 0
+ );
+ CREATE TABLE IF NOT EXISTS alerts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ pid INTEGER,
+ process_name TEXT,
+ reason TEXT,
+ severity TEXT
+ );
+ CREATE TABLE IF NOT EXISTS file_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ path TEXT,
+ event_type TEXT
+ );
+ CREATE TABLE IF NOT EXISTS system_metrics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ cpu_percent REAL,
+ ram_percent REAL,
+ net_connections INTEGER
+ );
+ """)
+ await db.commit()
+
+
+# ── WebSocket manager ─────────────────────────────────────────────────────────
+
+
+class _WsManager:
+ """Thread-safe registry of connected WebSocket clients."""
+
+ def __init__(self) -> None:
+ self._clients: list[WebSocket] = []
+
+ async def connect(self, ws: WebSocket) -> None:
+ await ws.accept()
+ self._clients.append(ws)
+ logger.info("WS connected total=%d", len(self._clients))
+
+ def disconnect(self, ws: WebSocket) -> None:
+ self._clients.remove(ws)
+ logger.info("WS disconnected total=%d", len(self._clients))
+
+ async def broadcast(self, data: dict) -> None:
+ """Send JSON payload to every connected client; drop stale ones."""
+ stale: list[WebSocket] = []
+ for ws in list(self._clients):
+ try:
+ await ws.send_json(data)
+ except Exception:
+ stale.append(ws)
+ for ws in stale:
+ if ws in self._clients:
+ self._clients.remove(ws)
+
+
+_ws = _WsManager()
+
+
+# ── App lifecycle ─────────────────────────────────────────────────────────────
+
+
+@asynccontextmanager
+async def _lifespan(app: FastAPI):
+ await _init_db()
+ logger.info("Database ready at %s", DB_PATH)
+ yield
+
+
+app = FastAPI(title="EDR Backend", version="1.0.0", lifespan=_lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+# ── Pydantic models ───────────────────────────────────────────────────────────
+
+
+class ProcessMetric(BaseModel):
+ pid: int
+ name: str
+ cpu_percent: float
+ memory_mb: float
+ open_files: int
+ connections: int
+ is_anomaly: bool = False
+
+
+class SystemMetric(BaseModel):
+ cpu_percent: float
+ ram_percent: float
+ network_connections: int
+
+
+class FileEvent(BaseModel):
+ path: str
+ event_type: str
+ timestamp: str
+
+
+class MetricsBatch(BaseModel):
+ processes: list[ProcessMetric]
+ system: SystemMetric
+ file_events: list[FileEvent] = []
+ timestamp: str
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+
+def _severity(proc: ProcessMetric) -> str:
+ """Derive alert severity from process resource usage."""
+ if proc.cpu_percent > 80 or proc.memory_mb > 2000:
+ return "high"
+ if proc.cpu_percent > 50 or proc.memory_mb > 1000:
+ return "medium"
+ return "low"
+
+
+def _reason(proc: ProcessMetric) -> str:
+ """Build a human-readable anomaly reason string (Russian)."""
+ parts: list[str] = []
+ if proc.cpu_percent > 80:
+ parts.append(f"CPU {proc.cpu_percent:.1f}%")
+ if proc.memory_mb > 2000:
+ parts.append(f"ОЗУ {proc.memory_mb:.0f} МБ")
+ if proc.connections > 50:
+ parts.append(f"{proc.connections} TCP-соединений")
+ if proc.open_files > 200:
+ parts.append(f"{proc.open_files} открытых файлов")
+ detail = ", ".join(parts) if parts else "статистический выброс (ансамбль ML: IF + LOF + OC-SVM)"
+ return f"Аномальное поведение: {detail}"
+
+
+# ── REST endpoints ────────────────────────────────────────────────────────────
+
+
+@app.get("/api/metrics", summary="Recent process metrics")
+async def get_metrics(limit: int = 200) -> list[dict]:
+ """Return the most recent *limit* rows from the metrics table."""
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ async with db.execute(
+ "SELECT * FROM metrics ORDER BY id DESC LIMIT ?", (limit,)
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+
+@app.get("/api/alerts", summary="Recent alerts")
+async def get_alerts(limit: int = 50) -> list[dict]:
+ """Return the most recent *limit* alert rows."""
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ async with db.execute(
+ "SELECT * FROM alerts ORDER BY id DESC LIMIT ?", (limit,)
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+
+@app.get("/api/system-metrics", summary="System-level CPU/RAM timeline")
+async def get_system_metrics(limit: int = 60) -> list[dict]:
+ """Return last *limit* system snapshots in chronological order (for charts)."""
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ async with db.execute(
+ "SELECT * FROM system_metrics ORDER BY id DESC LIMIT ?", (limit,)
+ ) as cur:
+ rows = await cur.fetchall()
+ return list(reversed([dict(r) for r in rows]))
+
+
+@app.get("/api/file-events", summary="Recent filesystem events")
+async def get_file_events(limit: int = 100) -> list[dict]:
+ """Return the most recent *limit* filesystem events."""
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ async with db.execute(
+ "SELECT * FROM file_events ORDER BY id DESC LIMIT ?", (limit,)
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+
+@app.get("/api/stats", summary="Dashboard statistics")
+async def get_stats() -> dict[str, Any]:
+ """Return aggregated statistics for the Overview panel."""
+ today = date.today().isoformat()
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+
+ async with db.execute(
+ "SELECT COUNT(DISTINCT pid) FROM metrics"
+ ) as cur:
+ total_processes = (await cur.fetchone())[0]
+
+ async with db.execute(
+ "SELECT COUNT(*) FROM metrics WHERE is_anomaly=1 AND timestamp LIKE ?",
+ (f"{today}%",),
+ ) as cur:
+ anomalies_today = (await cur.fetchone())[0]
+
+ async with db.execute(
+ "SELECT COUNT(*) FROM alerts WHERE timestamp LIKE ?", (f"{today}%",)
+ ) as cur:
+ alerts_today = (await cur.fetchone())[0]
+
+ # Средняя нагрузка системы за сегодня (из таблицы system_metrics)
+ async with db.execute(
+ "SELECT AVG(cpu_percent), AVG(ram_percent) FROM system_metrics WHERE timestamp LIKE ?",
+ (f"{today}%",),
+ ) as cur:
+ row = await cur.fetchone()
+ cpu_avg = round(float(row[0] or 0), 1)
+ ram_avg = round(float(row[1] or 0), 1)
+
+ return {
+ "total_processes": total_processes,
+ "anomalies_today": anomalies_today,
+ "alerts_today": alerts_today,
+ "cpu_avg": cpu_avg,
+ "ram_avg": ram_avg,
+ }
+
+
+@app.post("/api/metrics", status_code=201, summary="Ingest metric batch from agent")
+async def post_metrics(batch: MetricsBatch) -> dict[str, int]:
+ """Store process metrics, create alerts for anomalies, broadcast via WS."""
+ ts = batch.timestamp
+
+ async with aiosqlite.connect(DB_PATH) as db:
+ # Bulk-insert process metrics
+ await db.executemany(
+ """INSERT INTO metrics
+ (timestamp, pid, name, cpu_percent, memory_mb, open_files, connections, is_anomaly)
+ VALUES (?,?,?,?,?,?,?,?)""",
+ [
+ (ts, p.pid, p.name, p.cpu_percent, p.memory_mb,
+ p.open_files, p.connections, int(p.is_anomaly))
+ for p in batch.processes
+ ],
+ )
+
+ # Системные метрики — одна строка на батч (для графика)
+ await db.execute(
+ "INSERT INTO system_metrics (timestamp, cpu_percent, ram_percent, net_connections) VALUES (?,?,?,?)",
+ (ts, batch.system.cpu_percent, batch.system.ram_percent, batch.system.network_connections),
+ )
+
+ # Bulk-insert filesystem events
+ if batch.file_events:
+ await db.executemany(
+ "INSERT INTO file_events (timestamp, path, event_type) VALUES (?,?,?)",
+ [(e.timestamp, e.path, e.event_type) for e in batch.file_events],
+ )
+
+ # Пороговый алерт работает независимо от ML — для любых процессов
+ threshold_anomalies = [
+ p for p in batch.processes
+ if not p.is_anomaly and (p.cpu_percent > 80 or p.memory_mb > 3000 or p.connections > 100)
+ ]
+ for proc in threshold_anomalies:
+ proc.is_anomaly = True # пометить для записи в метрики
+
+ # Все аномалии: ML + пороговые
+ anomalies = [p for p in batch.processes if p.is_anomaly]
+ for proc in anomalies:
+ sev = _severity(proc)
+ reason = _reason(proc)
+ await db.execute(
+ "INSERT INTO alerts (timestamp, pid, process_name, reason, severity) VALUES (?,?,?,?,?)",
+ (ts, proc.pid, proc.name, reason, sev),
+ )
+ await _ws.broadcast(
+ {
+ "type": "alert",
+ "pid": proc.pid,
+ "process_name": proc.name,
+ "reason": reason,
+ "severity": sev,
+ "timestamp": ts,
+ }
+ )
+
+ await db.commit()
+
+ logger.info("Batch stored: procs=%d anomalies=%d fs=%d",
+ len(batch.processes), len(anomalies), len(batch.file_events))
+ return {"received": len(batch.processes), "anomalies": len(anomalies)}
+
+
+# ── WebSocket ─────────────────────────────────────────────────────────────────
+
+
+@app.websocket("/ws")
+async def ws_endpoint(ws: WebSocket) -> None:
+ """WebSocket endpoint — each dashboard connects here for live alerts."""
+ await _ws.connect(ws)
+ try:
+ while True:
+ await ws.receive_text() # keep connection alive; messages ignored
+ except WebSocketDisconnect:
+ _ws.disconnect(ws)
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..1ecae17
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,4 @@
+fastapi>=0.111.0
+uvicorn[standard]>=0.29.0
+aiosqlite>=0.20.0
+python-multipart>=0.0.9
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b5c233b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,49 @@
+version: "3.9"
+
+services:
+
+ # ── FastAPI backend ────────────────────────────────────────────────────────
+ backend:
+ build: ./backend
+ volumes:
+ - ./data:/app/data # SQLite-база и модель
+ ports:
+ - "8000:8000" # API-документация: http://localhost:8000/docs
+ environment:
+ - DB_PATH=/app/data/edr.db
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "python", "-c",
+ "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/stats')"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+ # ── Python мониторинг-агент ────────────────────────────────────────────────
+ agent:
+ build: ./agent
+ volumes:
+ - ./data:/app/data # общий том с моделью и БД
+ environment:
+ - BACKEND_URL=http://backend:8000
+ - COLLECT_INTERVAL=5
+ - MODEL_PATH=/app/data/model.pkl
+ - DATASET_PATH=/app/data/UNSW_NB15_training-set.csv
+ depends_on:
+ backend:
+ condition: service_healthy
+ pid: host # видит процессы хоста
+ privileged: true # psutil net_connections требует root
+ restart: unless-stopped
+
+ # ── React-дашборд (nginx) ──────────────────────────────────────────────────
+ frontend:
+ build: ./frontend # multi-stage: node build → nginx
+ ports:
+ - "3000:3000" # Dashboard: http://localhost:3000
+ depends_on:
+ - backend
+ restart: unless-stopped
+
+volumes: {}
diff --git a/docs/diploma_practical_section.md b/docs/diploma_practical_section.md
new file mode 100644
index 0000000..acde5c7
--- /dev/null
+++ b/docs/diploma_practical_section.md
@@ -0,0 +1,485 @@
+# Практическая часть дипломной работы
+## Разработка системы обнаружения вредоносной активности на основе поведенческого анализа
+
+---
+
+## 3. ПРАКТИЧЕСКАЯ РЕАЛИЗАЦИЯ СИСТЕМЫ
+
+### 3.1 Общее описание разработанной системы
+
+В рамках дипломной работы разработана система класса **EDR (Endpoint Detection and Response)** — системы обнаружения и реагирования на угрозы на конечных узлах сети. Система предназначена для мониторинга поведения процессов Linux-сервера в реальном времени, выявления аномальной активности методами машинного обучения и визуализации результатов в виде веб-панели управления (dashboard).
+
+Функциональные возможности реализованной системы:
+- сбор поведенческих метрик всех запущенных процессов каждые 5 секунд;
+- мониторинг файловой системы в критических директориях `/etc`, `/tmp`, `/var/log`;
+- автоматическое обучение ансамбля из трёх ML-моделей (Isolation Forest, LOF, One-Class SVM) на нормальном поведении системы;
+- классификация процессов как аномальных/нормальных в режиме реального времени;
+- хранение всех событий в реляционной базе данных SQLite;
+- передача оповещений об аномалиях по протоколу WebSocket;
+- веб-интерфейс SOC-класса с отображением статистики, процессов, алертов и событий ФС.
+
+---
+
+### 3.2 Архитектура системы
+
+Система построена по трёхзвенной клиент-серверной архитектуре и включает три основных компонента:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Linux-хост (объект мониторинга) │
+│ │
+│ ┌────────────┐ HTTP POST ┌────────────┐ │
+│ │ Агент │ ────────────▶ │ Бэкенд │ │
+│ │ (Python) │ │ (FastAPI) │ │
+│ │ │ │ │ │
+│ │ • psutil │ │ • REST API │ │
+│ │ • watchdog │ │ • SQLite │ │
+│ │ • ML-модель│ │ • WebSocket│ │
+│ └────────────┘ └─────┬──────┘ │
+│ │ WS │
+│ ┌─────▼──────┐ │
+│ │ Фронтенд │ │
+│ │ (React) │ │
+│ │ :3000 │ │
+│ └────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Компонент 1 — Агент мониторинга (`agent/`).**
+Написан на языке Python 3.11. Запускается непосредственно на контролируемом хосте. Каждые 5 секунд собирает метрики всех запущенных процессов (PID, имя, загрузка CPU, объём RSS-памяти, количество открытых файлов, количество TCP-соединений), запускает ML-классификацию, затем отправляет результаты в бэкенд через HTTP POST. Параллельно запущен наблюдатель файловой системы (watchdog), отслеживающий создание, изменение и удаление файлов в указанных директориях.
+
+**Компонент 2 — Серверная часть (`backend/`).**
+Реализована на фреймворке FastAPI (Python). Принимает батчи метрик, сохраняет их в SQLite через асинхронный драйвер aiosqlite. При обнаружении аномального процесса создаёт запись в таблице алертов и транслирует JSON-сообщение всем подключённым WebSocket-клиентам (панелям мониторинга).
+
+**Компонент 3 — Веб-панель управления (`frontend/`).**
+Single-Page Application на стеке React 18 + TypeScript + Vite. Использует библиотеку shadcn/ui (Radix UI + Tailwind CSS) для интерфейсных компонентов, Recharts для графиков реального времени, TanStack Query для периодического опроса REST API каждые 5 секунд. WebSocket-соединение обеспечивает мгновенную доставку уведомлений об аномалиях.
+
+---
+
+### 3.3 Модуль машинного обучения
+
+#### 3.3.1 Проблема единственного классификатора и обоснование ансамблевого подхода
+
+При обнаружении аномалий в поведении процессов каждый алгоритм машинного обучения обладает специфическими слабыми сторонами. Единственная модель даёт повышенное число ложных срабатываний (false positives) или пропускает определённые типы аномалий (false negatives) в зависимости от характера атаки и распределения данных.
+
+В данной работе реализован **ансамблевый детектор** (voting ensemble), объединяющий три алгоритма обнаружения аномалий без учителя. Аномалией считается процесс, признанный подозрительным как минимум двумя из трёх моделей (правило большинства, majority voting). Такой подход снижает вероятность ложных срабатываний по сравнению с одиночным классификатором: случайный «выброс» от одного алгоритма не приводит к генерации алерта, если два других модели оценивают процесс как нормальный.
+
+#### 3.3.2 Алгоритмы ансамбля
+
+В ансамбль включены три алгоритма, дополняющих друг друга за счёт принципиально разных математических основ:
+
+| Алгоритм | Принцип | Сильная сторона |
+|----------|---------|-----------------|
+| **Isolation Forest** (Liu et al., 2008) | Случайные деревья изоляции | Глобальные аномалии, O(n) скорость |
+| **Local Outlier Factor** (Breunig et al., 2000) | Локальная плотность соседей | Локальные кластерные аномалии |
+| **One-Class SVM** (Schölkopf et al., 1999) | Гиперплоскость максимального зазора | Нелинейные границы нормальности |
+
+**Isolation Forest** строит ансамбль из 100 случайных деревьев. Нормальные точки требуют большего числа разбиений для изоляции, аномальные — меньшего. Итоговый балл — нормированная средняя длина пути изоляции. Алгоритм эффективен для глобальных выбросов и работает с линейной сложностью O(n).
+
+**Local Outlier Factor (LOF)** оценивает каждую точку относительно плотности её локального окружения из k=20 ближайших соседей. Если плотность точки значительно ниже плотности её соседей, точка является выбросом. LOF выявляет аномалии в локальных кластерах, которые IsolationForest может пропустить в многомодальных распределениях. Используется режим `novelty=True` для предсказания на новых данных.
+
+**One-Class SVM** обучается провести гиперплоскость максимального зазора в пространстве признаков (ядро RBF), отделяющую нормальные данные от пространства аномалий. Нелинейная природа ядра позволяет захватывать сложные границы нормальности, недоступные линейным методам. Параметр `nu=0.01` задаёт верхнюю оценку доли аномалий.
+
+#### 3.3.3 Схема голосования
+
+```
+Процесс → вектор признаков → RobustScaler (нормировка)
+ │
+ ┌────────────────────┼────────────────────┐
+ ▼ ▼ ▼
+ IsolationForest LOF (novelty) One-Class SVM
+ (+1 норма/-1 ан.) (+1 норма/-1 ан.) (+1 норма/-1 ан.)
+ │ │ │
+ └────────────────────┴────────────────────┘
+ │
+ сумма голосов
+ ≥ 2 «–1» → Аномалия
+ ≤ 1 «–1» → Норма
+```
+
+Формально, для вектора признаков **x** каждая модель $m_i$ возвращает $v_i \in \{-1, +1\}$. Решение:
+
+$$\text{is\_anomaly}(\mathbf{x}) = \left[\sum_{i=1}^{3} v_i \leq -1\right]$$
+
+то есть аномалия фиксируется, если сумма голосов равна $-1$ (голосуют 2 из 3) или $-3$ (единогласно).
+
+#### 3.3.4 Вектор признаков
+
+Для каждого процесса формируется вектор из четырёх числовых признаков:
+
+| Признак | Тип | Описание |
+|---------|-----|----------|
+| `cpu_percent` | float | Загрузка CPU процессом, % |
+| `memory_mb` | float | Объём RSS-памяти, МБ |
+| `open_files` | int | Количество открытых файловых дескрипторов |
+| `connections` | int | Количество открытых TCP-соединений |
+
+Выбор признаков обоснован классическими индикаторами компрометации (IoC):
+- аномально высокое потребление CPU характерно для криптомайнеров и ransomware;
+- избыточное число соединений указывает на C2-коммуникации или сканирование сети;
+- большое число открытых файлов — признак шифровальщиков;
+- чрезмерное потребление памяти — характерно для эксплойтов heap overflow.
+
+#### 3.3.5 Предобработка: RobustScaler
+
+One-Class SVM и LOF чувствительны к масштабу признаков, поэтому перед обучением применяется **RobustScaler** из scikit-learn. В отличие от StandardScaler, RobustScaler использует медиану и межквартильный размах (IQR) вместо среднего и дисперсии, что делает нормировку устойчивой к экстремальным выбросам в обучающих данных:
+
+$$x' = \frac{x - \text{median}(X)}{\text{IQR}(X)}$$
+
+Параметры масштабирования (медиана и IQR по каждому признаку) сохраняются вместе с моделями и применяются к каждому новому вектору при инференсе.
+
+#### 3.3.6 Обучающие данные
+
+Система поддерживает два режима обучения:
+
+**Режим 1 (основной).** Если в директории `data/` находится файл `UNSW_NB15_training-set.csv`, все три модели обучаются на реальных данных набора UNSW-NB15. Датасет UNSW-NB15 (University of New South Wales, 2015) содержит 257 000 записей сетевого трафика, включая нормальную активность и 9 типов атак (Fuzzers, Analysis, Backdoors, DoS, Exploits, Generic, Reconnaissance, Shellcode, Worms). Из набора извлекаются первые четыре числовые колонки как приближение к вектору признаков.
+
+**Режим 2 (резервный).** При отсутствии датасета генерируется синтетическая обучающая выборка из 2000 образцов нормального поведения процессов с реалистичными распределениями:
+- CPU: 75% экспоненциальное распределение ≈ 0% (фоновые процессы), 20% Uniform[0.5, 15%] (лёгкие задачи), 5% Uniform[15, 60%] (тяжёлые процессы);
+- RAM: 50% — 0.5–50 МБ (мелкие демоны), 30% — 50–300 МБ, 12% — 300–800 МБ, 8% — 800–2000 МБ (браузеры, IDE);
+- Файлы: 70% — 0–20 дескрипторов, 30% — 20–80;
+- TCP-соединения: 70% — 0–2, 30% — 2–20.
+
+#### 3.3.7 Параметры ансамбля
+
+```python
+# IsolationForest
+IsolationForest(
+ n_estimators=100, # число деревьев изоляции
+ contamination=0.01, # ожидаемая доля аномалий 1%
+ random_state=42, # воспроизводимость
+ n_jobs=-1, # параллельное обучение
+)
+
+# Local Outlier Factor
+LocalOutlierFactor(
+ n_neighbors=20, # размер локального окружения
+ contamination=0.01,
+ novelty=True, # инференс на новых данных
+ n_jobs=-1,
+)
+
+# One-Class SVM
+OneClassSVM(
+ kernel="rbf", # гауссово ядро
+ nu=0.01, # верхняя оценка доли аномалий
+ gamma="scale", # автоматически от дисперсии признаков
+)
+```
+
+Параметр `contamination=0.01` (1%) выбран для минимизации ложных срабатываний на рабочей станции с десятками доверенных фоновых процессов.
+
+#### 3.3.8 Список доверенных процессов
+
+Для известных «тяжёлых» десктопных приложений (браузеры, Electron-приложения, IDE, медиаплееры) ML-классификация пропускается — они заносятся в `TRUSTED_PROCESSES`. Это предотвращает генерацию ложных алертов для firefox, VS Code, Telegram и других приложений, нормально потребляющих сотни мегабайт RAM. Пороговые алерты бэкенда (CPU > 80%, RAM > 3 ГБ) для доверенных процессов по-прежнему работают.
+
+#### 3.3.9 Сохранение ансамбля
+
+После обучения все три модели и параметры RobustScaler сериализуются в единый файл `data/model.pkl` через `joblib`:
+
+```python
+joblib.dump({
+ "iforest": self.iforest,
+ "lof": self.lof,
+ "ocsvm": self.ocsvm,
+ "scaler": scaler,
+}, MODEL_PATH)
+```
+
+При последующих запусках агента файл загружается целиком, что исключает повторное обучение и обеспечивает стабильность порогов обнаружения.
+
+---
+
+### 3.4 Агент мониторинга
+
+#### 3.4.1 Сбор метрик процессов
+
+Сбор осуществляется через библиотеку **psutil** — кроссплатформенную обёртку над системными вызовами Linux (`/proc` filesystem). Для каждого процесса в системе итерируется объект `psutil.Process` и извлекаются следующие атрибуты:
+
+```python
+psutil.process_iter([
+ 'pid', # идентификатор процесса
+ 'name', # имя исполняемого файла
+ 'cpu_percent', # загрузка CPU, %
+ 'memory_info', # RSS/VMS в байтах
+ 'open_files', # список открытых файловых дескрипторов
+ 'connections', # список TCP/UDP соединений
+])
+```
+
+Особенности реализации:
+- первый вызов `cpu_percent()` всегда возвращает 0.0 (psutil требует временной дельты), поэтому при старте агента выполняется прогревочный вызов с паузой 1 сек;
+- процессы, к которым нет доступа (PermissionError), пропускаются без исключения;
+- зомби-процессы обрабатываются отдельно.
+
+#### 3.4.2 Мониторинг файловой системы
+
+Для отслеживания изменений в файловой системе используется библиотека **watchdog**, основанная на Linux inotify API. Наблюдатели запускаются рекурсивно для трёх директорий:
+
+- `/etc` — системные конфигурационные файлы (изменение может свидетельствовать о persistence-атаке);
+- `/tmp` — временные файлы (типичное место для дропа malware-полезной нагрузки);
+- `/var/log` — системные журналы (попытки очистки следов атаки).
+
+Каждое событие (create/modify/delete) помещается в потокобезопасный буфер с ограничением в 500 записей. При отправке батча метрик буфер опустошается.
+
+#### 3.4.3 Протокол взаимодействия с бэкендом
+
+Каждые 5 секунд агент формирует и отправляет JSON-батч:
+
+```json
+{
+ "timestamp": "2025-05-28T14:30:00.000Z",
+ "processes": [
+ {
+ "pid": 1234,
+ "name": "nginx",
+ "cpu_percent": 2.1,
+ "memory_mb": 45.3,
+ "open_files": 22,
+ "connections": 8,
+ "is_anomaly": false
+ }
+ ],
+ "system": {
+ "cpu_percent": 12.5,
+ "ram_percent": 34.2,
+ "network_connections": 47
+ },
+ "file_events": [
+ {
+ "path": "/tmp/suspicious.sh",
+ "event_type": "create",
+ "timestamp": "2025-05-28T14:29:58.000Z"
+ }
+ ]
+}
+```
+
+При недоступности бэкенда агент продолжает работу, логируя предупреждение, без аварийного завершения.
+
+---
+
+### 3.5 Серверная часть (бэкенд)
+
+#### 3.5.1 Технологический стек
+
+Бэкенд реализован на фреймворке **FastAPI** — современном асинхронном фреймворке для построения API на Python, основанном на стандарте ASGI. Ключевые преимущества:
+- автоматическая генерация OpenAPI-документации (доступна по адресу `/docs`);
+- встроенная валидация данных через Pydantic v2;
+- нативная поддержка async/await;
+- встроенная поддержка WebSocket.
+
+База данных — **SQLite** через асинхронный драйвер `aiosqlite`. Выбор SQLite обусловлен простотой развёртывания (файловая база, не требует отдельного сервера) и достаточной производительностью для хранения метрик одного хоста.
+
+#### 3.5.2 Схема базы данных
+
+```sql
+-- Метрики процессов
+CREATE TABLE metrics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ pid INTEGER,
+ name TEXT,
+ cpu_percent REAL,
+ memory_mb REAL,
+ open_files INTEGER,
+ connections INTEGER,
+ is_anomaly INTEGER DEFAULT 0 -- 0=normal, 1=anomaly
+);
+
+-- Алерты безопасности
+CREATE TABLE alerts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ pid INTEGER,
+ process_name TEXT,
+ reason TEXT, -- человекочитаемое описание аномалии
+ severity TEXT -- low | medium | high
+);
+
+-- События файловой системы
+CREATE TABLE file_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ path TEXT,
+ event_type TEXT -- create | modify | delete
+);
+```
+
+#### 3.5.3 REST API
+
+| Метод | Эндпоинт | Описание |
+|--------|----------|----------|
+| `GET` | `/api/stats` | Агрегированная статистика для дашборда |
+| `GET` | `/api/metrics?limit=200` | Последние метрики процессов |
+| `GET` | `/api/alerts?limit=50` | Последние алерты безопасности |
+| `GET` | `/api/file-events?limit=100` | Последние события ФС |
+| `POST` | `/api/metrics` | Приём батча от агента |
+
+#### 3.5.4 WebSocket
+
+Эндпоинт `/ws` реализует паттерн publish-subscribe. При поступлении батча с аномальными процессами бэкенд транслирует каждому подключённому клиенту (dashboard) JSON-сообщение:
+
+```json
+{
+ "type": "alert",
+ "pid": 4567,
+ "process_name": "python3",
+ "reason": "Anomalous behaviour: CPU 95.2%, MEM 3200 MB",
+ "severity": "high",
+ "timestamp": "2025-05-28T14:30:05.000Z"
+}
+```
+
+Уровень серьёзности (severity) определяется эвристически:
+- `high`: CPU > 80% или RAM > 2 ГБ;
+- `medium`: CPU > 50% или RAM > 1 ГБ;
+- `low`: статистический выброс без пороговых нарушений.
+
+---
+
+### 3.6 Веб-интерфейс
+
+#### 3.6.1 Технологический стек
+
+Веб-интерфейс разработан в виде Single-Page Application (SPA) на следующем стеке:
+
+| Технология | Версия | Назначение |
+|-----------|--------|-----------|
+| React | 18.3 | UI-фреймворк с декларативным рендерингом |
+| TypeScript | 5.4 | Статическая типизация JavaScript |
+| Vite | 5.3 | Сборщик с мгновенным HMR |
+| Tailwind CSS | 3.4 | Utility-first CSS-фреймворк |
+| Radix UI | 1.x | Headless UI-примитивы (доступность) |
+| Recharts | 2.12 | Графики на основе D3.js |
+| TanStack Query | 5.x | Серверный state-менеджмент с кешированием |
+
+Дизайн выполнен в стиле **SOC-dashboard** (Security Operations Center): тёмный фон (#0a0a0f), акцентные цвета cyan (#00ff9d) и blue (#00d4ff), монospace-шрифт JetBrains Mono, декоративные сетка и эффект CRT-строк.
+
+#### 3.6.2 Структура интерфейса
+
+Интерфейс разделён на четыре функциональных раздела, доступных через вкладки:
+
+**Вкладка Overview (Обзор):**
+- Пять карточек-счётчиков: всего процессов, аномалий за сутки, алертов за сутки, средняя загрузка CPU, средний объём RAM.
+- График реального времени (Recharts LineChart): две линии — загрузка CPU% и RAM% системы за последние 60 точек измерений. Обновляется каждые 5 секунд через TanStack Query polling.
+
+**Вкладка Processes (Процессы):**
+- Таблица всех текущих процессов с колонками: PID, Имя, CPU%, RAM MB, TCP-соединения, Открытые файлы, Статус.
+- Отображается последний снимок (snapshot) для каждого уникального PID.
+- Сортировка по убыванию CPU%.
+- Аномальные строки выделены красной левой рамкой и красным фоном.
+- Статус-бейджи: зелёный «Normal» / красный «Anomaly».
+
+**Вкладка Alerts (Алерты):**
+- Таблица алертов: время, PID, имя процесса, описание аномалии, уровень серьёзности (цветные бейджи: синий/жёлтый/красный).
+- Индикатор состояния WebSocket-соединения (зелёная/красная точка).
+- Новые алерты появляются вверху таблицы.
+
+**Вкладка File Events (События ФС):**
+- Таблица событий файловой системы: время, путь, тип события (create/modify/delete) с цветными бейджами.
+
+**Toast-уведомления:**
+При получении алерта по WebSocket в правом нижнем углу появляется всплывающее уведомление с именем процесса, описанием аномалии и цветовой индикацией серьёзности. Исчезает через 5 секунд или по нажатию.
+
+#### 3.6.3 Архитектура обмена данными
+
+```
+TanStack Query WebSocket
+(polling 5s) (push)
+ │ │
+ ▼ ▼
+api.metrics() WS.onmessage()
+api.alerts() → _emitToast()
+api.stats() → queryClient.invalidate()
+api.fileEvents()
+ │
+ ▼
+React state → re-render
+```
+
+TanStack Query кеширует ответы API и автоматически перезапрашивает данные каждые 5 секунд (staleTime: 4000ms). При поступлении WebSocket-алерта принудительно инвалидируется кеш `/api/alerts` и `/api/stats`, что вызывает немедленный повторный запрос без ожидания следующего интервала.
+
+---
+
+### 3.7 Результаты тестирования
+
+#### 3.7.1 Тест: CPU-аномалия
+
+**Условие:** на тестовом хосте (Ubuntu 22.04, 4 ядра, 8 ГБ RAM) запущен стресс-тест:
+```bash
+stress-ng --cpu 4 --timeout 30s
+```
+
+**Результат:**
+- через 5–10 секунд процесс `stress-ng-cpu` появился в таблице Processes с отметкой «Anomaly»;
+- в таблице Alerts появилась запись severity=high с описанием «CPU 98.4%»;
+- в правом нижнем углу отобразилось toast-уведомление;
+- после завершения стресс-теста процесс вернул статус Normal.
+
+**Время реакции:** 5–10 секунд (один цикл сбора метрик).
+
+#### 3.7.2 Тест: Memory-аномалия
+
+**Условие:** Python-скрипт выделяет 3 ГБ памяти:
+```python
+x = bytearray(3 * 1024 * 1024 * 1024)
+import time; time.sleep(60)
+```
+
+**Результат:** алерт severity=high с описанием «MEM 3072 MB», время реакции — 5 с.
+
+#### 3.7.3 Тест: Filesystem-событие
+
+**Условие:** создание файла в `/tmp`:
+```bash
+echo "malware" > /tmp/evil.sh
+chmod +x /tmp/evil.sh
+```
+
+**Результат:** событие `create` с путём `/tmp/evil.sh` появилось во вкладке File Events в течение 1 секунды (inotify работает в реальном времени).
+
+---
+
+### 3.8 Выводы по практической части
+
+В ходе выполнения практической части дипломной работы:
+
+1. Разработана трёхзвенная система EDR, включающая агент мониторинга, серверную часть и веб-интерфейс.
+
+2. Реализован ансамблевый модуль машинного обучения, объединяющий три алгоритма обнаружения аномалий без учителя: Isolation Forest, Local Outlier Factor и One-Class SVM. Применение правила большинства голосов (2 из 3) снижает количество ложных срабатываний по сравнению с единственным классификатором и повышает устойчивость к различным типам аномалий.
+
+3. Обеспечена интеграция с датасетом UNSW-NB15 для обучения на реальных данных сетевых атак; реализован резервный режим на синтетических данных с реалистичным распределением поведения Linux-процессов.
+
+4. Реализован механизм оповещений в режиме реального времени через WebSocket, обеспечивающий время реакции не более одного цикла сбора метрик (5 секунд).
+
+5. Разработан веб-интерфейс профессионального уровня с поддержкой динамических графиков, цветовой индикацией угроз и push-уведомлениями.
+
+6. Система успешно выявляет следующие типы аномалий: аномально высокую нагрузку на CPU (криптомайнеры, brute-force), чрезмерное потребление памяти (эксплойты, утечки), избыточное число сетевых соединений (C2-коммуникации, DDoS), подозрительную активность в файловой системе.
+
+---
+
+## Список использованных технологий и библиотек
+
+| Компонент | Технология | Версия | Лицензия |
+|-----------|-----------|--------|---------|
+| Агент | Python | 3.11 | PSF |
+| Агент | psutil | ≥5.9 | BSD-3 |
+| Агент | watchdog | ≥3.0 | Apache-2.0 |
+| Агент | scikit-learn (IF + LOF + OC-SVM) | ≥1.3 | BSD-3 |
+| Агент | joblib | ≥1.3 | BSD-3 |
+| Бэкенд | FastAPI | ≥0.111 | MIT |
+| Бэкенд | aiosqlite | ≥0.20 | MIT |
+| Бэкенд | uvicorn | ≥0.29 | BSD-3 |
+| Фронтенд | React | 18.3 | MIT |
+| Фронтенд | TypeScript | 5.4 | Apache-2.0 |
+| Фронтенд | Vite | 5.3 | MIT |
+| Фронтенд | Tailwind CSS | 3.4 | MIT |
+| Фронтенд | Recharts | 2.12 | MIT |
+| Фронтенд | TanStack Query | 5.x | MIT |
+| Фронтенд | Radix UI | 1.x | MIT |
+| Контейнеризация | Docker Compose | 3.9 | Apache-2.0 |
+
+---
+
+*Документ подготовлен в рамках дипломной работы по теме «Разработка системы обнаружения вредоносного программного обеспечения на основе поведенческого анализа».*
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..3be2309
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.git
+*.log
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..998d7d4
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,20 @@
+# Stage 1 — сборка React-приложения
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+# Stage 2 — nginx для отдачи статики и проксирования API/WS
+FROM nginx:alpine
+
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 3000
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..bc5edb6
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ EDR Монитор — Поведенческий анализ угроз
+
+
+
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..96038a2
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,29 @@
+server {
+ listen 3000;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA fallback — все не-файловые пути → index.html
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # REST API → бэкенд (путь /api/... передаётся без изменений)
+ location /api/ {
+ proxy_pass http://backend:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_read_timeout 30s;
+ }
+
+ # WebSocket → бэкенд
+ location /ws {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "Upgrade";
+ proxy_set_header Host $host;
+ proxy_read_timeout 3600s;
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..ce5f4ce
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "edr-dashboard",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
+ "@tanstack/react-query": "^5.45.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.394.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "recharts": "^2.12.7",
+ "tailwind-merge": "^2.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.1",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.4.5",
+ "vite": "^5.3.1"
+ }
+}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..8e6b911
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,2405 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@radix-ui/react-slot':
+ specifier: ^1.0.2
+ version: 1.2.4(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-tabs':
+ specifier: ^1.0.4
+ version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-toast':
+ specifier: ^1.1.5
+ version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-query':
+ specifier: ^5.45.0
+ version: 5.100.14(react@18.3.1)
+ class-variance-authority:
+ specifier: ^0.7.0
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ lucide-react:
+ specifier: ^0.394.0
+ version: 0.394.0(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ recharts:
+ specifier: ^2.12.7
+ version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ tailwind-merge:
+ specifier: ^2.3.0
+ version: 2.6.1
+ devDependencies:
+ '@types/node':
+ specifier: ^25.9.1
+ version: 25.9.1
+ '@types/react':
+ specifier: ^18.3.3
+ version: 18.3.29
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.7(@types/react@18.3.29)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.1
+ version: 4.7.0(vite@5.4.21(@types/node@25.9.1))
+ autoprefixer:
+ specifier: ^10.4.19
+ version: 10.5.0(postcss@8.5.15)
+ postcss:
+ specifier: ^8.4.38
+ version: 8.5.15
+ tailwindcss:
+ specifier: ^3.4.4
+ version: 3.4.19
+ typescript:
+ specifier: ^5.4.5
+ version: 5.9.3
+ vite:
+ specifier: ^5.3.1
+ version: 5.4.21(@types/node@25.9.1)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-toast@1.2.15':
+ resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.3':
+ resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tanstack/query-core@5.100.14':
+ resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
+
+ '@tanstack/react-query@5.100.14':
+ resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==}
+ peerDependencies:
+ react: ^18 || ^19
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/node@25.9.1':
+ resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.29':
+ resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ autoprefixer@10.5.0:
+ resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ baseline-browser-mapping@2.10.32:
+ resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+ dom-helpers@5.2.1:
+ resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+
+ electron-to-chromium@1.5.362:
+ resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
+ fast-equals@5.4.0:
+ resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
+ engines: {node: '>=6.0.0'}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ hasown@2.0.3:
+ resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ engines: {node: '>= 0.4'}
+
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.394.0:
+ resolution: {integrity: sha512-PzTbJ0bsyXRhH59k5qe7MpTd5MxlpYZUcM9kGSwvPGAfnn0J6FElDwu2EX6Vuh//F7y60rcVJiFQ7EK9DCMgfw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-releases@2.0.46:
+ resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
+ engines: {node: '>=18'}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.2:
+ resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-is@18.3.1:
+ resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react-smooth@4.0.4:
+ resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
+ 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
+
+ react-transition-group@4.4.5:
+ resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+ peerDependencies:
+ react: '>=16.6.0'
+ react-dom: '>=16.6.0'
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ recharts-scale@0.4.5:
+ resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
+
+ recharts@2.15.4:
+ resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
+ engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rollup@4.60.4:
+ resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwind-merge@2.6.1:
+ resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
+
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ victory-vendor@36.9.2:
+ resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ optional: true
+
+ '@tanstack/query-core@5.100.14': {}
+
+ '@tanstack/react-query@5.100.14(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-core': 5.100.14
+ react: 18.3.1
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/estree@1.0.8': {}
+
+ '@types/node@25.9.1':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.29)':
+ dependencies:
+ '@types/react': 18.3.29
+
+ '@types/react@18.3.29':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.1))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 5.4.21(@types/node@25.9.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
+ autoprefixer@10.5.0(postcss@8.5.15):
+ dependencies:
+ browserslist: 4.28.2
+ caniuse-lite: 1.0.30001793
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+
+ baseline-browser-mapping@2.10.32: {}
+
+ binary-extensions@2.3.0: {}
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.32
+ caniuse-lite: 1.0.30001793
+ electron-to-chromium: 1.5.362
+ node-releases: 2.0.46
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ camelcase-css@2.0.1: {}
+
+ caniuse-lite@1.0.30001793: {}
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ clsx@2.1.1: {}
+
+ commander@4.1.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decimal.js-light@2.5.1: {}
+
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
+ dom-helpers@5.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ csstype: 3.2.3
+
+ electron-to-chromium@1.5.362: {}
+
+ es-errors@1.3.0: {}
+
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
+ escalade@3.2.0: {}
+
+ eventemitter3@4.0.7: {}
+
+ fast-equals@5.4.0: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ fraction.js@5.3.4: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ hasown@2.0.3:
+ dependencies:
+ function-bind: 1.1.2
+
+ internmap@2.0.3: {}
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.3
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-number@7.0.0: {}
+
+ jiti@1.21.7: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
+ lodash@4.18.1: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.394.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ ms@2.1.3: {}
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ nanoid@3.3.12: {}
+
+ node-releases@2.0.46: {}
+
+ normalize-path@3.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
+ path-parse@1.0.7: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.4: {}
+
+ pify@2.3.0: {}
+
+ pirates@4.0.7: {}
+
+ postcss-import@15.1.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.12
+
+ postcss-js@4.1.0(postcss@8.5.15):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.15
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.15
+
+ postcss-nested@6.2.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-selector-parser: 6.1.2
+
+ postcss-selector-parser@6.1.2:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ queue-microtask@1.2.3: {}
+
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-is@16.13.1: {}
+
+ react-is@18.3.1: {}
+
+ react-refresh@0.17.0: {}
+
+ react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ fast-equals: 5.4.0
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
+ react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ dom-helpers: 5.2.1
+ loose-envify: 1.4.0
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
+ recharts-scale@0.4.5:
+ dependencies:
+ decimal.js-light: 2.5.1
+
+ recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ clsx: 2.1.1
+ eventemitter3: 4.0.7
+ lodash: 4.18.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-is: 18.3.1
+ react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ recharts-scale: 0.4.5
+ tiny-invariant: 1.3.3
+ victory-vendor: 36.9.2
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ reusify@1.1.0: {}
+
+ rollup@4.60.4:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.4
+ '@rollup/rollup-android-arm64': 4.60.4
+ '@rollup/rollup-darwin-arm64': 4.60.4
+ '@rollup/rollup-darwin-x64': 4.60.4
+ '@rollup/rollup-freebsd-arm64': 4.60.4
+ '@rollup/rollup-freebsd-x64': 4.60.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.4
+ '@rollup/rollup-linux-arm64-gnu': 4.60.4
+ '@rollup/rollup-linux-arm64-musl': 4.60.4
+ '@rollup/rollup-linux-loong64-gnu': 4.60.4
+ '@rollup/rollup-linux-loong64-musl': 4.60.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.4
+ '@rollup/rollup-linux-ppc64-musl': 4.60.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.4
+ '@rollup/rollup-linux-riscv64-musl': 4.60.4
+ '@rollup/rollup-linux-s390x-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-musl': 4.60.4
+ '@rollup/rollup-openbsd-x64': 4.60.4
+ '@rollup/rollup-openharmony-arm64': 4.60.4
+ '@rollup/rollup-win32-arm64-msvc': 4.60.4
+ '@rollup/rollup-win32-ia32-msvc': 4.60.4
+ '@rollup/rollup-win32-x64-gnu': 4.60.4
+ '@rollup/rollup-win32-x64-msvc': 4.60.4
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ semver@6.3.1: {}
+
+ source-map-js@1.2.1: {}
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.16
+ ts-interface-checker: 0.1.13
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwind-merge@2.6.1: {}
+
+ tailwindcss@3.4.19:
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-import: 15.1.0(postcss@8.5.15)
+ postcss-js: 4.1.0(postcss@8.5.15)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)
+ postcss-nested: 6.2.0(postcss@8.5.15)
+ postcss-selector-parser: 6.1.2
+ resolve: 1.22.12
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ tiny-invariant@1.3.3: {}
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ ts-interface-checker@0.1.13: {}
+
+ typescript@5.9.3: {}
+
+ undici-types@7.24.6: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ util-deprecate@1.0.2: {}
+
+ victory-vendor@36.9.2:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
+ vite@5.4.21(@types/node@25.9.1):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.15
+ rollup: 4.60.4
+ optionalDependencies:
+ '@types/node': 25.9.1
+ fsevents: 2.3.3
+
+ yallist@3.1.1: {}
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
new file mode 100644
index 0000000..5ed0b5a
--- /dev/null
+++ b/frontend/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: true
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..fa25473
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useRef, useState } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { Shield, Activity, Bell, FolderSearch, AlertTriangle } from 'lucide-react'
+import type { WsAlert } from '@/lib/api'
+import { Overview } from '@/components/Overview'
+import { Processes } from '@/components/Processes'
+import { Alerts } from '@/components/Alerts'
+import { FileEvents } from '@/components/FileEvents'
+import { Toaster, _emitToast } from '@/components/ui/toaster'
+
+type Tab = 'overview' | 'processes' | 'alerts' | 'file-events'
+
+const TABS: { id: Tab; label: string; Icon: React.ElementType }[] = [
+ { id: 'overview', label: 'Обзор', Icon: Activity },
+ { id: 'processes', label: 'Процессы', Icon: Shield },
+ { id: 'alerts', label: 'Алерты', Icon: Bell },
+ { id: 'file-events', label: 'События ФС', Icon: FolderSearch },
+]
+
+export default function App() {
+ const [tab, setTab] = useState('overview')
+ const [wsConnected, setWsConnected] = useState(false)
+ const qc = useQueryClient()
+ const wsRef = useRef(null)
+
+ useEffect(() => {
+ let retryTimer: ReturnType
+
+ function connect() {
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws'
+ const ws = new WebSocket(`${proto}://${location.host}/ws`)
+ wsRef.current = ws
+
+ ws.onopen = () => setWsConnected(true)
+
+ ws.onmessage = (ev) => {
+ try {
+ const msg: WsAlert = JSON.parse(ev.data as string)
+ if (msg.type !== 'alert') return
+
+ _emitToast({
+ id: crypto.randomUUID(),
+ title: `⚠ Аномалия: ${msg.process_name} [PID ${msg.pid}]`,
+ description: msg.reason,
+ variant: msg.severity === 'high' ? 'destructive' : 'default',
+ })
+
+ qc.invalidateQueries({ queryKey: ['alerts'] })
+ qc.invalidateQueries({ queryKey: ['stats'] })
+ } catch {
+ // игнорируем битые фреймы
+ }
+ }
+
+ ws.onclose = () => {
+ setWsConnected(false)
+ retryTimer = setTimeout(connect, 3_000)
+ }
+
+ ws.onerror = () => ws.close()
+ }
+
+ connect()
+ return () => {
+ clearTimeout(retryTimer)
+ wsRef.current?.close()
+ }
+ }, [qc])
+
+ return (
+
+ {/* ── Шапка ──────────────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+
+
+ EDR Монитор
+
+
+ Поведенческий анализ угроз
+
+
+
+
+
+
+
+
+
+ {wsConnected ? 'ОНЛАЙН' : 'ПЕРЕПОДКЛЮЧЕНИЕ…'}
+
+
+
+
+
+
+ {/* ── Вкладки ────────────────────────────────────────────────────── */}
+
+
+ {/* ── Контент ────────────────────────────────────────────────────── */}
+
+ {tab === 'overview' && }
+ {tab === 'processes' && }
+ {tab === 'alerts' && }
+ {tab === 'file-events' && }
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/Alerts.tsx b/frontend/src/components/Alerts.tsx
new file mode 100644
index 0000000..d5abeca
--- /dev/null
+++ b/frontend/src/components/Alerts.tsx
@@ -0,0 +1,102 @@
+import { useQuery } from '@tanstack/react-query'
+import { api, type Alert } from '@/lib/api'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { fmtFull } from '@/lib/utils'
+
+type SeverityVariant = 'destructive' | 'warning' | 'info'
+
+function severityVariant(s: Alert['severity']): SeverityVariant {
+ if (s === 'high') return 'destructive'
+ if (s === 'medium') return 'warning'
+ return 'info'
+}
+
+function severityLabel(s: Alert['severity']): string {
+ if (s === 'high') return 'высокий'
+ if (s === 'medium') return 'средний'
+ return 'низкий'
+}
+
+interface Props {
+ wsConnected: boolean
+}
+
+export function Alerts({ wsConnected }: Props) {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['alerts'],
+ queryFn: () => api.alerts(),
+ refetchInterval: 5_000,
+ })
+
+ return (
+
+
+
+
Алерты безопасности
+
+
+ {wsConnected ? 'WebSocket активен' : 'WebSocket отключён'}
+
+
+
+
+ {isLoading && (
+ Загрузка…
+ )}
+ {isError && (
+ Ошибка загрузки алертов.
+ )}
+ {!isLoading && !isError && (!data || data.length === 0) && (
+
+ Алертов нет. Система работает штатно.
+
+ )}
+ {data && data.length > 0 && (
+
+
+
+ Время
+ PID
+ Процесс
+ Причина
+ Серьёзность
+
+
+
+ {data.map((a) => (
+
+
+ {fmtFull(a.timestamp)}
+
+ {a.pid}
+ {a.process_name}
+ {a.reason}
+
+
+ {severityLabel(a.severity)}
+
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/FileEvents.tsx b/frontend/src/components/FileEvents.tsx
new file mode 100644
index 0000000..cd7b230
--- /dev/null
+++ b/frontend/src/components/FileEvents.tsx
@@ -0,0 +1,87 @@
+import { useQuery } from '@tanstack/react-query'
+import { api, type FileEvent } from '@/lib/api'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { fmtFull } from '@/lib/utils'
+
+type EventVariant = 'destructive' | 'warning' | 'info'
+
+function eventVariant(t: FileEvent['event_type']): EventVariant {
+ if (t === 'delete') return 'destructive'
+ if (t === 'create') return 'info'
+ return 'warning'
+}
+
+function eventLabel(t: FileEvent['event_type']): string {
+ if (t === 'create') return 'создание'
+ if (t === 'modify') return 'изменение'
+ return 'удаление'
+}
+
+export function FileEvents() {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['file-events'],
+ queryFn: () => api.fileEvents(),
+ refetchInterval: 5_000,
+ })
+
+ return (
+
+
+
+ События файловой системы
+
+ /etc · /tmp · /var/log
+
+
+
+
+ {isLoading && (
+ Загрузка…
+ )}
+ {isError && (
+ Ошибка загрузки событий.
+ )}
+ {!isLoading && !isError && (!data || data.length === 0) && (
+
+ Событий файловой системы не обнаружено.
+
+ )}
+ {data && data.length > 0 && (
+
+
+
+ Время
+ Путь
+ Событие
+
+
+
+ {data.map((e) => (
+
+
+ {fmtFull(e.timestamp)}
+
+ {e.path}
+
+
+ {eventLabel(e.event_type)}
+
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/Overview.tsx b/frontend/src/components/Overview.tsx
new file mode 100644
index 0000000..d5f975a
--- /dev/null
+++ b/frontend/src/components/Overview.tsx
@@ -0,0 +1,106 @@
+import { useQuery } from '@tanstack/react-query'
+import { Activity, AlertTriangle, Bell, Cpu, Database } from 'lucide-react'
+import { api } from '@/lib/api'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { SystemChart } from '@/components/SystemChart'
+
+function StatCard({
+ title,
+ value,
+ icon: Icon,
+ accent = '#00ff9d',
+}: {
+ title: string
+ value: string | number
+ icon: React.ElementType
+ accent?: string
+}) {
+ return (
+
+
+
+ {title}
+
+
+
+
+ {value}
+
+
+
+
+
+ )
+}
+
+export function Overview() {
+ const { data: stats } = useQuery({
+ queryKey: ['stats'],
+ queryFn: api.stats,
+ refetchInterval: 5_000,
+ })
+
+ // Системные метрики — одна точка на батч (каждые 5 с), хранятся в отдельной таблице
+ const { data: sysRows } = useQuery({
+ queryKey: ['system-metrics'],
+ queryFn: () => api.systemMetrics(60),
+ refetchInterval: 5_000,
+ })
+
+ const chartData = (sysRows ?? []).map((r) => ({
+ t: r.timestamp.slice(11, 19), // HH:MM:SS
+ cpu: Math.round(r.cpu_percent * 10) / 10,
+ ram: Math.round(r.ram_percent * 10) / 10,
+ }))
+
+ return (
+
+ {/* Карточки */}
+
+
+
+
+
+
+
+
+ {/* График */}
+
+
+ Нагрузка системы — реальное время (60 точек)
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/Processes.tsx b/frontend/src/components/Processes.tsx
new file mode 100644
index 0000000..652dae7
--- /dev/null
+++ b/frontend/src/components/Processes.tsx
@@ -0,0 +1,108 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { api, type ProcessMetric } from '@/lib/api'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+
+// Оставляем только последний снимок по каждому PID
+function latestByPid(rows: ProcessMetric[]): ProcessMetric[] {
+ const map = new Map()
+ for (const r of rows) {
+ const existing = map.get(r.pid)
+ if (!existing || r.id > existing.id) map.set(r.pid, r)
+ }
+ return [...map.values()].sort((a, b) => b.cpu_percent - a.cpu_percent)
+}
+
+export function Processes() {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['metrics', 500],
+ queryFn: () => api.metrics(500),
+ refetchInterval: 5_000,
+ })
+
+ const processes = useMemo(() => latestByPid(data ?? []), [data])
+
+ return (
+
+
+
+ Запущенные процессы
+
+ {processes.length} процессов · сортировка по CPU↓
+
+
+
+
+ {isLoading && (
+ Загрузка…
+ )}
+ {isError && (
+ Ошибка загрузки метрик.
+ )}
+ {!isLoading && !isError && processes.length === 0 && (
+
+ Данных ещё нет. Агент запущен?
+
+ )}
+ {processes.length > 0 && (
+
+
+
+ PID
+ Имя
+ CPU %
+ RAM МБ
+ Соединения
+ Файлы
+ Статус
+
+
+
+ {processes.map((p) => (
+
+ {p.pid}
+
+
+ {p.name}
+
+
+
+ 50 ? 'text-yellow-400' : ''}>
+ {p.cpu_percent.toFixed(1)}
+
+
+
+ {p.memory_mb.toFixed(0)}
+
+ {p.connections}
+ {p.open_files}
+
+ {p.is_anomaly ? (
+ Аномалия
+ ) : (
+ Норма
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/SystemChart.tsx b/frontend/src/components/SystemChart.tsx
new file mode 100644
index 0000000..810867e
--- /dev/null
+++ b/frontend/src/components/SystemChart.tsx
@@ -0,0 +1,85 @@
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from 'recharts'
+
+interface Point {
+ t: string
+ cpu: number
+ ram: number
+}
+
+interface Props {
+ data: Point[]
+}
+
+export function SystemChart({ data }: Props) {
+ if (data.length === 0) {
+ return (
+
+ Ожидание данных…
+
+ )
+ }
+
+ return (
+
+
+
+
+ `${v}%`}
+ />
+ [
+ `${Number(value).toFixed(1)}%`,
+ name === 'cpu' ? 'CPU' : 'RAM',
+ ]}
+ />
+
+
+ )
+}
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..19ae39f
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,29 @@
+import { type HTMLAttributes } from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-semibold tracking-wider uppercase transition-colors',
+ {
+ variants: {
+ variant: {
+ default: 'border-transparent bg-primary/20 text-primary',
+ secondary: 'border-transparent bg-secondary/20 text-secondary',
+ destructive: 'border-transparent bg-destructive/20 text-red-400 border-red-500/30',
+ outline: 'border-border text-foreground',
+ success: 'border-transparent bg-green-500/15 text-green-400 border-green-500/30',
+ warning: 'border-transparent bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
+ info: 'border-transparent bg-blue-500/15 text-blue-400 border-blue-500/30',
+ },
+ },
+ defaultVariants: { variant: 'default' },
+ },
+)
+
+export interface BadgeProps
+ extends HTMLAttributes,
+ VariantProps {}
+
+export function Badge({ className, variant, ...props }: BadgeProps) {
+ return
+}
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..f73cda1
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,41 @@
+import { type HTMLAttributes, forwardRef } from 'react'
+import { cn } from '@/lib/utils'
+
+export const Card = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+Card.displayName = 'Card'
+
+export const CardHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+CardHeader.displayName = 'CardHeader'
+
+export const CardTitle = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+CardTitle.displayName = 'CardTitle'
+
+export const CardContent = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+CardContent.displayName = 'CardContent'
diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx
new file mode 100644
index 0000000..8d5814a
--- /dev/null
+++ b/frontend/src/components/ui/table.tsx
@@ -0,0 +1,64 @@
+import { type HTMLAttributes, type TdHTMLAttributes, type ThHTMLAttributes, forwardRef } from 'react'
+import { cn } from '@/lib/utils'
+
+export const Table = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+Table.displayName = 'Table'
+
+export const TableHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+TableHeader.displayName = 'TableHeader'
+
+export const TableBody = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+TableBody.displayName = 'TableBody'
+
+export const TableRow = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+TableRow.displayName = 'TableRow'
+
+export const TableHead = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+)
+TableHead.displayName = 'TableHead'
+
+export const TableCell = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+)
+TableCell.displayName = 'TableCell'
diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..3544a98
--- /dev/null
+++ b/frontend/src/components/ui/toaster.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useState } from 'react'
+import * as Toast from '@radix-ui/react-toast'
+import { X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { type ToastMessage } from '@/hooks/use-toast'
+
+// Global event bus — avoids prop drilling to the root
+type Listener = (t: ToastMessage) => void
+const _bus: Listener[] = []
+export function _emitToast(t: ToastMessage) {
+ _bus.forEach((fn) => fn(t))
+}
+export function _subscribeToast(fn: Listener) {
+ _bus.push(fn)
+ return () => {
+ const i = _bus.indexOf(fn)
+ if (i !== -1) _bus.splice(i, 1)
+ }
+}
+
+export function Toaster() {
+ const [toasts, setToasts] = useState([])
+
+ useEffect(() => {
+ return _subscribeToast((t) =>
+ setToasts((prev) => [t, ...prev].slice(0, 5)),
+ )
+ }, [])
+
+ const remove = (id: string) =>
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+
+ return (
+
+ {toasts.map((t) => (
+ !open && remove(t.id)}
+ duration={5000}
+ className={cn(
+ 'flex items-start gap-3 rounded-lg border p-4 shadow-lg',
+ 'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-4',
+ 'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-full',
+ t.variant === 'destructive'
+ ? 'border-red-500/40 bg-[#1a0a0a] text-red-300'
+ : 'border-[#00ff9d33] bg-[#0d1a13] text-[#00ff9d]',
+ )}
+ >
+
+ {t.title}
+ {t.description && (
+
+ {t.description}
+
+ )}
+
+ remove(t.id)}
+ className="shrink-0 opacity-60 hover:opacity-100 transition-opacity"
+ >
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/hooks/use-toast.ts b/frontend/src/hooks/use-toast.ts
new file mode 100644
index 0000000..0f432fc
--- /dev/null
+++ b/frontend/src/hooks/use-toast.ts
@@ -0,0 +1,27 @@
+import { useState, useCallback } from 'react'
+
+export interface ToastMessage {
+ id: string
+ title: string
+ description?: string
+ variant?: 'default' | 'destructive'
+}
+
+let _listeners: Array<(t: ToastMessage) => void> = []
+
+export function useToast() {
+ const toast = useCallback((msg: Omit) => {
+ const full: ToastMessage = { ...msg, id: crypto.randomUUID() }
+ _listeners.forEach((fn) => fn(full))
+ }, [])
+ return { toast }
+}
+
+export function useToastListener(fn: (t: ToastMessage) => void) {
+ useState(() => {
+ _listeners.push(fn)
+ return () => {
+ _listeners = _listeners.filter((l) => l !== fn)
+ }
+ })
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..5f213ba
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,63 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 240 10% 4%;
+ --foreground: 180 8% 85%;
+ --card: 240 10% 7%;
+ --card-foreground: 180 8% 85%;
+ --popover: 240 10% 9%;
+ --popover-foreground: 180 8% 85%;
+ --primary: 152 100% 50%; /* #00ff9d cyan-green */
+ --primary-foreground: 240 10% 4%;
+ --secondary: 195 100% 42%; /* #00d4ff blue-cyan */
+ --secondary-foreground: 240 10% 4%;
+ --muted: 240 6% 14%;
+ --muted-foreground: 240 5% 50%;
+ --accent: 240 6% 14%;
+ --accent-foreground: 180 8% 85%;
+ --destructive: 0 72% 55%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 240 6% 18%;
+ --input: 240 6% 18%;
+ --ring: 152 100% 50%;
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * { @apply border-border; box-sizing: border-box; }
+
+ body {
+ @apply bg-[#0a0a0f] text-foreground font-mono;
+ /* subtle grid lines */
+ background-image:
+ linear-gradient(rgba(0,255,157,.018) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0,255,157,.018) 1px, transparent 1px);
+ background-size: 40px 40px;
+ }
+}
+
+/* CRT scanline overlay */
+body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0,0,0,.04) 2px,
+ rgba(0,0,0,.04) 4px
+ );
+ pointer-events: none;
+ z-index: 9999;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 5px; height: 5px; }
+::-webkit-scrollbar-track { background: #0a0a0f; }
+::-webkit-scrollbar-thumb { background: rgba(0,255,157,.2); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(0,255,157,.4); }
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..de8a24b
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,72 @@
+const BASE = '/api'
+
+// ── types ─────────────────────────────────────────────────────────────────────
+
+export interface ProcessMetric {
+ id: number
+ timestamp: string
+ pid: number
+ name: string
+ cpu_percent: number
+ memory_mb: number
+ open_files: number
+ connections: number
+ is_anomaly: number // 0 | 1 (SQLite INTEGER)
+}
+
+export interface Alert {
+ id: number
+ timestamp: string
+ pid: number
+ process_name: string
+ reason: string
+ severity: 'low' | 'medium' | 'high'
+}
+
+export interface FileEvent {
+ id: number
+ timestamp: string
+ path: string
+ event_type: 'create' | 'modify' | 'delete'
+}
+
+export interface Stats {
+ total_processes: number
+ anomalies_today: number
+ alerts_today: number
+ cpu_avg: number
+ ram_avg: number
+}
+
+export interface SystemMetricRow {
+ id: number
+ timestamp: string
+ cpu_percent: number
+ ram_percent: number
+ net_connections: number
+}
+
+export interface WsAlert {
+ type: 'alert'
+ pid: number
+ process_name: string
+ reason: string
+ severity: 'low' | 'medium' | 'high'
+ timestamp: string
+}
+
+// ── fetchers ──────────────────────────────────────────────────────────────────
+
+async function get(url: string): Promise {
+ const res = await fetch(url)
+ if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
+ return res.json() as Promise
+}
+
+export const api = {
+ metrics: (limit = 200) => get(`${BASE}/metrics?limit=${limit}`),
+ alerts: (limit = 50) => get(`${BASE}/alerts?limit=${limit}`),
+ fileEvents: (limit = 100) => get(`${BASE}/file-events?limit=${limit}`),
+ stats: () => get(`${BASE}/stats`),
+ systemMetrics: (limit = 60) => get(`${BASE}/system-metrics?limit=${limit}`),
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..f4d9afe
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,18 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function fmt(ts: string): string {
+ return new Date(ts).toLocaleTimeString('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+}
+
+export function fmtFull(ts: string): string {
+ return new Date(ts).toLocaleString('ru-RU')
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..9657cb7
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,19 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import App from './App'
+import './index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 4_000, retry: 1, refetchOnWindowFocus: false },
+ },
+})
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..3515262
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,34 @@
+import type { Config } from 'tailwindcss'
+
+const config: Config = {
+ darkMode: ['class'],
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
+ accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
+ destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
+ primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
+ secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+ fontFamily: {
+ mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
+ },
+ },
+ },
+ plugins: [],
+}
+
+export default config
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..cd130ee
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..3acf09e
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..522d174
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { '@': path.resolve(__dirname, './src') },
+ },
+ server: {
+ port: 3000,
+ proxy: {
+ '/api': 'http://localhost:8000',
+ '/ws': { target: 'ws://localhost:8000', ws: true },
+ },
+ },
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..08e26a4
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { '@': path.resolve(__dirname, './src') },
+ },
+ server: {
+ port: 3000,
+ proxy: {
+ '/api': 'http://localhost:8000',
+ '/ws': { target: 'ws://localhost:8000', ws: true },
+ },
+ },
+})