From ead75ae4512cd64a34e1ca2bd9a251c7372236f3 Mon Sep 17 00:00:00 2001 From: Till Tomczak Date: Sat, 24 May 2025 17:47:05 +0200 Subject: [PATCH] Remove deprecated backend files and documentation, including Docker configurations, environment variables, and various scripts, to streamline the project structure and eliminate unused components. --- Dokumentation.md | 47 - README.md | 475 - SEPARATE_SERVERS_GUIDE.md | 436 - backend/.env | 5 - backend/.gitignore | 48 - backend/COMMON_ERRORS.md | 375 + backend/Dockerfile | 52 - backend/Dockerfile.dev | 49 - backend/PRODUCTION_SETUP.md | 327 - backend/README.md | 120 + backend/README_BACKEND_SCRIPTS.md | 1 - backend/ROADMAP.md | 217 + backend/app.py | 1958 --- backend/app/app.py | 1001 ++ backend/app/config/settings.py | 66 + backend/app/models.py | 180 + backend/app/utils/__init__.py | 1 + backend/app/utils/job_scheduler.py | 230 + backend/app/utils/logging_config.py | 101 + backend/cleanup.sh | 218 - backend/config.py | 235 - backend/debug-server/app.py | 1244 -- backend/debug-server/requirements.txt | 15 - .../static/css/debug-dashboard.css | 617 - backend/debug-server/static/css/style.css | 291 - backend/debug-server/static/js/chart.min.js | 13 - .../debug-server/static/js/debug-charts.js | 470 - .../debug-server/static/js/debug-dashboard.js | 1234 -- backend/debug-server/static/js/script.js | 306 - backend/debug-server/templates/dashboard.html | 444 - backend/debug-server/templates/debug.html | 261 - backend/debug-server/templates/index.html | 150 - backend/development/crontab-example | 8 - .../development/initialize_myp_database.sh | 84 - backend/development/tests/api-test.drucker.py | 95 - backend/development/tests/capture.pcap | Bin 7322 -> 0 bytes backend/development/tests/handshake.py | 128 - backend/development/tests/tapo.py | 9 - backend/development/tests/tests.py | 253 - backend/docker-compose.backend.yml | 178 - backend/docker-compose.yml | 39 - backend/docs/API_DOCS.md | 647 - backend/docs/COMMON_ERRORS.md | 230 + backend/docs/KIOSK-SETUP.md | 246 + backend/docs/PROJEKTDOKUMENTATION.md | 213 - backend/docs/README.md | 243 +- backend/docs/ROADMAP.md | 62 + backend/env.backend | 86 - backend/env.example | 68 - backend/frontend_v2_routes.py | 346 - backend/install.ps1 | 284 - backend/install.sh | 364 - backend/install/kiosk.service | 11 + backend/install/kiosk.sh | 19 + backend/install/myp.service | 14 + backend/install/requirements.txt | 6 + backend/install/setup.sh | 90 + backend/install/watchdog.sh | 40 + backend/log.txt | 89 - backend/migrations/README | 1 - backend/migrations/alembic.ini | 50 - backend/migrations/env.py | 113 - backend/migrations/script.py.mako | 24 - .../versions/add_waiting_approval.py | 42 - backend/migrations/versions/af3faaa3844c_.py | 81 - backend/monitoring.py | 330 - backend/myp-backend.service | 36 - backend/network_config.py | 185 - backend/requirements.txt | 93 +- backend/security.py | 220 - backend/setup_myp.sh | 806 ++ backend/start-backend-server.ps1 | 372 - backend/start-backend-server.sh | 103 - backend/start-debug-server.bat | 36 - backend/start-debug-server.sh | 84 - backend/start-production.bat | 38 - backend/start-production.sh | 81 - backend/static/css/bootstrap.css | 12068 ---------------- backend/static/js/bootstrap.bundle.js | 6314 -------- backend/templates/base.html | 169 - backend/templates/dashboard.html | 304 - backend/templates/jobs.html | 443 - backend/templates/login.html | 37 - backend/templates/network_config.html | 119 - backend/templates/printers.html | 280 - backend/templates/register.html | 45 - backend/templates/stats.html | 395 - backend/templates/users.html | 238 - backend/test-backend-setup.py | 286 - backend/wsgi.py | 44 - docker-compose.dev.yml | 110 - docker-compose.yml | 236 - docs/Aktueller Stand.md | 53 - docs/Aufräumarbeiten.md | 51 - docs/Entwicklungsrichtlinien.md | 59 - docs/PROJECT_STRUCTURE.md | 175 - docs/README.md | 80 - docs/SEPARATE_SERVERS_GUIDE.md | 287 - 98 files changed, 3917 insertions(+), 35610 deletions(-) delete mode 100755 Dokumentation.md delete mode 100755 README.md delete mode 100644 SEPARATE_SERVERS_GUIDE.md delete mode 100644 backend/.env delete mode 100755 backend/.gitignore create mode 100644 backend/COMMON_ERRORS.md delete mode 100755 backend/Dockerfile delete mode 100644 backend/Dockerfile.dev delete mode 100644 backend/PRODUCTION_SETUP.md create mode 100644 backend/README.md delete mode 100644 backend/README_BACKEND_SCRIPTS.md create mode 100644 backend/ROADMAP.md delete mode 100755 backend/app.py create mode 100644 backend/app/app.py create mode 100644 backend/app/config/settings.py create mode 100644 backend/app/models.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/job_scheduler.py create mode 100644 backend/app/utils/logging_config.py delete mode 100644 backend/cleanup.sh delete mode 100644 backend/config.py delete mode 100644 backend/debug-server/app.py delete mode 100644 backend/debug-server/requirements.txt delete mode 100644 backend/debug-server/static/css/debug-dashboard.css delete mode 100644 backend/debug-server/static/css/style.css delete mode 100644 backend/debug-server/static/js/chart.min.js delete mode 100644 backend/debug-server/static/js/debug-charts.js delete mode 100644 backend/debug-server/static/js/debug-dashboard.js delete mode 100644 backend/debug-server/static/js/script.js delete mode 100644 backend/debug-server/templates/dashboard.html delete mode 100644 backend/debug-server/templates/debug.html delete mode 100644 backend/debug-server/templates/index.html delete mode 100644 backend/development/crontab-example delete mode 100644 backend/development/initialize_myp_database.sh delete mode 100644 backend/development/tests/api-test.drucker.py delete mode 100644 backend/development/tests/capture.pcap delete mode 100644 backend/development/tests/handshake.py delete mode 100644 backend/development/tests/tapo.py delete mode 100644 backend/development/tests/tests.py delete mode 100644 backend/docker-compose.backend.yml delete mode 100755 backend/docker-compose.yml delete mode 100644 backend/docs/API_DOCS.md create mode 100644 backend/docs/COMMON_ERRORS.md create mode 100644 backend/docs/KIOSK-SETUP.md delete mode 100644 backend/docs/PROJEKTDOKUMENTATION.md create mode 100644 backend/docs/ROADMAP.md delete mode 100644 backend/env.backend delete mode 100644 backend/env.example delete mode 100644 backend/frontend_v2_routes.py delete mode 100644 backend/install.ps1 delete mode 100644 backend/install.sh create mode 100644 backend/install/kiosk.service create mode 100755 backend/install/kiosk.sh create mode 100644 backend/install/myp.service create mode 100644 backend/install/requirements.txt create mode 100755 backend/install/setup.sh create mode 100755 backend/install/watchdog.sh delete mode 100644 backend/log.txt delete mode 100644 backend/migrations/README delete mode 100644 backend/migrations/alembic.ini delete mode 100644 backend/migrations/env.py delete mode 100644 backend/migrations/script.py.mako delete mode 100644 backend/migrations/versions/add_waiting_approval.py delete mode 100644 backend/migrations/versions/af3faaa3844c_.py delete mode 100644 backend/monitoring.py delete mode 100644 backend/myp-backend.service delete mode 100644 backend/network_config.py delete mode 100644 backend/security.py create mode 100755 backend/setup_myp.sh delete mode 100644 backend/start-backend-server.ps1 delete mode 100644 backend/start-backend-server.sh delete mode 100644 backend/start-debug-server.bat delete mode 100644 backend/start-debug-server.sh delete mode 100644 backend/start-production.bat delete mode 100644 backend/start-production.sh delete mode 100644 backend/static/css/bootstrap.css delete mode 100644 backend/static/js/bootstrap.bundle.js delete mode 100644 backend/templates/base.html delete mode 100644 backend/templates/dashboard.html delete mode 100644 backend/templates/jobs.html delete mode 100644 backend/templates/login.html delete mode 100644 backend/templates/network_config.html delete mode 100644 backend/templates/printers.html delete mode 100644 backend/templates/register.html delete mode 100644 backend/templates/stats.html delete mode 100644 backend/templates/users.html delete mode 100644 backend/test-backend-setup.py delete mode 100644 backend/wsgi.py delete mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.yml delete mode 100755 docs/Aktueller Stand.md delete mode 100644 docs/Aufräumarbeiten.md delete mode 100644 docs/Entwicklungsrichtlinien.md delete mode 100644 docs/PROJECT_STRUCTURE.md delete mode 100644 docs/README.md delete mode 100644 docs/SEPARATE_SERVERS_GUIDE.md diff --git a/Dokumentation.md b/Dokumentation.md deleted file mode 100755 index 01803490..00000000 --- a/Dokumentation.md +++ /dev/null @@ -1,47 +0,0 @@ -# Dokumentation MYP - Manage your Printer - -## Projektbeschreibung - -MYP (Manage your Printer) ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde. - -## Projektstruktur - -- `backend/`: Flask-Backend für die API-Anbindung und Datenbankzugriff -- `frontend/`: Next.js Frontend für die Benutzeroberfläche -- `docs/`: Ausführliche Dokumentationen, Datenbankschema und Diagramme -- `scripts/`: Deployment- und Setup-Skripte -- `logs/`: Fehlerprotokolle und Logs - -## Umfassende Dokumentation - -Detaillierte Dokumentationen finden Sie in den folgenden Dateien: - -- [Technische Dokumentation](docs/README.md) -- [Datenbankstruktur](docs/MYP.dbml) -- [Aktueller Projektstand](docs/Aktueller%20Stand.md) -- [IHK-Dokumentation](docs/Dokumentation_IHK.md) - -## Herausforderungen und Komplikationen - -- Netzwerkanbindung -- Ermitteln der Schnittstellen der Drucker -- Auswahl der Anbindung, Entwickeln eines Netzwerkkonzeptes -- Beschaffung der Hardware (beschränkte Auswahlmöglichkeiten) -- Welches Betriebssystem? OpenSuse, NixOS, Debian -- Frontend verstehen lernen -- Netzwerk einrichten, Frontend anbinden - -## Verwendete Technologien - -- Backend: Python, Flask -- Frontend: Next.js, React, TypeScript -- Datenbank: SQL -- Docker für Containerisierung -- Raspberry Pi für Druckersteuerung - -## Installation und Einsatz - -Installation und Einrichtung werden durch die Skripte im Verzeichnis `scripts/` unterstützt. - -- `scripts/setup/`: Einrichtungsskripte für Backend, Docker und OAuth -- `scripts/deployment/`: Bereitstellungsskripte für Raspberry Pi diff --git a/README.md b/README.md deleted file mode 100755 index 685031b2..00000000 --- a/README.md +++ /dev/null @@ -1,475 +0,0 @@ -# 🖨️ MYP - Manage your Printer - -[![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker)](https://docker.com) -[![Linux](https://img.shields.io/badge/Linux-Compatible-green?logo=linux)](https://linux.org) -[![Windows](https://img.shields.io/badge/Windows-Compatible-blue?logo=windows)](https://windows.com) -[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) -[![Version](https://img.shields.io/badge/Version-2.0.0-brightgreen.svg)](https://github.com) - -MYP *(Manage your Printer)* ist eine moderne, containerbasierte Plattform zur Verwaltung und Reservierung von 3D-Druckern, entwickelt für die TBA im Werk 040, Berlin-Marienfelde. - -## 🏗️ Architektur - -Das System basiert auf einer **Microservice-Architektur** mit klarer Trennung zwischen Frontend und Backend: - -- **🖥️ Backend**: Flask API Server (Python) - Port 5000 -- **🌐 Frontend**: Next.js Web Interface (TypeScript/React) - Port 3000 -- **🔄 Proxy**: Caddy Reverse Proxy - Port 80/443 -- **📊 Monitoring**: Prometheus & Grafana (Entwicklung) - -### 🔧 Technologie-Stack - -**Backend:** -- Flask 3.0+ (Python Web Framework) -- SQLAlchemy (ORM) -- JWT Authentication -- TAPO Smart Plug Integration -- RESTful API Design - -**Frontend:** -- Next.js 14+ (React Framework) -- TypeScript (Type Safety) -- Tailwind CSS (Styling) -- Drizzle ORM (Database) -- Modern UI Components - -**Infrastructure:** -- Docker & Docker Compose -- Caddy (Reverse Proxy) -- Prometheus (Monitoring) -- Grafana (Dashboards) - -## 🚀 Schnellstart - -### Voraussetzungen - -- **Docker** & **Docker Compose** installiert -- **Git** (empfohlen für Entwicklung) -- **4GB RAM** und **10GB freier Speicherplatz** -- **PowerShell 5.1+** (Windows) oder **Bash 4.0+** (Linux/macOS) - -### Installation - -1. **Repository klonen** - ```bash - git clone - cd Projektarbeit-MYP - ``` - -2. **System starten** - - **Windows:** - ```powershell - # Einfacher Start (Entwicklungsumgebung) - .\start.ps1 - - # Mit Optionen - .\start.ps1 -Help # Hilfe anzeigen - .\start.ps1 prod # Produktionsumgebung - .\start.ps1 dev -Clean # Mit Bereinigung - ``` - - **Linux/macOS:** - ```bash - # Ausführungsrechte setzen (einmalig) - chmod +x start.sh cleanup.sh - - # Einfacher Start (Entwicklungsumgebung) - ./start.sh - - # Mit Optionen - ./start.sh --help # Hilfe anzeigen - ./start.sh prod # Produktionsumgebung - ./start.sh dev --clean # Mit Bereinigung - ``` - -3. **Zugriff auf die Anwendung** - - 🌐 **Web Interface**: http://localhost - - 🔧 **Backend API**: http://localhost/api - - ⚛️ **Frontend Dev**: http://localhost:3000 (nur Entwicklung) - -## 📋 Verfügbare Umgebungen - -### 🛠️ Entwicklung (Standard) -```bash -# Windows -.\start.ps1 dev - -# Linux/macOS -./start.sh dev -``` - -**Features:** -- Hot Reload für Frontend und Backend -- Debug-Server auf Port 5555 (Backend) und 8081 (Frontend) -- Monitoring: Prometheus (9090), Grafana (3001) -- Datenbank-Viewer: Adminer (8080) -- Redis Cache (6379) -- Entwickler-Tools aktiviert - -### 🚀 Produktion -```bash -# Windows -.\start.ps1 prod - -# Linux/macOS -./start.sh prod -``` - -**Features:** -- Optimierte Container-Images -- SSL-Verschlüsselung (Let's Encrypt) -- Performance-Optimierungen -- Sicherheitsheader -- Automatische Backups -- Health Checks - -### 🧪 Test -```bash -# Windows -.\start.ps1 test - -# Linux/macOS -./start.sh test -``` - -**Features:** -- In-Memory-Datenbank -- Mock-Services -- Test-Fixtures -- Coverage-Reports -- Automatisierte Tests - -## 🛠️ Entwicklung - -### Projektstruktur - -``` -Projektarbeit-MYP/ -├── 🖥️ backend/ # Flask API Server -│ ├── app.py # Hauptanwendung -│ ├── config.py # Konfiguration -│ ├── security.py # Sicherheitsmodule -│ ├── monitoring.py # Monitoring & Logging -│ ├── requirements.txt # Python-Abhängigkeiten -│ ├── Dockerfile # Produktions-Container -│ ├── Dockerfile.dev # Entwicklungs-Container -│ ├── instance/ # Datenbank & Uploads -│ ├── logs/ # Anwendungslogs -│ ├── migrations/ # Datenbankmigrationen -│ ├── static/ # Statische Dateien -│ └── templates/ # HTML-Templates -│ -├── 🌐 frontend/ # Next.js Web Interface -│ ├── src/ # Quellcode -│ │ ├── app/ # App Router (Next.js 14) -│ │ ├── components/ # React-Komponenten -│ │ ├── server/ # Server-seitige Logik -│ │ └── utils/ # Hilfsfunktionen -│ ├── public/ # Öffentliche Dateien -│ ├── package.json # Node.js-Abhängigkeiten -│ ├── Dockerfile # Produktions-Container -│ ├── Dockerfile.dev # Entwicklungs-Container -│ ├── tailwind.config.ts # Tailwind-Konfiguration -│ ├── tsconfig.json # TypeScript-Konfiguration -│ └── drizzle/ # Datenbank-Schema -│ -├── 🔄 proxy/ # Caddy Reverse Proxy -│ └── Caddyfile # Proxy-Konfiguration -│ -├── 📊 monitoring/ # Prometheus & Grafana -│ ├── prometheus/ # Monitoring-Konfiguration -│ └── grafana/ # Dashboard-Konfiguration -│ -├── 🔧 infrastructure/ # Deployment & Konfiguration -│ ├── scripts/ # Deployment-Skripte -│ │ ├── start.ps1 # Windows-Startskript -│ │ ├── start.sh # Linux/macOS-Startskript -│ │ ├── cleanup.ps1 # Windows-Bereinigung -│ │ └── cleanup.sh # Linux/macOS-Bereinigung -│ └── environments/ # Umgebungskonfigurationen -│ ├── development.env # Entwicklungsumgebung -│ ├── production.env # Produktionsumgebung -│ └── test.env # Testumgebung -│ -├── 🧪 tests/ # Übergreifende Tests -│ ├── e2e/ # End-to-End-Tests -│ └── integration/ # Integrationstests -│ -├── 📚 docs/ # Projektdokumentation -├── 📝 logs/ # Systemlogs -├── 🐳 docker-compose.yml # Hauptkonfiguration -├── 🐳 docker-compose.dev.yml # Entwicklungskonfiguration -├── 📋 README.md # Diese Datei -├── 📋 PROJECT_STRUCTURE.md # Detaillierte Architektur -├── 📋 Dokumentation.md # Deutsche Dokumentation -└── 📄 LICENSE.md # Lizenzinformationen -``` - -### Backend-Entwicklung - -```bash -cd backend - -# Virtuelle Umgebung erstellen -python -m venv venv - -# Aktivieren -source venv/bin/activate # Linux/macOS -venv\Scripts\activate # Windows - -# Abhängigkeiten installieren -pip install -r requirements.txt - -# Entwicklungsserver starten -flask run --debug --host=0.0.0.0 --port=5000 -``` - -### Frontend-Entwicklung - -```bash -cd frontend - -# Abhängigkeiten installieren -pnpm install - -# Entwicklungsserver starten -pnpm dev - -# Build für Produktion -pnpm build - -# Linting & Formatierung -pnpm lint -pnpm format -``` - -### API-Dokumentation - -Die API-Dokumentation ist verfügbar unter: -- **Swagger UI**: http://localhost/api/docs -- **OpenAPI Spec**: http://localhost/api/swagger.json -- **Redoc**: http://localhost/api/redoc - -## 🔧 Konfiguration - -### Umgebungsvariablen - -Konfigurationsdateien befinden sich in `infrastructure/environments/`: - -- `development.env` - Entwicklungsumgebung -- `production.env` - Produktionsumgebung -- `test.env` - Testumgebung - -### Drucker-Konfiguration - -Drucker werden über die `PRINTERS` Umgebungsvariable konfiguriert: - -```json -{ - "Drucker 1": { - "ip": "192.168.0.100", - "model": "UltiMaker S5", - "location": "Raum A.1.01" - }, - "Drucker 2": { - "ip": "192.168.0.101", - "model": "UltiMaker S5", - "location": "Raum A.1.02" - } -} -``` - -### TAPO Smart Plug Integration - -```env -TAPO_USERNAME=your-email@example.com -TAPO_PASSWORD=your-secure-password -``` - -### SSL/TLS-Konfiguration (Produktion) - -```env -CADDY_DOMAIN=your-domain.com -CADDY_EMAIL=admin@your-domain.com -SSL_ENABLED=true -``` - -## 🧹 Wartung - -### System bereinigen - -**Windows:** -```powershell -# Interaktive Bereinigung -.\cleanup.ps1 - -# Automatische Bereinigung -.\cleanup.ps1 -Force - -# Vollständige Bereinigung (inkl. Volumes) -.\cleanup.ps1 -All -Force -``` - -**Linux/macOS:** -```bash -# Interaktive Bereinigung -./cleanup.sh - -# Automatische Bereinigung -./cleanup.sh --force - -# Vollständige Bereinigung (inkl. Volumes) -./cleanup.sh --all --force -``` - -### Logs anzeigen - -```bash -# Alle Services -docker-compose logs -f - -# Spezifische Services -docker-compose logs -f backend -docker-compose logs -f frontend -docker-compose logs -f caddy - -# Mit Zeitstempel -docker-compose logs -f -t -``` - -### Container-Status prüfen - -```bash -# Status aller Container -docker-compose ps - -# Detaillierte Informationen -docker-compose top - -# Ressourcenverbrauch -docker stats - -# Health Checks -docker-compose exec backend curl -f http://localhost:5000/health -docker-compose exec frontend curl -f http://localhost:3000/api/health -``` - -### Backup & Restore - -```bash -# Datenbank-Backup erstellen -docker-compose exec backend python -c " -import sqlite3 -import shutil -shutil.copy('instance/myp.db', 'instance/backup_$(date +%Y%m%d_%H%M%S).db') -" - -# Logs archivieren -tar -czf logs_backup_$(date +%Y%m%d_%H%M%S).tar.gz logs/ -``` - -## 🔒 Sicherheit - -### Produktionsumgebung - -- **SSL/TLS**: Automatische Let's Encrypt-Zertifikate -- **Security Headers**: HSTS, CSP, X-Frame-Options -- **Rate Limiting**: API-Endpunkt-Schutz -- **Authentication**: JWT-basierte Authentifizierung -- **Input Validation**: Umfassende Eingabevalidierung -- **CORS**: Konfigurierbare Cross-Origin-Richtlinien - -### Geheimnisse verwalten - -```bash -# Sichere Umgebungsvariablen setzen -echo "SECRET_KEY=$(openssl rand -hex 32)" >> infrastructure/environments/production.env -echo "JWT_SECRET=$(openssl rand -hex 32)" >> infrastructure/environments/production.env -``` - -## 🚨 Fehlerbehebung - -### Häufige Probleme - -**Docker-Container starten nicht:** -```bash -# Docker-Status prüfen -docker info - -# Container-Logs anzeigen -docker-compose logs - -# Ports prüfen -netstat -tulpn | grep :80 -netstat -tulpn | grep :3000 -netstat -tulpn | grep :5000 -``` - -**Frontend kann Backend nicht erreichen:** -```bash -# Netzwerk-Konfiguration prüfen -docker network ls -docker network inspect projektarbeit-myp_myp-network - -# API-Erreichbarkeit testen -curl -f http://localhost/api/health -``` - -**Datenbank-Probleme:** -```bash -# Datenbank-Integrität prüfen -docker-compose exec backend python -c " -import sqlite3 -conn = sqlite3.connect('instance/myp.db') -conn.execute('PRAGMA integrity_check;') -print(conn.fetchall()) -" -``` - -### Support - -Bei Problemen: -1. Prüfen Sie die Logs: `docker-compose logs` -2. Überprüfen Sie die Systemvoraussetzungen -3. Führen Sie eine Bereinigung durch: `./cleanup.sh --force` -4. Starten Sie das System neu: `./start.sh` - -## 📈 Performance - -### Monitoring - -- **Prometheus**: http://localhost:9090 (nur Entwicklung) -- **Grafana**: http://localhost:3001 (nur Entwicklung) -- **Caddy Admin**: http://localhost:2019 - -### Optimierungen - -- **Container-Images**: Multi-Stage-Builds für minimale Größe -- **Caching**: Redis für Session-Management -- **CDN**: Statische Assets über Caddy -- **Database**: SQLite mit WAL-Modus für bessere Performance - -## 🤝 Beitragen - -1. Fork des Repositories erstellen -2. Feature-Branch erstellen: `git checkout -b feature/neue-funktion` -3. Änderungen committen: `git commit -am 'Neue Funktion hinzufügen'` -4. Branch pushen: `git push origin feature/neue-funktion` -5. Pull Request erstellen - -## 📄 Lizenz - -Dieses Projekt steht unter der MIT-Lizenz. Siehe [LICENSE.md](LICENSE.md) für Details. - -## 🙏 Danksagungen - -- **TBA Werk 040** - Für die Unterstützung und Anforderungen -- **Open Source Community** - Für die verwendeten Technologien -- **Docker Team** - Für die Container-Technologie -- **Next.js Team** - Für das Frontend-Framework -- **Flask Team** - Für das Backend-Framework - ---- - -**Entwickelt mit ❤️ für die TBA im Werk 040, Berlin-Marienfelde** diff --git a/SEPARATE_SERVERS_GUIDE.md b/SEPARATE_SERVERS_GUIDE.md deleted file mode 100644 index 9c7fd037..00000000 --- a/SEPARATE_SERVERS_GUIDE.md +++ /dev/null @@ -1,436 +0,0 @@ -# 🏗️ MYP - Separate Server Architektur - -## Übersicht - -Das MYP-System wurde in **zwei vollständig unabhängige Server** aufgeteilt: - -- **🏭 Backend-Server** (Port 5000): Flask-API für Geschäftslogik und Datenmanagement -- **🎨 Frontend-Server** (Port 3000): Next.js-Anwendung für Benutzeroberfläche - -## 🔗 Server-Kommunikation - -``` -┌─────────────────┐ HTTP/API ┌─────────────────┐ -│ Frontend │◄───────────────►│ Backend │ -│ (Next.js) │ │ (Flask) │ -│ Port: 3000 │ │ Port: 5000 │ -└─────────────────┘ └─────────────────┘ -``` - -## 🚀 Separate Server starten - -### Backend-Server (unabhängig) - -#### **Windows (PowerShell)** -```powershell -cd backend -.\install.ps1 -Production # Installation -.\start-backend-server.ps1 -Development # Entwicklung -.\start-backend-server.ps1 -Production # Produktion -.\start-backend-server.ps1 -Development -Logs # Mit Live-Logs -``` - -#### **Linux/macOS (Bash)** -```bash -cd backend -./install.sh --production # Installation -./start-backend-server.sh --development # Entwicklung -./start-backend-server.sh --production # Produktion -./start-backend-server.sh --development --logs # Mit Live-Logs -``` - -### Frontend-Server (unabhängig) - -```bash -cd frontend -npm install # Dependencies installieren -npm run dev # Entwicklungsserver -npm run build && npm start # Produktionsserver -``` - -## 📋 Konfiguration - -### Backend-Konfiguration (`backend/env.backend`) - -```bash -# === FLASK KONFIGURATION === -FLASK_APP=app.py -FLASK_ENV=production -PYTHONUNBUFFERED=1 - -# === DATENBANK === -DATABASE_PATH=instance/myp.db - -# === SICHERHEIT === -SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -JWT_SECRET=secure-jwt-secret-backend-2024 - -# === CORS KONFIGURATION === -CORS_ORIGINS=http://localhost:3000,https://frontend.myp.local - -# === DRUCKER KONFIGURATION === -PRINTERS={"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}} - -# === TAPO SMART PLUG === -TAPO_USERNAME=your.email@company.com -TAPO_PASSWORD=your_password - -# === NETZWERK === -HOST=0.0.0.0 -PORT=5000 -BACKEND_URL=http://localhost:5000 -``` - -### Frontend-Konfiguration (`frontend/env.frontend`) - -```bash -# === NODE.JS KONFIGURATION === -NODE_ENV=production -NEXT_TELEMETRY_DISABLED=1 - -# === FRONTEND SERVER === -PORT=3000 -HOSTNAME=0.0.0.0 -FRONTEND_URL=http://localhost:3000 - -# === BACKEND API KONFIGURATION === -BACKEND_API_URL=http://localhost:5000/api -BACKEND_HOST=localhost:5000 -NEXT_PUBLIC_API_URL=http://localhost:5000/api -NEXT_PUBLIC_BACKEND_HOST=localhost:5000 - -# === AUTHENTIFIZIERUNG === -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=frontend-auth-secret-2024 -``` - -## 🔧 Entwicklung - -### Beide Server parallel starten - -#### **Windows** -```powershell -# Terminal 1: Backend -cd backend -.\start-backend-server.ps1 -Development -Logs - -# Terminal 2: Frontend -cd frontend -npm run dev -``` - -#### **Linux/macOS** -```bash -# Terminal 1: Backend -cd backend -./start-backend-server.sh --development --logs - -# Terminal 2: Frontend -cd frontend -npm run dev -``` - -### API-Endpoints testen - -```bash -# Backend Health-Check -curl http://localhost:5000/monitoring/health/simple - -# Backend API Test -curl http://localhost:5000/api/test - -# Frontend Health-Check -curl http://localhost:3000/health -``` - -## 🏭 Produktion - -### Docker-Deployment - -#### **Backend-Container** -```bash -cd backend -docker-compose -f docker-compose.backend.yml up -d -``` - -#### **Frontend-Container** -```bash -cd frontend -docker-compose -f docker-compose.frontend.yml up -d -``` - -### Native Deployment - -#### **Backend (Linux/Ubuntu)** -```bash -cd backend -./install.sh --production - -# Systemd-Service -sudo systemctl start myp-backend -sudo systemctl enable myp-backend -sudo systemctl status myp-backend -``` - -#### **Frontend (mit PM2)** -```bash -cd frontend -npm install -g pm2 -npm run build -pm2 start npm --name "myp-frontend" -- start -pm2 save -pm2 startup -``` - -## 🔒 Sicherheit - -### CORS-Konfiguration - -Das Backend ist konfiguriert, um nur Anfragen von autorisierten Frontend-Domains zu akzeptieren: - -```python -# backend/app.py -CORS(app, - origins=['http://localhost:3000', 'https://frontend.myp.local'], - supports_credentials=True, - allow_headers=['Content-Type', 'Authorization', 'X-Requested-With'], - methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']) -``` - -### Umgebungsvariablen - -- Verwenden Sie sichere, zufällige Secret Keys -- Lagern Sie Passwörter nie im Code -- Nutzen Sie unterschiedliche Secrets für verschiedene Umgebungen - -## 📊 Monitoring - -### Health-Checks - -| Service | Endpoint | Port | Beschreibung | -|----------|----------|------|--------------| -| Backend | `/monitoring/health/simple` | 5000 | Einfacher Health-Check | -| Backend | `/monitoring/health` | 5000 | Detaillierter Health-Check | -| Frontend | `/health` | 3000 | Frontend-Health-Check | - -### Logs - -#### **Backend-Logs** -```bash -# Windows -Get-Content backend\logs\myp.log -Wait - -# Linux/macOS -tail -f backend/logs/myp.log -``` - -#### **Frontend-Logs** -```bash -# Development -npm run dev # Logs in der Konsole - -# Production mit PM2 -pm2 logs myp-frontend -``` - -## 🔍 Troubleshooting - -### Häufige Probleme - -#### **Backend startet nicht** -```bash -# 1. Python-Version prüfen -python --version # Mindestens 3.8 erforderlich - -# 2. Dependencies prüfen -cd backend -pip install -r requirements.txt - -# 3. Test-Skript ausführen -python test-backend-setup.py -``` - -#### **Frontend startet nicht** -```bash -# 1. Node-Version prüfen -node --version # Mindestens 18 erforderlich - -# 2. Dependencies installieren -cd frontend -rm -rf node_modules package-lock.json -npm install - -# 3. Build-Fehler beheben -npm run build -``` - -#### **CORS-Fehler** -```bash -# Backend CORS-Origins in env.backend prüfen -CORS_ORIGINS=http://localhost:3000,https://your-frontend-domain.com - -# Frontend API-URL in env.frontend prüfen -NEXT_PUBLIC_API_URL=http://localhost:5000/api -``` - -### Debug-Modus - -#### **Backend Debug** -```bash -# Windows -.\start-backend-server.ps1 -Development -Logs - -# Linux/macOS -./start-backend-server.sh --development --logs -``` - -#### **Frontend Debug** -```bash -cd frontend -npm run dev # Automatisches Reload bei Änderungen -``` - -## 🌐 Reverse Proxy (Produktionsempfehlung) - -### Nginx-Konfiguration - -```nginx -# /etc/nginx/sites-available/myp -server { - listen 80; - server_name your-domain.com; - - # Frontend - location / { - proxy_pass http://localhost:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - } - - # Backend API - location /api { - proxy_pass http://localhost:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Backend Health-Check - location /monitoring { - proxy_pass http://localhost:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -### SSL-Konfiguration mit Certbot - -```bash -# SSL-Zertifikat installieren -sudo certbot --nginx -d your-domain.com - -# Automatische Erneuerung -sudo crontab -e -# Füge hinzu: 0 12 * * * /usr/bin/certbot renew --quiet -``` - -## 📈 Performance-Optimierung - -### Backend-Optimierung - -```bash -# Gunicorn-Worker anpassen (env.backend) -WORKERS=4 # CPU-Kerne * 2 -TIMEOUT=30 # Request-Timeout -MAX_REQUESTS=1000 # Requests pro Worker -``` - -### Frontend-Optimierung - -```bash -# Build-Optimierung -cd frontend -npm run build # Produktions-Build -npm run analyze # Bundle-Analyse -``` - -## 🔄 CI/CD Pipeline - -### GitHub Actions Beispiel - -```yaml -# .github/workflows/deploy.yml -name: Deploy MYP - -on: - push: - branches: [main] - -jobs: - deploy-backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Deploy Backend - run: | - cd backend - ./install.sh --production - sudo systemctl restart myp-backend - - deploy-frontend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Deploy Frontend - run: | - cd frontend - npm install - npm run build - pm2 restart myp-frontend -``` - -## 📝 Wartung - -### Regelmäßige Aufgaben - -```bash -# Logs rotieren -sudo logrotate -f /etc/logrotate.d/myp - -# Dependencies aktualisieren -cd backend && pip install -r requirements.txt --upgrade -cd frontend && npm update - -# Datenbank-Backup -cd backend && cp instance/myp.db backups/myp-$(date +%Y%m%d).db - -# Health-Check-Monitoring -curl -f http://localhost:5000/monitoring/health/simple || echo "Backend down" -curl -f http://localhost:3000/health || echo "Frontend down" -``` - -## 📞 Support - -### Kontakt -- **Entwickler**: Till Tomczak -- **E-Mail**: till.tomczak@mercedes-benz.com -- **Repository**: https://github.com/your-org/myp - -### Dokumentation -- **Backend-API**: http://localhost:5000/docs (falls Swagger aktiviert) -- **Frontend-Dokumentation**: Siehe `frontend/README.md` -- **System-Architektur**: Siehe `docs/ARCHITECTURE.md` - ---- - -**Hinweis**: Diese Dokumentation beschreibt die neue separate Server-Architektur. Die alte gekoppelte Architektur wird nicht mehr unterstützt. \ No newline at end of file diff --git a/backend/.env b/backend/.env deleted file mode 100644 index bd243888..00000000 --- a/backend/.env +++ /dev/null @@ -1,5 +0,0 @@ -SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -DATABASE_PATH=instance/myp.db -TAPO_USERNAME=till.tomczak@mercedes-benz.com -TAPO_PASSWORD=744563017196A -PRINTERS={"Printer 1": {"ip": "192.168.0.100"}, "Printer 2": {"ip": "192.168.0.101"}, "Printer 3": {"ip": "192.168.0.102"}, "Printer 4": {"ip": "192.168.0.103"}, "Printer 5": {"ip": "192.168.0.104"}, "Printer 6": {"ip": "192.168.0.106"}} diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100755 index c7beae63..00000000 --- a/backend/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Flask -instance/ -.webassets-cache - -# Logs -logs/ -*.log - -# SQLite Datenbank-Dateien -*.db -*.db-journal - -# Virtuelle Umgebungen -venv/ -ENV/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# Betriebssystem -.DS_Store -Thumbs.db \ No newline at end of file diff --git a/backend/COMMON_ERRORS.md b/backend/COMMON_ERRORS.md new file mode 100644 index 00000000..adbffb49 --- /dev/null +++ b/backend/COMMON_ERRORS.md @@ -0,0 +1,375 @@ +# MYP V2 - Häufige Fehler und Lösungen + +## Übersicht +Diese Datei dokumentiert häufige Fehler, die während der Entwicklung und dem Betrieb von MYP V2 auftreten können, sowie deren Lösungen. + +## 🔧 Setup und Installation + +### Fehler: ModuleNotFoundError für PyP100 +**Symptom**: `ModuleNotFoundError: No module named 'PyP100'` + +**Ursache**: PyP100-Bibliothek ist nicht installiert + +**Lösung**: +```bash +pip3.11 install PyP100 +``` + +### Fehler: Datenbankverbindung fehlgeschlagen +**Symptom**: `sqlite3.OperationalError: unable to open database file` + +**Ursache**: +- Fehlende Schreibberechtigung im Datenbank-Verzeichnis +- Verzeichnis existiert nicht + +**Lösung**: +```bash +# Verzeichnis erstellen +mkdir -p /path/to/database/directory +# Berechtigungen setzen +chmod 755 /path/to/database/directory +``` + +### Fehler: Log-Verzeichnis nicht gefunden +**Symptom**: `FileNotFoundError: [Errno 2] No such file or directory: 'logs/app/app.log'` + +**Ursache**: Log-Verzeichnisse wurden nicht erstellt + +**Lösung**: Die `ensure_log_directories()` Funktion in `logging_config.py` wird automatisch aufgerufen + +## 🔐 Authentifizierung und Autorisierung + +### Fehler: Session-Timeout zu kurz +**Symptom**: Benutzer werden zu häufig abgemeldet + +**Ursache**: `SESSION_LIFETIME` in `settings.py` zu niedrig eingestellt + +**Lösung**: +```python +# In config/settings.py +SESSION_LIFETIME = timedelta(days=7) # Oder gewünschte Dauer +``` + +### Fehler: Admin-Rechte nicht erkannt +**Symptom**: `AttributeError: 'AnonymousUserMixin' object has no attribute 'is_admin'` + +**Ursache**: Benutzer ist nicht angemeldet oder UserMixin-Objekt falsch erstellt + +**Lösung**: Prüfung in Decorators verbessern: +```python +if not current_user.is_authenticated or not hasattr(current_user, 'is_admin') or not current_user.is_admin: + return jsonify({"error": "Keine Berechtigung"}), 403 +``` + +### Fehler: Passwort-Hash-Fehler +**Symptom**: `ValueError: Invalid salt` + +**Ursache**: Inkonsistente Passwort-Hash-Methoden + +**Lösung**: Einheitliche Verwendung von `werkzeug.security`: +```python +from werkzeug.security import generate_password_hash, check_password_hash +``` + +## 🖨️ Drucker und Smart Plug-Steuerung + +### Fehler: Tapo-Verbindung fehlgeschlagen +**Symptom**: `Exception: Failed to establish a new connection` + +**Ursache**: +- Falsche IP-Adresse +- Netzwerkprobleme +- Falsche Credentials + +**Lösung**: +1. IP-Adresse in `PRINTERS` Konfiguration prüfen +2. Netzwerkverbindung testen: `ping ` +3. Credentials in `settings.py` überprüfen + +### Fehler: Plug-Status kann nicht abgerufen werden +**Symptom**: `KeyError: 'device_on'` + +**Ursache**: Unerwartete API-Antwort von Tapo-Gerät + +**Lösung**: Defensive Programmierung: +```python +plug_state = plug_info.get("device_on", False) +power_consumption = plug_info.get("current_power", 0) +``` + +### Fehler: Drucker nicht in Konfiguration gefunden +**Symptom**: `Drucker nicht in Konfiguration gefunden` + +**Ursache**: Drucker-Name stimmt nicht mit `PRINTERS` Dictionary überein + +**Lösung**: +1. Verfügbare Drucker in `settings.py` prüfen +2. Exakte Schreibweise verwenden +3. Neue Drucker zur Konfiguration hinzufügen + +## 📅 Job-Management + +### Fehler: Überlappende Jobs nicht erkannt +**Symptom**: Mehrere Jobs laufen gleichzeitig auf einem Drucker + +**Ursache**: Fehlerhafte Überlappungsprüfung in der Datenbank-Abfrage + +**Lösung**: Korrekte SQL-Abfrage verwenden: +```python +overlapping_jobs = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled", "active"]), + ((Job.start_time <= start_time) & (Job.end_time > start_time)) | + ((Job.start_time < end_time) & (Job.end_time >= end_time)) | + ((Job.start_time >= start_time) & (Job.end_time <= end_time)) +).count() +``` + +### Fehler: Datumsformat-Parsing fehlgeschlagen +**Symptom**: `ValueError: time data does not match format` + +**Ursache**: Inkonsistente Datumsformate zwischen Frontend und Backend + +**Lösung**: ISO-Format verwenden: +```python +start_time = datetime.fromisoformat(data["start_time"].replace("Z", "+00:00")) +``` + +### Fehler: Job-Status nicht aktualisiert +**Symptom**: Jobs bleiben im "scheduled" Status obwohl sie laufen sollten + +**Ursache**: +- Scheduler läuft nicht +- Fehler im Job-Monitor + +**Lösung**: +1. Scheduler-Status prüfen: `GET /api/scheduler/status` +2. Logs überprüfen: `logs/scheduler/scheduler.log` +3. Scheduler neu starten: `POST /api/scheduler/start` + +## 🗄️ Datenbank-Probleme + +### Fehler: Foreign Key Constraint +**Symptom**: `sqlite3.IntegrityError: FOREIGN KEY constraint failed` + +**Ursache**: Versuch, referenzierte Datensätze zu löschen + +**Lösung**: Abhängigkeiten vor dem Löschen prüfen: +```python +# Vor dem Löschen eines Druckers +active_jobs = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled", "active"]) +).count() + +if active_jobs > 0: + return jsonify({"error": "Es existieren aktive Jobs für diesen Drucker"}), 400 +``` + +### Fehler: Datenbank-Session nicht geschlossen +**Symptom**: `ResourceWarning: unclosed ` + +**Ursache**: Vergessene `db_session.close()` Aufrufe + +**Lösung**: Immer `try/finally` verwenden: +```python +db_session = get_db_session() +try: + # Datenbankoperationen + pass +finally: + db_session.close() +``` + +### Fehler: Unique Constraint Violation +**Symptom**: `sqlite3.IntegrityError: UNIQUE constraint failed` + +**Ursache**: Versuch, doppelte Einträge zu erstellen + +**Lösung**: Vor dem Einfügen prüfen: +```python +existing = db_session.query(User).filter(User.email == email).first() +if existing: + return jsonify({"error": "E-Mail bereits registriert"}), 400 +``` + +## 📊 Logging und Monitoring + +### Fehler: Log-Rotation funktioniert nicht +**Symptom**: Log-Dateien werden sehr groß + +**Ursache**: `RotatingFileHandler` nicht korrekt konfiguriert + +**Lösung**: Konfiguration in `logging_config.py` prüfen: +```python +handler = RotatingFileHandler( + log_file, + maxBytes=10*1024*1024, # 10MB + backupCount=5 +) +``` + +### Fehler: Logger schreibt nicht in Datei +**Symptom**: Keine Log-Einträge in den Dateien + +**Ursache**: +- Log-Level zu hoch eingestellt +- Handler nicht korrekt hinzugefügt + +**Lösung**: +1. Log-Level prüfen: `logger.setLevel(logging.INFO)` +2. Handler hinzufügen: `logger.addHandler(handler)` + +### Fehler: Doppelte Log-Einträge +**Symptom**: Jeder Log-Eintrag erscheint mehrfach + +**Ursache**: Logger-Vererbung oder mehrfache Handler-Registrierung + +**Lösung**: `propagate` deaktivieren: +```python +logger.propagate = False +``` + +## 🔄 Scheduler-Probleme + +### Fehler: Scheduler startet nicht +**Symptom**: `scheduler.is_running()` gibt `False` zurück + +**Ursache**: +- `SCHEDULER_ENABLED = False` in Konfiguration +- Fehler beim Task-Registrieren + +**Lösung**: +1. Konfiguration prüfen: `SCHEDULER_ENABLED = True` +2. Task-Registrierung überprüfen +3. Logs analysieren: `logs/scheduler/scheduler.log` + +### Fehler: Tasks werden nicht ausgeführt +**Symptom**: Job-Monitor läuft nicht automatisch + +**Ursache**: +- Task nicht korrekt registriert +- Fehler in der Task-Funktion + +**Lösung**: +1. Task-Status prüfen: `scheduler.get_task_info()` +2. Task-Funktion auf Fehler prüfen +3. Interval-Einstellungen überprüfen + +### Fehler: Scheduler-Thread blockiert +**Symptom**: Anwendung reagiert nicht mehr + +**Ursache**: Endlosschleife oder blockierende Operation in Task + +**Lösung**: +1. Timeout für externe Operationen setzen +2. Exception-Handling in Tasks verbessern +3. Scheduler neu starten + +## 🌐 API und HTTP-Probleme + +### Fehler: CORS-Probleme +**Symptom**: `Access-Control-Allow-Origin` Fehler im Browser + +**Ursache**: Frontend und Backend auf verschiedenen Ports + +**Lösung**: Flask-CORS installieren und konfigurieren: +```python +from flask_cors import CORS +CORS(app) +``` + +### Fehler: 500 Internal Server Error +**Symptom**: Unspezifische Server-Fehler + +**Ursache**: Unbehandelte Exceptions + +**Lösung**: +1. Debug-Modus aktivieren: `FLASK_DEBUG = True` +2. Logs überprüfen +3. Try-Catch-Blöcke erweitern + +### Fehler: JSON-Serialisierung fehlgeschlagen +**Symptom**: `TypeError: Object of type datetime is not JSON serializable` + +**Ursache**: Datetime-Objekte in JSON-Response + +**Lösung**: `.isoformat()` verwenden: +```python +"created_at": obj.created_at.isoformat() if obj.created_at else None +``` + +## 🔧 Performance-Probleme + +### Fehler: Langsame Datenbankabfragen +**Symptom**: API-Responses dauern sehr lange + +**Ursache**: Fehlende Indizes oder ineffiziente Abfragen + +**Lösung**: +1. Indizes auf häufig abgefragte Spalten erstellen +2. Query-Optimierung mit `EXPLAIN QUERY PLAN` +3. Eager Loading für Beziehungen verwenden + +### Fehler: Speicher-Leaks +**Symptom**: Speicherverbrauch steigt kontinuierlich + +**Ursache**: +- Nicht geschlossene Datenbankverbindungen +- Zirkuläre Referenzen + +**Lösung**: +1. Connection-Pooling implementieren +2. Regelmäßige Garbage Collection +3. Memory-Profiling mit `memory_profiler` + +## 🛠️ Entwicklungsumgebung + +### Fehler: Import-Fehler in Tests +**Symptom**: `ModuleNotFoundError` beim Ausführen von Tests + +**Ursache**: PYTHONPATH nicht korrekt gesetzt + +**Lösung**: +```bash +export PYTHONPATH="${PYTHONPATH}:$(pwd)" +python3.11 -m pytest +``` + +### Fehler: Konfigurationsdateien nicht gefunden +**Symptom**: `FileNotFoundError` für `settings.py` + +**Ursache**: Arbeitsverzeichnis stimmt nicht + +**Lösung**: Relative Pfade verwenden oder Arbeitsverzeichnis setzen: +```bash +cd /path/to/MYP_V2 +python3.11 app/app.py +``` + +## 📋 Checkliste für Fehlerbehebung + +### Vor jeder Änderung: +1. [ ] Aktuelle Logs überprüfen +2. [ ] Datenbank-Backup erstellen +3. [ ] Konfiguration validieren +4. [ ] Tests ausführen (falls vorhanden) + +### Nach jeder Änderung: +1. [ ] Funktionalität testen +2. [ ] Logs auf neue Fehler prüfen +3. [ ] Performance-Impact bewerten +4. [ ] Dokumentation aktualisieren + +### Bei kritischen Fehlern: +1. [ ] Service stoppen +2. [ ] Fehlerursache identifizieren +3. [ ] Rollback-Plan erstellen +4. [ ] Fix implementieren und testen +5. [ ] Service neu starten +6. [ ] Monitoring für 24h verstärken + +--- + +**Letzte Aktualisierung**: Dezember 2024 +**Hinweis**: Diese Datei sollte bei jedem neuen Fehler aktualisiert werden. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100755 index ef55684c..00000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -FROM python:slim - -WORKDIR /app - -# Install system dependencies (curl, sqlite3 for database, wget for healthcheck) -RUN apt-get update && apt-get install -y \ - curl \ - sqlite3 \ - wget \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Create required directories -RUN mkdir -p logs instance - -ENV FLASK_APP=app.py -ENV PYTHONUNBUFFERED=1 - -# Add health check endpoint -RUN echo 'from flask import Blueprint\n\ -health_bp = Blueprint("health", __name__)\n\ -\n\ -@health_bp.route("/health")\n\ -def health_check():\n\ - return {"status": "healthy"}, 200\n'\ -> /app/health.py - -# Add the health blueprint to app.py if it doesn't exist -RUN grep -q "health_bp" app.py || sed -i '/from flask import/a from health import health_bp' app.py -RUN grep -q "app.register_blueprint(health_bp)" app.py || sed -i '/app = Flask/a app.register_blueprint(health_bp)' app.py - -EXPOSE 5000 - -# Add startup script to initialize database if needed -RUN echo '#!/bin/bash\n\ -if [ ! -f "instance/myp.db" ] || [ ! -s "instance/myp.db" ]; then\n\ - echo "Initializing database..."\n\ - python -c "from app import init_db; init_db()"\n\ -fi\n\ -\n\ -echo "Starting gunicorn server..."\n\ -gunicorn --bind 0.0.0.0:5000 app:app\n'\ -> /app/start.sh && chmod +x /app/start.sh - -CMD ["/app/start.sh"] \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev deleted file mode 100644 index 4a40b9c2..00000000 --- a/backend/Dockerfile.dev +++ /dev/null @@ -1,49 +0,0 @@ -# 🔧 MYP Backend - Entwicklungs-Container -# Optimiert für Hot Reload und Debugging - -FROM python:3.11-slim - -# Arbeitsverzeichnis setzen -WORKDIR /app - -# System-Abhängigkeiten installieren -RUN apt-get update && apt-get install -y \ - curl \ - sqlite3 \ - wget \ - git \ - build-essential \ - && rm -rf /var/lib/apt/lists/* - -# Python-Abhängigkeiten installieren -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Entwicklungs-spezifische Pakete -RUN pip install --no-cache-dir \ - watchdog \ - flask-debugtoolbar \ - pytest \ - pytest-cov \ - black \ - flake8 - -# Verzeichnisse erstellen -RUN mkdir -p logs instance - -# Umgebungsvariablen für Entwicklung -ENV FLASK_APP=app.py -ENV FLASK_ENV=development -ENV FLASK_DEBUG=1 -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 - -# Port freigeben -EXPOSE 5000 5555 - -# Health Check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:5000/health || exit 1 - -# Entwicklungs-Startbefehl (wird durch docker-compose überschrieben) -CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debugger"] \ No newline at end of file diff --git a/backend/PRODUCTION_SETUP.md b/backend/PRODUCTION_SETUP.md deleted file mode 100644 index ffe4d731..00000000 --- a/backend/PRODUCTION_SETUP.md +++ /dev/null @@ -1,327 +0,0 @@ -# MYP Backend - Produktions-Setup Anleitung - -Diese Anleitung beschreibt die Installation und Konfiguration des MYP Backends für den Produktionsbetrieb. - -## Voraussetzungen - -- Linux-Server (Ubuntu 20.04 LTS oder höher empfohlen) -- Python 3.8 oder höher -- Nginx (optional, für Reverse Proxy) -- Systemd (für Service-Management) - -## Installation - -### 1. System-Updates und Abhängigkeiten - -```bash -sudo apt update && sudo apt upgrade -y -sudo apt install python3 python3-pip python3-venv nginx sqlite3 -y -``` - -### 2. Benutzer und Verzeichnisse erstellen - -```bash -# MYP-Benutzer erstellen -sudo useradd --system --group --home /opt/myp myp - -# Verzeichnisse erstellen -sudo mkdir -p /opt/myp/{backend,logs} -sudo chown -R myp:myp /opt/myp -``` - -### 3. Anwendung installieren - -```bash -# Als myp-Benutzer wechseln -sudo -u myp bash - -# In das Backend-Verzeichnis wechseln -cd /opt/myp/backend - -# Repository klonen (oder Dateien kopieren) -# git clone https://github.com/your-org/myp.git . - -# Virtual Environment erstellen -python3 -m venv /opt/myp/venv -source /opt/myp/venv/bin/activate - -# Dependencies installieren -pip install -r requirements.txt -``` - -### 4. Konfiguration - -```bash -# Umgebungsvariablen konfigurieren -cp env.example .env -nano .env - -# Wichtige Konfigurationen: -# - SECRET_KEY: Sicheren Schlüssel generieren -# - TAPO_USERNAME/TAPO_PASSWORD: Tapo-Anmeldedaten -# - PRINTERS: Drucker-Konfiguration als JSON -``` - -#### Beispiel für sichere SECRET_KEY-Generierung: - -```bash -python3 -c "import secrets; print(secrets.token_hex(32))" -``` - -### 5. Datenbank initialisieren - -```bash -# Datenbank-Verzeichnis erstellen -mkdir -p instance - -# Flask-Anwendung starten, um Datenbank zu initialisieren -python3 app.py -# Ctrl+C nach erfolgreicher Initialisierung -``` - -### 6. Systemd Service einrichten - -```bash -# Als root-Benutzer -sudo cp myp-backend.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable myp-backend -sudo systemctl start myp-backend -``` - -### 7. Service-Status überprüfen - -```bash -sudo systemctl status myp-backend -sudo journalctl -u myp-backend -f -``` - -## Nginx Reverse Proxy (Optional) - -### Nginx-Konfiguration erstellen - -```bash -sudo nano /etc/nginx/sites-available/myp-backend -``` - -```nginx -server { - listen 80; - server_name your-domain.com; - - location / { - proxy_pass http://127.0.0.1:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 30; - proxy_send_timeout 30; - proxy_read_timeout 30; - } - - # Statische Dateien direkt ausliefern - location /static/ { - alias /opt/myp/backend/static/; - expires 1h; - add_header Cache-Control "public, immutable"; - } - - # Health Check - location /monitoring/health/simple { - access_log off; - proxy_pass http://127.0.0.1:5000; - } -} -``` - -### Nginx aktivieren - -```bash -sudo ln -s /etc/nginx/sites-available/myp-backend /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx -``` - -## SSL/HTTPS Setup (Empfohlen) - -### Mit Let's Encrypt (Certbot) - -```bash -sudo apt install certbot python3-certbot-nginx -y -sudo certbot --nginx -d your-domain.com -``` - -### Umgebungsvariable für HTTPS setzen - -```bash -# In .env-Datei -FORCE_HTTPS=true -``` - -## Monitoring und Logs - -### Log-Dateien - -- Anwendungs-Logs: `/opt/myp/backend/logs/myp.log` -- Error-Logs: `/opt/myp/backend/logs/myp-errors.log` -- Security-Logs: `/opt/myp/backend/logs/security.log` -- Systemd-Logs: `journalctl -u myp-backend` - -### Health Check-Endpunkte - -- Einfacher Health Check: `http://your-domain.com/monitoring/health/simple` -- Detaillierter Health Check: `http://your-domain.com/monitoring/health` -- System-Metriken: `http://your-domain.com/monitoring/metrics` -- Anwendungsinfo: `http://your-domain.com/monitoring/info` - -## Wartung und Updates - -### Service neustarten - -```bash -sudo systemctl restart myp-backend -``` - -### Logs rotieren - -Die Log-Rotation ist automatisch konfiguriert. Bei Bedarf manuell: - -```bash -sudo logrotate -f /etc/logrotate.d/myp-backend -``` - -### Updates installieren - -```bash -sudo -u myp bash -cd /opt/myp/backend -source /opt/myp/venv/bin/activate - -# Code aktualisieren -git pull - -# Dependencies aktualisieren -pip install -r requirements.txt --upgrade - -# Service neustarten -sudo systemctl restart myp-backend -``` - -### Datenbank-Backup - -```bash -# Backup erstellen -sudo -u myp sqlite3 /opt/myp/backend/instance/myp.db ".backup /opt/myp/backup/myp_$(date +%Y%m%d_%H%M%S).db" - -# Automatisches Backup via Cron -sudo -u myp crontab -e -# Füge hinzu: 0 2 * * * sqlite3 /opt/myp/backend/instance/myp.db ".backup /opt/myp/backup/myp_$(date +\%Y\%m\%d_\%H\%M\%S).db" -``` - -## Sicherheit - -### Firewall konfigurieren - -```bash -sudo ufw allow ssh -sudo ufw allow 'Nginx Full' -sudo ufw --force enable -``` - -### Fail2Ban für zusätzlichen Schutz - -```bash -sudo apt install fail2ban -y -sudo nano /etc/fail2ban/jail.local -``` - -```ini -[DEFAULT] -bantime = 3600 -findtime = 600 -maxretry = 5 - -[nginx-http-auth] -enabled = true -port = http,https -logpath = /var/log/nginx/error.log -``` - -### Regelmäßige Security-Updates - -```bash -sudo apt install unattended-upgrades -y -sudo dpkg-reconfigure -plow unattended-upgrades -``` - -## Troubleshooting - -### Service startet nicht - -```bash -# Logs überprüfen -sudo journalctl -u myp-backend --no-pager -sudo -u myp cat /opt/myp/backend/logs/myp-errors.log -``` - -### Hohe Speichernutzung - -```bash -# Memory-Statistiken -sudo systemctl status myp-backend -sudo ps aux | grep gunicorn -``` - -### Datenbankprobleme - -```bash -# Datenbank-Integrität prüfen -sudo -u myp sqlite3 /opt/myp/backend/instance/myp.db "PRAGMA integrity_check;" -``` - -### Netzwerk-Konnektivität - -```bash -# Port-Verfügbarkeit prüfen -sudo netstat -tlnp | grep :5000 -curl -I http://localhost:5000/monitoring/health/simple -``` - -## Performance-Optimierung - -### Gunicorn Worker anpassen - -```bash -# In .env oder Service-Datei -WORKERS=8 # 2 * CPU-Kerne + 1 -``` - -### Nginx-Caching aktivieren - -```nginx -# In Nginx-Konfiguration -location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { - expires 1y; - add_header Cache-Control "public, immutable"; -} -``` - -### SQLite-Optimierung - -```sql --- Für bessere Performance (einmalig ausführen) -PRAGMA journal_mode=WAL; -PRAGMA synchronous=NORMAL; -PRAGMA cache_size=10000; -PRAGMA temp_store=memory; -``` - -## Support - -Bei Problemen oder Fragen: - -1. Überprüfen Sie die Log-Dateien -2. Testen Sie die Health Check-Endpunkte -3. Konsultieren Sie die Systemd-Logs -4. Kontaktieren Sie das Entwicklungsteam \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..90923d81 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,120 @@ +# MYP - Manage Your Printer + +Ein System zur Verwaltung und Steuerung von 3D-Druckern über TP-Link Tapo P110 Smart Plugs. + +## Verzeichnisstruktur + +Diese MYP-Installation ist wie folgt organisiert: + +- **app/** - Enthält den Anwendungscode (app.py, models.py) +- **docs/** - Enthält die Dokumentation (README, Anleitungen, Fehlerbehebung) +- **install/** - Enthält Installationsskripte und Konfigurationsdateien + +## Installation + +Zur Installation und Konfiguration nutzen Sie bitte das Hauptinstallationsskript: + +```bash +chmod +x setup_myp.sh +./setup_myp.sh +``` + +Das Skript führt Sie durch die verfügbaren Optionen: +1. Standardinstallation (nur MYP-Anwendung) +2. Kiosk-Modus Installation (vollautomatischer Start nach Boot) +3. Dokumentation anzeigen + +## Ausführliche Dokumentation + +Die vollständige Dokumentation finden Sie im `docs/`-Verzeichnis: + +- Allgemeine Anleitung: [docs/README.md](docs/README.md) +- Kiosk-Modus Anleitung: [docs/KIOSK-SETUP.md](docs/KIOSK-SETUP.md) +- Fehlerbehebung: [docs/COMMON_ERRORS.md](docs/COMMON_ERRORS.md) +- Entwicklungsplan: [docs/ROADMAP.md](docs/ROADMAP.md) + +## Funktionsumfang + +- Benutzer- und Rechteverwaltung (Admin/User) +- Verwaltung von Druckern und Smart Plugs +- Reservierungssystem für Drucker (Zeitplanung) +- Automatisches Ein-/Ausschalten der Drucker über Smart Plugs +- Statistikerfassung für Druckaufträge +- **NEU**: Kiosk-Modus für automatischen Start auf Raspberry Pi + +## Systemvoraussetzungen + +- Raspberry Pi 4 (oder kompatibel) +- Python 3.11+ +- Internetzugang für die Installation (danach offline nutzbar) +- TP-Link Tapo P110 Smart Plugs im lokalen Netzwerk + +## Erster Start + +Beim ersten Start wird eine leere Datenbank angelegt. Es muss ein erster Administrator angelegt werden: + +``` +POST /api/create-initial-admin +``` + +Mit folgendem JSON-Body: +```json +{ + "email": "admin@example.com", + "password": "sicheres-passwort", + "name": "Administrator" +} +``` + +## API-Dokumentation + +Die Anwendung stellt folgende REST-API-Endpunkte bereit: + +### Authentifizierung + +- `POST /auth/register` - Neuen Benutzer registrieren +- `POST /auth/login` - Anmelden (erstellt eine Session für 7 Tage) + +### Drucker + +- `GET /api/printers` - Alle Drucker auflisten +- `GET /api/printers/` - Einzelnen Drucker abrufen +- `POST /api/printers` - Neuen Drucker anlegen +- `DELETE /api/printers/` - Drucker löschen + +### Jobs/Reservierungen + +- `GET /api/jobs` - Alle Reservierungen abrufen +- `POST /api/jobs` - Neue Reservierung anlegen +- `GET /api/jobs/` - Reservierungsdetails abrufen +- `POST /api/jobs//finish` - Job beenden (Plug ausschalten) +- `POST /api/jobs//abort` - Job abbrechen (Plug ausschalten) +- `POST /api/jobs//extend` - Endzeit verschieben +- `GET /api/jobs//status` - Aktuellen Plug-Status abrufen +- `GET /api/jobs//remaining-time` - Restzeit in Sekunden abrufen +- `DELETE /api/jobs/` - Job löschen + +### Benutzer + +- `GET /api/users` - Alle Benutzer auflisten (nur Admin) +- `GET /api/users/` - Einzelnen Benutzer abrufen +- `DELETE /api/users/` - Benutzer löschen (nur Admin) + +### Sonstiges + +- `GET /api/stats` - Globale Statistik (Druckzeit, etc.) +- `GET /api/test` - Health-Check + +## Sicherheitshinweise + +- Diese Anwendung speichert alle Zugangsdaten direkt im Code und in der Datenbank. +- Die Anwendung sollte ausschließlich in einem geschützten, lokalen Netzwerk betrieben werden. +- Es wird keine Verschlüsselung für die API-Kommunikation verwendet. + +## Wartung und Troubleshooting + +- Die Logdatei `myp.log` enthält alle wichtigen Ereignisse und Fehler +- Die Datenbank wird in `database/myp.db` gespeichert +- Häufig auftretende Probleme und Lösungen finden sich in [COMMON_ERRORS.md](COMMON_ERRORS.md) +- Zukünftige Entwicklungspläne sind in [ROADMAP.md](ROADMAP.md) dokumentiert + diff --git a/backend/README_BACKEND_SCRIPTS.md b/backend/README_BACKEND_SCRIPTS.md deleted file mode 100644 index 0519ecba..00000000 --- a/backend/README_BACKEND_SCRIPTS.md +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/ROADMAP.md b/backend/ROADMAP.md new file mode 100644 index 00000000..e8aa1c68 --- /dev/null +++ b/backend/ROADMAP.md @@ -0,0 +1,217 @@ +# MYP V2 - Roadmap + +## Projektübersicht +MYP V2 ist ein 3D-Drucker-Management-System mit automatischer Smart Plug-Steuerung für TP-Link Tapo P110 Geräte. + +## Aktuelle Implementierung (Stand: Dezember 2024) + +### ✅ Abgeschlossene Features + +#### Backend-Infrastruktur +- **Flask-Anwendung** mit vollständiger REST-API +- **SQLite-Datenbank** mit SQLAlchemy ORM +- **Benutzerauthentifizierung** mit Flask-Login +- **Rollenbasierte Zugriffskontrolle** (Admin/User) +- **Job-Scheduler** für automatische Aufgabenausführung +- **Logging-System** mit konfigurierbaren Log-Levels +- **Konfigurationsmanagement** mit hardcodierten Credentials + +#### Datenmodelle +- **User**: Benutzerverwaltung mit Rollen +- **Printer**: 3D-Drucker mit Smart Plug-Integration +- **Job**: Druckaufträge mit Zeitplanung +- **Stats**: Systemstatistiken und Metriken + +#### API-Endpunkte +- **Authentifizierung**: Register, Login, Logout +- **Drucker-Management**: CRUD-Operationen +- **Job-Management**: Erstellen, Überwachen, Steuern von Druckaufträgen +- **Benutzer-Management**: Admin-Funktionen +- **Statistiken**: Systemmetriken und Berichte +- **Scheduler-Steuerung**: Start/Stop/Status des Job-Monitors + +#### Smart Plug-Integration +- **TP-Link Tapo P110** Steuerung über PyP100 +- **Automatisches Ein-/Ausschalten** basierend auf Job-Zeiten +- **Stromverbrauchsüberwachung** +- **Fehlerbehandlung** bei Verbindungsproblemen + +#### Logging & Monitoring +- **Strukturiertes Logging** mit separaten Loggern für verschiedene Komponenten +- **Log-Rotation** und Archivierung +- **Startup-Informationen** und Systemstatus +- **Fehlerprotokollierung** mit Stack-Traces + +### 🔧 Technische Architektur + +#### Verzeichnisstruktur +``` +MYP_V2/ +├── app/ +│ ├── blueprints/ # Flask Blueprints (leer) +│ ├── config/ +│ │ └── settings.py # Konfiguration und Credentials +│ ├── models.py # Datenbankmodelle +│ ├── app.py # Haupt-Flask-Anwendung +│ ├── static/ # CSS, JS, Bilder (leer) +│ ├── templates/ # HTML-Templates (leer) +│ └── utils/ +│ ├── job_scheduler.py # Background-Task-Scheduler +│ └── logging_config.py # Logging-Konfiguration +├── docs/ # Dokumentation +├── install/ # Installationsskripte +├── logs/ # Log-Dateien +│ ├── app/ # Anwendungs-Logs +│ ├── auth/ # Authentifizierungs-Logs +│ ├── jobs/ # Job-Logs +│ ├── printers/ # Drucker-Logs +│ └── scheduler/ # Scheduler-Logs +├── ROADMAP.md # Diese Datei +└── setup_myp.sh # Setup-Skript +``` + +#### Konfiguration +- **Hardcodierte Credentials** für Tapo-Geräte +- **Drucker-Konfiguration** mit IP-Adressen +- **Flask-Einstellungen** (Host, Port, Debug-Modus) +- **Session-Management** mit konfigurierbarer Lebensdauer +- **Scheduler-Einstellungen** mit aktivierbarem/deaktivierbarem Modus + +## 🚀 Geplante Features + +### Phase 1: Frontend-Entwicklung +- [ ] **React/Vue.js Frontend** für Benutzeroberfläche +- [ ] **Dashboard** mit Echtzeit-Status der Drucker +- [ ] **Job-Kalender** für Terminplanung +- [ ] **Benutzer-Management-Interface** für Admins +- [ ] **Responsive Design** für mobile Geräte + +### Phase 2: Erweiterte Features +- [ ] **Datei-Upload** für 3D-Modelle (.stl, .gcode) +- [ ] **Druckzeit-Schätzung** basierend auf Dateianalyse +- [ ] **Material-Tracking** mit Verbrauchsberechnung +- [ ] **Wartungsplanung** für Drucker +- [ ] **Benachrichtigungssystem** (E-Mail, Push) + +### Phase 3: Integration & Automatisierung +- [ ] **Octoprint-Integration** für erweiterte Druckersteuerung +- [ ] **Kamera-Integration** für Live-Überwachung +- [ ] **Temperatur-Monitoring** über zusätzliche Sensoren +- [ ] **Automatische Qualitätskontrolle** mit KI-basierter Bilderkennung +- [ ] **Multi-Standort-Support** für verteilte Druckerfarms + +### Phase 4: Enterprise Features +- [ ] **Kostenverfolgung** pro Job und Benutzer +- [ ] **Reporting & Analytics** mit erweiterten Metriken +- [ ] **API-Dokumentation** mit Swagger/OpenAPI +- [ ] **Backup & Recovery** System +- [ ] **LDAP/Active Directory** Integration + +## 🔒 Sicherheit & Compliance + +### Aktuelle Sicherheitsmaßnahmen +- ✅ **Session-basierte Authentifizierung** +- ✅ **Rollenbasierte Zugriffskontrolle** +- ✅ **Passwort-Hashing** mit Werkzeug +- ✅ **SQL-Injection-Schutz** durch SQLAlchemy ORM + +### Geplante Sicherheitsverbesserungen +- [ ] **JWT-Token-Authentifizierung** für API-Zugriff +- [ ] **Rate Limiting** für API-Endpunkte +- [ ] **HTTPS-Erzwingung** in Produktionsumgebung +- [ ] **Audit-Logging** für kritische Aktionen +- [ ] **Verschlüsselung** sensibler Daten in der Datenbank + +## 📊 Performance & Skalierung + +### Aktuelle Architektur +- **SQLite-Datenbank** für einfache Bereitstellung +- **Single-Thread-Scheduler** für Job-Monitoring +- **Synchrone API-Verarbeitung** + +### Geplante Verbesserungen +- [ ] **PostgreSQL/MySQL** Support für größere Installationen +- [ ] **Redis** für Session-Storage und Caching +- [ ] **Celery** für asynchrone Task-Verarbeitung +- [ ] **Load Balancing** für Multi-Instance-Deployments +- [ ] **Containerisierung** mit Docker + +## 🧪 Testing & Qualitätssicherung + +### Geplante Test-Infrastruktur +- [ ] **Unit Tests** für alle Komponenten +- [ ] **Integration Tests** für API-Endpunkte +- [ ] **End-to-End Tests** für kritische Workflows +- [ ] **Performance Tests** für Lastszenarien +- [ ] **Security Tests** für Penetrationstests + +## 📚 Dokumentation + +### Geplante Dokumentation +- [ ] **API-Dokumentation** mit interaktiven Beispielen +- [ ] **Benutzerhandbuch** für End-User +- [ ] **Administrator-Handbuch** für System-Setup +- [ ] **Entwickler-Dokumentation** für Beiträge +- [ ] **Deployment-Guide** für verschiedene Umgebungen + +## 🔄 Deployment & DevOps + +### Aktuelle Bereitstellung +- **Manuelles Setup** über setup_myp.sh +- **Lokale Entwicklungsumgebung** + +### Geplante Verbesserungen +- [ ] **Docker-Container** für einfache Bereitstellung +- [ ] **CI/CD-Pipeline** mit GitHub Actions +- [ ] **Automatisierte Tests** bei Pull Requests +- [ ] **Staging-Umgebung** für Pre-Production-Tests +- [ ] **Monitoring & Alerting** mit Prometheus/Grafana + +## 📈 Metriken & KPIs + +### Zu verfolgende Metriken +- **Druckzeit-Effizienz**: Verhältnis geplante vs. tatsächliche Druckzeit +- **Systemverfügbarkeit**: Uptime der Drucker und Services +- **Benutzeraktivität**: Anzahl aktiver Benutzer und Jobs +- **Fehlerrate**: Anzahl fehlgeschlagener Jobs und Systemfehler +- **Ressourcenverbrauch**: CPU, Memory, Disk Usage + +## 🎯 Meilensteine + +### Q1 2025 +- [ ] Frontend-Grundgerüst implementieren +- [ ] Basis-Dashboard mit Drucker-Status +- [ ] Job-Erstellung über Web-Interface + +### Q2 2025 +- [ ] Datei-Upload und -Management +- [ ] Erweiterte Job-Steuerung +- [ ] Benutzer-Management-Interface + +### Q3 2025 +- [ ] Mobile App (React Native/Flutter) +- [ ] Erweiterte Integrationen (Octoprint, Kameras) +- [ ] Performance-Optimierungen + +### Q4 2025 +- [ ] Enterprise-Features +- [ ] Multi-Tenant-Support +- [ ] Vollständige API-Dokumentation + +## 🤝 Beitrag & Community + +### Entwicklungsrichtlinien +- **Code-Qualität**: Einhaltung von PEP 8 für Python +- **Dokumentation**: Vollständige Docstrings für alle Funktionen +- **Testing**: Mindestens 80% Code-Coverage +- **Security**: Regelmäßige Sicherheitsüberprüfungen + +### Lizenzierung +- **Open Source**: MIT-Lizenz für Community-Beiträge +- **Enterprise**: Kommerzielle Lizenz für erweiterte Features + +--- + +**Letzte Aktualisierung**: Dezember 2024 +**Version**: 2.0.0-alpha +**Maintainer**: MYP Development Team \ No newline at end of file diff --git a/backend/app.py b/backend/app.py deleted file mode 100755 index 28367eb0..00000000 --- a/backend/app.py +++ /dev/null @@ -1,1958 +0,0 @@ -from flask import Flask, request, jsonify, g, redirect, url_for, session as flask_session, render_template, flash, send_from_directory -from werkzeug.security import generate_password_hash, check_password_hash -from flask_cors import CORS -import secrets # Für bessere Salt-Generierung -from functools import wraps -import jwt -import datetime -import os -import json -import logging -import uuid -import sqlite3 -import threading -import time -from logging.handlers import RotatingFileHandler -from datetime import timedelta -from PyP100 import PyP100 -from dotenv import load_dotenv - -# Importiere Konfiguration -from config import config - -# Importiere Netzwerkkonfiguration -from network_config import NetworkConfig - -# Importiere Frontend V2 Blueprint -from frontend_v2_routes import frontend_v2, set_app_functions - -# Lade Umgebungsvariablen -load_dotenv() - -def create_app(config_name=None): - """ - Application Factory Pattern für die Flask-Anwendung. - - Args: - config_name: Name der zu verwendenden Konfiguration ('development', 'production', 'testing') - - Returns: - Flask: Konfigurierte Flask-Anwendung - """ - app = Flask(__name__) - - # CORS-Konfiguration für Frontend-Server - cors_origins = os.environ.get('CORS_ORIGINS', 'http://localhost:3000').split(',') - CORS(app, - origins=cors_origins, - supports_credentials=True, - allow_headers=['Content-Type', 'Authorization', 'X-Requested-With'], - methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']) - - # Bestimme Konfiguration - if config_name is None: - config_name = os.environ.get('FLASK_ENV', 'development') - - # Lade Konfiguration - config_object = config.get(config_name, config['default']) - app.config.from_object(config_object) - - # Initialisiere Konfiguration - config_object.init_app(app) - - # Initialisiere Netzwerkkonfiguration - network_config = NetworkConfig(app) - - # Globale Variablen setzen - app.config['PRINTERS'] = json.loads(app.config.get('PRINTERS', '{}')) - - # Database functions registrieren - register_database_functions(app) - - # Authentifizierung registrieren - register_auth_functions(app) - - # API-Routen registrieren - register_api_routes(app) - - # Web-UI-Routen registrieren - register_web_routes(app) - - # Error-Handler registrieren - register_error_handlers(app) - - # Blueprint registrieren - register_blueprints(app) - - # Middleware registrieren - register_middleware(app) - - # Hintergrund-Tasks registrieren - register_background_tasks(app) - - # Parse PRINTERS als JSON - printers_env = app.config.get('PRINTERS', '{}') - if isinstance(printers_env, str): - try: - app.config['PRINTERS'] = json.loads(printers_env) - except (json.JSONDecodeError, TypeError): - app.config['PRINTERS'] = {} - elif isinstance(printers_env, dict): - app.config['PRINTERS'] = printers_env - else: - app.config['PRINTERS'] = {} - - return app - -# Initialisierung - wird später durch create_app ersetzt -app = Flask(__name__) - -# CORS-Konfiguration für Frontend-Server (Legacy) -cors_origins = os.environ.get('CORS_ORIGINS', 'http://localhost:3000').split(',') -CORS(app, - origins=cors_origins, - supports_credentials=True, - allow_headers=['Content-Type', 'Authorization', 'X-Requested-With'], - methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']) - -# Initialisiere Netzwerkkonfiguration -network_config = NetworkConfig(app) - -# Temporäre Konfiguration für Legacy-Code -app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_secret_key') -app.config['DATABASE'] = os.environ.get('DATABASE_PATH', 'instance/myp.db') -app.config['SESSION_COOKIE_HTTPONLY'] = True -app.config['SESSION_COOKIE_SECURE'] = os.environ.get('FLASK_ENV') == 'production' -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) -app.config['JOB_CHECK_INTERVAL'] = int(os.environ.get('JOB_CHECK_INTERVAL', '60')) # Sekunden - -# Registriere Frontend V2 Blueprint -app.register_blueprint(frontend_v2, url_prefix='/frontend_v2') - -# Übergebe Funktionen an das Frontend v2 -def setup_frontend_v2(): - app_functions = { - 'get_current_user': get_current_user, - 'get_user_by_id': get_user_by_id, - 'get_socket_by_id': get_socket_by_id, - 'get_job_by_id': get_job_by_id, - 'get_all_sockets': get_all_sockets, - 'get_all_users': get_all_users, - 'get_all_jobs': get_all_jobs, - 'get_jobs_by_user': get_jobs_by_user, - 'login_required': login_required, - 'admin_required': admin_required, - 'delete_session': delete_session, - 'socket_to_dict': socket_to_dict, - 'job_to_dict': job_to_dict, - 'user_to_dict': user_to_dict - } - set_app_functions(app_functions) - -# Konfiguriere statische Dateien für Frontend v2 -@app.route('/frontend_v2/static/') -def frontend_v2_static(filename): - return send_from_directory(os.path.join(app.root_path, 'frontend_v2/static'), filename) - -# Steckdosen-Konfiguration -TAPO_USERNAME = os.environ.get('TAPO_USERNAME') -TAPO_PASSWORD = os.environ.get('TAPO_PASSWORD') - -def setup_logging(app): - """ - Konfiguriert das Logging basierend auf der Umgebung. - - Args: - app: Flask-Anwendung - """ - if not app.debug and not app.testing: - # Production logging - if not os.path.exists('logs'): - os.mkdir('logs') - - file_handler = RotatingFileHandler('logs/myp.log', maxBytes=10240, backupCount=10) - file_handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' - )) - file_handler.setLevel(logging.INFO) - app.logger.addHandler(file_handler) - - app.logger.setLevel(logging.INFO) - app.logger.info('MYP Backend starting in production mode') - else: - # Development logging - app.logger.setLevel(logging.DEBUG) - app.logger.info('MYP Backend starting in development mode') - -# Logging - Legacy (wird durch setup_logging ersetzt) -if not os.path.exists('logs'): - os.mkdir('logs') -file_handler = RotatingFileHandler('logs/myp.log', maxBytes=10240, backupCount=10) -file_handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' -)) -file_handler.setLevel(logging.INFO) -app.logger.addHandler(file_handler) -app.logger.setLevel(logging.INFO) -app.logger.info('MYP Backend starting up') - -# Database functions -def get_db(): - if 'db' not in g: - # Stelle sicher, dass das instance-Verzeichnis existiert - os.makedirs(os.path.dirname(app.config['DATABASE']), exist_ok=True) - g.db = sqlite3.connect(app.config['DATABASE']) - g.db.row_factory = sqlite3.Row - return g.db - -def close_db(e=None): - db = g.pop('db', None) - if db is not None: - db.close() - -def init_db(): - """Initialisiere die Datenbank, falls sie noch nicht existiert.""" - db = get_db() - db.execute('PRAGMA foreign_keys = ON') # SQLite-Fremdschlüsselunterstützung aktivieren - - # Tabellen erstellen - db.executescript(''' - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name TEXT, - email TEXT UNIQUE, - role TEXT DEFAULT 'user' - ); - - CREATE TABLE IF NOT EXISTS session ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - expires_at TIMESTAMP NOT NULL, - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS socket ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - status INTEGER DEFAULT 0, - ip_address TEXT, - last_seen TIMESTAMP, - connection_status TEXT DEFAULT 'unknown' - ); - - CREATE TABLE IF NOT EXISTS job ( - id TEXT PRIMARY KEY, - socket_id TEXT NOT NULL, - user_id TEXT NOT NULL, - start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - duration_in_minutes INTEGER NOT NULL, - comments TEXT, - aborted INTEGER DEFAULT 0, - abort_reason TEXT, - waiting_approval INTEGER DEFAULT 0, - FOREIGN KEY (socket_id) REFERENCES socket (id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS socket_uptime ( - id TEXT PRIMARY KEY, - socket_id TEXT NOT NULL, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status TEXT NOT NULL, - duration_seconds INTEGER, - FOREIGN KEY (socket_id) REFERENCES socket (id) ON DELETE CASCADE - ); - ''') - - # Überprüfe, ob die fehlenden Spalten bereits existieren, und füge sie hinzu, falls nicht - try: - # Prüfe, ob die connection_status-Spalte existiert - db.execute('SELECT connection_status FROM socket LIMIT 1') - except sqlite3.OperationalError: - # Spalte existiert nicht, füge sie hinzu - app.logger.info("Füge connection_status-Spalte zur socket-Tabelle hinzu") - db.execute('ALTER TABLE socket ADD COLUMN connection_status TEXT DEFAULT "unknown"') - - try: - # Prüfe, ob die last_seen-Spalte existiert - db.execute('SELECT last_seen FROM socket LIMIT 1') - except sqlite3.OperationalError: - # Spalte existiert nicht, füge sie hinzu - app.logger.info("Füge last_seen-Spalte zur socket-Tabelle hinzu") - db.execute('ALTER TABLE socket ADD COLUMN last_seen TIMESTAMP') - - db.commit() - -PRINTERS = json.loads(os.environ.get('PRINTERS', '{}')) - -def init_printers(): - app.logger.info("Initialisiere Drucker aus Umgebungsvariablen") - db = get_db() - - # Existierende IP-Adressen aus der Datenbank abrufen - existing_ips = {row['ip_address']: row['id'] for row in db.execute('SELECT id, ip_address FROM socket').fetchall() if row['ip_address']} - - for printer_name, config in PRINTERS.items(): - ip_address = config.get('ip') - if not ip_address: - continue # Überspringe Einträge ohne IP - description = f"Drucker mit IP: {ip_address}" - - if ip_address in existing_ips: - app.logger.info(f"Drucker mit IP {ip_address} existiert bereits in der Datenbank") - # Setze den Status des existierenden Druckers auf 0 (verfügbar) - socket_id = existing_ips[ip_address] - update_socket(socket_id, status=0) - # Stelle sicher, dass die Steckdose wirklich ausgeschaltet ist - turn_off_socket(ip_address) - app.logger.info(f"Steckdose mit IP {ip_address} wurde beim Start ausgeschaltet") - else: - # Neuen Drucker eintragen - new_socket = create_socket(name=printer_name, description=description, ip_address=ip_address, status=0) - app.logger.info(f"Neuer Drucker angelegt: {printer_name} mit IP {ip_address}") - # Stelle sicher, dass die Steckdose wirklich ausgeschaltet ist - turn_off_socket(ip_address) - app.logger.info(f"Neue Steckdose mit IP {ip_address} wurde beim Start ausgeschaltet") - -# Benutzerverwaltung -def get_user_by_id(user_id): - db = get_db() - row = db.execute('SELECT * FROM user WHERE id = ?', (user_id,)).fetchone() - if not row: - return None - return dict(row) - -def get_user_by_username(username): - db = get_db() - row = db.execute('SELECT * FROM user WHERE username = ?', (username,)).fetchone() - if not row: - return None - return dict(row) - -def get_all_users(): - db = get_db() - rows = db.execute('SELECT * FROM user').fetchall() - return [dict(row) for row in rows] - -def create_user(username, password, display_name=None, email=None, role='user'): - user_id = str(uuid.uuid4()) - - # Verwende einen sicheren Hash-Algorithmus (pbkdf2:sha256) mit mehr Iterationen (150000) - # und automatischem Salting durch Werkzeug - password_hash = generate_password_hash( - password, - method='pbkdf2:sha256', - salt_length=16 # Standardwert ist 8, aber wir erhöhen auf 16 für mehr Sicherheit - ) - - display_name = display_name or username - - db = get_db() - db.execute( - 'INSERT INTO user (id, username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?, ?)', - (user_id, username, password_hash, display_name, email, role) - ) - db.commit() - - app.logger.info(f"Benutzer {username} erstellt mit sicherem Password-Hash (pbkdf2:sha256, salt_length=16)") - return get_user_by_id(user_id) - -def update_user(user_id, username=None, password=None, display_name=None, email=None, role=None): - user = get_user_by_id(user_id) - if not user: - return None - - values = [] - params = [] - - if username: - values.append('username = ?') - params.append(username) - - if password: - values.append('password_hash = ?') - # Verwende den gleichen verbesserten Hashing-Mechanismus wie bei create_user - params.append(generate_password_hash( - password, - method='pbkdf2:sha256', - salt_length=16 - )) - - if display_name: - values.append('display_name = ?') - params.append(display_name) - - if email: - values.append('email = ?') - params.append(email) - - if role: - values.append('role = ?') - params.append(role) - - if not values: - return user - - query = f'UPDATE user SET {", ".join(values)} WHERE id = ?' - params.append(user_id) - - db = get_db() - db.execute(query, params) - db.commit() - - return get_user_by_id(user_id) - -def delete_user(user_id): - db = get_db() - db.execute('DELETE FROM user WHERE id = ?', (user_id,)) - db.commit() - return True - -def check_password(user_dict, password): - # Überprüfe das Passwort mit dem gespeicherten Hash - is_valid = check_password_hash(user_dict['password_hash'], password) - - # Wenn das Passwort gültig ist, überprüfe, ob der Hash aktualisiert werden muss - if is_valid: - # Überprüfe, ob der aktuelle Hash nicht das empfohlene Format verwendet - if not user_dict['password_hash'].startswith('pbkdf2:sha256:'): - # Hash muss aktualisiert werden, da er nicht den neuesten Sicherheitsstandards entspricht - app.logger.info(f"Migriere unsicheren Passwort-Hash für Benutzer {user_dict['username']} zu pbkdf2:sha256") - - # Erstelle neuen Hash mit dem bestätigten Passwort - new_hash = generate_password_hash( - password, - method='pbkdf2:sha256', - salt_length=16 - ) - - # Aktualisiere in der Datenbank - db = get_db() - db.execute('UPDATE user SET password_hash = ? WHERE id = ?', - (new_hash, user_dict['id'])) - db.commit() - - return is_valid - -def user_to_dict(user): - if not user: - return None - return { - 'id': user['id'], - 'username': user['username'], - 'displayName': user['display_name'], - 'email': user['email'], - 'role': user['role'] - } - -# Session-Verwaltung -def get_session_by_id(session_id): - db = get_db() - row = db.execute('SELECT * FROM session WHERE id = ?', (session_id,)).fetchone() - if not row: - return None - return dict(row) - -def delete_sessions_by_user(user_id): - db = get_db() - db.execute('DELETE FROM session WHERE user_id = ?', (user_id,)) - db.commit() - -def create_session(user_id): - session_id = str(uuid.uuid4()) - expires_at = datetime.datetime.utcnow() + timedelta(days=7) - - db = get_db() - db.execute( - 'INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)', - (session_id, user_id, expires_at.isoformat()) - ) - db.commit() - - flask_session['session_id'] = session_id - flask_session.permanent = True - - return session_id - -def delete_session(session_id): - db = get_db() - db.execute('DELETE FROM session WHERE id = ?', (session_id,)) - db.commit() - -# Steckdosen-Verwaltung -def get_socket_by_id(socket_id): - db = get_db() - row = db.execute('SELECT * FROM socket WHERE id = ?', (socket_id,)).fetchone() - if not row: - return None - return dict(row) - -def get_all_sockets(): - db = get_db() - rows = db.execute('SELECT * FROM socket').fetchall() - return [dict(row) for row in rows] - -def create_socket(name, description, ip_address=None, status=0): - socket_id = str(uuid.uuid4()) - - db = get_db() - db.execute( - 'INSERT INTO socket (id, name, description, status, ip_address) VALUES (?, ?, ?, ?, ?)', - (socket_id, name, description, status, ip_address) - ) - db.commit() - - return get_socket_by_id(socket_id) - -def update_socket(socket_id, name=None, description=None, status=None, ip_address=None): - socket = get_socket_by_id(socket_id) - if not socket: - return None - - values = [] - params = [] - - if name: - values.append('name = ?') - params.append(name) - - if description: - values.append('description = ?') - params.append(description) - - if status is not None: - values.append('status = ?') - params.append(status) - - if ip_address: - values.append('ip_address = ?') - params.append(ip_address) - - if not values: - return socket - - query = f'UPDATE socket SET {", ".join(values)} WHERE id = ?' - params.append(socket_id) - - db = get_db() - db.execute(query, params) - db.commit() - - return get_socket_by_id(socket_id) - -def delete_socket(socket_id): - db = get_db() - db.execute('DELETE FROM socket WHERE id = ?', (socket_id,)) - db.commit() - return True - -def get_latest_job_for_socket(socket_id): - db = get_db() - row = db.execute(''' - SELECT * FROM job - WHERE socket_id = ? - ORDER BY start_at DESC - LIMIT 1 - ''', (socket_id,)).fetchone() - - if not row: - return None - return dict(row) - -def socket_to_dict(socket): - if not socket: - return None - - latest_job = get_latest_job_for_socket(socket['id']) - waiting_jobs = get_waiting_jobs_for_socket(socket['id']) - - # Verbindungsstatus-Informationen - connection_status = socket.get('connection_status', 'unknown') - last_seen = socket.get('last_seen') - uptime_info = None - - if last_seen and connection_status == 'offline': - # Berechne wie lange die Steckdose offline ist - try: - last_seen_dt = datetime.datetime.fromisoformat(last_seen) - now = datetime.datetime.utcnow() - offline_duration = int((now - last_seen_dt).total_seconds()) - - # Formatiere die Offline-Zeit benutzerfreundlich - hours, remainder = divmod(offline_duration, 3600) - minutes, seconds = divmod(remainder, 60) - - uptime_info = { - 'offline_since': last_seen, - 'offline_duration': offline_duration, - 'offline_duration_formatted': f"{hours}h {minutes}m {seconds}s" - } - except (ValueError, TypeError): - # Wenn das Datum nicht geparst werden kann - uptime_info = { - 'offline_since': last_seen, - 'offline_duration': None, - 'offline_duration_formatted': "Unbekannt" - } - - return { - 'id': socket['id'], - 'name': socket['name'], - 'description': socket['description'], - 'status': socket['status'], - 'ipAddress': socket.get('ip_address'), - 'connectionStatus': connection_status, - 'lastSeen': last_seen, - 'uptimeInfo': uptime_info, - 'latestJob': job_to_dict(latest_job) if latest_job else None, - 'waitingJobs': [job_to_dict(job) for job in waiting_jobs] if waiting_jobs else [] - } - -# Job-Verwaltung -def get_job_by_id(job_id): - db = get_db() - row = db.execute('SELECT * FROM job WHERE id = ?', (job_id,)).fetchone() - if not row: - return None - return dict(row) - -def get_jobs_by_user(user_id): - db = get_db() - rows = db.execute('SELECT * FROM job WHERE user_id = ?', (user_id,)).fetchall() - return [dict(row) for row in rows] - -def get_all_jobs(): - db = get_db() - rows = db.execute('SELECT * FROM job').fetchall() - return [dict(row) for row in rows] - -def get_expired_jobs(): - db = get_db() - now = datetime.datetime.utcnow().isoformat() - rows = db.execute(''' - SELECT * FROM job - WHERE aborted = 0 - AND waiting_approval = 0 - AND datetime(start_at, '+' || duration_in_minutes || ' minutes') <= datetime(?) - ''', (now,)).fetchall() - return [dict(row) for row in rows] - -def get_waiting_jobs_for_socket(socket_id): - """Findet alle Jobs, die auf Freischaltung für eine bestimmte Steckdose warten.""" - db = get_db() - rows = db.execute(''' - SELECT * FROM job - WHERE socket_id = ? - AND aborted = 0 - AND waiting_approval = 1 - ORDER BY start_at ASC - ''', (socket_id,)).fetchall() - return [dict(row) for row in rows] - -def create_job(socket_id, user_id, duration_in_minutes, comments=None, waiting_approval=0): - job_id = str(uuid.uuid4()) - start_at = datetime.datetime.utcnow() - - db = get_db() - db.execute( - '''INSERT INTO job - (id, socket_id, user_id, start_at, duration_in_minutes, comments, aborted, abort_reason, waiting_approval) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''', - (job_id, socket_id, user_id, start_at.isoformat(), duration_in_minutes, comments, 0, None, waiting_approval) - ) - db.commit() - - return get_job_by_id(job_id) - -def update_job(job_id, socket_id=None, user_id=None, duration_in_minutes=None, - comments=None, aborted=None, abort_reason=None, waiting_approval=None): - job = get_job_by_id(job_id) - if not job: - return None - - values = [] - params = [] - - if socket_id: - values.append('socket_id = ?') - params.append(socket_id) - - if user_id: - values.append('user_id = ?') - params.append(user_id) - - if duration_in_minutes: - values.append('duration_in_minutes = ?') - params.append(duration_in_minutes) - - if comments is not None: - values.append('comments = ?') - params.append(comments) - - if aborted is not None: - values.append('aborted = ?') - params.append(1 if aborted else 0) - - if abort_reason is not None: - values.append('abort_reason = ?') - params.append(abort_reason) - - if waiting_approval is not None: - values.append('waiting_approval = ?') - params.append(1 if waiting_approval else 0) - - if not values: - return job - - query = f'UPDATE job SET {", ".join(values)} WHERE id = ?' - params.append(job_id) - - db = get_db() - db.execute(query, params) - db.commit() - - return get_job_by_id(job_id) - -def delete_job(job_id): - db = get_db() - db.execute('DELETE FROM job WHERE id = ?', (job_id,)) - db.commit() - return True - -def calculate_remaining_time(job): - if job['aborted']: - return 0 - - start_at = datetime.datetime.fromisoformat(job['start_at']) - end_at = start_at + timedelta(minutes=job['duration_in_minutes']) - - now = datetime.datetime.utcnow() - if now > end_at: - return 0 - - diff = end_at - now - return int(diff.total_seconds() / 60) - -def job_to_dict(job): - if not job: - return None - - # Bei älteren Jobs könnte waiting_approval fehlen, deshalb mit get abrufen und Default setzen - waiting_approval = job.get('waiting_approval', 0) if isinstance(job, dict) else getattr(job, 'waiting_approval', 0) - - return { - 'id': job['id'], - 'socketId': job['socket_id'], - 'userId': job['user_id'], - 'startAt': job['start_at'], - 'durationInMinutes': job['duration_in_minutes'], - 'comments': job['comments'], - 'aborted': bool(job['aborted']), - 'abortReason': job['abort_reason'], - 'waitingApproval': bool(waiting_approval), - 'remainingMinutes': calculate_remaining_time(job) - } - -# Socket Uptime-Überwachung -def log_socket_connection_event(socket_id, status, duration_seconds=None): - """Speichert ein Ereignis zum Verbindungsstatus einer Steckdose""" - event_id = str(uuid.uuid4()) - timestamp = datetime.datetime.utcnow().isoformat() - - db = get_db() - db.execute( - 'INSERT INTO socket_uptime (id, socket_id, timestamp, status, duration_seconds) VALUES (?, ?, ?, ?, ?)', - (event_id, socket_id, timestamp, status, duration_seconds) - ) - db.commit() - app.logger.info(f"Verbindungsstatus für Steckdose {socket_id} geändert: {status}") - - # Aktualisiere auch den Verbindungsstatus in der socket-Tabelle - db.execute( - 'UPDATE socket SET connection_status = ?, last_seen = ? WHERE id = ?', - (status, timestamp if status == 'online' else None, socket_id) - ) - db.commit() - - return event_id - -def get_socket_uptime_events(socket_id=None, limit=100): - """Ruft Verbindungsereignisse für eine oder alle Steckdosen ab""" - db = get_db() - - if socket_id: - rows = db.execute(''' - SELECT su.*, s.name, s.ip_address FROM socket_uptime su - JOIN socket s ON su.socket_id = s.id - WHERE su.socket_id = ? - ORDER BY su.timestamp DESC - LIMIT ? - ''', (socket_id, limit)).fetchall() - else: - rows = db.execute(''' - SELECT su.*, s.name, s.ip_address FROM socket_uptime su - JOIN socket s ON su.socket_id = s.id - ORDER BY su.timestamp DESC - LIMIT ? - ''', (limit,)).fetchall() - - return [dict(row) for row in rows] - -def check_socket_connection(socket_id, timeout=8): - """ - Überprüft die Verbindung zu einer Steckdose und aktualisiert den Status. - - Args: - socket_id: ID der Steckdose - timeout: Timeout in Sekunden, nach dem die Verbindung als fehlgeschlagen gilt - - Returns: - True wenn die Steckdose online ist, sonst False - """ - socket = get_socket_by_id(socket_id) - if not socket or not socket['ip_address']: - return False - - previous_status = socket.get('connection_status', 'unknown') - last_seen = socket.get('last_seen') - - try: - # Verwende den Timeout-Parameter für die Geräteverbindung - device = get_socket_device(socket['ip_address'], timeout=timeout) - if device: - # Verbindung erfolgreich - if previous_status != 'online': - # Status hat sich von offline/unknown auf online geändert - duration = None - if previous_status == 'offline' and last_seen: - # Berechne die Dauer des Ausfalls - try: - offline_since = datetime.datetime.fromisoformat(last_seen) - now = datetime.datetime.utcnow() - duration = int((now - offline_since).total_seconds()) - except (ValueError, TypeError): - # Wenn das Datum nicht geparst werden kann - duration = None - - log_socket_connection_event(socket_id, 'online', duration) - return True - else: - # Keine Verbindung möglich oder Timeout - if previous_status != 'offline': - # Status hat sich von online/unknown auf offline geändert - log_socket_connection_event(socket_id, 'offline') - return False - except Exception as e: - app.logger.error(f"Fehler bei der Überprüfung der Steckdose {socket['ip_address']}: {e}") - if previous_status != 'offline': - log_socket_connection_event(socket_id, 'offline') - return False - -# Steckdosen-Steuerung mit PyP100 -def get_socket_device(ip_address, timeout=8): - """ - Stellt eine Verbindung zu einer Tapo P100-Steckdose her, mit einem konfigurierbaren Timeout. - - Args: - ip_address: IP-Adresse der Steckdose - timeout: Timeout in Sekunden, nach dem die Verbindung als fehlgeschlagen gilt - - Returns: - Das PyP100-Geräteobjekt bei erfolgreicher Verbindung, sonst None - """ - try: - # Nutze Threading mit Timeout für die Verbindung - import threading - import queue - - result_queue = queue.Queue() - - def connect_with_timeout(): - try: - device = PyP100.P100(ip_address, TAPO_USERNAME, TAPO_PASSWORD) - device.handshake() # Erstellt die erforderlichen Cookies - device.login() # Sendet Anmeldedaten und erstellt AES-Schlüssel - result_queue.put(device) - except Exception as e: - app.logger.error(f"Fehler bei der Anmeldung an P100-Gerät {ip_address}: {e}") - result_queue.put(None) - - # Starte den Verbindungsversuch in einem Thread - connect_thread = threading.Thread(target=connect_with_timeout) - connect_thread.daemon = True - connect_thread.start() - - # Warte mit Timeout auf das Ergebnis - try: - device = result_queue.get(timeout=timeout) - if device: - app.logger.info(f"PyP100 Verbindung zu {ip_address} hergestellt") - return device - except queue.Empty: - app.logger.error(f"Timeout bei der Verbindung zu {ip_address} nach {timeout} Sekunden") - return None - - except Exception as e: - app.logger.error(f"Unerwarteter Fehler bei der Anmeldung an P100-Gerät {ip_address}: {e}") - return None - -def turn_on_socket(ip_address, timeout=8): - """ - Schaltet eine Steckdose ein mit konfiguriertem Timeout. - - Args: - ip_address: IP-Adresse der Steckdose - timeout: Timeout in Sekunden für die Verbindung - - Returns: - True bei Erfolg, False bei Fehlern oder Timeout - """ - try: - device = get_socket_device(ip_address, timeout=timeout) - if device: - device.turnOn() - app.logger.info(f"P100-Steckdose {ip_address} eingeschaltet") - return True - return False - except Exception as e: - app.logger.error(f"Fehler beim Einschalten der P100-Steckdose {ip_address}: {e}") - return False - -def turn_off_socket(ip_address, timeout=8): - """ - Schaltet eine Steckdose aus mit konfiguriertem Timeout. - - Args: - ip_address: IP-Adresse der Steckdose - timeout: Timeout in Sekunden für die Verbindung - - Returns: - True bei Erfolg, False bei Fehlern oder Timeout - """ - try: - device = get_socket_device(ip_address, timeout=timeout) - if device: - device.turnOff() - app.logger.info(f"P100-Steckdose {ip_address} ausgeschaltet") - return True - return False - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der P100-Steckdose {ip_address}: {e}") - return False - -# Authentifizierung und Autorisierung -def get_current_user(): - session_id = flask_session.get('session_id') - if not session_id: - return None - - session = get_session_by_id(session_id) - if not session or datetime.datetime.fromisoformat(session['expires_at']) < datetime.datetime.utcnow(): - if session: - delete_session(session['id']) - flask_session.pop('session_id', None) - return None - - return get_user_by_id(session['user_id']) - -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - user = get_current_user() - if not user: - return jsonify({'message': 'Authentifizierung erforderlich!'}), 401 - - g.current_user = user - return f(*args, **kwargs) - - return decorated - -def admin_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if not g.get('current_user') or g.current_user['role'] != 'admin': - return jsonify({'message': 'Admin-Rechte erforderlich!'}), 403 - return f(*args, **kwargs) - - return decorated - -# Authentifizierungs-Routen -@app.route('/auth/register', methods=['POST']) -def register(): - data = request.get_json() - - if not data or not data.get('username') or not data.get('password'): - return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400 - - username = data.get('username') - password = data.get('password') - display_name = data.get('displayName', username) - email = data.get('email', '') - - if get_user_by_username(username): - return jsonify({'message': 'Benutzername bereits vergeben!'}), 400 - - # Prüfen, ob es bereits einen Admin gibt - db = get_db() - admin_exists = db.execute("SELECT 1 FROM user WHERE role = 'admin' LIMIT 1").fetchone() is not None - - # Falls kein Admin existiert, wird der erste Benutzer zum Admin - role = 'admin' if not admin_exists else 'user' - - user = create_user(username, password, display_name, email, role) - app.logger.info(f'Neuer Benutzer registriert: {username} (Rolle: {role})') - - # Session erstellen - create_session(user['id']) - - return jsonify({ - 'message': 'Registrierung erfolgreich!', - 'user': user_to_dict(user) - }), 201 - -@app.route('/auth/login', methods=['POST']) -def login(): - data = request.get_json() - - if not data or not data.get('username') or not data.get('password'): - return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400 - - username = data.get('username') - password = data.get('password') - - user = get_user_by_username(username) - - if not user or not check_password(user, password): - return jsonify({'message': 'Ungültiger Benutzername oder Passwort!'}), 401 - - # Session erstellen - create_session(user['id']) - - return jsonify({ - 'message': 'Anmeldung erfolgreich!', - 'user': user_to_dict(user) - }) - -@app.route('/auth/logout', methods=['POST']) -def logout(): - session_id = flask_session.get('session_id') - if session_id: - delete_session(session_id) - flask_session.pop('session_id', None) - - return jsonify({'message': 'Erfolgreich abgemeldet!'}), 200 - -# API-Routen -@app.route('/api/me', methods=['GET']) -def get_me(): - user = get_current_user() - if not user: - return jsonify({'authenticated': False}), 401 - - return jsonify({ - 'authenticated': True, - 'user': user_to_dict(user) - }) - -@app.route('/api/printers', methods=['GET']) -def get_printers(): - sockets = get_all_sockets() - return jsonify([socket_to_dict(socket) for socket in sockets]) - -@app.route('/api/printers', methods=['POST']) -@login_required -@admin_required -def create_printer(): - data = request.get_json() - - if not data or not data.get('name') or not data.get('description'): - return jsonify({'message': 'Name und Beschreibung sind erforderlich!'}), 400 - - socket = create_socket( - name=data.get('name'), - description=data.get('description'), - status=data.get('status', 0), - ip_address=data.get('ipAddress') - ) - - return jsonify(socket_to_dict(socket)), 201 - -@app.route('/api/printers/', methods=['GET']) -def get_printer(printer_id): - socket = get_socket_by_id(printer_id) - if not socket: - return jsonify({'message': 'Steckdose nicht gefunden!'}), 404 - return jsonify(socket_to_dict(socket)) - -@app.route('/api/printers/', methods=['PUT']) -@login_required -@admin_required -def update_printer(printer_id): - socket = get_socket_by_id(printer_id) - if not socket: - return jsonify({'message': 'Steckdose nicht gefunden!'}), 404 - - data = request.get_json() - - updated_socket = update_socket( - printer_id, - name=data.get('name'), - description=data.get('description'), - status=data.get('status') if 'status' in data else None, - ip_address=data.get('ipAddress') - ) - - return jsonify(socket_to_dict(updated_socket)) - -@app.route('/api/printers/', methods=['DELETE']) -@login_required -@admin_required -def delete_printer(printer_id): - socket = get_socket_by_id(printer_id) - if not socket: - return jsonify({'message': 'Steckdose nicht gefunden!'}), 404 - - delete_socket(printer_id) - return jsonify({'message': 'Steckdose gelöscht!'}) - -@app.route('/api/jobs', methods=['GET']) -@login_required -def get_jobs(): - # Admins sehen alle Jobs, normale Benutzer nur ihre eigenen - if g.current_user['role'] == 'admin': - jobs = get_all_jobs() - else: - jobs = get_jobs_by_user(g.current_user['id']) - - return jsonify([job_to_dict(job) for job in jobs]) - -@app.route('/api/jobs', methods=['POST']) -@login_required -def create_job_endpoint(): - data = request.get_json() - - if not data or not data.get('printerId') or not data.get('durationInMinutes'): - return jsonify({'message': 'Steckdosen-ID und Dauer sind erforderlich!'}), 400 - - socket = get_socket_by_id(data['printerId']) - if not socket: - return jsonify({'message': 'Steckdose nicht gefunden!'}), 404 - - duration = int(data['durationInMinutes']) - allow_queued_jobs = data.get('allowQueuedJobs', False) - - # Prüfen, ob der Drucker bereits belegt ist - if socket['status'] != 0: # 0 = available - if allow_queued_jobs: - # Erstelle einen Job, der auf Freischaltung wartet - job = create_job( - socket_id=socket['id'], - user_id=g.current_user['id'], - duration_in_minutes=duration, - comments=data.get('comments', ''), - waiting_approval=1 # Job wartet auf Freischaltung - ) - app.logger.info(f"Wartender Job {job['id']} für belegten Drucker {socket['id']} erstellt.") - return jsonify(job_to_dict(job)), 201 - else: - return jsonify({'message': 'Steckdose ist nicht verfügbar!'}), 400 - - # Normaler Job für verfügbaren Drucker - job = create_job( - socket_id=socket['id'], - user_id=g.current_user['id'], - duration_in_minutes=duration, - comments=data.get('comments', ''), - waiting_approval=0 # Job ist sofort aktiv - ) - - # Steckdose als belegt markieren - update_socket(socket['id'], status=1) # 1 = busy - - # Steckdose einschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - try: - success = turn_on_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für Job {job['id']} eingeschaltet.") - else: - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} für Job {job['id']} nicht einschalten.") - except Exception as e: - app.logger.error(f"Fehler beim Einschalten der Steckdose {socket['ip_address']}: {e}") - - return jsonify(job_to_dict(job)), 201 - -@app.route('/api/jobs/', methods=['GET']) -@login_required -def get_job_endpoint(job_id): - # Admins können alle Jobs sehen, Benutzer nur ihre eigenen - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - return jsonify(job_to_dict(job)) - -@app.route('/api/jobs//abort', methods=['POST']) -@login_required -def abort_job(job_id): - # Admins können alle Jobs abbrechen, Benutzer nur ihre eigenen - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - data = request.get_json() - - updated_job = update_job(job_id, aborted=True, abort_reason=data.get('reason', '')) - - # Steckdose wieder verfügbar machen - socket = get_socket_by_id(job['socket_id']) - if socket: - update_socket(socket['id'], status=0) # 0 = available - - # Steckdose ausschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - # Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - success = turn_off_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für abgebrochenen Job {job['id']} ausgeschaltet (Versuch {attempt}).") - break - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).") - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})") - - # Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen - if attempt < max_attempts: - import time - time.sleep(1) - - return jsonify(job_to_dict(updated_job)) - -@app.route('/api/jobs//finish', methods=['POST']) -@login_required -def finish_job(job_id): - # Admins können alle Jobs beenden, Benutzer nur ihre eigenen - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - # Aktuelle Zeit als Ende setzen - now = datetime.datetime.utcnow() - start_at = datetime.datetime.fromisoformat(job['start_at']) - actual_duration = int((now - start_at).total_seconds() / 60) - - updated_job = update_job(job_id, duration_in_minutes=actual_duration) - - # Steckdose wieder verfügbar machen - socket = get_socket_by_id(job['socket_id']) - if socket: - update_socket(socket['id'], status=0) # 0 = available - - # Steckdose ausschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - # Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - success = turn_off_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für beendeten Job {job['id']} ausgeschaltet (Versuch {attempt}).") - break - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).") - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})") - - # Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen - if attempt < max_attempts: - import time - time.sleep(1) - - return jsonify(job_to_dict(updated_job)) - -@app.route('/api/jobs//extend', methods=['POST']) -@login_required -def extend_job(job_id): - # Admins können alle Jobs verlängern, Benutzer nur ihre eigenen - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - data = request.get_json() - minutes = int(data.get('minutes', 0)) - hours = int(data.get('hours', 0)) - - additional_minutes = minutes + (hours * 60) - if additional_minutes <= 0: - return jsonify({'message': 'Ungültige Verlängerungszeit!'}), 400 - - new_duration = job['duration_in_minutes'] + additional_minutes - updated_job = update_job(job_id, duration_in_minutes=new_duration) - - return jsonify(job_to_dict(updated_job)) - -@app.route('/api/jobs//approve', methods=['POST']) -@login_required -def approve_job(job_id): - """Aktiviert einen wartenden Job und schaltet die Steckdose ein.""" - # Nur Admins oder der Job-Ersteller können Jobs freischalten - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - # Prüfen, ob Job auf Freischaltung wartet - waiting_approval = job.get('waiting_approval', 0) - if not waiting_approval: - return jsonify({'message': 'Dieser Job wartet nicht auf Freischaltung!'}), 400 - - # Drucker abrufen - socket = get_socket_by_id(job['socket_id']) - if not socket: - return jsonify({'message': 'Drucker nicht gefunden!'}), 404 - - # Prüfen, ob der Drucker verfügbar ist - if socket['status'] != 0: # 0 = available - return jsonify({'message': 'Drucker ist noch belegt! Bitte warten, bis der laufende Job beendet ist.'}), 400 - - # Job aktualisieren - updated_job = update_job(job_id, waiting_approval=0) - - # Steckdose als belegt markieren - update_socket(socket['id'], status=1) # 1 = busy - - # Steckdose einschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - try: - success = turn_on_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für freigeschalteten Job {job['id']} eingeschaltet.") - else: - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} für freigeschalteten Job {job['id']} nicht einschalten.") - except Exception as e: - app.logger.error(f"Fehler beim Einschalten der Steckdose {socket['ip_address']}: {e}") - - return jsonify(job_to_dict(updated_job)) - -@app.route('/api/jobs//comments', methods=['PUT']) -@login_required -def update_job_comments(job_id): - # Admins können alle Jobs bearbeiten, Benutzer nur ihre eigenen - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']: - return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403 - - data = request.get_json() - updated_job = update_job(job_id, comments=data.get('comments', '')) - - return jsonify(job_to_dict(updated_job)) - -@app.route('/api/job//remaining-time', methods=['GET']) -def job_remaining_time(job_id): - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - remaining = calculate_remaining_time(job) - - # Wenn die verbleibende Zeit 0 ist und der Job nicht manuell abgebrochen wurde, - # automatisch die Steckdose ausschalten und Status aktualisieren - if remaining == 0 and not job['aborted']: - socket = get_socket_by_id(job['socket_id']) - if socket and socket['status'] == 1: # busy - update_socket(socket['id'], status=0) # available - app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.") - - # Steckdose ausschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - # Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - success = turn_off_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).") - break - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).") - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})") - - # Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen - if attempt < max_attempts: - import time - time.sleep(1) - - return jsonify({ - 'remaining_minutes': remaining, - 'job_status': 'completed' if remaining == 0 else 'active', - 'socket_status': 'available' if remaining == 0 else 'busy' - }) - -@app.route('/api/users', methods=['GET']) -@login_required -@admin_required -def get_users(): - users = get_all_users() - return jsonify([user_to_dict(user) for user in users]) - -@app.route('/api/users/', methods=['GET']) -@login_required -@admin_required -def get_user(user_id): - user = get_user_by_id(user_id) - if not user: - return jsonify({'message': 'Benutzer nicht gefunden!'}), 404 - return jsonify(user_to_dict(user)) - -@app.route('/api/users/', methods=['PUT']) -@login_required -@admin_required -def update_user_endpoint(user_id): - user = get_user_by_id(user_id) - if not user: - return jsonify({'message': 'Benutzer nicht gefunden!'}), 404 - - data = request.get_json() - updated_user = update_user( - user_id, - username=data.get('username'), - password=data.get('password'), - display_name=data.get('displayName'), - email=data.get('email'), - role=data.get('role') - ) - - return jsonify(user_to_dict(updated_user)) - -@app.route('/api/users/', methods=['DELETE']) -@login_required -@admin_required -def delete_user_endpoint(user_id): - user = get_user_by_id(user_id) - if not user: - return jsonify({'message': 'Benutzer nicht gefunden!'}), 404 - - # Löschen aller Sessions des Benutzers - delete_sessions_by_user(user_id) - - delete_user(user_id) - return jsonify({'message': 'Benutzer gelöscht!'}) - -@app.route('/api/stats', methods=['GET']) -@login_required -@admin_required -def stats(): - db = get_db() - - # Steckdosen-Nutzungsstatistiken - total_sockets = db.execute('SELECT COUNT(*) as count FROM socket').fetchone()['count'] - available_sockets = db.execute('SELECT COUNT(*) as count FROM socket WHERE status = 0').fetchone()['count'] - - # Verbindungsstatistiken - online_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'online'").fetchone()['count'] - offline_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'offline'").fetchone()['count'] - unknown_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'unknown' OR connection_status IS NULL").fetchone()['count'] - - # Job-Statistiken - total_jobs = db.execute('SELECT COUNT(*) as count FROM job').fetchone()['count'] - - now = datetime.datetime.utcnow().isoformat() - active_jobs = db.execute(''' - SELECT COUNT(*) as count FROM job - WHERE aborted = 0 - AND datetime(start_at, '+' || duration_in_minutes || ' minutes') > datetime(?) - ''', (now,)).fetchone()['count'] - - completed_jobs = db.execute(''' - SELECT COUNT(*) as count FROM job - WHERE aborted = 0 - AND datetime(start_at, '+' || duration_in_minutes || ' minutes') <= datetime(?) - ''', (now,)).fetchone()['count'] - - # Benutzerstatistiken - total_users = db.execute('SELECT COUNT(*) as count FROM user').fetchone()['count'] - - # Durchschnittliche Druckdauer - avg_duration_result = db.execute('SELECT AVG(duration_in_minutes) as avg FROM job').fetchone() - avg_duration = int(avg_duration_result['avg']) if avg_duration_result['avg'] else 0 - - # Steckdosen-Fehlerstatistiken (letzten 7 Tage) - seven_days_ago = (datetime.datetime.utcnow() - timedelta(days=7)).isoformat() - outages = db.execute(''' - SELECT COUNT(*) as count FROM socket_uptime - WHERE status = 'offline' - AND timestamp > ? - ''', (seven_days_ago,)).fetchone()['count'] - - # Steckdosen mit aktuellen Problemen - problem_sockets = db.execute(''' - SELECT s.name, s.connection_status, s.last_seen - FROM socket s - WHERE s.connection_status = 'offline' - ''').fetchall() - - return jsonify({ - 'printers': { - 'total': total_sockets, - 'available': available_sockets, - 'utilization_rate': (total_sockets - available_sockets) / total_sockets if total_sockets > 0 else 0, - 'online': online_sockets, - 'offline': offline_sockets, - 'unknown': unknown_sockets, - 'connectivity_rate': online_sockets / total_sockets if total_sockets > 0 else 0 - }, - 'jobs': { - 'total': total_jobs, - 'active': active_jobs, - 'completed': completed_jobs, - 'avg_duration': avg_duration - }, - 'users': { - 'total': total_users - }, - 'uptime': { - 'outages_last_7_days': outages, - 'problem_printers': [{'name': row['name'], 'status': row['connection_status'], 'last_seen': row['last_seen']} for row in problem_sockets] - } - }) - -@app.route('/api/uptime', methods=['GET']) -@login_required -@admin_required -def uptime_stats(): - """Liefert detaillierte Uptime-Statistiken für das Dashboard.""" - socket_id = request.args.get('socket_id') - limit = int(request.args.get('limit', 100)) - - # Rufe die letzten Uptime-Ereignisse ab - events = get_socket_uptime_events(socket_id, limit) - - # Gruppiere Ereignisse nach Steckdose - sockets = {} - for event in events: - socket_id = event['socket_id'] - if socket_id not in sockets: - sockets[socket_id] = { - 'id': socket_id, - 'name': event['name'], - 'ip_address': event['ip_address'], - 'events': [] - } - - # Füge Ereignis zur Steckdosenliste hinzu - sockets[socket_id]['events'].append({ - 'id': event['id'], - 'timestamp': event['timestamp'], - 'status': event['status'], - 'duration_seconds': event['duration_seconds'] - }) - - # Hole den aktuellen Status aller Steckdosen - all_sockets = get_all_sockets() - current_status = {} - for socket in all_sockets: - current_status[socket['id']] = { - 'connection_status': socket.get('connection_status', 'unknown'), - 'last_seen': socket.get('last_seen') - } - - # Füge den aktuellen Status zu den Socket-Informationen hinzu - for socket_id, socket_data in sockets.items(): - if socket_id in current_status: - socket_data['current_status'] = current_status[socket_id] - - return jsonify({ - 'sockets': list(sockets.values()) - }) - -# Regelmäßige Überprüfung der Jobs und automatische Abschaltung der Steckdosen -def check_jobs(): - """Überprüft abgelaufene Jobs und schaltet Steckdosen automatisch aus.""" - with app.app_context(): - expired_jobs = get_expired_jobs() - handled_jobs = 0 - - for job in expired_jobs: - socket = get_socket_by_id(job['socket_id']) - - if socket and socket['status'] == 1: # busy - update_socket(socket['id'], status=0) # available - app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.") - handled_jobs += 1 - - # Steckdose ausschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - # Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - success = turn_off_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).") - break - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).") - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})") - - # Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen - if attempt < max_attempts: - time.sleep(1) - - app.logger.info(f"{len(expired_jobs)} abgelaufene Jobs überprüft, {handled_jobs} Steckdosen aktualisiert.") - -def check_socket_connections(): - """Überprüft periodisch die Verbindung zu allen Steckdosen mit 8-Sekunden-Timeout.""" - with app.app_context(): - sockets = get_all_sockets() - app.logger.info(f"Überprüfe Verbindungsstatus von {len(sockets)} Steckdosen") - - online_count = 0 - offline_count = 0 - skipped_count = 0 - - for socket in sockets: - if not socket['ip_address']: - skipped_count += 1 - continue # Überspringe Steckdosen ohne IP-Adresse - - is_online = check_socket_connection(socket['id']) - if is_online: - online_count += 1 - else: - offline_count += 1 - app.logger.warning(f"Steckdose {socket['name']} ({socket['ip_address']}) ist nicht erreichbar") - - app.logger.info(f"Verbindungsüberprüfung abgeschlossen: {online_count} online, {offline_count} offline, {skipped_count} übersprungen") - -# Hintergrund-Thread für das Job-Polling und Steckdosen-Monitoring -def background_job_checker(): - """Hintergrund-Thread, der regelmäßig abgelaufene Jobs und Steckdosenverbindungen überprüft.""" - app.logger.info("Starte Hintergrund-Thread für Job-Überprüfung und Steckdosen-Monitoring") - - # Standardintervall für Socket-Überprüfungen (2 Minuten) - socket_check_interval = int(os.environ.get('SOCKET_CHECK_INTERVAL', '120')) - last_socket_check = 0 - - while True: - try: - # Überprüfe Jobs bei jedem Durchlauf - check_jobs() - - # Überprüfe Steckdosen in regelmäßigen Intervallen - current_time = time.time() - if current_time - last_socket_check >= socket_check_interval: - # Socket-Überprüfung mit 8-Sekunden-Timeout pro Gerät - check_socket_connections() - last_socket_check = current_time - app.logger.info(f"Nächste Socket-Überprüfung in {socket_check_interval} Sekunden") - - except Exception as e: - app.logger.error(f"Fehler im Hintergrund-Thread: {e}") - - # Pause zwischen den Überprüfungen - time.sleep(app.config['JOB_CHECK_INTERVAL']) - -# CLI-Befehle für manuelle Ausführung -@app.cli.command("check-jobs") -def cli_check_jobs(): - """CLI-Befehl zur manuellen Überprüfung abgelaufener Jobs.""" - check_jobs() - -@app.cli.command("check-sockets") -def cli_check_sockets(): - """CLI-Befehl zur manuellen Überprüfung aller Steckdosenverbindungen.""" - check_socket_connections() - -@app.route('/api/job//status', methods=['GET']) -def job_status(job_id): - """Endpunkt zum Überprüfen des Status eines Jobs für Frontend-Polling.""" - job = get_job_by_id(job_id) - if not job: - return jsonify({'message': 'Job nicht gefunden!'}), 404 - - remaining = calculate_remaining_time(job) - socket = get_socket_by_id(job['socket_id']) - socket_status = socket['status'] if socket else None - - # Wenn die verbleibende Zeit 0 ist und der Job nicht manuell abgebrochen wurde, - # automatisch die Steckdose ausschalten und Status aktualisieren - if remaining == 0 and not job['aborted'] and socket and socket['status'] == 1: - # Update socket status to available - update_socket(socket['id'], status=0) - socket_status = 0 - app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.") - - # Steckdose ausschalten, falls IP-Adresse hinterlegt ist - if socket['ip_address']: - # Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - success = turn_off_socket(socket['ip_address']) - if success: - app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).") - break - app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).") - except Exception as e: - app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})") - - # Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen - if attempt < max_attempts: - import time - time.sleep(1) - - job_status = 'aborted' if job['aborted'] else ('completed' if remaining == 0 else 'active') - - return jsonify({ - 'job': job_to_dict(job), - 'status': job_status, - 'socketStatus': 'available' if socket_status == 0 else 'busy', - 'remainingMinutes': remaining - }) - -@app.route('/api/test', methods=['GET']) -def test(): - return jsonify({'message': 'API funktioniert!', 'status': 'success'}) - -@app.route('/health', methods=['GET']) -def health_check(): - """Health Check Endpoint für Backend-Server""" - try: - # Prüfe Datenbankverbindung - db = get_db() - db.execute('SELECT 1').fetchone() - - return jsonify({ - 'status': 'healthy', - 'service': 'myp-backend', - 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z', - 'version': '1.0.0', - 'database': 'connected' - }), 200 - except Exception as e: - return jsonify({ - 'status': 'unhealthy', - 'service': 'myp-backend', - 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z', - 'error': str(e) - }), 503 - -@app.route('/api/create-initial-admin', methods=['POST']) -def create_initial_admin(): - db = get_db() - admin_exists = db.execute("SELECT 1 FROM user WHERE role = 'admin' LIMIT 1").fetchone() is not None - - if admin_exists: - return jsonify({'message': 'Es existiert bereits ein Administrator!'}), 400 - - data = request.get_json() - - if not data or not data.get('username') or not data.get('password'): - return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400 - - username = data.get('username') - password = data.get('password') - display_name = data.get('displayName', username) - email = data.get('email', '') - - user = create_user(username, password, display_name, email, 'admin') - app.logger.info(f'Initialer Admin-Benutzer erstellt: {username}') - - return jsonify({ - 'message': 'Administrator wurde erfolgreich erstellt!', - 'user': user_to_dict(user) - }), 201 - -# Error Handler -@app.errorhandler(404) -def not_found(error): - return jsonify({'message': 'Nicht gefunden!'}), 404 - -@app.errorhandler(500) -def server_error(error): - app.logger.error(f'Serverfehler: {error}') - return jsonify({'message': 'Interner Serverfehler!'}), 500 - -# Web UI Routen -@app.route('/') -def index(): - current_user = get_current_user() - if current_user: - return render_template('dashboard.html', current_user=current_user, active_page='home') - return redirect(url_for('login_page')) - -@app.route('/login') -def login_page(): - return render_template('login.html', active_page='login') - -@app.route('/register') -def register_page(): - return render_template('register.html', active_page='register') - -@app.route('/logout') -def logout_page(): - session_id = flask_session.get('session_id') - if session_id: - delete_session(session_id) - flask_session.pop('session_id', None) - - flash('Sie wurden erfolgreich abgemeldet.', 'success') - return redirect(url_for('login_page')) - -@app.route('/admin/printers') -def printers_page(): - current_user = get_current_user() - if not current_user: - return redirect(url_for('login_page')) - return render_template('printers.html', current_user=current_user, active_page='printers') - -@app.route('/admin/jobs') -def jobs_page(): - current_user = get_current_user() - if not current_user: - return redirect(url_for('login_page')) - return render_template('jobs.html', current_user=current_user, active_page='jobs') - -@app.route('/admin/users') -def users_page(): - current_user = get_current_user() - if not current_user or current_user['role'] != 'admin': - flash('Sie haben keine Berechtigung, diese Seite zu besuchen.', 'danger') - return redirect(url_for('index')) - return render_template('users.html', current_user=current_user, active_page='users') - -@app.route('/admin/stats') -def stats_page(): - current_user = get_current_user() - if not current_user or current_user['role'] != 'admin': - flash('Sie haben keine Berechtigung, diese Seite zu besuchen.', 'danger') - return redirect(url_for('index')) - return render_template('stats.html', current_user=current_user, active_page='stats') - -# Registrierungsfunktionen für modularen Aufbau -def register_database_functions(app): - """Registriert Database-Funktionen und Teardown-Handler.""" - app.teardown_appcontext(close_db) - -def register_auth_functions(app): - """Registriert Authentifizierungsfunktionen.""" - # Authentifizierungsfunktionen sind bereits global definiert - pass - -def register_api_routes(app): - """Registriert alle API-Routen.""" - # API-Routen sind bereits global als Funktionen definiert - # Diese werden automatisch registriert, wenn sie mit @app.route dekoriert sind - pass - -def register_web_routes(app): - """Registriert alle Web-UI-Routen.""" - # Web-Routen sind bereits global als Funktionen definiert - # Diese werden automatisch registriert, wenn sie mit @app.route dekoriert sind - pass - -def register_error_handlers(app): - """Registriert Error-Handler.""" - # Error-Handler sind bereits global definiert - # Diese werden automatisch registriert, wenn sie mit @app.errorhandler dekoriert sind - pass - -def register_blueprints(app): - """Registriert alle Flask-Blueprints.""" - # Frontend V2 Blueprint - app.register_blueprint(frontend_v2, url_prefix='/frontend_v2') - - # Monitoring Blueprint - from monitoring import monitoring_bp - app.register_blueprint(monitoring_bp) - - # Konfiguriere statische Dateien für Frontend v2 - @app.route('/frontend_v2/static/') - def frontend_v2_static(filename): - return send_from_directory(os.path.join(app.root_path, 'frontend_v2/static'), filename) - -def register_middleware(app): - """Registriert Middleware-Komponenten.""" - # Monitoring-Middleware - if app.config.get('FLASK_ENV') != 'testing': - from monitoring import request_metrics - request_metrics.init_app(app) - - # Sicherheits-Middleware wird bereits in der Konfiguration registriert - pass - -def register_background_tasks(app): - """Registriert Hintergrund-Tasks.""" - @app.before_request - def initialize_background_tasks(): - """Startet den Hintergrund-Thread für Job-Überprüfung beim ersten Request.""" - # Überprüfung, ob dieser Handler bereits ausgeführt wurde - if getattr(app, '_job_thread_initialized', False): - return - - # Starte den Hintergrund-Thread nur, wenn er noch nicht läuft - for thread in threading.enumerate(): - if thread.name == 'job_checker_thread': - app.logger.info("Hintergrund-Thread für Job-Überprüfung läuft bereits") - app._job_thread_initialized = True - return - - # Thread starten - job_thread = threading.Thread(target=background_job_checker, daemon=True, name='job_checker_thread') - job_thread.start() - app.logger.info("Hintergrund-Thread für Job-Überprüfung beim ersten Request gestartet") - app._job_thread_initialized = True - -# Server starten -if __name__ == '__main__': - # Legacy-Modus für direkte Ausführung - with app.app_context(): - init_db() - if PRINTERS: - # Initialisiere Drucker und schalte alle Steckdosen beim Start aus - init_printers() - - # Starte den Hintergrund-Thread für die Job-Überprüfung - job_thread = threading.Thread(target=background_job_checker, daemon=True, name='job_checker_thread') - job_thread.start() - app.logger.info("Hintergrund-Thread für Job-Überprüfung gestartet") - setup_frontend_v2() - - # Produktionsmodus aktivieren - flask_env = os.environ.get('FLASK_ENV', 'development') - debug_mode = flask_env == 'development' - - app.run(host='0.0.0.0', port=5000, debug=debug_mode) -else: - # Für WSGI-Server wie Gunicorn - verwende Application Factory - flask_env = os.environ.get('FLASK_ENV', 'production') - app = create_app(flask_env) - - with app.app_context(): - init_db() - printers_config = app.config.get('PRINTERS', {}) - if printers_config: - init_printers() - setup_frontend_v2() \ No newline at end of file diff --git a/backend/app/app.py b/backend/app/app.py new file mode 100644 index 00000000..84be3f74 --- /dev/null +++ b/backend/app/app.py @@ -0,0 +1,1001 @@ +import os +import threading +import time +import json +import secrets +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple, Any, Union +from functools import wraps + +from flask import Flask, request, jsonify, session +from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user, login_required +import sqlalchemy.exc +from PyP100 import PyP110 + +from config.settings import ( + SECRET_KEY, TAPO_USERNAME, TAPO_PASSWORD, PRINTERS, + FLASK_HOST, FLASK_PORT, FLASK_DEBUG, SESSION_LIFETIME, + SCHEDULER_INTERVAL, SCHEDULER_ENABLED +) +from utils.logging_config import setup_logging, get_logger, log_startup_info +from models import User, Printer, Job, Stats, init_db, get_db_session, create_initial_admin +from utils.job_scheduler import scheduler + +# Logging initialisieren +setup_logging() +log_startup_info() + +# Logger für verschiedene Komponenten +app_logger = get_logger("app") +auth_logger = get_logger("auth") +jobs_logger = get_logger("jobs") +printers_logger = get_logger("printers") + +# Flask-App initialisieren +app = Flask(__name__) +app.secret_key = SECRET_KEY +app.config["PERMANENT_SESSION_LIFETIME"] = SESSION_LIFETIME + +# Flask-Login initialisieren +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "/login" + +# Datenbank initialisieren +init_db() + +# Flask-Login User-Loader +@login_manager.user_loader +def load_user(user_id: str) -> Optional[UserMixin]: + db_session = get_db_session() + user = db_session.query(User).filter(User.id == int(user_id)).first() + db_session.close() + + if not user: + return None + + # UserMixin-Objekt erstellen + user_mixin = UserMixin() + user_mixin.id = str(user.id) + user_mixin.is_admin = user.is_admin() + user_mixin.email = user.email + user_mixin.user_obj = user + + return user_mixin + + +# Dekorator für Admin-Zugriffskontrolle +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + auth_logger.warning(f"Unbefugter Admin-Zugriff versucht von User {getattr(current_user, 'id', 'anonym')}") + return jsonify({"error": "Nur Administratoren haben Zugriff auf diese Ressource"}), 403 + return f(*args, **kwargs) + return decorated_function + + +# Dekorator für Owner-Zugriffskontrolle (nur Job-Eigentümer oder Admin) +def job_owner_required(f): + @wraps(f) + def decorated_function(job_id, *args, **kwargs): + db_session = get_db_session() + job = db_session.query(Job).filter(Job.id == job_id).first() + db_session.close() + + if not job: + return jsonify({"error": "Job nicht gefunden"}), 404 + + if not current_user.is_authenticated: + auth_logger.warning(f"Nicht authentifizierter Zugriff auf Job {job_id}") + return jsonify({"error": "Nicht authentifiziert"}), 401 + + if not current_user.is_admin and int(current_user.id) != job.user_id: + auth_logger.warning(f"Unbefugter Zugriff auf Job {job_id} von User {current_user.id}") + return jsonify({"error": "Keine Berechtigung für diesen Job"}), 403 + + return f(job_id, *args, **kwargs) + return decorated_function + + +# Smart Plug Steuerung +def set_plug_state(mac_address: str, state: bool) -> bool: + """ + Steuert einen TP-Link Tapo P110 Smart Plug. + + Args: + mac_address: MAC-Adresse des Plugs zur Identifikation + state: True für Ein, False für Aus + + Returns: + bool: True bei Erfolg, False bei Fehler + """ + try: + db_session = get_db_session() + printer = db_session.query(Printer).filter(Printer.mac_address == mac_address).first() + db_session.close() + + if not printer: + printers_logger.error(f"Drucker mit MAC {mac_address} nicht gefunden") + return False + + p110 = PyP110.P110(printer.plug_ip, TAPO_USERNAME, TAPO_PASSWORD) + p110.handshake() + p110.login() + + if state: + p110.turnOn() + printers_logger.info(f"Plug {mac_address} eingeschaltet") + else: + p110.turnOff() + printers_logger.info(f"Plug {mac_address} ausgeschaltet") + + return True + except Exception as e: + printers_logger.error(f"Fehler beim Schalten des Plugs {mac_address}: {str(e)}") + return False + + +# Job-Monitor-Funktion für den Scheduler +def job_monitor() -> None: + """ + Überwacht aktive Jobs und schaltet Plugs ein/aus basierend auf den Start- und Endzeiten. + """ + jobs_logger.info("Job-Monitor-Check läuft...") + now = datetime.now() + db_session = get_db_session() + + try: + # Jobs finden, die jetzt starten sollten + jobs_to_start = db_session.query(Job).filter( + Job.status == "scheduled", + Job.start_time <= now, + Job.end_time > now + ).all() + + for job in jobs_to_start: + jobs_logger.info(f"Job {job.id} ({job.title}) wird gestartet") + + # Drucker holen + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + if printer: + # Plug einschalten + success = set_plug_state(printer.mac_address, True) + if success: + job.status = "active" + jobs_logger.info(f"Job {job.id} aktiviert, Plug eingeschaltet") + else: + jobs_logger.error(f"Konnte Plug für Job {job.id} nicht einschalten") + + # Jobs finden, die jetzt enden sollten + jobs_to_end = db_session.query(Job).filter( + Job.status == "active", + Job.end_time <= now + ).all() + + for job in jobs_to_end: + jobs_logger.info(f"Job {job.id} ({job.title}) wird beendet") + + # Drucker holen + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + if printer: + # Plug ausschalten + success = set_plug_state(printer.mac_address, False) + if success: + job.status = "completed" + job.actual_end_time = now + + # Statistik aktualisieren + stats = db_session.query(Stats).first() + if stats: + # Druckzeit in Sekunden berechnen + print_time_seconds = int((now - job.start_time).total_seconds()) + stats.total_print_time += print_time_seconds + stats.total_jobs_completed += 1 + + if job.material_used: + stats.total_material_used += job.material_used + + stats.last_updated = now + + jobs_logger.info(f"Job {job.id} beendet, Plug ausgeschaltet") + else: + jobs_logger.error(f"Konnte Plug für Job {job.id} nicht ausschalten") + + db_session.commit() + + except Exception as e: + jobs_logger.error(f"Fehler im Job-Monitor: {str(e)}") + db_session.rollback() + finally: + db_session.close() + + +# Authentifizierungs-Routen +@app.route("/auth/register", methods=["POST"]) +def register(): + data = request.json + + if not data or not all(k in data for k in ["email", "password", "name"]): + auth_logger.warning("Registrierung mit unvollständigen Daten versucht") + return jsonify({"error": "Unvollständige Daten"}), 400 + + db_session = get_db_session() + + # Prüfen, ob der erste Benutzer angelegt wird (wird automatisch Admin) + is_first_user = db_session.query(User).count() == 0 + + try: + new_user = User( + email=data["email"], + name=data["name"], + role="admin" if is_first_user else "user" + ) + new_user.set_password(data["password"]) + + db_session.add(new_user) + db_session.commit() + + # Falls erster Benutzer, Stats anlegen + if is_first_user: + stats = Stats() + db_session.add(stats) + db_session.commit() + + auth_logger.info(f"Neuer Benutzer registriert: {data['email']} (Admin: {is_first_user})") + result = {"success": True, "user_id": new_user.id, "is_admin": is_first_user} + + except sqlalchemy.exc.IntegrityError: + db_session.rollback() + auth_logger.warning(f"Registrierung fehlgeschlagen - E-Mail bereits vorhanden: {data['email']}") + result = {"error": "E-Mail-Adresse bereits registriert"}, 400 + except Exception as e: + db_session.rollback() + auth_logger.error(f"Fehler bei der Registrierung: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/auth/login", methods=["POST"]) +def login(): + data = request.json + + if not data or not all(k in data for k in ["email", "password"]): + auth_logger.warning("Login mit unvollständigen Daten versucht") + return jsonify({"error": "Unvollständige Daten"}), 400 + + db_session = get_db_session() + user = db_session.query(User).filter(User.email == data["email"]).first() + db_session.close() + + if not user or not user.check_password(data["password"]): + auth_logger.warning(f"Fehlgeschlagener Login-Versuch für: {data['email']}") + return jsonify({"error": "Ungültige Anmeldedaten"}), 401 + + # UserMixin-Objekt erstellen + user_mixin = UserMixin() + user_mixin.id = str(user.id) + user_mixin.is_admin = user.is_admin() + user_mixin.email = user.email + + # Benutzer anmelden und persistente Session erstellen + login_user(user_mixin, remember=True) + session.permanent = True + + auth_logger.info(f"Erfolgreicher Login: {user.email}") + + return jsonify({ + "success": True, + "user": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role + } + }) + + +@app.route("/auth/logout", methods=["POST"]) +@login_required +def logout(): + user_email = getattr(current_user, 'email', 'unbekannt') + logout_user() + auth_logger.info(f"Benutzer abgemeldet: {user_email}") + return jsonify({"success": True, "message": "Erfolgreich abgemeldet"}) + + +@app.route("/api/create-initial-admin", methods=["POST"]) +def api_create_initial_admin(): + data = request.json + + if not data or not all(k in data for k in ["email", "password", "name"]): + return jsonify({"error": "Unvollständige Daten"}), 400 + + success = create_initial_admin( + email=data["email"], + password=data["password"], + name=data["name"] + ) + + if success: + auth_logger.info(f"Initialer Admin erstellt: {data['email']}") + return jsonify({"success": True, "message": "Admin-Benutzer erfolgreich angelegt"}) + else: + return jsonify({"error": "Es existieren bereits Benutzer"}), 400 + + +# Drucker-Routen +@app.route("/api/printers", methods=["GET"]) +@login_required +def get_printers(): + db_session = get_db_session() + printers = db_session.query(Printer).all() + db_session.close() + + return jsonify({ + "printers": [printer.to_dict() for printer in printers] + }) + + +@app.route("/api/printers/", methods=["GET"]) +@login_required +def get_printer(printer_id): + db_session = get_db_session() + printer = db_session.query(Printer).filter(Printer.id == printer_id).first() + db_session.close() + + if not printer: + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + return jsonify(printer.to_dict()) + + +@app.route("/api/printers", methods=["POST"]) +@login_required +@admin_required +def create_printer(): + data = request.json + + if not data or not all(k in data for k in ["name", "mac_address"]): + return jsonify({"error": "Unvollständige Daten"}), 400 + + # IP-Adresse aus der PRINTERS-Konfiguration holen + printer_name = data["name"] + if printer_name not in PRINTERS: + printers_logger.error(f"Drucker {printer_name} nicht in Konfiguration gefunden") + return jsonify({"error": "Drucker nicht in Konfiguration gefunden"}), 400 + + plug_ip = PRINTERS[printer_name]["ip"] + + db_session = get_db_session() + + try: + # Prüfen, ob MAC bereits existiert + existing = db_session.query(Printer).filter(Printer.mac_address == data["mac_address"]).first() + if existing: + db_session.close() + return jsonify({"error": "MAC-Adresse bereits registriert"}), 400 + + new_printer = Printer( + name=data["name"], + location=data.get("location", ""), + mac_address=data["mac_address"], + plug_ip=plug_ip, + plug_username=TAPO_USERNAME, + plug_password=TAPO_PASSWORD, + active=data.get("active", True) + ) + + db_session.add(new_printer) + db_session.commit() + + printers_logger.info(f"Neuer Drucker erstellt: {data['name']} ({plug_ip})") + result = {"success": True, "printer": new_printer.to_dict()} + + except Exception as e: + db_session.rollback() + printers_logger.error(f"Fehler beim Anlegen des Druckers: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/printers/", methods=["DELETE"]) +@login_required +@admin_required +def delete_printer(printer_id): + db_session = get_db_session() + + try: + printer = db_session.query(Printer).filter(Printer.id == printer_id).first() + + if not printer: + db_session.close() + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + # Prüfen, ob aktive Jobs existieren + active_jobs = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled", "active"]) + ).count() + + if active_jobs > 0: + db_session.close() + return jsonify({"error": "Es existieren aktive Jobs für diesen Drucker"}), 400 + + printer_name = printer.name + db_session.delete(printer) + db_session.commit() + + printers_logger.info(f"Drucker gelöscht: {printer_name}") + result = {"success": True, "message": "Drucker erfolgreich gelöscht"} + + except Exception as e: + db_session.rollback() + printers_logger.error(f"Fehler beim Löschen des Druckers: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +# Job-Routen +@app.route("/api/jobs", methods=["GET"]) +@login_required +def get_jobs(): + db_session = get_db_session() + + # Admin sieht alle Jobs, User nur eigene + if hasattr(current_user, "is_admin") and current_user.is_admin: + jobs = db_session.query(Job).all() + else: + jobs = db_session.query(Job).filter(Job.user_id == int(current_user.id)).all() + + db_session.close() + + return jsonify({ + "jobs": [job.to_dict() for job in jobs] + }) + + +@app.route("/api/jobs/", methods=["GET"]) +@login_required +@job_owner_required +def get_job(job_id): + db_session = get_db_session() + job = db_session.query(Job).filter(Job.id == job_id).first() + db_session.close() + + return jsonify(job.to_dict()) + + +@app.route("/api/jobs", methods=["POST"]) +@login_required +def create_job(): + data = request.json + + if not data or not all(k in data for k in ["title", "printer_id", "start_time", "end_time"]): + return jsonify({"error": "Unvollständige Daten"}), 400 + + db_session = get_db_session() + + try: + # Drucker prüfen + printer = db_session.query(Printer).filter(Printer.id == data["printer_id"]).first() + if not printer: + db_session.close() + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + # Datumsformate parsen + start_time = datetime.fromisoformat(data["start_time"].replace("Z", "+00:00")) + end_time = datetime.fromisoformat(data["end_time"].replace("Z", "+00:00")) + + # Zeitvalidierung + if start_time < datetime.now(): + db_session.close() + return jsonify({"error": "Startzeit liegt in der Vergangenheit"}), 400 + + if end_time <= start_time: + db_session.close() + return jsonify({"error": "Endzeit muss nach der Startzeit liegen"}), 400 + + # Überlappende Jobs prüfen + overlapping_jobs = db_session.query(Job).filter( + Job.printer_id == data["printer_id"], + Job.status.in_(["scheduled", "active"]), + ((Job.start_time <= start_time) & (Job.end_time > start_time)) | + ((Job.start_time < end_time) & (Job.end_time >= end_time)) | + ((Job.start_time >= start_time) & (Job.end_time <= end_time)) + ).count() + + if overlapping_jobs > 0: + db_session.close() + return jsonify({"error": "Es existieren bereits überlappende Jobs"}), 400 + + new_job = Job( + title=data["title"], + user_id=int(current_user.id), + printer_id=data["printer_id"], + start_time=start_time, + end_time=end_time, + notes=data.get("notes", ""), + status="scheduled" + ) + + db_session.add(new_job) + db_session.commit() + + jobs_logger.info(f"Neuer Job erstellt: {data['title']} von User {current_user.id}") + result = {"success": True, "job": new_job.to_dict()} + + except ValueError as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Datum-Parsing: {str(e)}") + result = {"error": "Ungültiges Datumsformat"}, 400 + except Exception as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Anlegen des Jobs: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs/", methods=["DELETE"]) +@login_required +@job_owner_required +def delete_job(job_id): + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + job_title = job.title + + # Job löschen + db_session.delete(job) + db_session.commit() + + # Wenn Job aktiv war, Plug ausschalten + if job.status == "active": + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + if printer: + set_plug_state(printer.mac_address, False) + + jobs_logger.info(f"Job gelöscht: {job_title} (ID: {job_id})") + result = {"success": True, "message": "Job erfolgreich gelöscht"} + + except Exception as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Löschen des Jobs: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs//finish", methods=["POST"]) +@login_required +@job_owner_required +def finish_job(job_id): + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + + if job.status not in ["scheduled", "active"]: + db_session.close() + return jsonify({"error": "Nur geplante oder aktive Jobs können beendet werden"}), 400 + + # Drucker holen und Plug ausschalten + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + if not printer: + db_session.close() + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + set_plug_state(printer.mac_address, False) + + # Job als beendet markieren + job.status = "completed" + job.actual_end_time = datetime.now() + + # Statistik aktualisieren + stats = db_session.query(Stats).first() + if stats: + # Druckzeit in Sekunden berechnen + if job.start_time and job.actual_end_time: + print_time_seconds = int((job.actual_end_time - job.start_time).total_seconds()) + stats.total_print_time += print_time_seconds + + stats.total_jobs_completed += 1 + + if job.material_used: + stats.total_material_used += job.material_used + + stats.last_updated = datetime.now() + + db_session.commit() + + jobs_logger.info(f"Job manuell beendet: {job.title} (ID: {job_id})") + result = {"success": True, "job": job.to_dict()} + + except Exception as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Beenden des Jobs: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs//abort", methods=["POST"]) +@login_required +@job_owner_required +def abort_job(job_id): + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + + if job.status not in ["scheduled", "active"]: + db_session.close() + return jsonify({"error": "Nur geplante oder aktive Jobs können abgebrochen werden"}), 400 + + # Drucker holen und Plug ausschalten + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + if not printer: + db_session.close() + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + set_plug_state(printer.mac_address, False) + + # Job als abgebrochen markieren + job.status = "aborted" + job.actual_end_time = datetime.now() + + db_session.commit() + + jobs_logger.info(f"Job abgebrochen: {job.title} (ID: {job_id})") + result = {"success": True, "job": job.to_dict()} + + except Exception as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Abbrechen des Jobs: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs//extend", methods=["POST"]) +@login_required +@job_owner_required +def extend_job(job_id): + data = request.json + + if not data or "minutes" not in data: + return jsonify({"error": "Anzahl der Minuten fehlt"}), 400 + + minutes = int(data["minutes"]) + + if minutes <= 0 or minutes > 180: # Max. 3 Stunden Verlängerung + return jsonify({"error": "Ungültige Minutenanzahl (1-180)"}), 400 + + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + + if job.status not in ["scheduled", "active"]: + db_session.close() + return jsonify({"error": "Nur geplante oder aktive Jobs können verlängert werden"}), 400 + + # Neue Endzeit berechnen + new_end_time = job.end_time + timedelta(minutes=minutes) + + # Überlappende Jobs prüfen + overlapping_jobs = db_session.query(Job).filter( + Job.printer_id == job.printer_id, + Job.id != job_id, + Job.status.in_(["scheduled", "active"]), + Job.start_time < new_end_time, + Job.start_time > job.end_time + ).count() + + if overlapping_jobs > 0: + db_session.close() + return jsonify({"error": "Verlängerung überschneidet sich mit nachfolgenden Jobs"}), 400 + + # Endzeit aktualisieren + job.end_time = new_end_time + + db_session.commit() + + jobs_logger.info(f"Job verlängert: {job.title} (ID: {job_id}) um {minutes} Minuten") + result = {"success": True, "job": job.to_dict()} + + except Exception as e: + db_session.rollback() + jobs_logger.error(f"Fehler beim Verlängern des Jobs: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs//status", methods=["GET"]) +@login_required +@job_owner_required +def get_job_status(job_id): + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + printer = db_session.query(Printer).filter(Printer.id == job.printer_id).first() + + if not printer: + db_session.close() + return jsonify({"error": "Drucker nicht gefunden"}), 404 + + # Plug-Status abfragen + try: + p110 = PyP110.P110(printer.plug_ip, TAPO_USERNAME, TAPO_PASSWORD) + p110.handshake() + p110.login() + plug_info = p110.getDeviceInfo() + + plug_state = plug_info.get("device_on", False) + power_consumption = plug_info.get("current_power", 0) + + except Exception as e: + printers_logger.error(f"Fehler beim Abfragen des Plug-Status: {str(e)}") + plug_state = None + power_consumption = None + + result = { + "job_id": job.id, + "status": job.status, + "plug_state": plug_state, + "power_consumption": power_consumption + } + + except Exception as e: + jobs_logger.error(f"Fehler beim Abfragen des Job-Status: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +@app.route("/api/jobs//remaining-time", methods=["GET"]) +@login_required +@job_owner_required +def get_remaining_time(job_id): + db_session = get_db_session() + + try: + job = db_session.query(Job).filter(Job.id == job_id).first() + + if job.status != "active": + db_session.close() + return jsonify({"error": "Nur aktive Jobs haben eine Restzeit"}), 400 + + now = datetime.now() + + if now > job.end_time: + remaining_seconds = 0 + else: + remaining_seconds = int((job.end_time - now).total_seconds()) + + result = { + "job_id": job.id, + "remaining_seconds": remaining_seconds, + "end_time": job.end_time.isoformat() + } + + except Exception as e: + jobs_logger.error(f"Fehler beim Berechnen der Restzeit: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +# Benutzer-Routen +@app.route("/api/users", methods=["GET"]) +@login_required +@admin_required +def get_users(): + db_session = get_db_session() + users = db_session.query(User).all() + db_session.close() + + return jsonify({ + "users": [user.to_dict() for user in users] + }) + + +@app.route("/api/users/", methods=["GET"]) +@login_required +def get_user(user_id): + # Normale Benutzer dürfen nur sich selbst sehen + if not current_user.is_admin and int(current_user.id) != user_id: + return jsonify({"error": "Keine Berechtigung"}), 403 + + db_session = get_db_session() + user = db_session.query(User).filter(User.id == user_id).first() + db_session.close() + + if not user: + return jsonify({"error": "Benutzer nicht gefunden"}), 404 + + return jsonify(user.to_dict()) + + +@app.route("/api/users/", methods=["DELETE"]) +@login_required +@admin_required +def delete_user(user_id): + # Admin kann sich nicht selbst löschen + if int(current_user.id) == user_id: + return jsonify({"error": "Admin kann sich nicht selbst löschen"}), 400 + + db_session = get_db_session() + + try: + user = db_session.query(User).filter(User.id == user_id).first() + + if not user: + db_session.close() + return jsonify({"error": "Benutzer nicht gefunden"}), 404 + + # Prüfen, ob aktive Jobs existieren + active_jobs = db_session.query(Job).filter( + Job.user_id == user_id, + Job.status.in_(["scheduled", "active"]) + ).count() + + if active_jobs > 0: + db_session.close() + return jsonify({"error": "Es existieren aktive Jobs für diesen Benutzer"}), 400 + + user_email = user.email + db_session.delete(user) + db_session.commit() + + auth_logger.info(f"Benutzer gelöscht: {user_email}") + result = {"success": True, "message": "Benutzer erfolgreich gelöscht"} + + except Exception as e: + db_session.rollback() + auth_logger.error(f"Fehler beim Löschen des Benutzers: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +# Statistik-Route +@app.route("/api/stats", methods=["GET"]) +@login_required +def get_stats(): + db_session = get_db_session() + + try: + stats = db_session.query(Stats).first() + + if not stats: + stats = Stats() + db_session.add(stats) + db_session.commit() + + # Zusätzliche Statistikdaten berechnen + user_count = db_session.query(User).count() + printer_count = db_session.query(Printer).count() + active_jobs = db_session.query(Job).filter(Job.status.in_(["scheduled", "active"])).count() + completed_jobs = db_session.query(Job).filter(Job.status == "completed").count() + + result = { + "total_print_time_seconds": stats.total_print_time, + "total_print_time_hours": round(stats.total_print_time / 3600, 2), + "total_jobs_completed": stats.total_jobs_completed, + "total_material_used": stats.total_material_used, + "user_count": user_count, + "printer_count": printer_count, + "active_jobs": active_jobs, + "completed_jobs": completed_jobs, + "last_updated": stats.last_updated.isoformat() if stats.last_updated else None + } + + except Exception as e: + app_logger.error(f"Fehler beim Abrufen der Statistik: {str(e)}") + result = {"error": "Interner Serverfehler"}, 500 + finally: + db_session.close() + + return jsonify(result) + + +# Health-Check-Route +@app.route("/api/test", methods=["GET"]) +def test(): + return jsonify({ + "status": "ok", + "time": datetime.now().isoformat(), + "version": "1.0.0" + }) + + +# API-Endpoint zur Scheduler-Steuerung +@app.route("/api/scheduler/status", methods=["GET"]) +@login_required +@admin_required +def get_scheduler_status(): + """Gibt den aktuellen Status des Schedulers zurück.""" + return jsonify({ + "running": scheduler.is_running(), + "tasks": scheduler.get_task_info() + }) + +@app.route("/api/scheduler/start", methods=["POST"]) +@login_required +@admin_required +def start_scheduler_api(): + """Startet den Scheduler.""" + success = scheduler.start() + return jsonify({ + "success": success, + "running": scheduler.is_running() + }) + +@app.route("/api/scheduler/stop", methods=["POST"]) +@login_required +@admin_required +def stop_scheduler_api(): + """Stoppt den Scheduler.""" + success = scheduler.stop() + return jsonify({ + "success": success, + "running": scheduler.is_running() + }) + +# Scheduler starten +def start_scheduler(): + """Initialisiert und startet den Scheduler mit den erforderlichen Tasks.""" + if not SCHEDULER_ENABLED: + app_logger.info("Scheduler ist deaktiviert") + return + + # Registriere Job-Monitor-Task + scheduler.register_task( + task_id="job_monitor", + func=job_monitor, + interval=SCHEDULER_INTERVAL, + enabled=True + ) + + # Scheduler starten + scheduler.start() + app_logger.info("Job-Scheduler gestartet") + + +if __name__ == "__main__": + # Scheduler starten + start_scheduler() + + # Flask-App starten + app_logger.info(f"Flask-App wird gestartet auf {FLASK_HOST}:{FLASK_PORT}") + app.run(host=FLASK_HOST, port=FLASK_PORT, debug=FLASK_DEBUG) \ No newline at end of file diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py new file mode 100644 index 00000000..beb65ef1 --- /dev/null +++ b/backend/app/config/settings.py @@ -0,0 +1,66 @@ +import os +import json +from datetime import timedelta + +# Hardcodierte Konfiguration +SECRET_KEY = "7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F" +DATABASE_PATH = "database/myp.db" +TAPO_USERNAME = "till.tomczak@mercedes-benz.com" +TAPO_PASSWORD = "744563017196A" + +# Drucker-Konfiguration +PRINTERS = { + "Printer 1": {"ip": "192.168.0.100"}, + "Printer 2": {"ip": "192.168.0.101"}, + "Printer 3": {"ip": "192.168.0.102"}, + "Printer 4": {"ip": "192.168.0.103"}, + "Printer 5": {"ip": "192.168.0.104"}, + "Printer 6": {"ip": "192.168.0.106"} +} + +# Logging-Konfiguration +LOG_DIR = "logs" +LOG_SUBDIRS = ["app", "scheduler", "auth", "jobs", "printers", "errors"] +LOG_LEVEL = "INFO" +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# Flask-Konfiguration +FLASK_HOST = "0.0.0.0" +FLASK_PORT = 5000 +FLASK_DEBUG = True +SESSION_LIFETIME = timedelta(days=7) + +# Scheduler-Konfiguration +SCHEDULER_INTERVAL = 60 # Sekunden +SCHEDULER_ENABLED = True + +# Datenbank-Konfiguration +DB_ENGINE = f"sqlite:///{DATABASE_PATH}" + +def get_log_file(category: str) -> str: + """ + Gibt den Pfad zur Log-Datei für eine bestimmte Kategorie zurück. + + Args: + category: Log-Kategorie (app, scheduler, auth, jobs, printers, errors) + + Returns: + str: Pfad zur Log-Datei + """ + if category not in LOG_SUBDIRS: + category = "app" + + return os.path.join(LOG_DIR, category, f"{category}.log") + +def ensure_log_directories(): + """Erstellt alle erforderlichen Log-Verzeichnisse.""" + os.makedirs(LOG_DIR, exist_ok=True) + for subdir in LOG_SUBDIRS: + os.makedirs(os.path.join(LOG_DIR, subdir), exist_ok=True) + +def ensure_database_directory(): + """Erstellt das Datenbank-Verzeichnis.""" + db_dir = os.path.dirname(DATABASE_PATH) + if db_dir: + os.makedirs(db_dir, exist_ok=True) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 00000000..de144f4c --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,180 @@ +import os +import logging +from datetime import datetime +from typing import Optional, List + +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, ForeignKey, Float +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, sessionmaker, Session +import bcrypt + +from config.settings import DATABASE_PATH, ensure_database_directory +from utils.logging_config import get_logger + +Base = declarative_base() +logger = get_logger("app") + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + email = Column(String(120), unique=True, nullable=False) + password_hash = Column(String(128), nullable=False) + name = Column(String(100), nullable=False) + role = Column(String(20), default="user") # "admin" oder "user" + created_at = Column(DateTime, default=datetime.now) + + jobs = relationship("Job", back_populates="user", cascade="all, delete-orphan") + + def set_password(self, password: str) -> None: + password_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + self.password_hash = bcrypt.hashpw(password_bytes, salt).decode('utf-8') + + def check_password(self, password: str) -> bool: + password_bytes = password.encode('utf-8') + hash_bytes = self.password_hash.encode('utf-8') + return bcrypt.checkpw(password_bytes, hash_bytes) + + def is_admin(self) -> bool: + return self.role == "admin" + + def to_dict(self) -> dict: + return { + "id": self.id, + "email": self.email, + "name": self.name, + "role": self.role, + "created_at": self.created_at.isoformat() if self.created_at else None + } + + +class Printer(Base): + __tablename__ = "printers" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + location = Column(String(100)) + mac_address = Column(String(50), nullable=False, unique=True) + plug_ip = Column(String(50), nullable=False) + plug_username = Column(String(100), nullable=False) + plug_password = Column(String(100), nullable=False) + active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + + jobs = relationship("Job", back_populates="printer", cascade="all, delete-orphan") + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "location": self.location, + "mac_address": self.mac_address, + "plug_ip": self.plug_ip, + "active": self.active, + "created_at": self.created_at.isoformat() if self.created_at else None + } + + +class Job(Base): + __tablename__ = "jobs" + + id = Column(Integer, primary_key=True) + title = Column(String(200), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=False) + actual_end_time = Column(DateTime) + status = Column(String(20), default="scheduled") # scheduled, active, completed, aborted + created_at = Column(DateTime, default=datetime.now) + notes = Column(String(500)) + material_used = Column(Float) # in Gramm + + user = relationship("User", back_populates="jobs") + printer = relationship("Printer", back_populates="jobs") + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "user_id": self.user_id, + "printer_id": self.printer_id, + "start_time": self.start_time.isoformat() if self.start_time else None, + "end_time": self.end_time.isoformat() if self.end_time else None, + "actual_end_time": self.actual_end_time.isoformat() if self.actual_end_time else None, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + "notes": self.notes, + "material_used": self.material_used, + "user": self.user.to_dict() if self.user else None, + "printer": self.printer.to_dict() if self.printer else None + } + + +class Stats(Base): + __tablename__ = "stats" + + id = Column(Integer, primary_key=True) + total_print_time = Column(Integer, default=0) # in Sekunden + total_jobs_completed = Column(Integer, default=0) + total_material_used = Column(Float, default=0.0) # in Gramm + last_updated = Column(DateTime, default=datetime.now) + + +def init_db() -> None: + """Initialisiert die Datenbank und erstellt alle Tabellen.""" + ensure_database_directory() + engine = create_engine(f"sqlite:///{DATABASE_PATH}") + Base.metadata.create_all(engine) + logger.info("Datenbank initialisiert.") + + +def create_initial_admin(email: str, password: str, name: str) -> bool: + """ + Erstellt einen initialen Admin-Benutzer, falls die Datenbank leer ist. + + Args: + email: E-Mail-Adresse des Admins + password: Passwort des Admins + name: Name des Admins + + Returns: + bool: True, wenn der Admin erstellt wurde, False sonst + """ + engine = create_engine(f"sqlite:///{DATABASE_PATH}") + Session_class = sessionmaker(bind=engine) + session = Session_class() + + # Prüfen, ob bereits Benutzer existieren + user_count = session.query(User).count() + if user_count > 0: + session.close() + return False + + # Ersten Admin anlegen + admin = User( + email=email, + name=name, + role="admin" + ) + admin.set_password(password) + + session.add(admin) + session.commit() + + # Statistik-Eintrag anlegen + stats = Stats() + session.add(stats) + session.commit() + + session.close() + logger.info(f"Initialer Admin-Benutzer {email} wurde angelegt.") + return True + + +def get_db_session() -> Session: + """Gibt eine neue Datenbank-Session zurück.""" + engine = create_engine(f"sqlite:///{DATABASE_PATH}") + Session_class = sessionmaker(bind=engine) + return Session_class() \ No newline at end of file diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 00000000..3b8fe57d --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils package for MYP \ No newline at end of file diff --git a/backend/app/utils/job_scheduler.py b/backend/app/utils/job_scheduler.py new file mode 100644 index 00000000..ee823e00 --- /dev/null +++ b/backend/app/utils/job_scheduler.py @@ -0,0 +1,230 @@ +import threading +import time +import logging +from typing import Dict, Callable, Any, List, Optional, Union +from datetime import datetime, timedelta + +from utils.logging_config import get_logger + +logger = get_logger("scheduler") + +class BackgroundTaskScheduler: + """ + Ein fortschrittlicher Hintergrund-Task-Scheduler, der registrierbare Worker-Funktionen unterstützt. + Tasks können als Platzhalter registriert und später konfiguriert werden. + """ + + def __init__(self): + self._tasks: Dict[str, Dict[str, Any]] = {} + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._running = False + + def register_task(self, + task_id: str, + func: Callable, + interval: int = 60, + args: List = None, + kwargs: Dict = None, + enabled: bool = True) -> bool: + """ + Registriert eine neue Hintergrund-Task. + + Args: + task_id: Eindeutige ID für die Task + func: Die auszuführende Funktion + interval: Intervall in Sekunden zwischen den Ausführungen + args: Positionsargumente für die Funktion + kwargs: Schlüsselwortargumente für die Funktion + enabled: Ob die Task aktiviert sein soll + + Returns: + bool: True wenn erfolgreich, False wenn die ID bereits existiert + """ + if task_id in self._tasks: + logger.error(f"Task mit ID {task_id} existiert bereits") + return False + + self._tasks[task_id] = { + "func": func, + "interval": interval, + "args": args or [], + "kwargs": kwargs or {}, + "enabled": enabled, + "last_run": None, + "next_run": datetime.now() if enabled else None + } + + logger.info(f"Task {task_id} registriert: Intervall {interval}s, Enabled: {enabled}") + return True + + def update_task(self, + task_id: str, + interval: Optional[int] = None, + args: Optional[List] = None, + kwargs: Optional[Dict] = None, + enabled: Optional[bool] = None) -> bool: + """ + Aktualisiert die Konfiguration einer bestehenden Task. + + Args: + task_id: ID der zu aktualisierenden Task + interval: Neues Intervall in Sekunden + args: Neue Positionsargumente + kwargs: Neue Schlüsselwortargumente + enabled: Neuer Aktivierungsstatus + + Returns: + bool: True wenn erfolgreich, False wenn die ID nicht existiert + """ + if task_id not in self._tasks: + logger.error(f"Task mit ID {task_id} existiert nicht") + return False + + task = self._tasks[task_id] + + if interval is not None: + task["interval"] = interval + + if args is not None: + task["args"] = args + + if kwargs is not None: + task["kwargs"] = kwargs + + if enabled is not None and enabled != task["enabled"]: + task["enabled"] = enabled + if enabled: + task["next_run"] = datetime.now() + else: + task["next_run"] = None + + logger.info(f"Task {task_id} aktualisiert: Intervall {task['interval']}s, Enabled: {task['enabled']}") + return True + + def remove_task(self, task_id: str) -> bool: + """ + Entfernt eine Task aus dem Scheduler. + + Args: + task_id: ID der zu entfernenden Task + + Returns: + bool: True wenn erfolgreich, False wenn die ID nicht existiert + """ + if task_id not in self._tasks: + logger.error(f"Task mit ID {task_id} existiert nicht") + return False + + del self._tasks[task_id] + logger.info(f"Task {task_id} entfernt") + return True + + def get_task_info(self, task_id: Optional[str] = None) -> Union[Dict, List[Dict]]: + """ + Gibt Informationen zu einer Task oder allen Tasks zurück. + + Args: + task_id: ID der Task oder None für alle Tasks + + Returns: + Dict oder List: Task-Informationen + """ + if task_id is not None: + if task_id not in self._tasks: + return {} + + task = self._tasks[task_id] + return { + "id": task_id, + "interval": task["interval"], + "enabled": task["enabled"], + "last_run": task["last_run"].isoformat() if task["last_run"] else None, + "next_run": task["next_run"].isoformat() if task["next_run"] else None + } + + return [ + { + "id": tid, + "interval": task["interval"], + "enabled": task["enabled"], + "last_run": task["last_run"].isoformat() if task["last_run"] else None, + "next_run": task["next_run"].isoformat() if task["next_run"] else None + } + for tid, task in self._tasks.items() + ] + + def start(self) -> bool: + """ + Startet den Scheduler. + + Returns: + bool: True wenn erfolgreich gestartet, False wenn bereits läuft + """ + if self._running: + logger.warning("Scheduler läuft bereits") + return False + + self._stop_event.clear() + self._thread = threading.Thread(target=self._run) + self._thread.daemon = True + self._thread.start() + self._running = True + + logger.info("Scheduler gestartet") + return True + + def stop(self) -> bool: + """ + Stoppt den Scheduler. + + Returns: + bool: True wenn erfolgreich gestoppt, False wenn nicht läuft + """ + if not self._running: + logger.warning("Scheduler läuft nicht") + return False + + self._stop_event.set() + if self._thread: + self._thread.join(timeout=5.0) + + self._running = False + logger.info("Scheduler gestoppt") + return True + + def is_running(self) -> bool: + """ + Prüft, ob der Scheduler läuft. + + Returns: + bool: True wenn der Scheduler läuft, sonst False + """ + return self._running + + def _run(self) -> None: + """Interne Methode zum Ausführen des Scheduler-Loops.""" + while not self._stop_event.is_set(): + now = datetime.now() + + for task_id, task in self._tasks.items(): + if not task["enabled"] or not task["next_run"]: + continue + + if now >= task["next_run"]: + logger.info(f"Ausführung von Task {task_id}") + + try: + task["func"](*task["args"], **task["kwargs"]) + logger.info(f"Task {task_id} erfolgreich ausgeführt") + except Exception as e: + logger.error(f"Fehler bei Ausführung von Task {task_id}: {str(e)}") + + task["last_run"] = now + task["next_run"] = now + timedelta(seconds=task["interval"]) + + # 1 Sekunde warten und erneut prüfen + self._stop_event.wait(1) + +# Singleton-Instanz +scheduler = BackgroundTaskScheduler() \ No newline at end of file diff --git a/backend/app/utils/logging_config.py b/backend/app/utils/logging_config.py new file mode 100644 index 00000000..c0c29347 --- /dev/null +++ b/backend/app/utils/logging_config.py @@ -0,0 +1,101 @@ +import logging +import logging.handlers +import os +from typing import Dict +from config.settings import ( + LOG_DIR, LOG_SUBDIRS, LOG_LEVEL, LOG_FORMAT, LOG_DATE_FORMAT, + get_log_file, ensure_log_directories +) + +# Dictionary zur Speicherung der konfigurierten Logger +_loggers: Dict[str, logging.Logger] = {} + +def setup_logging(): + """Initialisiert das Logging-System und erstellt alle erforderlichen Verzeichnisse.""" + ensure_log_directories() + + # Root-Logger konfigurieren + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, LOG_LEVEL)) + + # Alle Handler entfernen + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Formatter erstellen + formatter = logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT) + + # Console Handler für alle Logs + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, LOG_LEVEL)) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + # File Handler für allgemeine App-Logs + app_log_file = get_log_file("app") + app_handler = logging.handlers.RotatingFileHandler( + app_log_file, maxBytes=10*1024*1024, backupCount=5 + ) + app_handler.setLevel(getattr(logging, LOG_LEVEL)) + app_handler.setFormatter(formatter) + root_logger.addHandler(app_handler) + +def get_logger(category: str) -> logging.Logger: + """ + Gibt einen konfigurierten Logger für eine bestimmte Kategorie zurück. + + Args: + category: Log-Kategorie (app, scheduler, auth, jobs, printers, errors) + + Returns: + logging.Logger: Konfigurierter Logger + """ + if category in _loggers: + return _loggers[category] + + # Logger erstellen + logger = logging.getLogger(f"myp.{category}") + logger.setLevel(getattr(logging, LOG_LEVEL)) + + # Verhindere doppelte Logs durch Parent-Logger + logger.propagate = False + + # Formatter erstellen + formatter = logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT) + + # Console Handler + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, LOG_LEVEL)) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # File Handler für spezifische Kategorie + log_file = get_log_file(category) + file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5 + ) + file_handler.setLevel(getattr(logging, LOG_LEVEL)) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # Error-Logs zusätzlich in errors.log schreiben + if category != "errors": + error_log_file = get_log_file("errors") + error_handler = logging.handlers.RotatingFileHandler( + error_log_file, maxBytes=10*1024*1024, backupCount=5 + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + logger.addHandler(error_handler) + + _loggers[category] = logger + return logger + +def log_startup_info(): + """Loggt Startup-Informationen.""" + app_logger = get_logger("app") + app_logger.info("=" * 50) + app_logger.info("MYP (Manage Your Printers) wird gestartet...") + app_logger.info(f"Log-Verzeichnis: {LOG_DIR}") + app_logger.info(f"Log-Level: {LOG_LEVEL}") + app_logger.info("=" * 50) \ No newline at end of file diff --git a/backend/cleanup.sh b/backend/cleanup.sh deleted file mode 100644 index 2fa77d5f..00000000 --- a/backend/cleanup.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/bin/bash - -# Raspberry Pi Bereinigungsskript für MYP-Projekt -# Dieses Skript bereinigt alte Docker-Installationen und installiert alle erforderlichen Abhängigkeiten - -# Farbcodes für Ausgabe -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Funktion zur Ausgabe mit Zeitstempel -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" -} - -error_log() { - echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] FEHLER:${NC} $1" >&2 -} - -# Prüfen, ob das Skript mit Root-Rechten ausgeführt wird -if [ "$EUID" -ne 0 ]; then - error_log "Dieses Skript muss mit Root-Rechten ausgeführt werden (sudo)." - exit 1 -fi - -log "${YELLOW}=== MYP Raspberry Pi Bereinigung und Setup ===${NC}" -log "Diese Skript wird alle alten Docker-Installationen entfernen und die erforderlichen Abhängigkeiten neu installieren." - -# Sicherstellen, dass apt funktioniert -log "Aktualisiere apt-Paketindex..." -apt-get update || { - error_log "Konnte apt-Paketindex nicht aktualisieren." - exit 1 -} - -# Installiere grundlegende Abhängigkeiten -log "Installiere grundlegende Abhängigkeiten..." -apt-get install -y \ - apt-transport-https \ - ca-certificates \ - curl \ - gnupg \ - lsb-release \ - wget \ - git \ - jq \ - || { - error_log "Konnte grundlegende Abhängigkeiten nicht installieren." - exit 1 -} - -# Stoppe alle laufenden Docker-Container -log "${YELLOW}Stoppe alle laufenden Docker-Container...${NC}" -if command -v docker &> /dev/null; then - docker stop $(docker ps -aq) 2>/dev/null || true - log "Alle Docker-Container gestoppt." -else - log "Docker ist nicht installiert, keine Container zu stoppen." -fi - -# Entferne alte Docker-Installation -log "${YELLOW}Entferne alte Docker-Installation...${NC}" -apt-get remove -y docker docker-engine docker.io containerd runc docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose || true -apt-get autoremove -y || true -rm -rf /var/lib/docker /var/lib/containerd /var/run/docker.sock /etc/docker /usr/local/bin/docker-compose 2>/dev/null || true -log "${GREEN}Alte Docker-Installation entfernt.${NC}" - -# Entferne alte Projektcontainer und -Dateien -log "${YELLOW}Entferne alte MYP-Projektcontainer und -Dateien...${NC}" -if command -v docker &> /dev/null; then - # Entferne Container - docker rm -f myp-frontend myp-backend 2>/dev/null || true - # Entferne Images - docker rmi -f myp-frontend myp-backend 2>/dev/null || true - # Entferne unbenutzte Volumes und Netzwerke - docker system prune -af --volumes 2>/dev/null || true -fi - -# Erkennen der Raspberry Pi-Architektur -log "Erkenne Systemarchitektur..." -ARCH=$(dpkg --print-architecture) -log "Erkannte Architektur: ${ARCH}" - -# Installiere Docker mit dem offiziellen Convenience-Skript -log "${YELLOW}Installiere Docker mit dem offiziellen Convenience-Skript...${NC}" -curl -fsSL https://get.docker.com -o get-docker.sh -sh get-docker.sh --channel stable - -# Überprüfen, ob Docker erfolgreich installiert wurde -if ! command -v docker &> /dev/null; then - error_log "Docker-Installation fehlgeschlagen!" - exit 1 -fi - -log "${GREEN}Docker erfolgreich installiert!${NC}" - -# Füge den aktuellen Benutzer zur Docker-Gruppe hinzu -if [ "$SUDO_USER" ]; then - log "Füge Benutzer $SUDO_USER zur Docker-Gruppe hinzu..." - usermod -aG docker $SUDO_USER - log "${YELLOW}Hinweis: Eine Neuanmeldung ist erforderlich, damit die Gruppenänderung wirksam wird.${NC}" -fi - -# Konfiguriere Docker mit DNS-Servern für bessere Netzwerkkompatibilität -log "Konfiguriere Docker mit Google DNS..." -mkdir -p /etc/docker -cat > /etc/docker/daemon.json << EOL -{ - "dns": ["8.8.8.8", "8.8.4.4"] -} -EOL - -# Starte Docker-Dienst neu -log "Starte Docker-Dienst neu..." -systemctl restart docker -systemctl enable docker -log "${GREEN}Docker-Dienst neu gestartet und für den Autostart aktiviert.${NC}" - -# Installiere Docker Compose v2 -log "${YELLOW}Installiere Docker Compose...${NC}" - -# Bestimme die passende Docker Compose-Version für die Architektur -if [ "$ARCH" = "armhf" ]; then - log "Installiere Docker Compose für armhf (32-bit)..." - curl -L "https://github.com/docker/compose/releases/download/v2.6.1/docker-compose-linux-armv7" -o /usr/local/bin/docker-compose -elif [ "$ARCH" = "arm64" ]; then - log "Installiere Docker Compose für arm64 (64-bit)..." - curl -L "https://github.com/docker/compose/releases/download/v2.6.1/docker-compose-linux-aarch64" -o /usr/local/bin/docker-compose -else - log "Unbekannte Architektur, verwende automatische Erkennung..." - curl -L "https://github.com/docker/compose/releases/download/v2.6.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose -fi - -chmod +x /usr/local/bin/docker-compose - -# Überprüfe, ob Docker Compose installiert wurde -if ! command -v docker-compose &> /dev/null; then - error_log "Docker Compose-Installation fehlgeschlagen!" - exit 1 -fi - -# Installiere Docker Compose Plugin (neuere Methode) -log "Installiere Docker Compose Plugin..." -apt-get update -apt-get install -y docker-compose-plugin - -log "${GREEN}Docker Compose erfolgreich installiert!${NC}" -docker compose version || docker-compose --version - -# Installiere zusätzliche Abhängigkeiten für die Projektunterstützung -log "${YELLOW}Installiere zusätzliche Projektabhängigkeiten...${NC}" -apt-get install -y \ - python3 \ - python3-pip \ - sqlite3 \ - build-essential \ - libffi-dev \ - libssl-dev \ - || { - error_log "Konnte zusätzliche Abhängigkeiten nicht installieren." - exit 1 -} - -# Optimieren des Raspberry Pi für Docker-Workloads -log "${YELLOW}Optimiere Raspberry Pi für Docker-Workloads...${NC}" - -# Swap erhöhen für bessere Performance bei begrenztem RAM -log "Konfiguriere Swap-Größe..." -CURRENT_SWAP=$(grep "CONF_SWAPSIZE" /etc/dphys-swapfile | cut -d= -f2) -log "Aktuelle Swap-Größe: ${CURRENT_SWAP}" - -# Erhöhe Swap auf 2GB, wenn weniger -if [ "$CURRENT_SWAP" -lt 2048 ]; then - sed -i 's/^CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile - log "Swap-Größe auf 2048MB erhöht, Neustart des Swap-Dienstes erforderlich." - - # Neustart des Swap-Dienstes - /etc/init.d/dphys-swapfile restart -else - log "Swap-Größe ist bereits ausreichend." -fi - -# Konfiguriere cgroup für Docker -if ! grep -q "cgroup_enable=memory" /boot/cmdline.txt; then - log "Konfiguriere cgroup für Docker..." - CMDLINE=$(cat /boot/cmdline.txt) - echo "$CMDLINE cgroup_enable=cpuset cgroup_enable=memory cgroup_memory=1" > /boot/cmdline.txt - log "${YELLOW}WICHTIG: Ein Systemneustart ist erforderlich, damit die cgroup-Änderungen wirksam werden.${NC}" -fi - -# Prüfe, ob Backend-Installationsdateien vorhanden sind -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BACKEND_DIR="$SCRIPT_DIR" - -if [ -d "$BACKEND_DIR" ] && [ -f "$BACKEND_DIR/docker-compose.yml" ]; then - log "${GREEN}Backend-Projektdateien gefunden in $BACKEND_DIR${NC}" -else - log "${YELLOW}Warnung: Backend-Projektdateien nicht gefunden in $BACKEND_DIR${NC}" -fi - -# Abschlussmeldung -log "${GREEN}=== Bereinigung und Setup abgeschlossen ===${NC}" -log "${YELLOW}WICHTIGE HINWEISE:${NC}" -log "1. Ein ${RED}SYSTEMNEUSTART${NC} ist ${RED}DRINGEND ERFORDERLICH${NC}, damit alle Änderungen wirksam werden." -log "2. Nach dem Neustart können Sie das Backend-Installationsskript ausführen:" -log " cd $BACKEND_DIR && ./install.sh" -log "3. Bei Problemen mit Docker-Berechtigungen stellen Sie sicher, dass Sie sich neu angemeldet haben." - -echo "" -read -p "Möchten Sie das System jetzt neu starten? (j/n): " REBOOT_CHOICE -if [[ "$REBOOT_CHOICE" == "j" ]]; then - log "System wird neu gestartet..." - reboot -else - log "Bitte starten Sie das System manuell neu, bevor Sie die Installationsskripte ausführen." -fi \ No newline at end of file diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 23e03f1c..00000000 --- a/backend/config.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Konfigurationsklassen für die MYP Flask-Anwendung. -Definiert verschiedene Konfigurationen für Development, Production und Testing. -""" - -import os -from datetime import timedelta -import secrets - -class Config: - """Basis-Konfigurationsklasse mit gemeinsamen Einstellungen.""" - - SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(32) - DATABASE = os.environ.get('DATABASE_PATH', 'instance/myp.db') - - # Session-Konfiguration - SESSION_COOKIE_HTTPONLY = True - SESSION_COOKIE_SAMESITE = 'Lax' - PERMANENT_SESSION_LIFETIME = timedelta(days=7) - - # Job-Konfiguration - JOB_CHECK_INTERVAL = int(os.environ.get('JOB_CHECK_INTERVAL', '60')) # Sekunden - - # Tapo-Konfiguration - TAPO_USERNAME = os.environ.get('TAPO_USERNAME') - TAPO_PASSWORD = os.environ.get('TAPO_PASSWORD') - - # Logging-Konfiguration - LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') - LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', '10485760')) # 10MB - LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', '10')) - - # Drucker-Konfiguration - PRINTERS = os.environ.get('PRINTERS', '{}') - - # JSON-Konfiguration parsen - @property - def PRINTERS_DICT(self): - """Parse PRINTERS configuration as JSON""" - import json - printers_str = self.PRINTERS - if isinstance(printers_str, dict): - return printers_str - try: - return json.loads(printers_str) if printers_str else {} - except (json.JSONDecodeError, TypeError): - return {} - - # API-Konfiguration - API_KEY = os.environ.get('API_KEY') - - # Rate Limiting - RATE_LIMIT_ENABLED = True - MAX_REQUESTS_PER_MINUTE = int(os.environ.get('MAX_REQUESTS_PER_MINUTE', '100')) - RATE_LIMIT_WINDOW_MINUTES = int(os.environ.get('RATE_LIMIT_WINDOW_MINUTES', '15')) - - # Security - SECURITY_ENABLED = True - MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB - - @staticmethod - def init_app(app): - """Initialisierung der Anwendung mit der Konfiguration.""" - pass - -class DevelopmentConfig(Config): - """Konfiguration für die Entwicklungsumgebung.""" - - DEBUG = True - TESTING = False - - # Session-Cookies in Development weniger strikt - SESSION_COOKIE_SECURE = False - - # Kürzere Job-Check-Intervalle für schnellere Entwicklung - JOB_CHECK_INTERVAL = int(os.environ.get('JOB_CHECK_INTERVAL', '30')) - - # Weniger strikte Sicherheit in Development - SECURITY_ENABLED = False - RATE_LIMIT_ENABLED = False - - @staticmethod - def init_app(app): - Config.init_app(app) - - # Development-spezifische Initialisierung - import logging - logging.basicConfig(level=logging.DEBUG) - -class ProductionConfig(Config): - """Konfiguration für die Produktionsumgebung.""" - - DEBUG = False - TESTING = False - - # Sichere Session-Cookies in Production - SESSION_COOKIE_SECURE = True - SESSION_COOKIE_HTTPONLY = True - SESSION_COOKIE_SAMESITE = 'Strict' - - # Strengere Sicherheitseinstellungen - WTF_CSRF_ENABLED = True - WTF_CSRF_TIME_LIMIT = None - - # Längere Job-Check-Intervalle für bessere Performance - JOB_CHECK_INTERVAL = int(os.environ.get('JOB_CHECK_INTERVAL', '60')) - - # Produktions-Sicherheit - SECURITY_ENABLED = True - RATE_LIMIT_ENABLED = True - MAX_REQUESTS_PER_MINUTE = int(os.environ.get('MAX_REQUESTS_PER_MINUTE', '60')) - - # HTTPS-Enforcement (wenn verfügbar) - FORCE_HTTPS = os.environ.get('FORCE_HTTPS', 'False').lower() == 'true' - - @staticmethod - def init_app(app): - Config.init_app(app) - - # Production-spezifische Initialisierung - import logging - from logging.handlers import RotatingFileHandler, SysLogHandler - - # Datei-Logging - if not os.path.exists('logs'): - os.mkdir('logs') - - # Prüfe ob Datei-Logging deaktiviert ist (für Tests) - if app.config.get('DISABLE_FILE_LOGGING', False): - app.logger.info('Datei-Logging deaktiviert (Test-Modus)') - return - - # Windows-kompatibles Logging - import platform - if platform.system() == 'Windows': - # Windows: Verwende TimedRotatingFileHandler statt RotatingFileHandler - from logging.handlers import TimedRotatingFileHandler - - file_handler = TimedRotatingFileHandler( - 'logs/myp.log', - when='midnight', - interval=1, - backupCount=Config.LOG_BACKUP_COUNT - ) - - error_handler = TimedRotatingFileHandler( - 'logs/myp-errors.log', - when='midnight', - interval=1, - backupCount=Config.LOG_BACKUP_COUNT - ) - - security_handler = TimedRotatingFileHandler( - 'logs/security.log', - when='midnight', - interval=1, - backupCount=Config.LOG_BACKUP_COUNT - ) - else: - # Linux/Unix: Verwende RotatingFileHandler - file_handler = RotatingFileHandler( - 'logs/myp.log', - maxBytes=Config.LOG_MAX_BYTES, - backupCount=Config.LOG_BACKUP_COUNT - ) - - error_handler = RotatingFileHandler( - 'logs/myp-errors.log', - maxBytes=Config.LOG_MAX_BYTES, - backupCount=Config.LOG_BACKUP_COUNT - ) - - security_handler = RotatingFileHandler( - 'logs/security.log', - maxBytes=Config.LOG_MAX_BYTES, - backupCount=Config.LOG_BACKUP_COUNT - ) - - file_handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' - )) - file_handler.setLevel(logging.INFO) - app.logger.addHandler(file_handler) - - error_handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' - )) - error_handler.setLevel(logging.ERROR) - app.logger.addHandler(error_handler) - - security_handler.setFormatter(logging.Formatter( - '%(asctime)s SECURITY %(levelname)s: %(message)s [%(name)s]' - )) - security_handler.setLevel(logging.WARNING) - - # Security-Logger - security_logger = logging.getLogger('security') - security_logger.addHandler(security_handler) - security_logger.setLevel(logging.WARNING) - - app.logger.setLevel(logging.INFO) - app.logger.info('MYP Backend starting in production mode') - - # Sicherheits-Middleware registrieren (optional) - if app.config.get('SECURITY_ENABLED', True): - try: - from security import security_middleware - security_middleware.init_app(app) - except ImportError: - app.logger.warning('Security module not found, skipping security middleware') - -class TestingConfig(Config): - """Konfiguration für Tests""" - TESTING = True - DEBUG = True - WTF_CSRF_ENABLED = False - DATABASE_PATH = ':memory:' # In-Memory-Datenbank für Tests - - # Deaktiviere Datei-Logging für Tests (Windows-Kompatibilität) - DISABLE_FILE_LOGGING = True - - @staticmethod - def init_app(app): - """Initialisierung für Test-Umgebung""" - # Nur Console-Logging für Tests - import logging - app.logger.setLevel(logging.WARNING) # Reduziere Log-Level für Tests - -# Konfigurationsmapping -config = { - 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'testing': TestingConfig, - 'default': DevelopmentConfig -} \ No newline at end of file diff --git a/backend/debug-server/app.py b/backend/debug-server/app.py deleted file mode 100644 index 1196b942..00000000 --- a/backend/debug-server/app.py +++ /dev/null @@ -1,1244 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys -import locale - -# Setze Standardkodierung auf UTF-8 -if sys.platform.startswith('win'): - # Setze für Windows die richtige Codepage - try: - # Versuche zuerst, die Codepage direkt zu setzen - import os - os.system('chcp 65001 > nul') - except: - # Fallback-Methode, falls der direkte Aufruf fehlschlägt - try: - import subprocess - subprocess.run(["cmd.exe", "/c", "chcp", "65001"], - capture_output=True, - check=False) - except: - print("Konnte Codepage nicht auf UTF-8 setzen. Eventuell werden Unicode-Zeichen nicht korrekt angezeigt.") - - # Setze das Locale auf die passende Einstellung - try: - locale.setlocale(locale.LC_ALL, 'C.UTF-8') - except: - try: - locale.setlocale(locale.LC_ALL, '') # Systemstandard - except: - print("Konnte Locale nicht setzen.") -else: - # Für Unix-Systeme - try: - locale.setlocale(locale.LC_ALL, 'C.UTF-8') - except: - try: - locale.setlocale(locale.LC_ALL, '') # Systemstandard - except: - print("Konnte Locale nicht setzen.") - -from flask import Flask, render_template, jsonify, request, redirect, url_for -import socket -import platform -import os -import subprocess -import json -import datetime -import psutil -import netifaces -import requests -import re -import time -import logging -import matplotlib -matplotlib.use('Agg') # Verwende Agg-Backend für Server ohne Display -import matplotlib.pyplot as plt -import io -import base64 -from flask_cors import CORS -import pandas as pd -import threading -import docker -import tempfile -from werkzeug.utils import secure_filename -import shutil - -app = Flask(__name__) -CORS(app) -DEBUG_PORT = 5555 # Port für den Debug-Server - -# Logging konfigurieren -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(os.path.join('..', '..', 'logs', 'debug-server.log')), - logging.StreamHandler() - ] -) -logger = logging.getLogger('debug-server') - -# Konfigurationsdatei -CONFIG_FILE = '../instance/network_config.json' -DEFAULT_CONFIG = { - 'backend_hostname': '192.168.0.105', - 'backend_port': 5000, - 'frontend_hostname': '192.168.0.106', - 'frontend_port': 3000 -} - -# Cache für Docker-Container-IDs und Namen -CONTAINER_CACHE = {} - -# Port-Scanner-Thread-Flag -PORT_SCAN_RUNNING = False - -# Docker-Client initialisieren -try: - docker_client = docker.from_env() - DOCKER_AVAILABLE = True -except: - DOCKER_AVAILABLE = False - logger.warning("Docker ist nicht verfügbar. Docker-bezogene Funktionen werden deaktiviert.") - -def get_config(): - """Lädt die Netzwerkkonfiguration.""" - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logger.error(f"Fehler beim Laden der Konfiguration: {e}") - return DEFAULT_CONFIG.copy() - -def save_config(config): - """Speichert die Netzwerkkonfiguration.""" - os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) - try: - with open(CONFIG_FILE, 'w') as f: - json.dump(config, f, indent=4) - return True - except Exception as e: - logger.error(f"Fehler beim Speichern der Konfiguration: {e}") - return False - -@app.route('/') -def index(): - """Zeigt die Debug-Oberfläche an.""" - return redirect(url_for('dashboard')) - -@app.route('/dashboard') -def dashboard(): - """Zeigt das neue Debug-Dashboard an.""" - interfaces = get_network_interfaces() - config = get_config() - - # Prüfe Verbindungen - backend_status = "Nicht überprüft" - frontend_status = "Nicht überprüft" - - try: - backend_url = f"http://{config['backend_hostname']}:{config['backend_port']}/api/test" - response = requests.get(backend_url, timeout=3) - if response.status_code == 200: - backend_status = "Verbunden" - else: - backend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - backend_status = f"Nicht erreichbar" - if ping_host(config['backend_hostname']): - backend_status += " (Host antwortet auf Ping)" - - try: - frontend_url = f"http://{config['frontend_hostname']}:{config['frontend_port']}" - response = requests.get(frontend_url, timeout=3) - if response.status_code == 200: - frontend_status = "Verbunden" - else: - frontend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - frontend_status = f"Nicht erreichbar" - if ping_host(config['frontend_hostname']): - frontend_status += " (Host antwortet auf Ping)" - - return render_template('dashboard.html', - interfaces=interfaces, - config=config, - backend_status=backend_status, - frontend_status=frontend_status, - last_check=datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S")) - -@app.route('/debug') -def debug(): - """Zeigt die alte Debug-Oberfläche an.""" - interfaces = get_network_interfaces() - config = get_config() - - # Prüfe Verbindungen - backend_status = "Nicht überprüft" - frontend_status = "Nicht überprüft" - - try: - backend_url = f"http://{config['backend_hostname']}:{config['backend_port']}/api/test" - response = requests.get(backend_url, timeout=3) - if response.status_code == 200: - backend_status = "Verbunden" - else: - backend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - backend_status = f"Nicht erreichbar" - if ping_host(config['backend_hostname']): - backend_status += " (Host antwortet auf Ping)" - - try: - frontend_url = f"http://{config['frontend_hostname']}:{config['frontend_port']}" - response = requests.get(frontend_url, timeout=3) - if response.status_code == 200: - frontend_status = "Verbunden" - else: - frontend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - frontend_status = f"Nicht erreichbar" - if ping_host(config['frontend_hostname']): - frontend_status += " (Host antwortet auf Ping)" - - return render_template('debug.html', - interfaces=interfaces, - config=config, - backend_status=backend_status, - frontend_status=frontend_status, - last_check=datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S")) - -# API-Endpunkte für das Dashboard -@app.route('/api/system/metrics') -def system_metrics(): - """Liefert aktuelle Systemmetriken.""" - try: - # CPU-Auslastung - cpu_percent = psutil.cpu_percent(interval=0.5) - - # Speicherauslastung - memory = psutil.virtual_memory() - memory_info = { - 'total': memory.total, - 'available': memory.available, - 'used': memory.used, - 'percent': memory.percent - } - - # Festplattennutzung - disk_usage = [] - for partition in psutil.disk_partitions(): - if os.name == 'nt' and ('cdrom' in partition.opts or partition.fstype == ''): - # Unter Windows: CD-ROMs und spezielle Laufwerke überspringen - continue - - usage = psutil.disk_usage(partition.mountpoint) - disk_usage.append({ - 'device': partition.device, - 'mountpoint': partition.mountpoint, - 'fstype': partition.fstype, - 'total': usage.total, - 'used': usage.used, - 'free': usage.free, - 'percent': usage.percent - }) - - # Netzwerk-I/O - net_io = psutil.net_io_counters() - network_io = { - 'bytes_sent': net_io.bytes_sent, - 'bytes_recv': net_io.bytes_recv, - 'packets_sent': net_io.packets_sent, - 'packets_recv': net_io.packets_recv, - 'errin': net_io.errin, - 'errout': net_io.errout, - 'dropin': net_io.dropin, - 'dropout': net_io.dropout - } - - return jsonify({ - 'success': True, - 'timestamp': datetime.datetime.now().isoformat(), - 'cpu_percent': cpu_percent, - 'memory': memory_info, - 'disk_usage': disk_usage, - 'network_io': network_io - }) - except Exception as e: - logger.error(f"Fehler beim Abrufen der Systemmetriken: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Abrufen der Systemmetriken: {str(e)}" - }), 500 - -@app.route('/api/docker/status') -def docker_status(): - """Liefert Statusinformationen zu Docker-Containern.""" - try: - containers = [] - docker_running = False - - try: - # Prüfe, ob Docker läuft - result = subprocess.run(['docker', 'info'], capture_output=True, text=True, check=False) - docker_running = result.returncode == 0 - - if docker_running: - # Liste alle Container auf - result = subprocess.run(['docker', 'ps', '-a', '--format', '{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}'], capture_output=True, text=True, check=True) - lines = result.stdout.strip().split('\n') - - for line in lines: - if not line: - continue - - parts = line.split('|') - if len(parts) < 4: - continue - - container_id, name, status, image = parts - is_running = status.startswith('Up') - - # CPU- und Speichernutzung nur für laufende Container abrufen - cpu_percent = None - memory_usage = None - network_io = None - - if is_running: - try: - # CPU-Nutzung - cpu_result = subprocess.run(['docker', 'stats', container_id, '--no-stream', '--format', '{{.CPUPerc}}'], capture_output=True, text=True, check=True) - cpu_text = cpu_result.stdout.strip() - cpu_match = re.search(r'([\d.]+)%', cpu_text) - if cpu_match: - cpu_percent = float(cpu_match.group(1)) - - # Speichernutzung - mem_result = subprocess.run(['docker', 'stats', container_id, '--no-stream', '--format', '{{.MemUsage}}'], capture_output=True, text=True, check=True) - mem_text = mem_result.stdout.strip() - mem_match = re.search(r'([\d.]+)([KMGiB]+)', mem_text) - if mem_match: - value = float(mem_match.group(1)) - unit = mem_match.group(2) - - # Konvertiere in Bytes - if unit.startswith('K'): - memory_usage = value * 1024 - elif unit.startswith('M'): - memory_usage = value * 1024 * 1024 - elif unit.startswith('G'): - memory_usage = value * 1024 * 1024 * 1024 - else: - memory_usage = value - - # Netzwerknutzung - net_result = subprocess.run(['docker', 'stats', container_id, '--no-stream', '--format', '{{.NetIO}}'], capture_output=True, text=True, check=True) - net_text = net_result.stdout.strip() - net_parts = net_text.split(' / ') - if len(net_parts) == 2: - rx_text, tx_text = net_parts - - # Empfangen (RX) - rx_match = re.search(r'([\d.]+)([KMGiB]+)', rx_text) - if rx_match: - rx_value = float(rx_match.group(1)) - rx_unit = rx_match.group(2) - - # Konvertiere in Bytes - rx_bytes = rx_value - if rx_unit.startswith('K'): - rx_bytes = rx_value * 1024 - elif rx_unit.startswith('M'): - rx_bytes = rx_value * 1024 * 1024 - elif rx_unit.startswith('G'): - rx_bytes = rx_value * 1024 * 1024 * 1024 - - # Gesendet (TX) - tx_match = re.search(r'([\d.]+)([KMGiB]+)', tx_text) - if tx_match: - tx_value = float(tx_match.group(1)) - tx_unit = tx_match.group(2) - - # Konvertiere in Bytes - tx_bytes = tx_value - if tx_unit.startswith('K'): - tx_bytes = tx_value * 1024 - elif tx_unit.startswith('M'): - tx_bytes = tx_value * 1024 * 1024 - elif tx_unit.startswith('G'): - tx_bytes = tx_value * 1024 * 1024 * 1024 - - network_io = { - 'rx_bytes': rx_bytes, - 'tx_bytes': tx_bytes - } - except Exception as e: - logger.error(f"Fehler beim Abrufen der Container-Metriken für {name}: {str(e)}") - - # In Cache speichern für spätere Verwendung - CONTAINER_CACHE[container_id] = name - - containers.append({ - 'id': container_id, - 'name': name, - 'status': status, - 'image': image, - 'running': is_running, - 'cpu_percent': cpu_percent, - 'memory_usage': memory_usage, - 'network_io': network_io - }) - except Exception as docker_error: - logger.error(f"Docker-Fehler: {str(docker_error)}") - - return jsonify({ - 'success': True, - 'docker_running': docker_running, - 'containers': containers - }) - except Exception as e: - logger.error(f"Fehler beim Abrufen der Docker-Container-Informationen: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Abrufen der Docker-Container-Informationen: {str(e)}" - }), 500 - -@app.route('/api/docker/info') -def docker_info(): - """Gibt detaillierte Docker-Informationen zurück.""" - try: - info = { - 'version': 'Unbekannt', - 'api_version': 'Unbekannt', - 'os': 'Unbekannt', - 'status': 'Nicht verfügbar' - } - - try: - result = subprocess.run(['docker', 'info', '--format', '{{json .}}'], capture_output=True, text=True, check=False) - - if result.returncode == 0: - json_data = json.loads(result.stdout) - info['version'] = json_data.get('ServerVersion', 'Unbekannt') - info['api_version'] = json_data.get('ApiVersion', 'Unbekannt') - info['os'] = json_data.get('OperatingSystem', 'Unbekannt') - info['status'] = 'Läuft' - else: - info['status'] = 'Gestoppt oder nicht installiert' - except Exception as docker_error: - logger.error(f"Docker-Info-Fehler: {str(docker_error)}") - info['status'] = 'Fehler: ' + str(docker_error) - - return jsonify({ - 'success': True, - 'info': info - }) - except Exception as e: - logger.error(f"Fehler beim Abrufen der Docker-Informationen: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Abrufen der Docker-Informationen: {str(e)}" - }), 500 - -@app.route('/api/docker/restart/', methods=['POST']) -def restart_container(container_id): - """Startet einen Docker-Container neu.""" - try: - result = subprocess.run(['docker', 'restart', container_id], capture_output=True, text=True, check=False) - - if result.returncode == 0: - return jsonify({ - 'success': True, - 'message': f"Container wurde erfolgreich neugestartet." - }) - else: - error_message = result.stderr.strip() - logger.error(f"Fehler beim Neustarten des Containers: {error_message}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Neustarten des Containers: {error_message}" - }), 500 - except Exception as e: - logger.error(f"Fehler beim Neustarten des Containers: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Neustarten des Containers: {str(e)}" - }), 500 - -@app.route('/api/docker/logs/') -def container_logs(container_id): - """Gibt die Logs eines Docker-Containers zurück.""" - try: - # Benutze den Container-Namen aus dem Cache, wenn verfügbar - container_name = CONTAINER_CACHE.get(container_id, container_id) - - # Limitiere die Anzahl der zurückgegebenen Zeilen - tail_option = request.args.get('tail', '100') - - result = subprocess.run(['docker', 'logs', '--tail', tail_option, container_id], capture_output=True, text=True, check=False) - - if result.returncode == 0: - return jsonify({ - 'success': True, - 'container_id': container_id, - 'container_name': container_name, - 'logs': result.stdout - }) - else: - error_message = result.stderr.strip() - logger.error(f"Fehler beim Abrufen der Container-Logs: {error_message}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Abrufen der Container-Logs: {error_message}" - }), 500 - except Exception as e: - logger.error(f"Fehler beim Abrufen der Container-Logs: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler beim Abrufen der Container-Logs: {str(e)}" - }), 500 - -@app.route('/api/logs/analyze') -def analyze_logs(): - """Analysiert Logdateien und gibt Fehler zurück.""" - try: - log_type = request.args.get('type', 'backend') - filter_text = request.args.get('filter', '') - - log_file = None - - # Bestimme den Pfad zur Logdatei - if log_type == 'backend': - log_file = os.path.join('..', '..', 'logs', 'backend.log') - elif log_type == 'frontend': - log_file = os.path.join('..', '..', 'logs', 'frontend.log') - elif log_type == 'docker': - # Docker-Logs dynamisch abrufen - result = subprocess.run(['docker', 'logs', 'myp-backend'], capture_output=True, text=True, check=False) - if result.returncode == 0: - docker_logs = result.stdout - entries = parse_docker_logs(docker_logs, filter_text) - return jsonify({ - 'success': True, - 'entries': entries - }) - else: - return jsonify({ - 'success': False, - 'message': f"Konnte Docker-Logs nicht abrufen: {result.stderr}" - }) - else: - return jsonify({ - 'success': False, - 'message': f"Unbekannter Log-Typ: {log_type}" - }) - - # Logdatei lesen und analysieren - if log_file and os.path.exists(log_file): - entries = [] - - with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: - log_content = f.readlines() - - # Logs parsen und nach Fehlern filtern - error_pattern = re.compile(r'(ERROR|CRITICAL|FEHLER|Exception|Error|Fehler)', re.IGNORECASE) - - for line in log_content: - if error_pattern.search(line) and (not filter_text or filter_text.lower() in line.lower()): - # Versuche, Zeitstempel, Meldungstyp und Meldung zu extrahieren - timestamp_match = re.search(r'(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2})', line) - timestamp = timestamp_match.group(1) if timestamp_match else 'Unbekannt' - - # Entferne Zeitstempel und andere Metadaten - message = re.sub(r'^\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}.*?\s-\s', '', line).strip() - - entries.append({ - 'timestamp': timestamp, - 'message': message, - 'raw': line.strip() - }) - - return jsonify({ - 'success': True, - 'entries': entries - }) - else: - return jsonify({ - 'success': False, - 'message': f"Logdatei nicht gefunden: {log_file}" - }) - except Exception as e: - logger.error(f"Fehler bei der Log-Analyse: {str(e)}") - return jsonify({ - 'success': False, - 'message': f"Fehler bei der Log-Analyse: {str(e)}" - }), 500 - -@app.route('/save-config', methods=['POST']) -def save_config_route(): - """Speichert die Netzwerkkonfiguration.""" - try: - config = get_config() - - # Aktualisiere Konfiguration - config['backend_hostname'] = request.form.get('backend_hostname', DEFAULT_CONFIG['backend_hostname']) - config['backend_port'] = int(request.form.get('backend_port', DEFAULT_CONFIG['backend_port'])) - config['frontend_hostname'] = request.form.get('frontend_hostname', DEFAULT_CONFIG['frontend_hostname']) - config['frontend_port'] = int(request.form.get('frontend_port', DEFAULT_CONFIG['frontend_port'])) - - # Speichere Konfiguration - if save_config(config): - return jsonify({"success": True, "message": "Konfiguration erfolgreich gespeichert."}) - else: - return jsonify({"success": False, "message": "Fehler beim Speichern der Konfiguration."}) - except Exception as e: - return jsonify({"success": False, "message": f"Fehler: {str(e)}"}) - -@app.route('/sync-frontend', methods=['POST']) -def sync_frontend(): - """Synchronisiert die Frontend-Konfiguration mit der gespeicherten Backend-URL.""" - try: - config = get_config() - backend_url = f"http://{config['backend_hostname']}:{config['backend_port']}" - - # Erstelle notwendige Verzeichnisse - frontend_dir = "../../frontend" - env_file = os.path.join(frontend_dir, ".env.local") - - if not os.path.exists(frontend_dir): - return jsonify({"success": False, "message": "Frontend-Verzeichnis nicht gefunden."}) - - # Bestimme den Hostnamen für OAuth - hostname = socket.gethostname() - if "corpintra" in hostname: - frontend_hostname = "m040tbaraspi001.de040.corpintra.net" - oauth_url = "http://m040tbaraspi001.de040.corpintra.net/auth/login/callback" - else: - frontend_hostname = hostname - oauth_url = f"http://{hostname}:3000/auth/login/callback" - - # Erstelle .env.local-Datei - env_content = f"""# Backend API Konfiguration -NEXT_PUBLIC_API_URL={backend_url} - -# Frontend-URL für OAuth Callback -NEXT_PUBLIC_FRONTEND_URL=http://{frontend_hostname} - -# Explizite OAuth Callback URL für GitHub -NEXT_PUBLIC_OAUTH_CALLBACK_URL={oauth_url} - -# OAuth Konfiguration -OAUTH_CLIENT_ID=client_id -OAUTH_CLIENT_SECRET=client_secret -""" - - try: - with open(env_file, 'w') as f: - f.write(env_content) - os.chmod(env_file, 0o600) - return jsonify({"success": True, "message": "Frontend erfolgreich mit Backend-URL konfiguriert."}) - except Exception as e: - return jsonify({"success": False, "message": f"Fehler beim Schreiben der Frontend-Konfiguration: {str(e)}"}) - except Exception as e: - return jsonify({"success": False, "message": f"Fehler: {str(e)}"}) - -@app.route('/test-connection', methods=['POST']) -def test_connection_route(): - """Testet die Verbindung zum Backend und Frontend.""" - try: - backend_hostname = request.form.get('backend_hostname') - backend_port = int(request.form.get('backend_port')) - frontend_hostname = request.form.get('frontend_hostname') - frontend_port = int(request.form.get('frontend_port')) - - results = { - 'backend': { - 'ping': ping_host(backend_hostname), - 'connection': check_connection(backend_hostname, backend_port) - }, - 'frontend': { - 'ping': ping_host(frontend_hostname), - 'connection': check_connection(frontend_hostname, frontend_port) - } - } - - return jsonify({"success": True, "results": results}) - except Exception as e: - return jsonify({"success": False, "message": f"Fehler: {str(e)}"}) - -@app.route('/systeminfo') -def systeminfo(): - """Gibt Systeminformationen zurück.""" - info = { - "hostname": socket.gethostname(), - "platform": platform.platform(), - "architecture": platform.machine(), - "processor": platform.processor(), - "python_version": platform.python_version(), - "uptime": get_uptime(), - "memory": get_memory_info(), - "disk": get_disk_info(), - } - return jsonify(info) - -@app.route('/network') -def network(): - """Gibt Netzwerkinformationen zurück.""" - info = { - "interfaces": get_network_interfaces(), - "connections": get_active_connections(), - "dns_servers": get_dns_servers(), - "gateway": get_default_gateway(), - } - return jsonify(info) - -@app.route('/docker') -def docker(): - """Gibt Docker-Informationen zurück.""" - return jsonify(get_docker_info()) - -@app.route('/ping/') -def ping_host(host): - """Pingt einen Host an.""" - param = '-n' if platform.system().lower() == 'windows' else '-c' - command = ['ping', param, '1', host] - try: - # Verwende errors='replace' um Dekodierungsfehler zu behandeln - result = subprocess.run(command, - capture_output=True, - text=True, - encoding='utf-8', - errors='replace', - timeout=5) - return result.returncode == 0 - except subprocess.TimeoutExpired: - return False - except Exception as e: - print(f"Fehler beim Ping von {host}: {e}") - return False - -@app.route('/traceroute/') -def traceroute_host(host): - """Führt einen Traceroute zum Zielhost durch und gibt das Ergebnis zurück.""" - if is_valid_hostname(host): - try: - # Verwende errors='replace' um Dekodierungsfehler zu behandeln - result = subprocess.run(['tracert', host], - capture_output=True, - text=True, - encoding='utf-8', - errors='replace', - timeout=30) - return jsonify({ - "success": True, - "output": result.stdout, - "error": result.stderr, - "return_code": result.returncode - }) - except subprocess.TimeoutExpired: - return jsonify({"success": False, "error": "Zeitüberschreitung beim Traceroute-Befehl"}) - except Exception as e: - return jsonify({"success": False, "error": str(e)}) - else: - return jsonify({"success": False, "error": "Ungültiger Hostname oder IP-Adresse"}) - -@app.route('/nslookup/') -def nslookup_host(host): - """Führt eine DNS-Abfrage für den angegebenen Host durch.""" - if is_valid_hostname(host): - try: - # Verwende errors='replace' um Dekodierungsfehler zu behandeln - result = subprocess.run(['nslookup', host], - capture_output=True, - text=True, - encoding='utf-8', - errors='replace', - timeout=10) - return jsonify({ - "success": result.returncode == 0, - "output": result.stdout, - "error": result.stderr, - "return_code": result.returncode - }) - except subprocess.TimeoutExpired: - return jsonify({"success": False, "error": "Zeitüberschreitung beim NSLookup-Befehl"}) - except Exception as e: - return jsonify({"success": False, "error": str(e)}) - else: - return jsonify({"success": False, "error": "Ungültiger Hostname oder IP-Adresse"}) - -@app.route('/backend-status') -def backend_status(): - """Überprüft den Status des Haupt-Backend-Servers.""" - try: - # Benutze den Localhost und den Port des Haupt-Backends (Standard: 5000) - backend_port = 5000 - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(2) - result = s.connect_ex(('localhost', backend_port)) - s.close() - - return jsonify({ - "status": "online" if result == 0 else "offline", - "port": backend_port, - "error_code": result - }) - except Exception as e: - return jsonify({ - "status": "error", - "message": str(e) - }) - -@app.route('/api/docker/inspect/') -def inspect_container(container_id): - """Gibt detaillierte Informationen zu einem Docker-Container zurück.""" - if not DOCKER_AVAILABLE: - return jsonify({'success': False, 'error': 'Docker ist nicht verfügbar'}) - - try: - # Container-Details abrufen - container = docker_client.containers.get(container_id) - inspect_data = container.attrs - - # Vereinfachte Übersicht zusammenstellen - simplified_data = { - 'id': inspect_data['Id'], - 'name': inspect_data['Name'].lstrip('/'), - 'image': inspect_data['Config']['Image'], - 'created': inspect_data['Created'], - 'state': inspect_data['State'], - 'ports': inspect_data['NetworkSettings']['Ports'], - 'volumes': inspect_data['Mounts'], - 'networks': inspect_data['NetworkSettings']['Networks'], - 'environment': inspect_data['Config']['Env'], - 'labels': inspect_data['Config']['Labels'], - 'cmd': inspect_data['Config']['Cmd'], - 'entrypoint': inspect_data['Config']['Entrypoint'], - 'host_config': { - 'restart_policy': inspect_data['HostConfig']['RestartPolicy'], - 'binds': inspect_data['HostConfig']['Binds'], - 'port_bindings': inspect_data['HostConfig']['PortBindings'], - } - } - - return jsonify({'success': True, 'data': simplified_data}) - except docker.errors.NotFound: - return jsonify({'success': False, 'error': f'Container mit ID {container_id} nicht gefunden'}) - except Exception as e: - logger.error(f"Fehler bei Container-Inspektion: {e}") - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/api/docker/logs/download/') -def download_container_logs(container_id): - """Lädt die Logs eines Docker-Containers als Datei herunter.""" - if not DOCKER_AVAILABLE: - return jsonify({'success': False, 'error': 'Docker ist nicht verfügbar'}) - - try: - # Container-Details abrufen - container = docker_client.containers.get(container_id) - logs = container.logs(timestamps=True).decode('utf-8', errors='replace') - - # Temporäre Datei erstellen - temp_dir = tempfile.mkdtemp() - container_name = container.name.replace('/', '_') - file_name = f"{container_name}_logs_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - file_path = os.path.join(temp_dir, secure_filename(file_name)) - - # Logs in Datei schreiben - with open(file_path, 'w', encoding='utf-8') as f: - f.write(logs) - - # Datei zum Download senden - response = send_file(file_path, as_attachment=True, download_name=file_name) - - # Aufräumen nach Response - @response.call_on_close - def cleanup(): - shutil.rmtree(temp_dir) - - return response - except docker.errors.NotFound: - return jsonify({'success': False, 'error': f'Container mit ID {container_id} nicht gefunden'}) - except Exception as e: - logger.error(f"Fehler beim Herunterladen der Container-Logs: {e}") - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/api/network/scan-ports', methods=['POST']) -def scan_ports(): - """Führt einen Port-Scan auf dem angegebenen Host durch.""" - global PORT_SCAN_RUNNING - - if PORT_SCAN_RUNNING: - return jsonify({'success': False, 'error': 'Ein Port-Scan läuft bereits'}) - - data = request.json - target_host = data.get('host') - port_range = data.get('port_range', '1-1024') - - if not target_host: - return jsonify({'success': False, 'error': 'Kein Ziel-Host angegeben'}) - - # Überprüfe, ob der Hostname/IP gültig ist - if not is_valid_hostname(target_host): - return jsonify({'success': False, 'error': 'Ungültiger Hostname oder IP-Adresse'}) - - # Parsen des Port-Bereichs - try: - if '-' in port_range: - start_port, end_port = map(int, port_range.split('-')) - else: - start_port = end_port = int(port_range) - - if start_port < 1 or end_port > 65535 or start_port > end_port: - raise ValueError("Ungültiger Port-Bereich") - except Exception: - return jsonify({'success': False, 'error': 'Ungültiger Port-Bereich (Format: 1-1024)'}) - - # Port-Scan im Hintergrund starten - thread = threading.Thread(target=port_scan_thread, args=(target_host, start_port, end_port)) - thread.daemon = True - thread.start() - - PORT_SCAN_RUNNING = True - return jsonify({'success': True, 'message': f'Port-Scan auf {target_host} für Ports {start_port}-{end_port} gestartet'}) - -@app.route('/api/network/scan-status') -def scan_status(): - """Gibt den Status des laufenden Port-Scans zurück.""" - global PORT_SCAN_RUNNING - global PORT_SCAN_RESULTS - - if PORT_SCAN_RUNNING: - return jsonify({'success': True, 'status': 'running', 'message': 'Port-Scan läuft'}) - else: - # Wenn kein Scan läuft, gib die Ergebnisse des letzten Scans zurück (falls vorhanden) - if 'PORT_SCAN_RESULTS' in globals(): - return jsonify({'success': True, 'status': 'completed', 'results': PORT_SCAN_RESULTS}) - else: - return jsonify({'success': True, 'status': 'idle', 'message': 'Kein Port-Scan aktiv'}) - -def port_scan_thread(host, start_port, end_port): - """Führt einen Port-Scan im Hintergrund durch.""" - global PORT_SCAN_RUNNING - global PORT_SCAN_RESULTS - - try: - results = [] - service_dict = get_common_services() - - # Timeout für Socket-Verbindungen (in Sekunden) - timeout = 0.5 - - # Port-Scan durchführen - for port in range(start_port, end_port + 1): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - result = sock.connect_ex((host, port)) - if result == 0: - service = service_dict.get(port, "Unbekannter Dienst") - results.append({ - 'port': port, - 'status': 'open', - 'service': service - }) - sock.close() - - # Kurze Pause, um die CPU-Last zu reduzieren - time.sleep(0.01) - - # Ergebnisse speichern - PORT_SCAN_RESULTS = { - 'host': host, - 'start_port': start_port, - 'end_port': end_port, - 'timestamp': datetime.datetime.now().isoformat(), - 'open_ports': results - } - except Exception as e: - logger.error(f"Fehler bei Port-Scan: {e}") - PORT_SCAN_RESULTS = { - 'host': host, - 'error': str(e), - 'timestamp': datetime.datetime.now().isoformat() - } - finally: - PORT_SCAN_RUNNING = False - -def get_common_services(): - """Gibt ein Wörterbuch mit gängigen Port-Dienst-Zuordnungen zurück.""" - return { - 21: "FTP", - 22: "SSH", - 23: "Telnet", - 25: "SMTP", - 53: "DNS", - 80: "HTTP", - 110: "POP3", - 143: "IMAP", - 443: "HTTPS", - 465: "SMTP (SSL)", - 587: "SMTP (TLS)", - 993: "IMAP (SSL)", - 995: "POP3 (SSL)", - 1433: "MS SQL Server", - 3306: "MySQL", - 3389: "RDP", - 5000: "Flask/Python Web", - 5432: "PostgreSQL", - 6379: "Redis", - 8080: "HTTP-Alternativ", - 8443: "HTTPS-Alternativ", - 9000: "PHP-FPM", - 9090: "Prometheus", - 9200: "Elasticsearch", - 27017: "MongoDB" - } - -@app.route('/api/network/interfaces') -def get_interfaces(): - """Gibt detaillierte Informationen zu allen Netzwerkschnittstellen zurück.""" - interfaces = [] - - for iface_name in netifaces.interfaces(): - iface_info = {} - iface_info['name'] = iface_name - - # IP-Adressen abrufen - addresses = netifaces.ifaddresses(iface_name) - - # IPv4-Adressen - if netifaces.AF_INET in addresses: - iface_info['ipv4'] = addresses[netifaces.AF_INET] - else: - iface_info['ipv4'] = [] - - # IPv6-Adressen - if netifaces.AF_INET6 in addresses: - iface_info['ipv6'] = addresses[netifaces.AF_INET6] - else: - iface_info['ipv6'] = [] - - # MAC-Adresse - if netifaces.AF_LINK in addresses: - iface_info['mac'] = addresses[netifaces.AF_LINK][0].get('addr', 'Nicht verfügbar') - else: - iface_info['mac'] = 'Nicht verfügbar' - - # Statistiken - try: - stats = psutil.net_io_counters(pernic=True).get(iface_name) - if stats: - iface_info['stats'] = { - 'bytes_sent': stats.bytes_sent, - 'bytes_recv': stats.bytes_recv, - 'packets_sent': stats.packets_sent, - 'packets_recv': stats.packets_recv, - 'errin': stats.errin, - 'errout': stats.errout, - 'dropin': stats.dropin, - 'dropout': stats.dropout - } - except: - iface_info['stats'] = 'Nicht verfügbar' - - interfaces.append(iface_info) - - return jsonify({'success': True, 'interfaces': interfaces}) - -@app.route('/api/network/active-connections') -def active_connections(): - """Gibt alle aktiven Netzwerkverbindungen zurück.""" - try: - connections = [] - - for conn in psutil.net_connections(kind='inet'): - if conn.status == 'ESTABLISHED': - connection_info = { - 'local_address': f"{conn.laddr.ip}:{conn.laddr.port}", - 'remote_address': f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else 'Keine', - 'status': conn.status, - 'pid': conn.pid, - 'process': None - } - - # Prozessinformationen hinzufügen, falls verfügbar - if conn.pid: - try: - process = psutil.Process(conn.pid) - connection_info['process'] = { - 'name': process.name(), - 'create_time': datetime.datetime.fromtimestamp(process.create_time()).strftime('%Y-%m-%d %H:%M:%S'), - 'username': process.username() - } - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - connections.append(connection_info) - - return jsonify({'success': True, 'connections': connections}) - except Exception as e: - logger.error(f"Fehler beim Abrufen der aktiven Verbindungen: {e}") - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/api/network/route-table') -def route_table(): - """Gibt die Routing-Tabelle zurück.""" - try: - if sys.platform.startswith('win'): - output = subprocess.check_output(['route', 'print'], universal_newlines=True) - else: - output = subprocess.check_output(['netstat', '-rn'], universal_newlines=True) - - return jsonify({'success': True, 'route_table': output}) - except subprocess.SubprocessError as e: - logger.error(f"Fehler beim Abrufen der Routing-Tabelle: {e}") - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/api/logs/tail/') -def tail_logs(log_type): - """Gibt die letzten Zeilen eines Logfiles zurück.""" - log_files = { - 'backend': os.path.join('..', '..', 'logs', 'backend.log'), - 'frontend': os.path.join('..', '..', 'logs', 'frontend.log'), - 'debug': os.path.join('..', '..', 'logs', 'debug-server.log') - } - - if log_type not in log_files: - return jsonify({'success': False, 'error': f'Unbekannter Log-Typ: {log_type}'}) - - log_file = log_files[log_type] - lines = request.args.get('lines', 100, type=int) - - if not os.path.exists(log_file): - return jsonify({'success': False, 'error': f'Log-Datei existiert nicht: {log_file}'}) - - try: - if sys.platform.startswith('win'): - # Windows-spezifischer Befehl - output = subprocess.check_output( - ['powershell', '-Command', f'Get-Content -Path "{log_file}" -Tail {lines}'], - universal_newlines=True - ) - else: - # Unix-Befehl - output = subprocess.check_output( - ['tail', '-n', str(lines), log_file], - universal_newlines=True - ) - - log_entries = output.splitlines() - return jsonify({'success': True, 'entries': log_entries, 'count': len(log_entries)}) - except subprocess.SubprocessError as e: - logger.error(f"Fehler beim Lesen der Log-Datei: {e}") - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/healthcheck') -def healthcheck(): - """Gibt eine einfache Gesundheitsprüfung zurück.""" - checks = {} - - # Systemressourcen prüfen - cpu_percent = psutil.cpu_percent(interval=0.1) - memory = psutil.virtual_memory() - disk = psutil.disk_usage('/') - - checks['system'] = { - 'cpu': { - 'percent': cpu_percent, - 'status': 'healthy' if cpu_percent < 90 else 'warning' if cpu_percent < 95 else 'critical' - }, - 'memory': { - 'percent': memory.percent, - 'status': 'healthy' if memory.percent < 90 else 'warning' if memory.percent < 95 else 'critical' - }, - 'disk': { - 'percent': disk.percent, - 'status': 'healthy' if disk.percent < 90 else 'warning' if disk.percent < 95 else 'critical' - } - } - - # Docker-Status prüfen - if DOCKER_AVAILABLE: - try: - docker_info = docker_client.info() - docker_status = 'running' - docker_containers_running = docker_info.get('ContainersRunning', 0) - - checks['docker'] = { - 'status': docker_status, - 'containers_running': docker_containers_running, - 'overall': 'healthy' - } - except Exception: - checks['docker'] = { - 'status': 'error', - 'overall': 'critical' - } - else: - checks['docker'] = { - 'status': 'not_available', - 'overall': 'warning' - } - - # Backend-Verbindung prüfen - config = get_config() - try: - backend_url = f"http://{config['backend_hostname']}:{config['backend_port']}/api/test" - response = requests.get(backend_url, timeout=2) - backend_status = { - 'status': 'connected' if response.status_code == 200 else 'error', - 'http_code': response.status_code, - 'overall': 'healthy' if response.status_code == 200 else 'critical' - } - except requests.exceptions.RequestException: - backend_status = { - 'status': 'unreachable', - 'overall': 'critical' - } - - checks['backend'] = backend_status - - # Frontend-Verbindung prüfen - try: - frontend_url = f"http://{config['frontend_hostname']}:{config['frontend_port']}" - response = requests.get(frontend_url, timeout=2) - frontend_status = { - 'status': 'connected' if response.status_code == 200 else 'error', - 'http_code': response.status_code, - 'overall': 'healthy' if response.status_code == 200 else 'critical' - } - except requests.exceptions.RequestException: - frontend_status = { - 'status': 'unreachable', - 'overall': 'critical' - } - - checks['frontend'] = frontend_status - - # Gesamtstatus bestimmen - overall_status = 'healthy' - for component, check in checks.items(): - if check.get('overall') == 'critical': - overall_status = 'critical' - break - elif check.get('overall') == 'warning' and overall_status != 'critical': - overall_status = 'warning' - - return jsonify({ - 'status': overall_status, - 'timestamp': datetime.datetime.now().isoformat(), - 'checks': checks - }) - -if __name__ == "__main__": - try: - os.makedirs("../../logs", exist_ok=True) - print(f"Debug-Server startet auf Port {DEBUG_PORT}") - app.run(host="0.0.0.0", port=DEBUG_PORT, debug=True) - except Exception as e: - print(f"Fehler beim Starten des Debug-Servers: {e}") - sys.exit(1) \ No newline at end of file diff --git a/backend/debug-server/requirements.txt b/backend/debug-server/requirements.txt deleted file mode 100644 index 0133aae1..00000000 --- a/backend/debug-server/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -flask==2.2.3 -flask-cors==3.0.10 -psutil==5.9.4 -matplotlib==3.7.1 -pandas==1.5.3 -requests==2.28.2 -netifaces==0.11.0 -Werkzeug==2.2.3 -docker==6.1.2 -pillow==9.4.0 -send_file==0.2.0 -python-dotenv>=1.0.0 -python-logging-loki>=0.3.1 -colorama>=0.4.6 -pygal>=3.0.0 \ No newline at end of file diff --git a/backend/debug-server/static/css/debug-dashboard.css b/backend/debug-server/static/css/debug-dashboard.css deleted file mode 100644 index dbbc98f4..00000000 --- a/backend/debug-server/static/css/debug-dashboard.css +++ /dev/null @@ -1,617 +0,0 @@ -/* Debug-Dashboard CSS */ - -/* Reset und Basis-Stile */ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; - color: #333; - background-color: #f5f7fa; - padding: 0; - margin: 0; -} - -h1, h2, h3, h4 { - color: #2c3e50; - margin-bottom: 15px; -} - -/* Layout */ -.page-header { - background-color: #2c3e50; - color: white; - padding: 20px; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; -} - -.page-header h1 { - color: white; - margin: 0; -} - -.last-update { - font-size: 0.9em; - opacity: 0.8; -} - -.header-actions { - display: flex; - gap: 10px; -} - -.dashboard-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); - gap: 20px; - padding: 20px; -} - -.dashboard-section { - padding: 20px; -} - -/* Karten */ -.card { - background-color: white; - border-radius: 5px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - overflow: hidden; - margin-bottom: 20px; -} - -.card-header { - background-color: #f1f5f9; - padding: 15px; - font-weight: bold; - border-bottom: 1px solid #e2e8f0; - display: flex; - justify-content: space-between; - align-items: center; -} - -.card-body { - padding: 20px; -} - -/* Statistik-Karten */ -.stats-row { - display: flex; - justify-content: space-between; - margin-bottom: 20px; - flex-wrap: wrap; -} - -.stat-card { - background-color: #f8fafc; - border-radius: 5px; - padding: 15px; - min-width: 150px; - text-align: center; - flex: 1; - margin: 0 5px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.stat-label { - font-size: 0.9em; - color: #64748b; - margin-bottom: 5px; -} - -.stat-value { - font-size: 1.5em; - font-weight: bold; - color: #334155; -} - -/* Charts */ -.chart-container { - position: relative; - height: 200px; - margin-bottom: 20px; -} - -.chart-container.small { - height: 150px; -} - -/* Formularelemente */ -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - margin-bottom: 5px; - font-weight: 500; -} - -.form-group input, -.form-group select { - width: 100%; - padding: 8px 12px; - border: 1px solid #cbd5e1; - border-radius: 4px; - font-size: 1em; -} - -.input-group { - display: flex; -} - -.input-group input { - flex: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -.input-group-append { - display: flex; -} - -.input-group-append .btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -/* Buttons */ -.btn { - background-color: #3b82f6; - color: white; - border: none; - padding: 8px 15px; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - transition: background-color 0.2s; -} - -.btn:hover { - background-color: #2563eb; -} - -.btn-sm { - padding: 5px 10px; - font-size: 0.9em; -} - -.btn-success { - background-color: #10b981; -} - -.btn-success:hover { - background-color: #059669; -} - -.btn-warning { - background-color: #f59e0b; -} - -.btn-warning:hover { - background-color: #d97706; -} - -.btn-danger { - background-color: #ef4444; -} - -.btn-danger:hover { - background-color: #dc2626; -} - -.btn-group { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -/* Status-Anzeigen */ -.status { - padding: 10px; - border-radius: 4px; - margin-bottom: 10px; - background-color: #f1f5f9; -} - -.status-good { - background-color: #d1fae5; - color: #064e3b; -} - -.status-warning { - background-color: #fff7ed; - color: #7c2d12; -} - -.status-error { - background-color: #fee2e2; - color: #7f1d1d; -} - -/* Nachrichten */ -.message { - display: none; - padding: 15px; - margin: 15px 20px; - border-radius: 5px; - font-weight: 500; -} - -.message-success { - background-color: #d1fae5; - color: #064e3b; -} - -.message-error { - background-color: #fee2e2; - color: #7f1d1d; -} - -/* Systemstatus-Banner */ -.system-health-banner { - display: none; - align-items: center; - padding: 10px 20px; - color: white; - position: relative; -} - -.system-health-banner.checking { - background-color: #3b82f6; -} - -.system-health-banner.healthy { - background-color: #10b981; -} - -.system-health-banner.warning { - background-color: #f59e0b; -} - -.system-health-banner.critical { - background-color: #ef4444; -} - -.health-icon { - font-size: 1.5em; - margin-right: 15px; -} - -.health-status { - flex: 1; -} - -.health-status-title { - font-weight: bold; - margin-bottom: 5px; -} - -.health-details { - font-size: 0.9em; - display: flex; - flex-wrap: wrap; - gap: 15px; -} - -.health-good { - color: #10b981; -} - -.health-warning { - color: #f59e0b; -} - -.health-critical { - color: #ef4444; -} - -/* Tabellen */ -table { - width: 100%; - border-collapse: collapse; - margin-bottom: 20px; -} - -table th, -table td { - padding: 12px 15px; - text-align: left; - border-bottom: 1px solid #e2e8f0; -} - -table th { - background-color: #f8fafc; - font-weight: 600; -} - -table tbody tr:hover { - background-color: #f1f5f9; -} - -/* Tabs */ -.tabs { - display: flex; - border-bottom: 1px solid #e2e8f0; - margin-bottom: 20px; -} - -.tab { - padding: 10px 20px; - cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.2s; -} - -.tab:hover { - background-color: #f1f5f9; -} - -.tab.active { - border-bottom-color: #3b82f6; - color: #3b82f6; - font-weight: 500; -} - -.tab-content { - display: none; -} - -.tab-content.active { - display: block; -} - -/* Docker-Container */ -.container-row { - transition: background-color 0.2s; -} - -.container-row.running { - border-left: 3px solid #10b981; -} - -.container-row.exited { - border-left: 3px solid #ef4444; -} - -.container-name { - font-weight: bold; -} - -.container-image { - font-size: 0.9em; - color: #64748b; -} - -.container-running { - color: #064e3b; - font-weight: 500; -} - -.filter-bar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; -} - -/* Logs und Terminal-Ausgabe */ -.logs-container { - background-color: #1e293b; - color: #e2e8f0; - border-radius: 5px; - padding: 15px; - margin-top: 15px; - max-height: 400px; - overflow-y: auto; - font-family: 'Consolas', 'Monaco', monospace; - line-height: 1.5; -} - -.log-placeholder { - color: #94a3b8; - text-align: center; - padding: 20px; -} - -.log-header { - display: flex; - justify-content: space-between; - padding: 10px; - background-color: #334155; - margin: -15px -15px 10px -15px; - border-top-left-radius: 5px; - border-top-right-radius: 5px; -} - -.log-line { - white-space: pre-wrap; - word-break: break-all; - margin-bottom: 2px; - padding: 2px 0; -} - -.log-error { - color: #f87171; -} - -.log-warning { - color: #fbbf24; -} - -.log-info { - color: #60a5fa; -} - -.log-debug { - color: #a3e635; -} - -.log-timestamp { - color: #94a3b8; -} - -pre.ping-output, -pre.traceroute-output, -pre.dns-output { - background-color: #1e293b; - color: #e2e8f0; - padding: 15px; - border-radius: 5px; - overflow-x: auto; - white-space: pre-wrap; - font-family: 'Consolas', 'Monaco', monospace; -} - -/* Netzwerkschnittstellen */ -.interface-item { - background-color: #f8fafc; - border-radius: 5px; - padding: 15px; - margin-bottom: 15px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.interface-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid #e2e8f0; -} - -.interface-mac { - font-family: monospace; - color: #64748b; -} - -.interface-ips h4, -.interface-stats h4 { - color: #475569; - font-size: 1em; - margin-top: 15px; - margin-bottom: 8px; -} - -.ip-item { - padding: 8px; - background-color: #f1f5f9; - border-radius: 4px; - margin-bottom: 8px; -} - -/* Ergebnisse-Container */ -.results-container { - margin-top: 15px; - max-height: 300px; - overflow-y: auto; -} - -/* Loading-Anzeige */ -.loading { - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - color: #64748b; -} - -.loading::before { - content: ""; - width: 20px; - height: 20px; - margin-right: 10px; - border: 2px solid #cbd5e1; - border-top-color: #3b82f6; - border-radius: 50%; - animation: spinner 0.8s linear infinite; -} - -@keyframes spinner { - to { - transform: rotate(360deg); - } -} - -/* Error-Anzeige */ -.error { - color: #ef4444; - padding: 10px; - border-radius: 4px; - background-color: #fee2e2; -} - -/* Log-Analyse */ -.error-list, -.warning-list { - margin-bottom: 20px; -} - -.error-item, -.warning-item { - background-color: #fee2e2; - border-left: 3px solid #ef4444; - padding: 10px; - margin-bottom: 10px; - border-radius: 4px; -} - -.warning-item { - background-color: #fff7ed; - border-left-color: #f59e0b; -} - -.error-time, -.warning-time { - font-size: 0.9em; - color: #64748b; - margin-bottom: 5px; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .dashboard-container { - grid-template-columns: 1fr; - } - - .page-header { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .header-actions { - width: 100%; - } - - .stats-row { - flex-direction: column; - gap: 10px; - } - - .stat-card { - margin: 0 0 10px 0; - } - - .btn-group { - flex-direction: column; - } - - .tabs { - flex-wrap: wrap; - } - - .tab { - flex: 1; - text-align: center; - padding: 10px; - } -} \ No newline at end of file diff --git a/backend/debug-server/static/css/style.css b/backend/debug-server/static/css/style.css deleted file mode 100644 index cf8c2bd1..00000000 --- a/backend/debug-server/static/css/style.css +++ /dev/null @@ -1,291 +0,0 @@ -/* Allgemeine Stile */ -:root { - --primary-color: #3498db; - --secondary-color: #2c3e50; - --background-color: #f5f7fa; - --card-color: #ffffff; - --text-color: #333333; - --border-color: #e0e0e0; - --success-color: #2ecc71; - --warning-color: #f39c12; - --danger-color: #e74c3c; - --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; - color: var(--text-color); - background-color: var(--background-color); - display: flex; - flex-direction: column; - min-height: 100vh; -} - -/* Header */ -header { - background-color: var(--primary-color); - color: white; - padding: 1rem 2rem; - box-shadow: var(--shadow); -} - -.header-content { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - max-width: 1400px; - margin: 0 auto; -} - -h1 { - font-size: 1.8rem; - margin-bottom: 0.5rem; -} - -.server-info { - display: flex; - flex-wrap: wrap; - gap: 1rem; - font-size: 0.9rem; -} - -/* Navigation */ -nav { - background-color: var(--secondary-color); - display: flex; - justify-content: center; - padding: 0.5rem 1rem; - overflow-x: auto; - white-space: nowrap; -} - -.nav-button { - background: none; - color: white; - border: none; - padding: 0.75rem 1.25rem; - margin: 0 0.25rem; - cursor: pointer; - font-size: 1rem; - border-radius: 4px; - transition: background-color 0.3s; -} - -.nav-button:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.nav-button.active { - background-color: var(--primary-color); - font-weight: bold; -} - -/* Hauptbereich */ -main { - flex: 1; - padding: 2rem; - max-width: 1400px; - margin: 0 auto; - width: 100%; -} - -.panel { - display: none; -} - -.panel.active { - display: block; -} - -h2 { - margin-bottom: 1.5rem; - color: var(--secondary-color); - border-bottom: 2px solid var(--primary-color); - padding-bottom: 0.5rem; -} - -.card-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.5rem; -} - -.card { - background-color: var(--card-color); - border-radius: 8px; - padding: 1.5rem; - box-shadow: var(--shadow); -} - -.card.full-width { - grid-column: 1 / -1; -} - -h3 { - margin-bottom: 1rem; - color: var(--secondary-color); - font-size: 1.2rem; -} - -/* Fortschrittsbalken */ -.progress-container { - margin-top: 1rem; - background-color: #f0f0f0; - border-radius: 4px; - height: 10px; - overflow: hidden; -} - -.progress-bar { - height: 100%; - background-color: var(--primary-color); - width: 0%; - transition: width 0.5s ease-in-out; -} - -/* Tool-Karten */ -.tool-card { - display: flex; - flex-direction: column; -} - -.tool-input { - display: flex; - margin-bottom: 1rem; - gap: 0.5rem; -} - -.tool-input input { - flex: 1; - padding: 0.5rem; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 1rem; -} - -.tool-input button { - background-color: var(--primary-color); - color: white; - border: none; - padding: 0.5rem 1rem; - cursor: pointer; - border-radius: 4px; - font-size: 1rem; - transition: background-color 0.3s; -} - -.tool-input button:hover { - background-color: #2980b9; -} - -.result-box { - background-color: #f8f9fa; - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 1rem; - overflow: auto; - min-height: 150px; - max-height: 300px; - font-family: 'Consolas', 'Courier New', monospace; - font-size: 0.9rem; - flex: 1; -} - -/* Tabellen */ -table { - width: 100%; - border-collapse: collapse; - margin-top: 1rem; -} - -th, td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border-color); -} - -th { - background-color: #f0f0f0; - font-weight: bold; -} - -tr:hover { - background-color: #f8f9fa; -} - -/* Status-Anzeigen */ -.status { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.9rem; - margin-left: 0.5rem; -} - -.status-online { - background-color: var(--success-color); - color: white; -} - -.status-offline { - background-color: var(--danger-color); - color: white; -} - -.status-warning { - background-color: var(--warning-color); - color: white; -} - -/* Footer */ -footer { - background-color: var(--secondary-color); - color: white; - text-align: center; - padding: 1rem; - margin-top: auto; - font-size: 0.9rem; -} - -footer p { - margin: 0.25rem 0; -} - -/* Responsive Design */ -@media (max-width: 768px) { - header { - padding: 1rem; - } - - .header-content { - flex-direction: column; - align-items: flex-start; - } - - .server-info { - margin-top: 1rem; - flex-direction: column; - gap: 0.5rem; - } - - main { - padding: 1rem; - } - - .card-container { - grid-template-columns: 1fr; - } - - .nav-button { - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - } -} \ No newline at end of file diff --git a/backend/debug-server/static/js/chart.min.js b/backend/debug-server/static/js/chart.min.js deleted file mode 100644 index 8f69759e..00000000 --- a/backend/debug-server/static/js/chart.min.js +++ /dev/null @@ -1,13 +0,0 @@ -/*! - * Chart.js v3.9.1 - * https://www.chartjs.org - * (c) 2022 Chart.js Contributors - * Released under the MIT License - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";function t(){}const e=function(){let t=0;return function(){return t++}}();function i(t){return null==t}function s(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function n(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}const o=t=>("number"==typeof t||t instanceof Number)&&isFinite(+t);function a(t,e){return o(t)?t:e}function r(t,e){return void 0===t?e:t}const l=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:t/e,h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function c(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function d(t,e,i,o){let a,r,l;if(s(t))if(r=t.length,o)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function y(t,e){const i=_[e]||(_[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const M=t=>void 0!==t,k=t=>"function"==typeof t,S=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function P(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const D=Math.PI,O=2*D,C=O+D,A=Number.POSITIVE_INFINITY,T=D/180,L=D/2,E=D/4,R=2*D/3,I=Math.log10,z=Math.sign;function F(t){const e=Math.round(t);t=N(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(I(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function V(t){const e=[],i=Math.sqrt(t);let s;for(s=1;st-e)).pop(),e}function B(t){return!isNaN(parseFloat(t))&&isFinite(t)}function N(t,e,i){return Math.abs(t-e)=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function tt(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const et=(t,e,i,s)=>tt(t,i,s?s=>t[s][e]<=i:s=>t[s][e]tt(t,i,(s=>t[s][e]>=i));function st(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function at(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(nt.forEach((e=>{delete t[e]})),delete t._chartjs)}function rt(t){const e=new Set;let i,s;for(i=0,s=t.length;iArray.prototype.slice.call(t));let n=!1,o=[];return function(...i){o=s(i),n||(n=!0,lt.call(window,(()=>{n=!1,t.apply(e,o)})))}}function ct(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const dt=t=>"start"===t?"left":"end"===t?"right":"center",ut=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,ft=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function gt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=Z(Math.min(et(r,a.axis,h).lo,i?s:et(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?Z(Math.max(et(r,a.axis,c,!0).hi+1,i?0:et(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function pt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}var mt=new class{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=lt.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}; -/*! - * @kurkle/color v0.2.1 - * https://github.com/kurkle/color#readme - * (c) 2022 Jukka Kurkela - * Released under the MIT License - */function bt(t){return t+.5|0}const xt=(t,e,i)=>Math.max(Math.min(t,i),e);function _t(t){return xt(bt(2.55*t),0,255)}function yt(t){return xt(bt(255*t),0,255)}function vt(t){return xt(bt(t/2.55)/100,0,1)}function wt(t){return xt(bt(100*t),0,100)}const Mt={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},kt=[..."0123456789ABCDEF"],St=t=>kt[15&t],Pt=t=>kt[(240&t)>>4]+kt[15&t],Dt=t=>(240&t)>>4==(15&t);function Ot(t){var e=(t=>Dt(t.r)&&Dt(t.g)&&Dt(t.b)&&Dt(t.a))(t)?St:Pt;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Ct=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function At(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Tt(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Lt(t,e,i){const s=At(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function Et(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Nt.transparent=[0,0,0,0]);const e=Nt[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const jt=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Ht=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,$t=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Yt(t,e,i){if(t){let s=Et(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=It(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function Ut(t,e){return t?Object.assign(e||{},t):t}function Xt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=yt(t[3]))):(e=Ut(t,{r:0,g:0,b:0,a:1})).a=yt(e.a),e}function qt(t){return"r"===t.charAt(0)?function(t){const e=jt.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?_t(t):xt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?_t(i):xt(i,0,255)),s=255&(e[4]?_t(s):xt(s,0,255)),n=255&(e[6]?_t(n):xt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Ft(t)}class Kt{constructor(t){if(t instanceof Kt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Xt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*Mt[s[1]],g:255&17*Mt[s[2]],b:255&17*Mt[s[3]],a:5===o?17*Mt[s[4]]:255}:7!==o&&9!==o||(n={r:Mt[s[1]]<<4|Mt[s[2]],g:Mt[s[3]]<<4|Mt[s[4]],b:Mt[s[5]]<<4|Mt[s[6]],a:9===o?Mt[s[7]]<<4|Mt[s[8]]:255})),i=n||Wt(t)||qt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=Ut(this._rgb);return t&&(t.a=vt(t.a)),t}set rgb(t){this._rgb=Xt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${vt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?Ot(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=Et(t),i=e[0],s=wt(e[1]),n=wt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${vt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=$t(vt(t.r)),n=$t(vt(t.g)),o=$t(vt(t.b));return{r:yt(Ht(s+i*($t(vt(e.r))-s))),g:yt(Ht(n+i*($t(vt(e.g))-n))),b:yt(Ht(o+i*($t(vt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Kt(this.rgb)}alpha(t){return this._rgb.a=yt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=bt(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Yt(this._rgb,2,t),this}darken(t){return Yt(this._rgb,2,-t),this}saturate(t){return Yt(this._rgb,1,t),this}desaturate(t){return Yt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=Et(t);i[0]=zt(i[0]+e),i=It(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Gt(t){return new Kt(t)}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Jt(t){return Zt(t)?t:Gt(t)}function Qt(t){return Zt(t)?t:Gt(t).saturate(.5).darken(.1).hexString()}const te=Object.create(null),ee=Object.create(null);function ie(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>Qt(e.backgroundColor),this.hoverBorderColor=(t,e)=>Qt(e.borderColor),this.hoverColor=(t,e)=>Qt(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t)}set(t,e){return se(this,t,e)}get(t){return ie(this,t)}describe(t,e){return se(ee,t,e)}override(t,e){return se(te,t,e)}route(t,e,i,s){const o=ie(this,t),a=ie(this,i),l="_"+e;Object.defineProperties(o,{[l]:{value:o[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[l],e=a[s];return n(t)?Object.assign({},e,t):r(t,e)},set(t){this[l]=t}}})}}({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}});function oe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ae(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function re(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const le=t=>window.getComputedStyle(t,null);function he(t,e){return le(t).getPropertyValue(e)}const ce=["top","right","bottom","left"];function de(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=ce[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}function ue(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=le(i),o="border-box"===n.boxSizing,a=de(n,"padding"),r=de(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(((t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot))(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const fe=t=>Math.round(10*t)/10;function ge(t,e,i,s){const n=le(t),o=de(n,"margin"),a=re(n.maxWidth,t,"clientWidth")||A,r=re(n.maxHeight,t,"clientHeight")||A,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ae(t);if(o){const t=o.getBoundingClientRect(),a=le(o),r=de(a,"border","width"),l=de(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=re(a.maxWidth,o,"clientWidth"),n=re(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||A,maxHeight:n||A}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=de(n,"border","width"),e=de(n,"padding");h-=e.width+t.width,c-=e.height+t.height}return h=Math.max(0,h-o.width),c=Math.max(0,s?Math.floor(h/s):c-o.height),h=fe(Math.min(h,a,l.maxWidth)),c=fe(Math.min(c,r,l.maxHeight)),h&&!c&&(c=fe(h/2)),{width:h,height:c}}function pe(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=n/s,t.width=o/s;const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const me=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function be(t,e){const i=he(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function xe(t){return!t||i(t.size)||i(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function _e(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function ye(t,e,i,n){let o=(n=n||{}).data=n.data||{},a=n.garbageCollect=n.garbageCollect||[];n.font!==e&&(o=n.data={},a=n.garbageCollect=[],n.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Se(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]);i(e.rotation)||t.rotate(e.rotation);e.color&&(t.fillStyle=e.color);e.textAlign&&(t.textAlign=e.textAlign);e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){M(s)||(s=$e("_fallback",t));const o={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:i,_fallback:s,_getTarget:n,override:n=>Ee([n,...t],e,i,s)};return new Proxy(o,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>Ve(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=$e(ze(o,t),i),M(n))return Fe(t,n)?je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>Ye(t).includes(e),ownKeys:t=>Ye(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function Re(t,e,i,o){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ie(t,o),setContext:e=>Re(t,e,i,o),override:s=>Re(t.override(s),e,i,o)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>Ve(t,e,(()=>function(t,e,i){const{_proxy:o,_context:a,_subProxy:r,_descriptors:l}=t;let h=o[e];k(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t),e=e(o,a||s),r.delete(t),Fe(t,e)&&(e=je(n._scopes,n,t,e));return e}(e,h,t,i));s(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:o,_context:a,_subProxy:r,_descriptors:l}=i;if(M(a.index)&&s(t))e=e[a.index%e.length];else if(n(e[0])){const i=e,s=o._scopes.filter((t=>t!==i));e=[];for(const n of i){const i=je(s,o,t,n);e.push(Re(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Fe(e,h)&&(h=Re(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ie(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:k(i)?i:()=>i,isIndexable:k(s)?s:()=>s}}const ze=(t,e)=>t?t+w(e):e,Fe=(t,e)=>n(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function Ve(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Be(t,e,i){return k(t)?t(e,i):t}const Ne=(t,e)=>!0===t?e:"string"==typeof t?y(e,t):void 0;function We(t,e,i,s,n){for(const o of e){const e=Ne(i,o);if(e){t.add(e);const o=Be(e._fallback,i,n);if(M(o)&&o!==i&&o!==s)return o}else if(!1===e&&M(s)&&i!==s)return null}return!1}function je(t,e,i,o){const a=e._rootScopes,r=Be(e._fallback,i,o),l=[...t,...a],h=new Set;h.add(o);let c=He(h,l,i,r||i,o);return null!==c&&((!M(r)||r===i||(c=He(h,l,r,c,o),null!==c))&&Ee(Array.from(h),[""],a,r,(()=>function(t,e,i){const o=t._getTarget();e in o||(o[e]={});const a=o[e];if(s(a)&&n(i))return i;return a}(e,i,o))))}function He(t,e,i,s,n){for(;i;)i=We(t,e,i,s,n);return i}function $e(t,e){for(const i of e){if(!i)continue;const e=i[t];if(M(e))return e}}function Ye(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function Ue(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function Ge(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=X(o,n),l=X(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function Ze(t,e="x"){const i=Ke(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=qe(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)Ze(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,ei=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ii=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,si={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*L),easeOutSine:t=>Math.sin(t*L),easeInOutSine:t=>-.5*(Math.cos(D*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ti(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ti(t)?t:ei(t,.075,.3),easeOutElastic:t=>ti(t)?t:ii(t,.075,.3),easeInOutElastic(t){const e=.1125;return ti(t)?t:t<.5?.5*ei(2*t,e,.45):.5+.5*ii(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-si.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*si.easeInBounce(2*t):.5*si.easeOutBounce(2*t-1)+.5};function ni(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function oi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function ai(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=ni(t,n,i),r=ni(n,o,i),l=ni(o,e,i),h=ni(a,r,i),c=ni(r,l,i);return ni(h,c,i)}const ri=new Map;function li(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=ri.get(i);return s||(s=new Intl.NumberFormat(t,e),ri.set(i,s)),s}(e,i).format(t)}const hi=new RegExp(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/),ci=new RegExp(/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/);function di(t,e){const i=(""+t).match(hi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}function ui(t,e){const i={},s=n(e),o=s?Object.keys(e):e,a=n(t)?s?i=>r(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of o)i[t]=+a(t)||0;return i}function fi(t){return ui(t,{top:"y",right:"x",bottom:"y",left:"x"})}function gi(t){return ui(t,["topLeft","topRight","bottomLeft","bottomRight"])}function pi(t){const e=fi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function mi(t,e){t=t||{},e=e||ne.font;let i=r(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=r(t.style,e.style);s&&!(""+s).match(ci)&&(console.warn('Invalid font style specified: "'+s+'"'),s="");const n={family:r(t.family,e.family),lineHeight:di(r(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:r(t.weight,e.weight),string:""};return n.string=xe(n),n}function bi(t,e,i,n){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function _i(t,e){return Object.assign(Object.create(t),e)}function yi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function vi(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function wi(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Mi(t){return"angle"===t?{between:G,compare:q,normalize:K}:{between:Q,compare:(t,e)=>t-e,normalize:t=>t}}function ki({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Si(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Mi(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Mi(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(ki({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(ki({start:_,end:d,loop:u,count:a,style:f})),g}function Pi(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Oi(t,[{start:a,end:r,loop:o}],i,e);return Oi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Vi={evaluateInteractionItems:Ei,modes:{index(t,e,i,s){const n=ue(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Ri(t,n,o,s,a):zi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ue(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Ri(t,n,o,s,a):zi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tRi(t,ue(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ue(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return zi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Fi(t,ue(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Fi(t,ue(e,t),"y",i.intersect,s)}};const Bi=["left","top","right","bottom"];function Ni(t,e){return t.filter((t=>t.pos===e))}function Wi(t,e){return t.filter((t=>-1===Bi.indexOf(t.pos)&&t.box.axis===e))}function ji(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Hi(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Bi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function qi(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=ji(Ni(e,"left"),!0),n=ji(Ni(e,"right")),o=ji(Ni(e,"top"),!0),a=ji(Ni(e,"bottom")),r=Wi(e,"x"),l=Wi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ni(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;d(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,u=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);Yi(f,pi(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Hi(l.concat(h),u);qi(r.fullSize,g,u,p),qi(l,g,u,p),qi(h,g,u,p)&&qi(l,g,u,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),Gi(r.leftAndTop,g,u,p),g.x+=g.w,g.y+=g.h,Gi(r.rightAndBottom,g,u,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},d(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class Ji{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class Qi extends Ji{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ts={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},es=t=>null===t||""===t;const is=!!me&&{passive:!0};function ss(t,e,i){t.canvas.removeEventListener(e,i,is)}function ns(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function os(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ns(i.addedNodes,s),e=e&&!ns(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function as(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ns(i.removedNodes,s),e=e&&!ns(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const rs=new Map;let ls=0;function hs(){const t=window.devicePixelRatio;t!==ls&&(ls=t,rs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function cs(t,e,i){const s=t.canvas,n=s&&ae(s);if(!n)return;const o=ht(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){rs.size||window.addEventListener("resize",hs),rs.set(t,e)}(t,o),a}function ds(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){rs.delete(t),rs.size||window.removeEventListener("resize",hs)}(t)}function us(t,e,i){const s=t.canvas,n=ht((e=>{null!==t.ctx&&i(function(t,e){const i=ts[t.type]||t.type,{x:s,y:n}=ue(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t,(t=>{const e=t[0];return[e,e.offsetX,e.offsetY]}));return function(t,e,i){t.addEventListener(e,i,is)}(s,e,n),n}class fs extends Ji{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t.$chartjs={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",es(n)){const e=be(t,"width");void 0!==e&&(t.width=e)}if(es(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=be(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e.$chartjs)return!1;const s=e.$chartjs.initial;["height","width"].forEach((t=>{const n=s[t];i(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=s.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e.$chartjs,!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:os,detach:as,resize:cs}[e]||us;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ds,detach:ds,resize:ds}[e]||ss)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return ge(t,e,i,s)}isAttached(t){const e=ae(t);return!(!e||!e.isConnected)}}function gs(t){return!oe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?Qi:fs}var ps=Object.freeze({__proto__:null,_detectPlatform:gs,BasePlatform:Ji,BasicPlatform:Qi,DomPlatform:fs});const ms="transparent",bs={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Jt(t||ms),n=s.valid&&Jt(e||ms);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class xs{constructor(t,e,i,s){const n=e[i];s=bi([t.to,s,n,t.from]);const o=bi([t.from,n,s]);this._active=!0,this._fn=t.fn||bs[t.type||typeof o],this._easing=si[t.easing]||si.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=bi([t.to,e,s,t.from]),this._from=bi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),ne.set("animations",{colors:{type:"color",properties:["color","borderColor","backgroundColor"]},numbers:{type:"number",properties:["x","y","borderWidth","radius","tension"]}}),ne.describe("animations",{_fallback:"animation"}),ne.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}});class ys{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!n(t))return;const e=this._properties;Object.getOwnPropertyNames(t).forEach((i=>{const o=t[i];if(!n(o))return;const a={};for(const t of _s)a[t]=o[t];(s(o.properties)&&o.properties||[i]).forEach((t=>{t!==i&&e.has(t)||e.set(t,a)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new xs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(mt.add(this._chart,i),!0):void 0}}function vs(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function ws(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Ds(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Cs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i]}}}const As=t=>"reset"===t||"none"===t,Ts=(t,e)=>e?t:Object.assign({},t);class Ls{constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=ks(t.vScale,t),this.addElements()}updateIndex(t){this.index!==t&&Cs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=r(i.xAxisID,Os(t,"x")),o=e.yAxisID=r(i.yAxisID,Os(t,"y")),a=e.rAxisID=r(i.rAxisID,Os(t,"r")),l=e.indexAxis,h=e.iAxisID=s(l,n,o,a),c=e.vAxisID=s(l,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&at(this._data,this),t._stacked&&Cs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(n(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=o,i._sorted=!0,d=o;else{d=s(o[t])?this.parseArrayData(i,o,t,e):n(o[t])?this.parseObjectData(i,o,t,e):this.parsePrimitiveData(i,o,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:ws(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!o(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,a;for(s=0,n=e.length;s=0&&tthis.getContext(i,s)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ts(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new ys(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||As(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){As(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!As(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}Es.defaults={},Es.defaultRoutes=void 0;const Rs={values:t=>s(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=I(Math.abs(o)),r=Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),li(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=t/Math.pow(10,Math.floor(I(t)));return 1===s||2===s||5===s?Rs.numeric.call(this,t,e,i):""}};var Is={formatters:Rs};function zs(t,e){const s=t.options.ticks,n=s.maxTicksLimit||function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=s.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;in)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(o,e,n);if(a>0){let t,s;const n=a>1?Math.round((l-r)/(a-1)):null;for(Fs(e,h,c,i(n)?0:r-n,r),t=0,s=a-1;te.lineWidth,tickColor:(t,e)=>e.color,offset:!1,borderDash:[],borderDashOffset:0,borderWidth:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Is.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),ne.route("scale.ticks","color","","color"),ne.route("scale.grid","color","","borderColor"),ne.route("scale.grid","borderColor","","borderColor"),ne.route("scale.title","color","","color"),ne.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t}),ne.describe("scales",{_fallback:"scale"}),ne.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t});const Vs=(t,e,i)=>"top"===e||"left"===e?t[e]+i:t[e]-i;function Bs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ws(t){return t.drawTicks?t.tickLength:0}function js(t,e){if(!t.display)return 0;const i=mi(t.font,e),n=pi(t.padding);return(s(t.text)?t.text.length:1)*i.lineHeight+n.height}function Hs(t,e,i){let s=dt(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class $s extends Es{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=a(t,Number.POSITIVE_INFINITY),e=a(e,Number.NEGATIVE_INFINITY),i=a(i,Number.POSITIVE_INFINITY),s=a(s,Number.NEGATIVE_INFINITY),{min:a(t,i),max:a(e,s),minDefined:o(t),maxDefined:o(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const r=this.getMatchingVisibleMetas();for(let a=0,l=r.length;as?s:i,s=n&&i>s?i:s,{min:a(i,a(s,i)),max:a(s,a(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){c(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=xi(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ws(t.grid)-e.padding-js(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=$(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){c(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){c(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=js(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ws(n)+o):(t.height=this.maxHeight,t.width=Ws(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=H(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){c(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,s;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,s=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:a[t]||0,height:r[t]||0});return{first:k(0),last:k(e-1),widest:k(w),highest:k(M),widths:a,heights:r}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return J(this._alignToPixels?ve(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:o,position:a}=s,l=o.offset,h=this.isHorizontal(),c=this.ticks.length+(l?1:0),d=Ws(o),u=[],f=o.setContext(this.getContext()),g=f.drawBorder?f.borderWidth:0,p=g/2,m=function(t){return ve(i,t,g)};let b,x,_,y,v,w,M,k,S,P,D,O;if("top"===a)b=m(this.bottom),w=this.bottom-d,k=b-p,P=m(t.top)+p,O=t.bottom;else if("bottom"===a)b=m(this.top),P=t.top,O=m(t.bottom)-p,w=b+p,k=this.top+d;else if("left"===a)b=m(this.right),v=this.right-d,M=b-p,S=m(t.left)+p,D=t.right;else if("right"===a)b=m(this.left),S=t.left,D=m(t.right)-p,v=b+p,M=this.left+d;else if("x"===e){if("center"===a)b=m((t.top+t.bottom)/2+.5);else if(n(a)){const t=Object.keys(a)[0],e=a[t];b=m(this.chart.scales[t].getPixelForValue(e))}P=t.top,O=t.bottom,w=b+p,k=w+d}else if("y"===e){if("center"===a)b=m((t.left+t.right)/2);else if(n(a)){const t=Object.keys(a)[0],e=a[t];b=m(this.chart.scales[t].getPixelForValue(e))}v=b-p,M=v-d,S=t.left,D=t.right}const C=r(s.ticks.maxTicksLimit,c),A=Math.max(1,Math.ceil(c/C));for(x=0;xe.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:i+1,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ne.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ne.describe(e,t.descriptors)}(t,o,i),this.override&&ne.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ne[s]&&(delete ne[s][i],this.override&&delete te[i])}}var Us=new class{constructor(){this.controllers=new Ys(Ls,"datasets",!0),this.elements=new Ys(Es,"elements"),this.plugins=new Ys(Object,"plugins"),this.scales=new Ys($s,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):d(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);c(i["before"+s],[],i),e[t](i),c(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function qs(t,e){return e||!1!==t?!0===t?{}:t:null}function Ks(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function Gs(t,e){const i=ne.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function Zs(t,e){return"x"===t||"y"===t?t:e.axis||("top"===(i=e.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.charAt(0).toLowerCase();var i}function Js(t){const e=t.options||(t.options={});e.plugins=r(e.plugins,{}),e.scales=function(t,e){const i=te[t.type]||{scales:{}},s=e.scales||{},o=Gs(t.type,e),a=Object.create(null),r=Object.create(null);return Object.keys(s).forEach((t=>{const e=s[t];if(!n(e))return console.error(`Invalid scale configuration for scale: ${t}`);if(e._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${t}`);const l=Zs(t,e),h=function(t,e){return t===e?"_index_":"_value_"}(l,o),c=i.scales||{};a[l]=a[l]||t,r[t]=b(Object.create(null),[{axis:l},e,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||Gs(n,e),l=(te[n]||{}).scales||{};Object.keys(l).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||a[e]||e;r[n]=r[n]||Object.create(null),b(r[n],[{axis:e},s[n],l[t]])}))})),Object.keys(r).forEach((t=>{const e=r[t];b(e,[ne.scales[e.type],ne.scale])})),r}(t,e)}function Qs(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const tn=new Map,en=new Set;function sn(t,e){let i=tn.get(t);return i||(i=e(),tn.set(t,i),en.add(i)),i}const nn=(t,e,i)=>{const s=y(e,i);void 0!==s&&t.add(s)};class on{constructor(t){this._config=function(t){return(t=t||{}).data=Qs(t.data),Js(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=Qs(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),Js(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return sn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return sn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return sn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return sn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>nn(r,t,e)))),e.forEach((t=>nn(r,s,t))),e.forEach((t=>nn(r,te[n]||{},t))),e.forEach((t=>nn(r,ne,t))),e.forEach((t=>nn(r,ee,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),en.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,te[e]||{},ne.datasets[e]||{},{type:e},ne,ee]}resolveNamedOptions(t,e,i,n=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=an(this._resolverCache,t,n);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:n}=Ie(t);for(const o of e){const e=i(o),a=n(o),r=(a||e)&&t[o];if(e&&(k(r)||rn(r))||a&&s(r))return!0}return!1}(a,e)){o.$shared=!1;l=Re(a,i=k(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:o}=an(this._resolverCache,t,i);return n(e)?Re(o,e,void 0,s):o}}function an(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:Ee(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const rn=t=>n(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||k(t[i])),!1);const ln=["top","bottom","left","right","chartArea"];function hn(t,e){return"top"===t||"bottom"===t||-1===ln.indexOf(t)&&"x"===e}function cn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function dn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),c(i&&i.onComplete,[t],e)}function un(t){const e=t.chart,i=e.options.animation;c(i&&i.onProgress,[t],e)}function fn(t){return oe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const gn={},pn=t=>{const e=fn(t);return Object.values(gn).filter((t=>t.canvas===e)).pop()};function mn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class bn{constructor(t,i){const s=this.config=new on(i),n=fn(t),o=pn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||gs(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=e(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new Xs,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=ct((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],gn[this.id]=this,r&&l?(mt.listen(this,"complete",dn),mt.listen(this,"progress",un),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:s,height:n,_aspectRatio:o}=this;return i(t)?e&&o?o:n?s/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():pe(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return we(this.canvas,this.ctx),this}stop(){return mt.stop(this),this}resize(t,e){mt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,pe(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),c(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){d(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=Zs(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),d(n,(e=>{const n=e.options,o=n.id,a=Zs(o,n),l=r(n.type,e.dtype);void 0!==n.position&&hn(n.position,a)===hn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===l)h=i[o];else{h=new(Us.getScale(l))({id:o,type:l,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),d(s,((t,e)=>{t||delete i[e]})),d(i,(t=>{Zi.configure(this,t,t.options),Zi.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(cn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){d(this.scales,(t=>{Zi.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);S(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){mn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;Zi.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],d(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=this.chartArea,o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Pe(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&De(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Se(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Vi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=_i(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);M(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),mt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};d(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){d(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},d(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!u(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=P(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,c(n.onHover,[t,a,this],this),r&&c(n.onClick,[t,a,this],this));const h=!u(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}const xn=()=>d(bn.instances,(t=>t._plugins.invalidate())),_n=!0;function yn(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}Object.defineProperties(bn,{defaults:{enumerable:_n,value:ne},instances:{enumerable:_n,value:gn},overrides:{enumerable:_n,value:te},registry:{enumerable:_n,value:Us},version:{enumerable:_n,value:"3.9.1"},getChart:{enumerable:_n,value:pn},register:{enumerable:_n,value:(...t)=>{Us.add(...t),xn()}},unregister:{enumerable:_n,value:(...t)=>{Us.remove(...t),xn()}}});class vn{constructor(t){this.options=t||{}}init(t){}formats(){return yn()}parse(t,e){return yn()}format(t,e){return yn()}add(t,e,i){return yn()}diff(t,e,i){return yn()}startOf(t,e,i){return yn()}endOf(t,e){return yn()}}vn.override=function(t){Object.assign(vn.prototype,t)};var wn={_date:vn};function Mn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(M(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,n):e[i.axis]=i.parse(t,n),e}function Sn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.baset.controller.options.grouped)),o=s.options.stacked,a=[],r=t=>{const s=t.controller.getParsed(e),n=s&&s[t.vScale.axis];if(i(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(d,e,a)*o,u===a&&(m-=d/2);const t=e.getPixelForDecimal(0),i=e.getPixelForDecimal(1),s=Math.min(t,i),n=Math.max(t,i);m=Math.max(Math.min(m,n),s),c=m+d}if(m===e.getPixelForValue(a)){const t=z(d)*e.getLineWidthForValue(a)/2;m+=t,d-=t}return{size:d,base:m,head:c,center:c+d/2}}_calculateBarIndexPixels(t,e){const s=e.scale,n=this.options,o=n.skipNull,a=r(n.maxBarThickness,1/0);let l,h;if(e.grouped){const s=o?this._getStackCount(t):e.stackCount,r="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,{xScale:i,yScale:s}=e,n=this.getParsed(t),o=i.getLabelForValue(n.x),a=s.getLabelForValue(n.y),r=n._custom;return{label:e.label,value:"("+o+", "+a+(r?", "+r:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d""}}}};class En extends Ls{constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let o,a,r=t=>+i[t];if(n(i[t])){const{key:t="value"}=this._parsing;r=e=>+y(i[e],t)}for(o=t,a=t+e;oG(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>G(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(L,c,u),b=g(D,h,d),x=g(D+L,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=h(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*c,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=li(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s"spacing"!==t,_indexable:t=>"spacing"!==t},En.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i}}=t.legend.options;return e.labels.map(((e,s)=>{const n=t.getDatasetMeta(0).controller.getStyle(s);return{text:e,fillStyle:n.backgroundColor,strokeStyle:n.borderColor,lineWidth:n.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(s),index:s}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}},tooltip:{callbacks:{title:()=>"",label(t){let e=t.label;const i=": "+t.formattedValue;return s(e)?(e=e.slice(),e[0]+=i):e+=i,e}}}}};class Rn extends Ls{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:i,data:s=[],_dataset:n}=e,o=this.chart._animationsDisabled;let{start:a,count:r}=gt(e,s,o);this._drawStart=a,this._drawCount=r,pt(e)&&(a=0,r=s.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!n._decimated,i.points=s;const l=this.resolveDatasetElementOptions(t);this.options.showLine||(l.borderWidth=0),l.segment=this.options.segment,this.updateElement(i,void 0,{animated:!o,options:l},t),this.updateElements(s,a,r,t)}updateElements(t,e,s,n){const o="reset"===n,{iScale:a,vScale:r,_stacked:l,_dataset:h}=this._cachedMeta,{sharedOptions:c,includeOptions:d}=this._getSharedOptions(e,n),u=a.axis,f=r.axis,{spanGaps:g,segment:p}=this.options,m=B(g)?g:Number.POSITIVE_INFINITY,b=this.chart._animationsDisabled||o||"none"===n;let x=e>0&&this.getParsed(e-1);for(let g=e;g0&&Math.abs(s[u]-x[u])>m,p&&(_.parsed=s,_.raw=h.data[g]),d&&(_.options=c||this.resolveDataElementOptions(g,e.active?"active":n)),b||this.updateElement(e,g,_,n),x=s}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}}Rn.id="line",Rn.defaults={datasetElementType:"line",dataElementType:"point",showLine:!0,spanGaps:!1},Rn.overrides={scales:{_index_:{type:"category"},_value_:{type:"linear"}}};class In extends Ls{constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=li(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return Ue.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*D;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?H(this.resolveDataElementOptions(t,e).angle||i):0}}In.id="polarArea",In.defaults={dataElementType:"arc",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:"number",properties:["x","y","startAngle","endAngle","innerRadius","outerRadius"]}},indexAxis:"r",startAngle:0},In.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i}}=t.legend.options;return e.labels.map(((e,s)=>{const n=t.getDatasetMeta(0).controller.getStyle(s);return{text:e,fillStyle:n.backgroundColor,strokeStyle:n.borderColor,lineWidth:n.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(s),index:s}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}},tooltip:{callbacks:{title:()=>"",label:t=>t.chart.data.labels[t.dataIndex]+": "+t.formattedValue}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};class zn extends En{}zn.id="pie",zn.defaults={cutout:0,rotation:0,circumference:360,radius:"100%"};class Fn extends Ls{getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return Ue.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(s[f]-_[f])>b,m&&(p.parsed=s,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=s}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}Vn.id="scatter",Vn.defaults={datasetElementType:!1,dataElementType:"point",showLine:!1,fill:!1},Vn.overrides={interaction:{mode:"point"},plugins:{tooltip:{callbacks:{title:()=>"",label:t=>"("+t.label+", "+t.formattedValue+")"}}},scales:{x:{type:"linear"},y:{type:"linear"}}};var Bn=Object.freeze({__proto__:null,BarController:Tn,BubbleController:Ln,DoughnutController:En,LineController:Rn,PolarAreaController:In,PieController:zn,RadarController:Fn,ScatterController:Vn});function Nn(t,e,i){const{startAngle:s,pixelMargin:n,x:o,y:a,outerRadius:r,innerRadius:l}=e;let h=n/r;t.beginPath(),t.arc(o,a,r,s-h,i+h),l>n?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+L,s-L),t.closePath(),t.clip()}function Wn(t,e,i,s){const n=ui(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function jn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Hn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/D)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Wn(e,u,d,b-m),w=d-x,M=d-_,k=m+x/w,S=b-_/M,P=u+y,O=u+v,C=m+y/P,A=b-v/O;if(t.beginPath(),o){if(t.arc(a,r,d,k,S),_>0){const e=jn(M,S,a,r);t.arc(e.x,e.y,_,S,b+L)}const e=jn(O,b,a,r);if(t.lineTo(e.x,e.y),v>0){const e=jn(O,A,a,r);t.arc(e.x,e.y,v,b+L,A+Math.PI)}if(t.arc(a,r,u,b-v/u,m+y/u,!0),y>0){const e=jn(P,C,a,r);t.arc(e.x,e.y,y,C+Math.PI,m-L)}const i=jn(w,m,a,r);if(t.lineTo(i.x,i.y),x>0){const e=jn(w,k,a,r);t.arc(e.x,e.y,x,m-L,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function $n(t,e,i,s,n,o){const{options:a}=e,{borderWidth:r,borderJoinStyle:l}=a,h="inner"===a.borderAlign;r&&(h?(t.lineWidth=2*r,t.lineJoin=l||"round"):(t.lineWidth=r,t.lineJoin=l||"bevel"),e.fullCircles&&function(t,e,i){const{x:s,y:n,startAngle:o,pixelMargin:a,fullCircles:r}=e,l=Math.max(e.outerRadius-a,0),h=e.innerRadius+a;let c;for(i&&Nn(t,e,o+O),t.beginPath(),t.arc(s,n,h,o+O,o,!0),c=0;c=O||G(n,a,l),g=Q(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius","circumference"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/2,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();let a=0;if(s){a=s/2;const e=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(e)*a,Math.sin(e)*a),this.circumference>=D&&(a=s)}t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor;const r=function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Hn(t,e,i,s,a+O,n);for(let e=0;er&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function Zn(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?Gn:Kn}Yn.id="arc",Yn.defaults={borderAlign:"center",borderColor:"#fff",borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0},Yn.defaultRoutes={backgroundColor:"backgroundColor"};const Jn="function"==typeof Path2D;function Qn(t,e,i,s){Jn&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Un(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=Zn(e);for(const r of n)Un(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class to extends Es{constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;Qe(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Di(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Pi(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?oi:t.tension||"monotone"===t.cubicInterpolationMode?ai:ni}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t&&"fill"!==t};class io extends Es{constructor(t){super(),this.options=void 0,this.parsed=void 0,this.skip=void 0,this.stop=void 0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.options,{x:n,y:o}=this.getProps(["x","y"],i);return Math.pow(t-n,2)+Math.pow(e-o,2){uo(t)}))}var go={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,s)=>{if(!s.enabled)return void fo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===bi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(et(e,o.axis,a).lo,0,i-1)),s=h?Z(et(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(s.threshold||4*n))return void uo(e);let f;switch(i(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),s.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,s);break;case"min-max":f=function(t,e,s,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+s-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const s=o-1;if(!i(c)&&!i(d)){const e=Math.min(c,d),i=Math.max(c,d);e!==u&&e!==s&&b.push({...t[e],x:p}),i!==u&&i!==s&&b.push({...t[i],x:p})}o>0&&s!==u&&b.push(t[s]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${s.algorithm}'`)}e._decimated=f}))},destroy(t){fo(t)}};function po(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=K(n),o=K(o)),{property:t,start:n,end:o}}function mo(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function bo(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function xo(t,e){let i=[],n=!1;return s(t)?(n=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=mo(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new to({points:i,options:{tension:0},_loop:n,_fullLoop:n}):null}function _o(t){return t&&!1!==t.fill}function yo(t,e,i){let s=t[e].fill;const n=[e];let a;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!o(s))return s;if(a=t[s],!a)return!1;if(a.visible)return s;n.push(s),s=a.fill}return!1}function vo(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=r(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(n(s))return!isNaN(s.value)&&s;let a=parseFloat(s);return o(a)&&Math.floor(a)===a?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,a,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function wo(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&Po(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;_o(i)&&Po(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;_o(s)&&"beforeDatasetDraw"===i.drawTime&&Po(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const Lo=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class Eo extends Es{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=c(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=mi(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=Lo(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,n,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const p=i+e/2+n.measureText(t.text).width;o>0&&u+s+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:s},d=Math.max(d,p),u+=s+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=yi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ut(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ut(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ut(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ut(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Pe(t,this),this._draw(),De(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ne.color,l=yi(t.rtl,this.left,this.width),h=mi(o.font),{color:c,padding:d}=o,u=h.size,f=u/2;let g;this.drawTitle(),s.textAlign=l.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:p,boxHeight:m,itemHeight:b}=Lo(o,u),x=this.isHorizontal(),_=this._computeTitleHeight();g=x?{x:ut(n,this.left+d,this.right-i[0]),y:this.top+d+_,line:0}:{x:this.left+d,y:ut(n,this.top+_+d,this.bottom-e[0].height),line:0},vi(this.ctx,t.textDirection);const y=b+d;this.legendItems.forEach(((v,w)=>{s.strokeStyle=v.fontColor||c,s.fillStyle=v.fontColor||c;const M=s.measureText(v.text).width,k=l.textAlign(v.textAlign||(v.textAlign=o.textAlign)),S=p+f+M;let P=g.x,D=g.y;l.setWidth(this.width),x?w>0&&P+S+d>this.right&&(D=g.y+=y,g.line++,P=g.x=ut(n,this.left+d,this.right-i[g.line])):w>0&&D+y>this.bottom&&(P=g.x=P+e[g.line].width+d,g.line++,D=g.y=ut(n,this.top+_+d,this.bottom-e[g.line].height));!function(t,e,i){if(isNaN(p)||p<=0||isNaN(m)||m<0)return;s.save();const n=r(i.lineWidth,1);if(s.fillStyle=r(i.fillStyle,a),s.lineCap=r(i.lineCap,"butt"),s.lineDashOffset=r(i.lineDashOffset,0),s.lineJoin=r(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=r(i.strokeStyle,a),s.setLineDash(r(i.lineDash,[])),o.usePointStyle){const a={radius:m*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},r=l.xPlus(t,p/2);ke(s,a,r,e+f,o.pointStyleWidth&&p)}else{const o=e+Math.max((u-m)/2,0),a=l.leftForLtr(t,p),r=gi(i.borderRadius);s.beginPath(),Object.values(r).some((t=>0!==t))?Le(s,{x:a,y:o,w:p,h:m,radius:r}):s.rect(a,o,p,m),s.fill(),0!==n&&s.stroke()}s.restore()}(l.x(P),D,v),P=ft(k,P+p+f,x?P+S:this.right,t.rtl),function(t,e,i){Ae(s,i.text,t,e+b/2,h,{strikethrough:i.hidden,textAlign:l.textAlign(i.textAlign)})}(l.x(P),D,v),x?g.x+=S+d:g.y+=y})),wi(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=mi(e.font),s=pi(e.padding);if(!e.display)return;const n=yi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ut(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ut(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ut(a,c,c+d);o.textAlign=n.textAlign(dt(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ae(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=mi(t.font),i=pi(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(Q(t,this.left,this.right)&&Q(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const a=t.controller.getStyle(i?0:void 0),r=pi(a.borderWidth);return{text:e[t.index].label,fillStyle:a.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:(r.width+r.height)/4,strokeStyle:a.borderColor,pointStyle:s||a.pointStyle,rotation:a.rotation,textAlign:n||a.textAlign,borderRadius:0,datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class Io extends Es{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const n=s(i.text)?i.text.length:1;this._padding=pi(i.padding);const o=n*mi(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ut(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ut(a,s,e),c=-.5*D):(l=n-t,h=ut(a,e,s),c=.5*D),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=mi(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ae(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:dt(e.align),textBaseline:"middle",translation:[n,o]})}}var zo={id:"title",_element:Io,start(t,e,i){!function(t,e){const i=new Io({ctx:t.ctx,options:e,chart:t});Zi.configure(t,i,e),Zi.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;Zi.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;Zi.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Fo=new WeakMap;var Vo={id:"subtitle",start(t,e,i){const s=new Io({ctx:t.ctx,options:i,chart:t});Zi.configure(t,s,i),Zi.addBox(t,s),Fo.set(t,s)},stop(t){Zi.removeBox(t,Fo.get(t)),Fo.delete(t)},beforeUpdate(t,e,i){const s=Fo.get(t);Zi.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Bo={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function jo(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ho(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=mi(e.bodyFont),h=mi(e.titleFont),c=mi(e.footerFont),u=o.length,f=n.length,g=s.length,p=pi(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,u&&(m+=u*h.lineHeight+(u-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,d(t.title,y),i.font=l.string,d(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,d(s,(t=>{d(t.before,y),d(t.lines,y),d(t.after,y)})),_=0,i.font=c.string,d(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function $o(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Yo(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||$o(t,e,i,s),yAlign:s}}function Uo(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=gi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Xo(t,e,i){const s=pi(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function qo(t){return No([],Wo(t))}function Ko(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}class Go extends Es{constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart||t._chart,this._chart=this.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,i=this.options.setContext(this.getContext()),s=i.enabled&&e.options.animation&&i.animations,n=new ys(this.chart,s);return s._cacheable&&(this._cachedAnimations=Object.freeze(n)),n}getContext(){return this.$context||(this.$context=(t=this.chart.getContext(),e=this,i=this._tooltipItems,_i(t,{tooltip:e,tooltipItems:i,type:"tooltip"})));var t,e,i}getTitle(t,e){const{callbacks:i}=e,s=i.beforeTitle.apply(this,[t]),n=i.title.apply(this,[t]),o=i.afterTitle.apply(this,[t]);let a=[];return a=No(a,Wo(s)),a=No(a,Wo(n)),a=No(a,Wo(o)),a}getBeforeBody(t,e){return qo(e.callbacks.beforeBody.apply(this,[t]))}getBody(t,e){const{callbacks:i}=e,s=[];return d(t,(t=>{const e={before:[],lines:[],after:[]},n=Ko(i,t);No(e.before,Wo(n.beforeLabel.call(this,t))),No(e.lines,n.label.call(this,t)),No(e.after,Wo(n.afterLabel.call(this,t))),s.push(e)})),s}getAfterBody(t,e){return qo(e.callbacks.afterBody.apply(this,[t]))}getFooter(t,e){const{callbacks:i}=e,s=i.beforeFooter.apply(this,[t]),n=i.footer.apply(this,[t]),o=i.afterFooter.apply(this,[t]);let a=[];return a=No(a,Wo(s)),a=No(a,Wo(n)),a=No(a,Wo(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),d(l,(e=>{const i=Ko(t.callbacks,e);s.push(i.labelColor.call(this,e)),n.push(i.labelPointStyle.call(this,e)),o.push(i.labelTextColor.call(this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Bo[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ho(this,i),a=Object.assign({},t,e),r=Yo(this.chart,i,a),l=Uo(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=gi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=yi(i.rtl,this.x,this.width);for(t.x=Xo(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=mi(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=o.multiKeyBackground,Le(t,{x:e,y:p,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),Le(t,{x:i,y:p+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=o.multiKeyBackground,t.fillRect(e,p,h,l),t.strokeRect(e,p,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,p+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=mi(i.bodyFont);let u=c.lineHeight,f=0;const g=yi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+u/2),t.y+=u+n},m=g.textAlign(o);let b,x,_,y,v,w,M;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Xo(this,m,i),e.fillStyle=i.bodyColor,d(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,w=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Bo[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ho(this,t),a=Object.assign({},i,this._size),r=Yo(e,t,a),l=Uo(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=pi(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),vi(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),wi(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!u(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!u(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Bo[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}Go.positioners=Bo;var Zo={id:"tooltip",_element:Go,positioners:Bo,afterInit(t,e,i){i&&(t.tooltip=new Go({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",i))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:{beforeTitle:t,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]},Jo=Object.freeze({__proto__:null,Decimation:go,Filler:To,Legend:Ro,SubTitle:Vo,Title:zo,Tooltip:Zo});function Qo(t,e,i,s){const n=t.indexOf(e);if(-1===n)return((t,e,i,s)=>("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}class ta extends $s{constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(i(t))return null;const s=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&s[e]===t?e:Qo(s,t,r(e,t),this._addedLabels),s.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){const e=this.getLabels();return t>=0&&te.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}}function ea(t,e,{horizontal:i,minRotation:s}){const n=H(s),o=(i?Math.sin(n):Math.cos(n))||.001,a=.75*e*(""+t).length;return Math.min(e/o,a)}ta.id="category",ta.defaults={ticks:{callback:ta.prototype.getLabelForValue}};class ia extends $s{constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(t,e){return i(t)||("number"==typeof t||t instanceof Number)&&!isFinite(+t)?null:+t}handleTickRangeOptions(){const{beginAtZero:t}=this.options,{minDefined:e,maxDefined:i}=this.getUserBounds();let{min:s,max:n}=this;const o=t=>s=e?s:t,a=t=>n=i?n:t;if(t){const t=z(s),e=z(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=1;(n>=Number.MAX_SAFE_INTEGER||s<=Number.MIN_SAFE_INTEGER)&&(e=Math.abs(.05*n)),a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let s=this.getTickLimit();s=Math.max(2,s);const n=function(t,e){const s=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!i(a),x=!i(r),_=!i(h),y=(m-p)/(d+1);let v,w,M,k,S=F((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=F(k*S/g/f)*f),i(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(w=Math.floor(p/S)*S,M=Math.ceil(m/S)*S):(w=p,M=m),b&&x&&o&&W((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,w=a,M=r):_?(w=b?a:w,M=x?r:M,k=h-1,S=(M-w)/k):(k=(M-w)/S,k=N(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(Y(S),Y(w));v=Math.pow(10,i(l)?P:l),w=Math.round(w*v)/v,M=Math.round(M*v)/v;let D=0;for(b&&(u&&w!==a?(s.push({value:a}),w0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=o(t)?Math.max(0,t):null,this.max=o(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t,a=(t,e)=>Math.pow(10,Math.floor(I(t))+e);i===s&&(i<=0?(n(1),o(10)):(n(a(i,-1)),o(a(s,1)))),i<=0&&n(a(s,-1)),s<=0&&o(a(i,1)),this._zero&&this.min!==this._suggestedMin&&i===a(this.min,0)&&n(a(i,-1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=function(t,e){const i=Math.floor(I(e.max)),s=Math.ceil(e.max/Math.pow(10,i)),n=[];let o=a(t.min,Math.pow(10,Math.floor(I(e.min)))),r=Math.floor(I(o)),l=Math.floor(o/Math.pow(10,r)),h=r<0?Math.pow(10,Math.abs(r)):1;do{n.push({value:o,major:na(o)}),++l,10===l&&(l=1,++r,h=r>=0?1:h),o=Math.round(l*Math.pow(10,r)*h)/h}while(rn?{start:e-i,end:e}:{start:e,end:e+i}}function la(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),n=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?D/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function ca(t){return 0===t||180===t?"center":t<180?"left":"right"}function da(t,e,i){return"right"===i?t-=e:"center"===i&&(t-=e/2),t}function ua(t,e,i){return 90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e),t}function fa(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;o{const i=c(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?la(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return K(t*(O/(this._pointLabels.length||1))+H(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(i(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(i(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;o--){const e=n.setContext(t.getPointLabelContext(o)),a=mi(e.font),{x:r,y:l,textAlign:h,left:c,top:d,right:u,bottom:f}=t._pointLabelItems[o],{backdropColor:g}=e;if(!i(g)){const t=gi(e.borderRadius),i=pi(e.backdropPadding);s.fillStyle=g;const n=c-i.left,o=d-i.top,a=u-c+i.width,r=f-d+i.height;Object.values(t).some((t=>0!==t))?(s.beginPath(),Le(s,{x:n,y:o,w:a,h:r,radius:t}),s.fill()):s.fillRect(n,o,a,r)}Ae(s,t._pointLabels[o],r,l+a.lineHeight/2,a,{color:e.color,textAlign:h,textBaseline:"middle"})}}(this,o),n.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);!function(t,e,i,s){const n=t.ctx,o=e.circular,{color:a,lineWidth:r}=e;!o&&!s||!a||!r||i<0||(n.save(),n.strokeStyle=a,n.lineWidth=r,n.setLineDash(e.borderDash),n.lineDashOffset=e.borderDashOffset,n.beginPath(),fa(t,i,o,s),n.closePath(),n.stroke(),n.restore())}(this,n.setContext(this.getContext(e-1)),r,o)}})),s.display){for(t.save(),a=o-1;a>=0;a--){const i=s.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=i;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(i.borderDash),t.lineDashOffset=i.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=mi(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=pi(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ae(t,s.label,0,-n,l,{color:r.color})})),t.restore()}drawTitle(){}}ga.id="radialLinear",ga.defaults={display:!0,animate:!0,position:"chartArea",angleLines:{display:!0,lineWidth:1,borderDash:[],borderDashOffset:0},grid:{circular:!1},startAngle:0,ticks:{showLabelBackdrop:!0,callback:Is.formatters.numeric},pointLabels:{backdropColor:void 0,backdropPadding:2,display:!0,font:{size:10},callback:t=>t,padding:5,centerPointLabels:!1}},ga.defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"},ga.descriptors={angleLines:{_fallback:"grid"}};const pa={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},ma=Object.keys(pa);function ba(t,e){return t-e}function xa(t,e){if(i(e))return null;const s=t._adapter,{parser:n,round:a,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),o(l)||(l="string"==typeof n?s.parse(l,n):s.parse(l)),null===l?null:(a&&(l="week"!==a||!B(r)&&!0!==r?s.startOf(l,a):s.startOf(l,"isoWeek",r)),+l)}function _a(t,e,i,s){const n=ma.length;for(let o=ma.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function va(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class wa extends $s{constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e){const i=t.time||(t.time={}),s=this._adapter=new wn._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:xa(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:a,maxDefined:r}=this.getUserBounds();function l(t){a||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}a&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=o(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=o(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=st(s,n,this.max);return this._unit=e.unit||(i.autoSkip?_a(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=ma.length-1;o>=ma.indexOf(i);o--){const i=ma[o];if(pa[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return ma[i?ma.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=ma.indexOf(t)+1,i=ma.length;e+t.value)))}initOffsets(t){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||_a(n.minUnit,e,i,this._getLabelCapacity(e)),a=r(n.stepSize,1),l="week"===o&&n.isoWeekday,h=B(l)||!0===l,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",l)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;dt-e)).map((t=>+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.time.displayFormats,a=this._unit,r=this._majorUnit,l=a&&o[a],h=r&&o[r],d=i[e],u=r&&h&&d&&d.major,f=this._adapter.format(t,s||(u?h:l)),g=n.ticks.callback;return g?c(g,[f,e,i],this):f}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=et(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=et(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}wa.id="time",wa.defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",major:{enabled:!1}}};class ka extends wa{constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ma(e,this.min),this._tableRange=Ma(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;o response.json()) - .then(data => { - // CPU-Nutzung aktualisieren - if (cpuUsageChart) { - cpuUsageChart.data.labels.push(new Date().toLocaleTimeString()); - cpuUsageChart.data.datasets[0].data.push(data.cpu_percent); - - // Behalte nur die letzten 30 Datenpunkte - if (cpuUsageChart.data.labels.length > 30) { - cpuUsageChart.data.labels.shift(); - cpuUsageChart.data.datasets[0].data.shift(); - } - - cpuUsageChart.update(); - } - - // Speichernutzung aktualisieren - if (memoryUsageChart) { - memoryUsageChart.data.datasets[0].data = [ - data.memory.used, - data.memory.available - ]; - memoryUsageChart.update(); - - // Aktualisiere die Speicherinfo-Texte - document.getElementById('memory-used').textContent = formatBytes(data.memory.used); - document.getElementById('memory-available').textContent = formatBytes(data.memory.available); - document.getElementById('memory-total').textContent = formatBytes(data.memory.total); - } - - // Festplattennutzung aktualisieren - if (diskUsageChart && data.disk_usage) { - const diskLabels = []; - const diskUsed = []; - const diskFree = []; - - for (const disk of data.disk_usage) { - diskLabels.push(disk.mountpoint); - diskUsed.push(disk.used); - diskFree.push(disk.free); - } - - diskUsageChart.data.labels = diskLabels; - diskUsageChart.data.datasets[0].data = diskUsed; - diskUsageChart.data.datasets[1].data = diskFree; - diskUsageChart.update(); - } - }) - .catch(error => console.error('Fehler beim Abrufen der Systemmetriken:', error)); -} - -// Initialisiere CPU-Nutzungsdiagramm -function initCpuUsageChart() { - const ctx = document.getElementById('cpu-usage-chart').getContext('2d'); - - cpuUsageChart = new Chart(ctx, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'CPU-Auslastung (%)', - data: [], - borderColor: 'rgb(75, 192, 192)', - tension: 0.1, - fill: true, - backgroundColor: 'rgba(75, 192, 192, 0.2)' - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - max: 100, - title: { - display: true, - text: 'Auslastung (%)' - } - }, - x: { - title: { - display: true, - text: 'Zeit' - } - } - }, - plugins: { - title: { - display: true, - text: 'CPU-Auslastung', - font: { - size: 16 - } - } - } - } - }); -} - -// Initialisiere Speichernutzungsdiagramm -function initMemoryUsageChart() { - const ctx = document.getElementById('memory-usage-chart').getContext('2d'); - - memoryUsageChart = new Chart(ctx, { - type: 'doughnut', - data: { - labels: ['Verwendet', 'Verfügbar'], - datasets: [{ - data: [0, 0], - backgroundColor: [ - 'rgba(255, 99, 132, 0.7)', - 'rgba(75, 192, 192, 0.7)' - ] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: true, - text: 'Speichernutzung', - font: { - size: 16 - } - } - } - } - }); -} - -// Initialisiere Festplattennutzungsdiagramm -function initDiskUsageChart() { - const ctx = document.getElementById('disk-usage-chart').getContext('2d'); - - diskUsageChart = new Chart(ctx, { - type: 'bar', - data: { - labels: [], - datasets: [ - { - label: 'Belegt', - data: [], - backgroundColor: 'rgba(255, 99, 132, 0.7)' - }, - { - label: 'Frei', - data: [], - backgroundColor: 'rgba(75, 192, 192, 0.7)' - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - x: { - stacked: true, - title: { - display: true, - text: 'Laufwerk' - } - }, - y: { - stacked: true, - title: { - display: true, - text: 'Speicherplatz (Bytes)' - }, - ticks: { - callback: function(value) { - return formatBytes(value); - } - } - } - }, - plugins: { - title: { - display: true, - text: 'Festplattennutzung', - font: { - size: 16 - } - } - } - } - }); -} - -// Aktualisiere Docker-Container-Status -function updateContainerStatus() { - fetch('/api/docker/status') - .then(response => response.json()) - .then(data => { - const containerTable = document.getElementById('container-table'); - if (!containerTable) return; - - // Tabelle leeren - containerTable.innerHTML = ''; - - // Überschriftenzeile - const headerRow = document.createElement('tr'); - ['Name', 'Status', 'CPU', 'Speicher', 'Netzwerk', 'Aktionen'].forEach(header => { - const th = document.createElement('th'); - th.textContent = header; - headerRow.appendChild(th); - }); - containerTable.appendChild(headerRow); - - // Containerdaten - data.containers.forEach(container => { - const row = document.createElement('tr'); - - // Name - const nameCell = document.createElement('td'); - nameCell.textContent = container.name; - row.appendChild(nameCell); - - // Status - const statusCell = document.createElement('td'); - const statusBadge = document.createElement('span'); - statusBadge.textContent = container.status; - statusBadge.className = container.running ? 'status-badge running' : 'status-badge stopped'; - statusCell.appendChild(statusBadge); - row.appendChild(statusCell); - - // CPU - const cpuCell = document.createElement('td'); - cpuCell.textContent = container.cpu_percent ? `${container.cpu_percent.toFixed(2)}%` : 'N/A'; - row.appendChild(cpuCell); - - // Speicher - const memoryCell = document.createElement('td'); - memoryCell.textContent = container.memory_usage ? formatBytes(container.memory_usage) : 'N/A'; - row.appendChild(memoryCell); - - // Netzwerk - const networkCell = document.createElement('td'); - if (container.network_io) { - networkCell.innerHTML = `↓ ${formatBytes(container.network_io.rx_bytes)}
↑ ${formatBytes(container.network_io.tx_bytes)}`; - } else { - networkCell.textContent = 'N/A'; - } - row.appendChild(networkCell); - - // Aktionen - const actionsCell = document.createElement('td'); - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'container-actions'; - - // Restart-Button - const restartBtn = document.createElement('button'); - restartBtn.className = 'btn btn-warning btn-sm'; - restartBtn.innerHTML = ''; - restartBtn.title = 'Container neustarten'; - restartBtn.onclick = () => restartContainer(container.id); - actionsDiv.appendChild(restartBtn); - - // Logs-Button - const logsBtn = document.createElement('button'); - logsBtn.className = 'btn btn-info btn-sm'; - logsBtn.innerHTML = ''; - logsBtn.title = 'Container-Logs anzeigen'; - logsBtn.onclick = () => showContainerLogs(container.id); - actionsDiv.appendChild(logsBtn); - - actionsCell.appendChild(actionsDiv); - row.appendChild(actionsCell); - - containerTable.appendChild(row); - }); - - // Container-Status-Diagramm aktualisieren - updateContainerStatusChart(data.containers); - }) - .catch(error => console.error('Fehler beim Abrufen der Docker-Informationen:', error)); -} - -// Initialisiere Container-Status-Diagramm -function initContainerStatusChart() { - const ctx = document.getElementById('container-status-chart').getContext('2d'); - - containerStatusChart = new Chart(ctx, { - type: 'pie', - data: { - labels: ['Aktiv', 'Inaktiv'], - datasets: [{ - data: [0, 0], - backgroundColor: [ - 'rgba(75, 192, 192, 0.7)', - 'rgba(255, 99, 132, 0.7)' - ] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: true, - text: 'Container-Status', - font: { - size: 16 - } - } - } - } - }); -} - -// Aktualisiere Container-Status-Diagramm -function updateContainerStatusChart(containers) { - if (!containerStatusChart) return; - - const running = containers.filter(c => c.running).length; - const stopped = containers.filter(c => !c.running).length; - - containerStatusChart.data.datasets[0].data = [running, stopped]; - containerStatusChart.update(); -} - -// Container neustarten -function restartContainer(containerId) { - fetch(`/api/docker/restart/${containerId}`, { - method: 'POST' - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showMessage('Container wird neugestartet...', false); - // Nach kurzer Verzögerung aktualisieren - setTimeout(updateContainerStatus, 2000); - } else { - showMessage('Fehler beim Neustarten des Containers: ' + data.message, true); - } - }) - .catch(error => { - showMessage('Fehler beim Neustarten des Containers', true); - console.error('Fehler beim Neustarten des Containers:', error); - }); -} - -// Container-Logs anzeigen -function showContainerLogs(containerId) { - fetch(`/api/docker/logs/${containerId}`) - .then(response => response.json()) - .then(data => { - if (data.logs) { - // Modal erstellen und anzeigen - const modal = document.createElement('div'); - modal.className = 'modal'; - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // Modal schließen, wenn auf X geklickt wird - modal.querySelector('.close').onclick = function() { - document.body.removeChild(modal); - }; - - // Modal schließen, wenn außerhalb geklickt wird - window.onclick = function(event) { - if (event.target === modal) { - document.body.removeChild(modal); - } - }; - - // Modal anzeigen - modal.style.display = 'block'; - } else { - showMessage('Keine Logs verfügbar', true); - } - }) - .catch(error => { - showMessage('Fehler beim Abrufen der Container-Logs', true); - console.error('Fehler beim Abrufen der Container-Logs:', error); - }); -} - -// Zeige Fehlermeldung oder Erfolgsmeldung -function showMessage(message, isError = false) { - const messageEl = document.getElementById('message'); - if (messageEl) { - messageEl.textContent = message; - messageEl.className = isError ? 'message message-error' : 'message message-success'; - messageEl.style.display = 'block'; - - // Verstecke Nachricht nach 5 Sekunden - setTimeout(() => { - messageEl.style.display = 'none'; - }, 5000); - } -} - -// Initialisiere alle Diagramme -function initAllCharts() { - // Überprüfe, ob Chart.js geladen ist - if (typeof Chart !== 'undefined') { - if (document.getElementById('cpu-usage-chart')) { - initCpuUsageChart(); - } - - if (document.getElementById('memory-usage-chart')) { - initMemoryUsageChart(); - } - - if (document.getElementById('disk-usage-chart')) { - initDiskUsageChart(); - } - - if (document.getElementById('container-status-chart')) { - initContainerStatusChart(); - } - - // Initialen Datenabruf starten - updateSystemCharts(); - updateContainerStatus(); - - // Regelmäßige Aktualisierung der Diagramme - setInterval(updateSystemCharts, 5000); // Alle 5 Sekunden aktualisieren - setInterval(updateContainerStatus, 10000); // Alle 10 Sekunden aktualisieren - } else { - console.error('Chart.js konnte nicht geladen werden.'); - } -} - -// Automatischer Start beim Laden der Seite -document.addEventListener('DOMContentLoaded', function() { - initAllCharts(); -}); \ No newline at end of file diff --git a/backend/debug-server/static/js/debug-dashboard.js b/backend/debug-server/static/js/debug-dashboard.js deleted file mode 100644 index c8b914a5..00000000 --- a/backend/debug-server/static/js/debug-dashboard.js +++ /dev/null @@ -1,1234 +0,0 @@ -// Debug-Dashboard JavaScript - -// Globale Variablen für Charts -let cpuChart = null; -let memoryChart = null; -let diskChart = null; -let containerStatusChart = null; - -// Speicher für historische Daten -const cpuData = { - labels: Array(20).fill(''), - datasets: [{ - label: 'CPU-Auslastung (%)', - data: Array(20).fill(0), - borderColor: '#3498db', - backgroundColor: 'rgba(52, 152, 219, 0.2)', - tension: 0.4, - fill: true - }] -}; - -const memoryData = { - labels: ['Verwendet', 'Frei'], - datasets: [{ - data: [0, 100], - backgroundColor: ['#e74c3c', '#2ecc71'], - hoverBackgroundColor: ['#c0392b', '#27ae60'] - }] -}; - -// Gemeinsame Chart-Optionen -const lineChartOptions = { - responsive: true, - maintainAspectRatio: false, - animation: { - duration: 500 - }, - scales: { - y: { - beginAtZero: true, - max: 100, - ticks: { - callback: value => `${value}%` - } - } - }, - plugins: { - legend: { - display: true, - position: 'top' - } - } -}; - -const pieChartOptions = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right' - } - } -}; - -// Helper-Funktionen -function showMessage(message, isError = false) { - const messageEl = document.getElementById('message'); - messageEl.textContent = message; - messageEl.className = isError ? 'message message-error' : 'message message-success'; - messageEl.style.display = 'block'; - - // Verstecke Nachricht nach 5 Sekunden - setTimeout(() => { - messageEl.style.display = 'none'; - }, 5000); -} - -function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} - -function formatUptime(seconds) { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - return `${days}d ${hours}h ${minutes}m`; -} - -// Dashboard-Initialisierung -document.addEventListener('DOMContentLoaded', function() { - // Tab-Wechsel einrichten - setupTabs(); - - // Charts initialisieren - initCharts(); - - // Daten laden - loadSystemMetrics(); - loadDockerStatus(); - refreshNetworkInterfaces(); - refreshActiveConnections(); - loadRouteTable(); - - // Regelmäßige Aktualisierungen - setInterval(loadSystemMetrics, 5000); - setInterval(loadDockerStatus, 10000); - - console.log('Debug-Dashboard initialisiert'); -}); - -// Tab-Funktionalität -function setupTabs() { - document.querySelectorAll('.tab').forEach(tab => { - tab.addEventListener('click', function() { - // Aktiven Tab-Status wechseln - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - this.classList.add('active'); - - // Tab-Inhalte wechseln - const tabId = this.getAttribute('data-tab'); - document.querySelectorAll('.tab-content').forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(tabId + '-tab').classList.add('active'); - }); - }); -} - -// Chart-Initialisierung -function initCharts() { - // CPU-Chart - const cpuCtx = document.getElementById('cpu-usage-chart').getContext('2d'); - cpuChart = new Chart(cpuCtx, { - type: 'line', - data: cpuData, - options: lineChartOptions - }); - - // Memory-Chart - const memoryCtx = document.getElementById('memory-usage-chart').getContext('2d'); - memoryChart = new Chart(memoryCtx, { - type: 'doughnut', - data: memoryData, - options: pieChartOptions - }); - - // Disk-Chart (Wird später initialisiert, wenn Daten verfügbar sind) - - // Container-Status-Chart (Wird später initialisiert, wenn Daten verfügbar sind) -} - -// Daten-Lade-Funktionen -function loadSystemMetrics() { - fetch('/api/system/metrics') - .then(response => response.json()) - .then(data => { - if (data.success) { - updateSystemMetrics(data); - } - }) - .catch(error => { - console.error('Fehler beim Laden der Systemmetriken:', error); - }); -} - -function updateSystemMetrics(data) { - // CPU-Auslastung aktualisieren - document.getElementById('cpu-percent').textContent = `${data.cpu_percent.toFixed(1)}%`; - - // CPU-Chart aktualisieren - cpuData.datasets[0].data.shift(); - cpuData.datasets[0].data.push(data.cpu_percent); - cpuChart.update(); - - // RAM-Auslastung aktualisieren - document.getElementById('memory-percent').textContent = `${data.memory.percent.toFixed(1)}%`; - document.getElementById('memory-used').textContent = formatBytes(data.memory.used); - document.getElementById('memory-available').textContent = formatBytes(data.memory.available); - document.getElementById('memory-total').textContent = formatBytes(data.memory.total); - - // Memory-Chart aktualisieren - memoryData.datasets[0].data = [data.memory.percent, 100 - data.memory.percent]; - memoryChart.update(); - - // Disk-Chart aktualisieren oder initialisieren, wenn noch nicht vorhanden - if (data.disk_usage && data.disk_usage.length > 0) { - updateDiskChart(data.disk_usage); - } -} - -function updateDiskChart(diskData) { - if (!diskChart) { - // Chart initialisieren, wenn noch nicht vorhanden - const labels = diskData.map(disk => disk.mountpoint); - const usedData = diskData.map(disk => disk.used); - const freeData = diskData.map(disk => disk.free); - - const data = { - labels: labels, - datasets: [ - { - label: 'Verwendet', - data: usedData, - backgroundColor: 'rgba(231, 76, 60, 0.7)' - }, - { - label: 'Frei', - data: freeData, - backgroundColor: 'rgba(46, 204, 113, 0.7)' - } - ] - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - scales: { - x: { - stacked: true - }, - y: { - stacked: true, - ticks: { - callback: value => formatBytes(value) - } - } - } - }; - - const diskCtx = document.getElementById('disk-usage-chart').getContext('2d'); - diskChart = new Chart(diskCtx, { - type: 'bar', - data: data, - options: options - }); - } else { - // Chart aktualisieren - diskChart.data.labels = diskData.map(disk => disk.mountpoint); - diskChart.data.datasets[0].data = diskData.map(disk => disk.used); - diskChart.data.datasets[1].data = diskData.map(disk => disk.free); - diskChart.update(); - } -} - -// Docker-Informationen laden -function loadDockerStatus() { - fetch('/api/docker/status') - .then(response => response.json()) - .then(data => { - if (data.success) { - updateDockerStatus(data); - } - }) - .catch(error => { - console.error('Fehler beim Laden des Docker-Status:', error); - }); -} - -function updateDockerStatus(data) { - // Docker-Version und Info aktualisieren - document.getElementById('docker-version').textContent = data.info.version || 'Nicht verfügbar'; - document.getElementById('docker-api-version').textContent = data.info.api_version || 'Nicht verfügbar'; - document.getElementById('docker-os').textContent = data.info.os || 'Nicht verfügbar'; - document.getElementById('docker-status').textContent = data.info.status || 'Nicht verfügbar'; - - // Anzahl aktiver Container aktualisieren - const activeContainers = data.containers.filter(c => c.state === 'running').length; - document.getElementById('active-containers').textContent = activeContainers; - - // Container-Status-Chart aktualisieren oder initialisieren - updateContainerStatusChart(data.containers); - - // Container-Tabelle aktualisieren - updateContainerTable(data.containers); - - // Container-Select für Logs aktualisieren - updateContainerSelect(data.containers); -} - -function updateContainerStatusChart(containers) { - // Zähle Container nach Status - const statusCounts = { - running: 0, - exited: 0, - created: 0, - other: 0 - }; - - containers.forEach(container => { - if (container.state === 'running') { - statusCounts.running++; - } else if (container.state === 'exited') { - statusCounts.exited++; - } else if (container.state === 'created') { - statusCounts.created++; - } else { - statusCounts.other++; - } - }); - - if (!containerStatusChart) { - // Chart initialisieren - const data = { - labels: ['Laufend', 'Beendet', 'Erstellt', 'Andere'], - datasets: [{ - data: [ - statusCounts.running, - statusCounts.exited, - statusCounts.created, - statusCounts.other - ], - backgroundColor: [ - '#2ecc71', // Grün für laufende Container - '#e74c3c', // Rot für beendete Container - '#3498db', // Blau für erstellte Container - '#95a5a6' // Grau für andere Status - ] - }] - }; - - const ctx = document.getElementById('container-status-chart').getContext('2d'); - containerStatusChart = new Chart(ctx, { - type: 'doughnut', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right' - } - } - } - }); - } else { - // Chart aktualisieren - containerStatusChart.data.datasets[0].data = [ - statusCounts.running, - statusCounts.exited, - statusCounts.created, - statusCounts.other - ]; - containerStatusChart.update(); - } -} - -function updateContainerTable(containers) { - const tableBody = document.querySelector('#container-table tbody'); - - if (containers.length === 0) { - tableBody.innerHTML = 'Keine Container gefunden'; - return; - } - - tableBody.innerHTML = ''; - - containers.forEach(container => { - const row = document.createElement('tr'); - row.className = `container-row ${container.state}`; - row.setAttribute('data-id', container.id); - - // Container-Status-Klasse - let statusClass = ''; - if (container.state === 'running') { - statusClass = 'status-good'; - } else if (container.state === 'exited') { - statusClass = 'status-error'; - } else { - statusClass = 'status-warning'; - } - - // Container-Name und Info - row.innerHTML = ` - -
${container.name}
-
${container.image}
- - ${container.state} - ${container.cpu || 'N/A'} - ${container.memory ? formatBytes(container.memory) : 'N/A'} - ${container.ports || 'N/A'} - - - - - - `; - - tableBody.appendChild(row); - }); -} - -function updateContainerSelect(containers) { - const select = document.getElementById('log-container-select'); - - // Alle Einträge außer dem ersten leeren löschen - while (select.options.length > 1) { - select.remove(1); - } - - // Container sortieren (laufende zuerst) - const sortedContainers = [...containers].sort((a, b) => { - if (a.state === 'running' && b.state !== 'running') return -1; - if (a.state !== 'running' && b.state === 'running') return 1; - return a.name.localeCompare(b.name); - }); - - // Container zum Select hinzufügen - sortedContainers.forEach(container => { - const option = document.createElement('option'); - option.value = container.id; - option.textContent = `${container.name} (${container.state})`; - - // Laufende Container hervorheben - if (container.state === 'running') { - option.className = 'container-running'; - } - - select.appendChild(option); - }); -} - -// Container-Aktionen -function inspectContainer(containerId) { - fetch(`/api/docker/inspect/${containerId}`) - .then(response => response.json()) - .then(data => { - if (data.success) { - showContainerInspect(data.data); - } else { - showMessage(`Fehler: ${data.error}`, true); - } - }) - .catch(error => { - showMessage(`Fehler bei der Container-Inspektion: ${error}`, true); - }); -} - -function showContainerInspect(inspectData) { - // Hier können wir ein Modal oder einen anderen Bereich für die Anzeige der Inspektionsdaten implementieren - const detailsHTML = ` -
-

${inspectData.name}

-
${inspectData.id.substring(0, 12)}
-
-
-

Allgemein

- - - - - -
Image${inspectData.image}
Erstellt${new Date(inspectData.created).toLocaleString()}
Status${inspectData.state.Status}
Gestartet${inspectData.state.StartedAt ? new Date(inspectData.state.StartedAt).toLocaleString() : 'Nicht gestartet'}
-
-
-

Netzwerk

-
- ${Object.keys(inspectData.networks).map(net => ` -
-
${net}
-
IP: ${inspectData.networks[net].IPAddress}
-
Gateway: ${inspectData.networks[net].Gateway}
-
- `).join('')} -
-
-
-

Ports

-
- ${inspectData.ports ? Object.keys(inspectData.ports).map(port => ` -
- ${port} → ${inspectData.ports[port] ? inspectData.ports[port].map(p => `${p.HostIp}:${p.HostPort}`).join(', ') : 'Nicht gemappt'} -
- `).join('') : 'Keine Ports'} -
-
- `; - - // Hier könnten wir ein Modal anzeigen oder die Daten in einen bestimmten Bereich einfügen - // Für dieses Beispiel verwenden wir ein einfaches Alert, aber in Produktion sollte ein ordentliches Modal verwendet werden - alert(`Container-Details:\n${inspectData.name}\n${inspectData.image}\nStatus: ${inspectData.state.Status}`); -} - -function restartContainer(containerId) { - if (confirm('Möchten Sie diesen Container wirklich neu starten?')) { - fetch(`/api/docker/restart/${containerId}`, { - method: 'POST' - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showMessage(`Container erfolgreich neu gestartet`, false); - setTimeout(() => loadDockerStatus(), 2000); - } else { - showMessage(`Fehler: ${data.error}`, true); - } - }) - .catch(error => { - showMessage(`Fehler beim Neustart des Containers: ${error}`, true); - }); - } -} - -function viewContainerLogs(containerId) { - // Container im Log-Select auswählen - document.getElementById('log-container-select').value = containerId; - - // Logs laden - loadContainerLogs(); -} - -function loadContainerLogs() { - const containerId = document.getElementById('log-container-select').value; - const filter = document.getElementById('log-filter').value; - - if (!containerId) { - showMessage('Bitte wählen Sie einen Container aus', true); - return; - } - - const logsContainer = document.getElementById('container-logs'); - logsContainer.innerHTML = '
Lade Logs...
'; - - fetch(`/api/docker/logs/${containerId}${filter ? `?filter=${filter}` : ''}`) - .then(response => response.json()) - .then(data => { - if (data.success) { - if (data.logs.length === 0) { - logsContainer.innerHTML = '
Keine Logs gefunden
'; - } else { - const logLines = data.logs.split('\n'); - - // Logs mit Syntax-Highlighting und Fehlerhervorhebung anzeigen - logsContainer.innerHTML = ` -
-
Logs für Container: ${data.container_name}
-
Zeilen: ${logLines.length}
-
-
${formatLogs(logLines)}
- `; - } - } else { - logsContainer.innerHTML = `
Fehler: ${data.error}
`; - } - }) - .catch(error => { - logsContainer.innerHTML = `
Fehler beim Laden der Logs: ${error}
`; - }); -} - -function formatLogs(logLines) { - return logLines.map(line => { - // Zeitstempel hervorheben - line = line.replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z)/g, '$1'); - - // Fehler hervorheben - if (/error|exception|fail|critical/i.test(line)) { - return `
${line}
`; - } - - // Warnungen hervorheben - if (/warning|warn/i.test(line)) { - return `
${line}
`; - } - - // Info-Meldungen hervorheben - if (/info|information/i.test(line)) { - return `
${line}
`; - } - - return `
${line}
`; - }).join(''); -} - -function downloadContainerLogs() { - const containerId = document.getElementById('log-container-select').value; - - if (!containerId) { - showMessage('Bitte wählen Sie einen Container aus', true); - return; - } - - // Direkter Download über einen Link - window.location.href = `/api/docker/logs/download/${containerId}`; -} - -// Netzwerk-Funktionen -function refreshNetworkInterfaces() { - const container = document.getElementById('network-interfaces'); - container.innerHTML = '
Lade Netzwerkschnittstellen...
'; - - fetch('/api/network/interfaces') - .then(response => response.json()) - .then(data => { - if (data.success) { - renderNetworkInterfaces(data.interfaces); - } else { - container.innerHTML = `
Fehler: ${data.error}
`; - } - }) - .catch(error => { - container.innerHTML = `
Fehler beim Laden der Netzwerkschnittstellen: ${error}
`; - }); -} - -function renderNetworkInterfaces(interfaces) { - const container = document.getElementById('network-interfaces'); - - if (interfaces.length === 0) { - container.innerHTML = '
Keine Netzwerkschnittstellen gefunden
'; - return; - } - - let html = ''; - - interfaces.forEach(iface => { - html += ` -
-
- ${iface.name} - ${iface.mac} -
- -
-

IPv4-Adressen

- ${iface.ipv4.length > 0 ? iface.ipv4.map(ip => ` -
-
IP: ${ip.addr}
-
Netzmaske: ${ip.netmask}
-
Broadcast: ${ip.broadcast || 'N/A'}
-
- `).join('') : '
Keine IPv4-Adressen
'} -
- - ${iface.stats !== 'Nicht verfügbar' ? ` -
-

Statistiken

-
-
Gesendet: ${formatBytes(iface.stats.bytes_sent)}
-
Empfangen: ${formatBytes(iface.stats.bytes_recv)}
-
-
-
Pakete gesendet: ${iface.stats.packets_sent}
-
Pakete empfangen: ${iface.stats.packets_recv}
-
-
-
Fehler (Ein): ${iface.stats.errin}
-
Fehler (Aus): ${iface.stats.errout}
-
-
- ` : ''} -
- `; - }); - - container.innerHTML = html; -} - -function refreshActiveConnections() { - const tableBody = document.querySelector('#connections-table tbody'); - tableBody.innerHTML = 'Lade aktive Verbindungen...'; - - fetch('/api/network/active-connections') - .then(response => response.json()) - .then(data => { - if (data.success) { - renderActiveConnections(data.connections); - } else { - tableBody.innerHTML = `Fehler: ${data.error}`; - } - }) - .catch(error => { - tableBody.innerHTML = `Fehler beim Laden der aktiven Verbindungen: ${error}`; - }); -} - -function renderActiveConnections(connections) { - const tableBody = document.querySelector('#connections-table tbody'); - - if (connections.length === 0) { - tableBody.innerHTML = 'Keine aktiven Verbindungen gefunden'; - return; - } - - tableBody.innerHTML = ''; - - connections.forEach(conn => { - const row = document.createElement('tr'); - - row.innerHTML = ` - ${conn.local_address} - ${conn.remote_address} - ${conn.status} - ${conn.process ? `${conn.process.name} (PID: ${conn.pid})` : `PID: ${conn.pid || 'N/A'}`} - `; - - tableBody.appendChild(row); - }); -} - -function loadRouteTable() { - const container = document.getElementById('route-table'); - container.textContent = 'Lade Routing-Tabelle...'; - - fetch('/api/network/route-table') - .then(response => response.json()) - .then(data => { - if (data.success) { - container.textContent = data.route_table; - } else { - container.textContent = `Fehler: ${data.error}`; - } - }) - .catch(error => { - container.textContent = `Fehler beim Laden der Routing-Tabelle: ${error}`; - }); -} - -// Diagnose-Funktionen -function pingHost() { - const host = document.getElementById('ping-host').value; - if (!host) { - showMessage('Bitte geben Sie einen Host ein', true); - return; - } - - document.getElementById('ping-result').innerHTML = '
Ping wird durchgeführt...
'; - document.getElementById('ping-result').className = 'status'; - - fetch(`/ping/${host}`) - .then(response => response.json()) - .then(data => { - let html = `

Ping-Ergebnisse für ${host}

`; - html += `
${data.output}
`; - - document.getElementById('ping-result').innerHTML = html; - document.getElementById('ping-result').className = data.success ? 'status status-good' : 'status status-error'; - }) - .catch(error => { - document.getElementById('ping-result').innerHTML = `
Fehler beim Durchführen des Ping-Tests: ${error}
`; - document.getElementById('ping-result').className = 'status status-error'; - }); -} - -function tracerouteHost() { - const host = document.getElementById('traceroute-host').value; - if (!host) { - showMessage('Bitte geben Sie einen Host ein', true); - return; - } - - document.getElementById('traceroute-result').innerHTML = '
Traceroute wird durchgeführt...
'; - document.getElementById('traceroute-result').className = 'status'; - - fetch(`/traceroute/${host}`) - .then(response => response.json()) - .then(data => { - let html = `

Traceroute-Ergebnisse für ${host}

`; - html += `
${data.output}
`; - - if (data.error) { - html += `
Fehler: ${data.error}
`; - } - - document.getElementById('traceroute-result').innerHTML = html; - document.getElementById('traceroute-result').className = 'status'; - }) - .catch(error => { - document.getElementById('traceroute-result').innerHTML = `
Fehler beim Durchführen des Traceroute: ${error}
`; - document.getElementById('traceroute-result').className = 'status status-error'; - }); -} - -function dnsLookup() { - const host = document.getElementById('dns-host').value; - if (!host) { - showMessage('Bitte geben Sie einen Hostnamen ein', true); - return; - } - - document.getElementById('dns-result').innerHTML = '
DNS-Abfrage wird durchgeführt...
'; - document.getElementById('dns-result').className = 'status'; - - fetch(`/nslookup/${host}`) - .then(response => response.json()) - .then(data => { - let html = `

DNS-Abfrageergebnisse für ${host}

`; - html += `
${data.output}
`; - - if (data.error) { - html += `
Fehler: ${data.error}
`; - } - - document.getElementById('dns-result').innerHTML = html; - document.getElementById('dns-result').className = 'status'; - }) - .catch(error => { - document.getElementById('dns-result').innerHTML = `
Fehler bei der DNS-Abfrage: ${error}
`; - document.getElementById('dns-result').className = 'status status-error'; - }); -} - -function startPortScan() { - const host = document.getElementById('port-scan-host').value; - const portRange = document.getElementById('port-scan-range').value; - - if (!host) { - showMessage('Bitte geben Sie einen Host ein', true); - return; - } - - document.getElementById('port-scan-status').innerHTML = '
Port-Scan wird gestartet...
'; - document.getElementById('port-scan-status').className = 'status'; - - // Tabelle leeren - document.querySelector('#port-scan-table tbody').innerHTML = ''; - - fetch('/api/network/scan-ports', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - host: host, - port_range: portRange - }) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - document.getElementById('port-scan-status').innerHTML = data.message; - document.getElementById('port-scan-status').className = 'status status-good'; - - // Status-Polling starten - pollPortScanStatus(); - } else { - document.getElementById('port-scan-status').innerHTML = `
Fehler: ${data.error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - } - }) - .catch(error => { - document.getElementById('port-scan-status').innerHTML = `
Fehler beim Starten des Port-Scans: ${error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - }); -} - -function checkPortScanStatus() { - fetch('/api/network/scan-status') - .then(response => response.json()) - .then(data => { - if (data.success) { - if (data.status === 'running') { - document.getElementById('port-scan-status').innerHTML = '
Port-Scan läuft...
'; - document.getElementById('port-scan-status').className = 'status status-warning'; - } else if (data.status === 'completed' && data.results) { - renderPortScanResults(data.results); - } else { - document.getElementById('port-scan-status').innerHTML = data.message; - document.getElementById('port-scan-status').className = 'status'; - } - } else { - document.getElementById('port-scan-status').innerHTML = `
Fehler: ${data.error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - } - }) - .catch(error => { - document.getElementById('port-scan-status').innerHTML = `
Fehler beim Abrufen des Port-Scan-Status: ${error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - }); -} - -function pollPortScanStatus() { - const intervalId = setInterval(() => { - fetch('/api/network/scan-status') - .then(response => response.json()) - .then(data => { - if (data.success) { - if (data.status === 'running') { - document.getElementById('port-scan-status').innerHTML = '
Port-Scan läuft...
'; - document.getElementById('port-scan-status').className = 'status status-warning'; - } else { - // Scan ist abgeschlossen oder anderweitig beendet - clearInterval(intervalId); - - if (data.status === 'completed' && data.results) { - renderPortScanResults(data.results); - } else { - document.getElementById('port-scan-status').innerHTML = data.message; - document.getElementById('port-scan-status').className = 'status'; - } - } - } else { - clearInterval(intervalId); - document.getElementById('port-scan-status').innerHTML = `
Fehler: ${data.error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - } - }) - .catch(error => { - clearInterval(intervalId); - document.getElementById('port-scan-status').innerHTML = `
Fehler beim Abrufen des Port-Scan-Status: ${error}
`; - document.getElementById('port-scan-status').className = 'status status-error'; - }); - }, 1000); -} - -function renderPortScanResults(results) { - const tableBody = document.querySelector('#port-scan-table tbody'); - tableBody.innerHTML = ''; - - document.getElementById('port-scan-status').innerHTML = ` -
Scan für ${results.host} abgeschlossen
-
Ports: ${results.start_port}-${results.end_port}
-
Zeit: ${new Date(results.timestamp).toLocaleString()}
-
Offene Ports: ${results.open_ports.length}
- `; - document.getElementById('port-scan-status').className = 'status status-good'; - - if (results.open_ports.length === 0) { - tableBody.innerHTML = 'Keine offenen Ports gefunden'; - return; - } - - results.open_ports.forEach(port => { - const row = document.createElement('tr'); - - row.innerHTML = ` - ${port.port} - ${port.status} - ${port.service} - `; - - tableBody.appendChild(row); - }); -} - -function loadLogs() { - const logType = document.getElementById('log-type').value; - const lines = document.getElementById('log-lines').value; - - const container = document.getElementById('log-content'); - container.innerHTML = '
Lade Logs...
'; - - fetch(`/api/logs/tail/${logType}?lines=${lines}`) - .then(response => response.json()) - .then(data => { - if (data.success) { - if (data.entries.length === 0) { - container.innerHTML = '
Keine Logs gefunden
'; - } else { - container.innerHTML = ` -
-
Logs für: ${logType}
-
Zeilen: ${data.count}
-
-
${formatLogEntries(data.entries)}
- `; - } - } else { - container.innerHTML = `
Fehler: ${data.error}
`; - } - }) - .catch(error => { - container.innerHTML = `
Fehler beim Laden der Logs: ${error}
`; - }); -} - -function formatLogEntries(entries) { - return entries.map(line => { - // Zeitstempel hervorheben - line = line.replace(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(,\d+)?)/g, '$1'); - - // Fehler hervorheben - if (/error|exception|fail|critical/i.test(line)) { - return `
${line}
`; - } - - // Warnungen hervorheben - if (/warning|warn/i.test(line)) { - return `
${line}
`; - } - - // Info-Meldungen hervorheben - if (/info|information/i.test(line)) { - return `
${line}
`; - } - - // Debug-Meldungen hervorheben - if (/debug/i.test(line)) { - return `
${line}
`; - } - - return `
${line}
`; - }).join(''); -} - -function analyzeLogs() { - const logType = document.getElementById('log-type').value; - - const container = document.getElementById('log-content'); - container.innerHTML = '
Analysiere Logs...
'; - - fetch(`/api/logs/analyze?type=${logType}`) - .then(response => response.json()) - .then(data => { - if (data.success) { - if (data.analysis.error_count === 0 && data.analysis.warning_count === 0) { - container.innerHTML = '
Keine Fehler oder Warnungen in den Logs gefunden
'; - } else { - let html = ` -
-
Log-Analyse für: ${logType}
-
Fehler: ${data.analysis.error_count}, Warnungen: ${data.analysis.warning_count}
-
- `; - - if (data.analysis.errors.length > 0) { - html += '

Fehler

'; - html += '
'; - data.analysis.errors.forEach(error => { - html += ` -
-
${error.timestamp}
-
${error.message}
-
- `; - }); - html += '
'; - } - - if (data.analysis.warnings.length > 0) { - html += '

Warnungen

'; - html += '
'; - data.analysis.warnings.forEach(warning => { - html += ` -
-
${warning.timestamp}
-
${warning.message}
-
- `; - }); - html += '
'; - } - - container.innerHTML = html; - } - } else { - container.innerHTML = `
Fehler: ${data.error}
`; - } - }) - .catch(error => { - container.innerHTML = `
Fehler bei der Log-Analyse: ${error}
`; - }); -} - -function checkHealth() { - const banner = document.getElementById('systemHealthBanner'); - banner.style.display = 'flex'; - banner.className = 'system-health-banner checking'; - banner.innerHTML = ` -
-
Systemstatus wird geprüft...
- `; - - fetch('/healthcheck') - .then(response => response.json()) - .then(data => { - let statusClass = ''; - let icon = ''; - - if (data.status === 'healthy') { - statusClass = 'healthy'; - icon = ''; - } else if (data.status === 'warning') { - statusClass = 'warning'; - icon = ''; - } else { - statusClass = 'critical'; - icon = ''; - } - - let statusHTML = ` -
${icon}
-
-
Systemstatus: ${data.status.toUpperCase()}
-
- `; - - // Einzelne Komponenten hinzufügen - Object.keys(data.checks).forEach(component => { - const check = data.checks[component]; - let componentIcon = ''; - - if (check.overall === 'healthy') { - componentIcon = ''; - } else if (check.overall === 'warning') { - componentIcon = ''; - } else { - componentIcon = ''; - } - - statusHTML += `
${componentIcon} ${component}: ${check.status}
`; - }); - - statusHTML += ` -
-
- - `; - - banner.className = `system-health-banner ${statusClass}`; - banner.innerHTML = statusHTML; - - // Banner nach 10 Sekunden ausblenden, wenn es nicht kritisch ist - if (data.status !== 'critical') { - setTimeout(() => { - if (banner.className.includes(statusClass)) { - banner.style.display = 'none'; - } - }, 10000); - } - }) - .catch(error => { - banner.className = 'system-health-banner critical'; - banner.innerHTML = ` -
-
Fehler bei der Systemstatus-Prüfung: ${error}
- - `; - }); -} - -function refreshPage() { - window.location.reload(); -} - -// Konfiguration -function saveConfig() { - const config = { - backend_hostname: document.getElementById('backend_hostname').value, - backend_port: parseInt(document.getElementById('backend_port').value, 10), - frontend_hostname: document.getElementById('frontend_hostname').value, - frontend_port: parseInt(document.getElementById('frontend_port').value, 10) - }; - - fetch('/save-config', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(config) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showMessage('Konfiguration erfolgreich gespeichert', false); - - // Nach kurzer Verzögerung die Seite neu laden - setTimeout(() => { - window.location.reload(); - }, 1500); - } else { - showMessage(`Fehler: ${data.message}`, true); - } - }) - .catch(error => { - showMessage(`Fehler: ${error}`, true); - }); -} - -function testConnection() { - const config = { - backend_hostname: document.getElementById('backend_hostname').value, - backend_port: parseInt(document.getElementById('backend_port').value, 10), - frontend_hostname: document.getElementById('frontend_hostname').value, - frontend_port: parseInt(document.getElementById('frontend_port').value, 10) - }; - - fetch('/test-connection', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(config) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - let message = 'Backend: '; - message += data.results.backend.ping ? 'Ping erfolgreich' : 'Ping fehlgeschlagen'; - message += ', '; - message += data.results.backend.connection ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'; - message += ' | Frontend: '; - message += data.results.frontend.ping ? 'Ping erfolgreich' : 'Ping fehlgeschlagen'; - message += ', '; - message += data.results.frontend.connection ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'; - - showMessage(message, false); - } else { - showMessage(`Fehler: ${data.message}`, true); - } - }) - .catch(error => { - showMessage(`Fehler: ${error}`, true); - }); -} - -function syncFrontend() { - fetch('/sync-frontend', { - method: 'POST' - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showMessage('Frontend erfolgreich synchronisiert', false); - } else { - showMessage(`Fehler: ${data.message}`, true); - } - }) - .catch(error => { - showMessage(`Fehler: ${error}`, true); - }); -} \ No newline at end of file diff --git a/backend/debug-server/static/js/script.js b/backend/debug-server/static/js/script.js deleted file mode 100644 index 52d909a6..00000000 --- a/backend/debug-server/static/js/script.js +++ /dev/null @@ -1,306 +0,0 @@ -// DOM-Element-Referenzen -const navButtons = document.querySelectorAll('.nav-button'); -const panels = document.querySelectorAll('.panel'); - -// Panel-Navigation -navButtons.forEach(button => { - button.addEventListener('click', () => { - const targetPanel = button.dataset.target; - - // Aktiven Button und Panel wechseln - navButtons.forEach(btn => btn.classList.remove('active')); - panels.forEach(panel => panel.classList.remove('active')); - - button.classList.add('active'); - document.getElementById(targetPanel).classList.add('active'); - }); -}); - -// Automatische Aktualisierung der Daten -const updateInterval = 10000; // 10 Sekunden - -// Initialisierung und erste Datenladung -document.addEventListener('DOMContentLoaded', () => { - // Systemdaten laden - loadSystemInfo(); - - // Netzwerkdaten laden - loadNetworkInfo(); - - // Docker-Daten laden - loadDockerInfo(); - - // Backend-Status laden - loadBackendStatus(); - - // Event-Listener für die Netzwerk-Tools - document.getElementById('ping-button').addEventListener('click', performPing); - document.getElementById('traceroute-button').addEventListener('click', performTraceroute); - document.getElementById('nslookup-button').addEventListener('click', performNSLookup); - - // Automatische Aktualisierung einrichten - setInterval(() => { - if (document.getElementById('system-panel').classList.contains('active')) { - loadSystemInfo(); - } else if (document.getElementById('network-panel').classList.contains('active')) { - loadNetworkInfo(); - } else if (document.getElementById('docker-panel').classList.contains('active')) { - loadDockerInfo(); - } else if (document.getElementById('backend-panel').classList.contains('active')) { - loadBackendStatus(); - } - }, updateInterval); -}); - -// API-Anfragen -async function fetchData(endpoint) { - try { - const response = await fetch(endpoint); - if (!response.ok) { - throw new Error(`HTTP-Fehler! Status: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error(`Fehler beim Abrufen von ${endpoint}:`, error); - return null; - } -} - -// System-Informationen laden -async function loadSystemInfo() { - const data = await fetchData('/systeminfo'); - if (!data) return; - - // Betriebssystem-Info aktualisieren - document.getElementById('platform-info').innerHTML = ` -

Plattform: ${data.platform}

-

Python Version: ${data.python_version}

-

Betriebszeit: ${data.uptime}

- `; - - // Hardware-Info aktualisieren - document.getElementById('hardware-info').innerHTML = ` -

Prozessor: ${data.processor}

-

Architektur: ${data.architecture}

- `; - - // Speicher-Info aktualisieren - document.getElementById('memory-info').innerHTML = ` -

Gesamt: ${data.memory.total}

-

Verwendet: ${data.memory.used} (${data.memory.percent}%)

-

Frei: ${data.memory.free}

- `; - document.getElementById('memory-bar').style.width = `${data.memory.percent}%`; - document.getElementById('memory-bar').style.backgroundColor = getColorByPercentage(data.memory.percent); - - // Festplatten-Info aktualisieren - document.getElementById('disk-info').innerHTML = ` -

Gesamt: ${data.disk.total}

-

Verwendet: ${data.disk.used} (${data.disk.percent}%)

-

Frei: ${data.disk.free}

- `; - document.getElementById('disk-bar').style.width = `${data.disk.percent}%`; - document.getElementById('disk-bar').style.backgroundColor = getColorByPercentage(data.disk.percent); -} - -// Netzwerk-Informationen laden -async function loadNetworkInfo() { - const data = await fetchData('/network'); - if (!data) return; - - // Netzwerkschnittstellen aktualisieren - let interfacesHTML = ''; - for (const [name, info] of Object.entries(data.interfaces)) { - interfacesHTML += ` - - - - - - - `; - } - interfacesHTML += '
SchnittstelleIP-AdresseNetzmaskeBroadcast
${name}${info.ip}${info.netmask}${info.broadcast}
'; - document.getElementById('network-interfaces').innerHTML = interfacesHTML; - - // DNS-Server aktualisieren - let dnsHTML = '
    '; - for (const server of data.dns_servers) { - dnsHTML += `
  • ${server}
  • `; - } - dnsHTML += '
'; - document.getElementById('dns-servers').innerHTML = dnsHTML; - - // Gateway aktualisieren - document.getElementById('default-gateway').innerHTML = `

${data.gateway}

`; - - // Aktive Verbindungen aktualisieren - if (data.connections && data.connections.length > 0) { - let connectionsHTML = ''; - for (const conn of data.connections) { - connectionsHTML += ` - - - - - - - `; - } - connectionsHTML += '
Lokale AdresseRemote-AdresseStatusPID
${conn.local_address}${conn.remote_address}${conn.status}${conn.pid}
'; - document.getElementById('active-connections').innerHTML = connectionsHTML; - } else { - document.getElementById('active-connections').innerHTML = '

Keine aktiven Verbindungen gefunden.

'; - } -} - -// Docker-Informationen laden -async function loadDockerInfo() { - const data = await fetchData('/docker'); - if (!data) return; - - // Docker-Status aktualisieren - if (data.installed) { - document.getElementById('docker-status').innerHTML = ` -

Installiert

-

Version: ${data.version}

- `; - - // Container aktualisieren - if (data.containers && data.containers.length > 0) { - let containersHTML = ''; - for (const container of data.containers) { - containersHTML += ` - - - - - - - - `; - } - containersHTML += '
IDNameImageStatusPorts
${container.id}${container.name}${container.image}${container.status}${container.ports}
'; - document.getElementById('docker-containers').innerHTML = containersHTML; - } else { - document.getElementById('docker-containers').innerHTML = '

Keine laufenden Container gefunden.

'; - } - } else { - document.getElementById('docker-status').innerHTML = '

Nicht installiert

'; - document.getElementById('docker-containers').innerHTML = '

Docker ist nicht installiert oder nicht zugänglich.

'; - } -} - -// Backend-Status laden -async function loadBackendStatus() { - const data = await fetchData('/backend-status'); - if (!data) return; - - let statusHTML = ''; - if (data.status === 'online') { - statusHTML = ` -

Status: Online

-

Port: ${data.port}

- `; - } else if (data.status === 'offline') { - statusHTML = ` -

Status: Offline

-

Port: ${data.port}

-

Fehlercode: ${data.error_code}

- `; - } else { - statusHTML = ` -

Status: Fehler

-

Nachricht: ${data.message}

- `; - } - document.getElementById('main-backend-status').innerHTML = statusHTML; -} - -// Netzwerk-Tools -async function performPing() { - const hostInput = document.getElementById('ping-host'); - const resultElement = document.getElementById('ping-result'); - const host = hostInput.value.trim(); - - if (!host) { - resultElement.textContent = 'Bitte geben Sie einen Hostnamen oder eine IP-Adresse ein.'; - return; - } - - resultElement.textContent = 'Ping wird ausgeführt...'; - - const data = await fetchData(`/ping/${encodeURIComponent(host)}`); - if (!data) { - resultElement.textContent = 'Fehler beim Ausführen des Ping-Befehls.'; - return; - } - - if (data.success) { - resultElement.textContent = data.output; - } else { - resultElement.textContent = `Fehler: ${data.error || 'Unbekannter Fehler'}`; - } -} - -async function performTraceroute() { - const hostInput = document.getElementById('traceroute-host'); - const resultElement = document.getElementById('traceroute-result'); - const host = hostInput.value.trim(); - - if (!host) { - resultElement.textContent = 'Bitte geben Sie einen Hostnamen oder eine IP-Adresse ein.'; - return; - } - - resultElement.textContent = 'Traceroute wird ausgeführt...'; - - const data = await fetchData(`/traceroute/${encodeURIComponent(host)}`); - if (!data) { - resultElement.textContent = 'Fehler beim Ausführen des Traceroute-Befehls.'; - return; - } - - if (data.success) { - resultElement.textContent = data.output; - } else { - resultElement.textContent = `Fehler: ${data.error || 'Unbekannter Fehler'}`; - } -} - -async function performNSLookup() { - const hostInput = document.getElementById('nslookup-host'); - const resultElement = document.getElementById('nslookup-result'); - const host = hostInput.value.trim(); - - if (!host) { - resultElement.textContent = 'Bitte geben Sie einen Hostnamen oder eine IP-Adresse ein.'; - return; - } - - resultElement.textContent = 'DNS-Abfrage wird ausgeführt...'; - - const data = await fetchData(`/nslookup/${encodeURIComponent(host)}`); - if (!data) { - resultElement.textContent = 'Fehler beim Ausführen des NSLookup-Befehls.'; - return; - } - - if (data.success) { - resultElement.textContent = data.output; - } else { - resultElement.textContent = `Fehler: ${data.error || 'Unbekannter Fehler'}`; - } -} - -// Hilfsfunktionen -function getColorByPercentage(percent) { - // Farbverlauf von Grün über Gelb nach Rot - if (percent < 70) { - return 'var(--success-color)'; - } else if (percent < 90) { - return 'var(--warning-color)'; - } else { - return 'var(--danger-color)'; - } -} \ No newline at end of file diff --git a/backend/debug-server/templates/dashboard.html b/backend/debug-server/templates/dashboard.html deleted file mode 100644 index d79f177c..00000000 --- a/backend/debug-server/templates/dashboard.html +++ /dev/null @@ -1,444 +0,0 @@ - - - - - - MYP Debug Dashboard - - - - - - - - - -
- - - - - -
-
-
- Systemübersicht -
-
-
-
-
CPU-Auslastung
-
-
-
- -
-
RAM-Auslastung
-
-
-
- -
-
Aktive Container
-
-
-
-
- -
- -
-
-
- -
-
- Speichernutzung -
-
-
- -
- -
-
-
Verwendet
-
-
-
- -
-
Verfügbar
-
-
-
- -
-
Gesamt
-
-
-
-
-
-
-
- -
-

Festplattennutzung

- -
- -
-
- -
-

Netzwerkkonfiguration

- -
-
- Verbindungsstatus -
-
-

Backend

-
- {{ backend_status }} -
- -

Frontend

-
- {{ frontend_status }} -
-
-
- -
-
- Netzwerkkonfiguration -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
-
-
-
- -
-

Docker-Container

- -
-
-
Container-Status
-
-
- -
-
-
- -
-
Docker-Info
-
- - - - - - - - - - - - - - - - - -
Version-
API-Version-
OS-
Status-
-
-
-
- -
-
Container-Liste
-
-
- -
- - - -
-
- - - - - - - - - - - - - - - - - -
NameStatusCPUSpeicherNetzwerkAktionen
Lade Container-Informationen...
-
-
- -
-
Docker-Logs Analyse
-
-
- - -
- -
- - -
- -
- - -
- -
-
Container auswählen, um Logs anzuzeigen
-
-
-
-
- -
-

Netzwerkschnittstellen

- -
-
- Netzwerkschnittstellen - -
-
-
Lade Netzwerkschnittstellen...
-
-
- -
-
- Aktive Verbindungen - -
-
- - - - - - - - - - - - - - -
Lokale AdresseRemote-AdresseStatusProzess
Lade aktive Verbindungen...
-
-
- -
-
- Routing-Tabelle - -
-
-
Lade Routing-Tabelle...
-
-
-
- -
-

Diagnose-Tools

- -
-
Ping-Test
-
Traceroute
-
DNS-Abfrage
-
Port-Scan
-
Log-Analyse
-
- -
-
-
-
- -
- -
- -
-
-
- -
- Führen Sie einen Ping-Test durch, um Ergebnisse zu sehen. -
-
-
-
- -
-
-
-
- -
- -
- -
-
-
- -
- Führen Sie einen Traceroute-Test durch, um Ergebnisse zu sehen. -
-
-
-
- -
-
-
-
- -
- -
- -
-
-
- -
- Führen Sie eine DNS-Abfrage durch, um Ergebnisse zu sehen. -
-
-
-
- -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- Kein Port-Scan aktiv. -
- -
- - - - - - - - - - -
PortStatusDienst
-
-
-
-
- -
-
-
-
- - -
- -
- - -
- -
- - -
- -
-
Wählen Sie einen Log-Typ und klicken Sie auf "Logs laden"
-
-
-
-
-
- - - - - \ No newline at end of file diff --git a/backend/debug-server/templates/debug.html b/backend/debug-server/templates/debug.html deleted file mode 100644 index 416c4474..00000000 --- a/backend/debug-server/templates/debug.html +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - MYP Debug Server - - - -

MYP Debug Server

-

Letzte Aktualisierung: {{ last_check }}

- -
- -
-

Netzwerkkonfiguration

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - - - -
-
- -
-

Verbindungsstatus

- -

Backend

-
- {{ backend_status }} -
- -

Frontend

-
- {{ frontend_status }} -
-
- -
-

Netzwerkschnittstellen

- {% for interface in interfaces %} -
- {{ interface.name }}: {{ interface.address }} -
- {% endfor %} -
- - - - \ No newline at end of file diff --git a/backend/debug-server/templates/index.html b/backend/debug-server/templates/index.html deleted file mode 100644 index c5d9b26b..00000000 --- a/backend/debug-server/templates/index.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - MYP Backend Debug-Server - - - - -
-
-

MYP Backend Debug-Server

-
- Hostname: {{ hostname }} - IP-Adresse: {{ ip_address }} - Timestamp: {{ timestamp }} -
-
-
- - - -
- -
-

Systeminformationen

-
-
-

Betriebssystem

-
Wird geladen...
-
-
-

Hardware

-
Wird geladen...
-
-
-

Speicher

-
Wird geladen...
-
-
-
-
-
-

Festplatte

-
Wird geladen...
-
-
-
-
-
-
- - -
-

Netzwerkinformationen

-
-
-

Netzwerkschnittstellen

-
Wird geladen...
-
-
-

DNS Server

-
Wird geladen...
-
-
-

Standard-Gateway

-
Wird geladen...
-
-
-

Aktive Verbindungen

-
Wird geladen...
-
-
-
- - -
-

Docker-Informationen

-
-
-

Docker-Status

-
Wird geladen...
-
-
-

Container

-
Wird geladen...
-
-
-
- - -
-

Netzwerk-Tools

-
-
-

Ping

-
- - -
-
Geben Sie einen Hostnamen oder eine IP-Adresse ein...
-
- -
-

Traceroute

-
- - -
-
Geben Sie einen Hostnamen oder eine IP-Adresse ein...
-
- -
-

DNS-Lookup

-
- - -
-
Geben Sie einen Hostnamen oder eine IP-Adresse ein...
-
-
-
- - -
-

Backend-Status

-
-
-

Haupt-Backend

-
Wird geladen...
-
-
-
-
- -
-

© 2025 MYP (Manage your Printer) | Debug-Server v1.0.0

-

Netzwerk- und Systemdiagnose-Tool

-
- - - - \ No newline at end of file diff --git a/backend/development/crontab-example b/backend/development/crontab-example deleted file mode 100644 index e133188b..00000000 --- a/backend/development/crontab-example +++ /dev/null @@ -1,8 +0,0 @@ -# MYP Backend Cron-Jobs -# Installiere mit: crontab crontab-example - -# Prüfe alle 5 Minuten auf abgelaufene Reservierungen und schalte Steckdosen aus -*/5 * * * * cd /pfad/zum/projektarbeit-myp/backend && /pfad/zur/venv/bin/flask check-jobs >> /pfad/zum/projektarbeit-myp/backend/logs/cron.log 2>&1 - -# Tägliche Sicherung der Datenbank um 3:00 Uhr -0 3 * * * cd /pfad/zum/projektarbeit-myp/backend && cp instance/myp.db instance/backups/myp-$(date +\%Y\%m\%d).db \ No newline at end of file diff --git a/backend/development/initialize_myp_database.sh b/backend/development/initialize_myp_database.sh deleted file mode 100644 index 51e00fc1..00000000 --- a/backend/development/initialize_myp_database.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# MYP Datenbank Initialisierungs-Skript -# Dieses Skript erstellt die erforderlichen Datenbanktabellen für das MYP Backend - -echo "=== MYP Datenbank Initialisierung ===" -echo "" - -# Prüfe, ob sqlite3 installiert ist -if ! command -v sqlite3 &> /dev/null; then - echo "FEHLER: sqlite3 ist nicht installiert." - echo "Bitte installiere sqlite3 mit deinem Paketmanager, z.B. 'apt install sqlite3'" - exit 1 -fi - -# Erstelle Instance-Ordner, falls nicht vorhanden -echo "Erstelle instance-Ordner, falls nicht vorhanden..." -mkdir -p instance/backups - -# Prüfen, ob die Datenbank bereits existiert -if [ -f "instance/myp.db" ]; then - echo "Datenbank existiert bereits." - echo "Erstelle Backup in instance/backups..." - cp instance/myp.db "instance/backups/myp_$(date '+%Y%m%d_%H%M%S').db" -fi - -# Erstelle die Datenbank und ihre Tabellen -echo "Erstelle neue Datenbank..." -sqlite3 instance/myp.db <7z^t*z){7GsRLvYNBi2zbBtdX)PF%7OhkvNxM+_`RA~=M<@FO5H0d+Wo(s|B(@9o{^?cUbzZQ&+B zdw(D2oO|B6Zx=rAIg&&yME!B55F)@o?kqm%yU<`ECGdA#(+9V1+Sm1Zw(Ezi)>N{Z z5Qn3TkYSsp=TrxgUZU=;5>90BGuV^7it(V*lb^gds(I1Kk z{F6G(6*6KcQQPje#!$OF@dLkr-f_*FSuksG8=^c6j|RpgAk`sG#Ar} zod_GZ#7TA3{Vqxdwlk{AtKYN-ZD?IdgP^e4wS zow)_Hk+>H3;OR_V0deqPEcTb+>Fj#J{%X9ZQ@E({@TU`bqP8>>curqRHpuJ1H<;Iq z3MH=|sNcJ4Ud_`m=dz{&^O{@0)Uy{g6bjZ!*6`gY8Du_K!==-ZLWES3Vj)SgCX9sq zIp!E2St*}tJ9U}jcY5T{5d(MHn4P+M=66!gtX9}5;m+C2&M`P8J!dwvllo{^Vm%6H zZaZ*#k<-rX9D~!V=UmS0L{3N4n9~sjP9JivVs?(f>C(8mg1z*O+6;?sTN~H0gRB2W_fo5LI;0aNq5qIp^ks=qDZm4blcPjA$ zDlRUD8%y$N42PH){tjQnS{jA*&S&FceP#MI13xeKp`V2dR6k3GWj_a*gEE?*({rYg zO`s4L7pcdk!-)LUS{1o=4*G}=(N2()!w=Qtj zJR=LUUqYh8L6xZdoNVEzH5ySY3x7dBo%?Xo$UR9;iTvE5T`_;rI_!#R9{{o!maqbA zDkpQsYqvCH{z`|q2N_&0G!!>#OHTmzBL+8y-Y2Qv>Ij@Q_rU>P&ip#Mc>(6osJW}4o?*DjxE;Bq_JyIUHdz-*>;JRq=c<-Hwc`M2Q zKkEHoyE%eab2QvyG+-XHTIG{XAFW>005``;)^PBcHAWv)ro(*uMLHcm1aY9#;qV{W z2Rq+bxnZn6_%7h`?~6vJ&Px>6rs5_xbz;3#y`$;p>V@iusWSon`vF(g&*8f4pdWBe z(BEzx|L@WoJ^@&DL3E`SUA6TrRKX*KBUZ4fdTi~E&xgJbb;YG21%N-P&;?`fQP zPDXBOw67y4jKL;zTDr8+n;Y4@Z7}(O&o3FD+W2o;Z9Kqe;CxdJ;Cqra%mfXize#L+ zmd4=%69+Bd3D?+4hLiJMiJaiuM9v}TBZs;OftT2Hap%2QN^;^zdlP)pJI1V5=z|Me z?q2&)>x1dlVSg@t$1Nq-`D<&d#p-aFUMCU%dN&~fz{kba({m+{VjNH0R^o`)s3G_w zUI9ko-uw6Xxl5TDFUIUhe!K%1wl+g^G0R^BxG*~=fcrc%-*B<2Z0y5W z?V@ioQL}D#Oqla5!-+MQtG + ``` +2. Überprüfe requirements.txt und installiere alle Abhängigkeiten: + ```bash + pip install -r requirements.txt + ``` + +### Fehler: "Address already in use" + +**Problem**: Flask kann nicht starten, weil Port 5000 bereits verwendet wird. + +**Lösung**: +1. Finde den Prozess, der Port 5000 verwendet: + ```bash + sudo lsof -i:5000 + ``` +2. Beende den Prozess: + ```bash + sudo kill + ``` +3. Falls nötig, ändere den Port in app.py: + ```python + app.run(host="0.0.0.0", port=5001, debug=True) + ``` + (Und passe auch die URL im kiosk.sh an) + +## Chromium Kiosk-Modus Probleme + +### Fehler: "Chromium startet nicht im Kiosk-Modus" + +**Problem**: Der Browser startet nicht automatisch oder nicht im Vollbildmodus. + +**Lösung**: +1. Überprüfe den Status des User-Services: + ```bash + systemctl --user status kiosk.service + ``` +2. Führe kiosk.sh manuell aus, um Fehlermeldungen zu sehen: + ```bash + /home/pi/kiosk.sh + ``` +3. Prüfe, ob die notwendigen Pakete installiert sind: + ```bash + sudo apt install --reinstall chromium-browser unclutter + ``` + +### Fehler: "Chromium zeigt Fehlerdialoge statt der MYP-Oberfläche" + +**Problem**: Der Browser zeigt Crash-Dialoge oder Warnungen an. + +**Lösung**: +1. Lösche die Chromium-Einstellungen und starte neu: + ```bash + rm -rf ~/.config/chromium/ + ``` +2. Füge zusätzliche Parameter zu chromium-browser in kiosk.sh hinzu: + ```bash + chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble \ + --disable-features=DialMediaRouteProvider --window-position=0,0 \ + --app=http://localhost:5000/ & + ``` + +### Fehler: "Chromium öffnet sich, aber zeigt nicht die MYP-Seite" + +**Problem**: Der Browser startet, aber die Anwendung wird nicht angezeigt. + +**Lösung**: +1. Überprüfe, ob der Flask-Dienst läuft: + ```bash + systemctl status myp.service + ``` +2. Teste, ob die Anwendung im Browser erreichbar ist: + ```bash + curl http://localhost:5000/ + ``` +3. Prüfe, ob Chromium mit der richtigen URL startet: + ```bash + # In kiosk.sh + chromium-browser --kiosk --noerrdialogs --disable-infobars \ + --window-position=0,0 --app=http://localhost:5000/ & + ``` + +## Watchdog-Probleme + +### Fehler: "Watchdog-Script funktioniert nicht" + +**Problem**: Der Watchdog-Cronjob scheint nicht zu funktionieren. + +**Lösung**: +1. Überprüfe, ob der Cron-Job eingerichtet ist: + ```bash + crontab -l + ``` +2. Prüfe die Berechtigungen des Watchdog-Scripts: + ```bash + chmod +x /home/pi/watchdog.sh + ``` +3. Führe das Script manuell aus und prüfe auf Fehler: + ```bash + /home/pi/watchdog.sh + ``` +4. Überprüfe die Logdatei: + ```bash + cat /home/pi/myp-watchdog.log + ``` + +### Fehler: "Watchdog kann systemctl nicht ausführen" + +**Problem**: Der Watchdog kann systemctl-Befehle nicht ausführen. + +**Lösung**: +1. Erlaubnis für den pi-Benutzer zum Ausführen von systemctl hinzufügen: + ```bash + echo "pi ALL=NOPASSWD: /bin/systemctl restart myp.service" | sudo tee /etc/sudoers.d/myp-watchdog + ``` + +## Allgemeine Probleme + +### Fehler: "Bildschirm wird nach einiger Zeit schwarz" + +**Problem**: Trotz Konfiguration schaltet sich der Bildschirm nach einiger Zeit aus. + +**Lösung**: +1. Stelle sicher, dass die xset-Befehle in kiosk.sh korrekt ausgeführt werden: + ```bash + xset s off + xset s noblank + xset -dpms + ``` +2. Aktualisiere die Autostart-Datei: + ```bash + sudo nano /etc/xdg/lxsession/LXDE-pi/autostart + ``` + Füge folgende Zeilen hinzu: + ``` + @xset s off + @xset -dpms + @xset s noblank + ``` +3. Verwende ein Tool wie Caffeine: + ```bash + sudo apt install caffeine + ``` + +### Fehler: "System bootet nicht automatisch in den Kiosk-Modus" + +**Problem**: Der automatische Start des Kiosk-Modus funktioniert nicht. + +**Lösung**: +1. Überprüfe, ob der automatische Login aktiviert ist: + ```bash + sudo raspi-config + # 1 System Options → S5 Boot/Auto Login → B4 Desktop Autologin + ``` +2. Stelle sicher, dass der User-Service aktiviert ist: + ```bash + systemctl --user enable kiosk.service + ``` +3. Aktiviere Linger für den pi-Benutzer: + ```bash + sudo loginctl enable-linger pi + ``` +4. Reboote das System und überprüfe den Status der Dienste: + ```bash + sudo reboot + ``` \ No newline at end of file diff --git a/backend/docs/KIOSK-SETUP.md b/backend/docs/KIOSK-SETUP.md new file mode 100644 index 00000000..ff965f17 --- /dev/null +++ b/backend/docs/KIOSK-SETUP.md @@ -0,0 +1,246 @@ +# MYP im Kiosk-Modus + +Diese Anleitung beschreibt, wie MYP (Manage Your Printer) auf einem Raspberry Pi 4 im Kiosk-Modus eingerichtet wird, sodass das System beim Booten automatisch startet. + +## Voraussetzungen + +- Raspberry Pi 4 (oder kompatibel) mit Raspbian/Raspberry Pi OS +- Internetverbindung für die Installation (nach der Installation wird keine Verbindung mehr benötigt) +- Bildschirm, Tastatur und Maus für die Einrichtung + +## Komponenten des Kiosk-Modus + +Die Kiosk-Einrichtung besteht aus mehreren Komponenten: + +1. **Flask-Backend-Dienst**: Systemd-Service zum Starten der MYP-Anwendung +2. **Chromium im Kiosk-Modus**: Browserinstanz, die das Dashboard anzeigt +3. **Watchdog**: Überwacht den Browser und das Backend, startet bei Bedarf neu + +## Automatische Installation + +Für die automatische Installation kann das mitgelieferte Setup-Script verwendet werden: + +```bash +chmod +x setup.sh +./setup.sh +``` + +Dieses Script führt alle notwendigen Schritte aus: +- Installation der benötigten Pakete +- Kopieren der MYP-Anwendung nach `/opt/myp` +- Einrichtung der Python-Umgebung und Installation der Abhängigkeiten +- Konfiguration der Systemd-Dienste +- Einrichtung des Kiosk-Modus +- Einrichtung des Watchdogs + +Nach der Ausführung des Scripts muss noch der automatische Login aktiviert werden: +```bash +sudo raspi-config +# 1 System Options → S5 Boot/Auto Login → B4 Desktop Autologin +``` + +## Manuelle Installation + +Falls eine manuelle Installation bevorzugt wird, können die folgenden Schritte ausgeführt werden: + +### 1. Pakete installieren + +```bash +sudo apt update +sudo apt install -y python3 python3-pip python3-venv chromium-browser \ + unclutter xdotool xscreensaver git +``` + +### 2. MYP nach /opt/myp kopieren + +```bash +sudo mkdir -p /opt/myp +sudo chown $USER:$USER /opt/myp +cp -r ./myp/* /opt/myp +cd /opt/myp +``` + +### 3. Python-Umgebung und Abhängigkeiten einrichten + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 4. Systemd-Dienst für das Flask-Backend + +Datei erstellen: `/etc/systemd/system/myp.service` + +```ini +[Unit] +Description=MYP Flask Backend +After=network-online.target +Wants=network-online.target + +[Service] +User=pi +WorkingDirectory=/opt/myp +ExecStart=/opt/myp/.venv/bin/python /opt/myp/app.py +Restart=always +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +Dienst aktivieren: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now myp.service +``` + +### 5. Kiosk-Script einrichten + +Datei erstellen: `/home/pi/kiosk.sh` + +```bash +#!/usr/bin/env bash + +# Bildschirm-Blanking verhindern +xset s off +xset s noblank +xset -dpms + +# Mauszeiger ausblenden +unclutter -idle 0.5 -root & + +# Chromium-Crash-Dialoge unterdrücken +sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' \ + "$HOME/.config/chromium/Default/Preferences" 2>/dev/null || true +sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' \ + "$HOME/.config/chromium/Default/Preferences" 2>/dev/null || true + +# Browser starten +chromium-browser --kiosk --noerrdialogs --disable-infobars \ + --window-position=0,0 --app=http://localhost:5000/ & +``` + +Ausführbar machen: + +```bash +chmod +x /home/pi/kiosk.sh +``` + +### 6. Systemd-User-Dienst für den Browser + +Verzeichnis erstellen: + +```bash +mkdir -p /home/pi/.config/systemd/user +``` + +Datei erstellen: `/home/pi/.config/systemd/user/kiosk.service` + +```ini +[Unit] +Description=Chromium Kiosk +PartOf=graphical-session.target + +[Service] +Type=forking +ExecStart=/home/pi/kiosk.sh +Restart=on-abort + +[Install] +WantedBy=xsession.target +``` + +Dienst aktivieren: + +```bash +systemctl --user daemon-reload +systemctl --user enable kiosk.service +sudo loginctl enable-linger pi +``` + +### 7. Watchdog einrichten + +Datei erstellen: `/home/pi/watchdog.sh` + +```bash +#!/usr/bin/env bash +# MYP Watchdog für Chromium Browser + +# Funktion zum Loggen von Nachrichten +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> /home/pi/myp-watchdog.log +} + +# Prüfen, ob Chromium läuft +if ! pgrep -x "chromium-browse" > /dev/null; then + log "Chromium nicht gefunden - starte neu" + + # Alle eventuell noch vorhandenen Chromium-Prozesse beenden + pkill -f chromium || true + + # Warten bis alle Prozesse beendet sind + sleep 2 + + # Kiosk-Script neu starten + /home/pi/kiosk.sh + + log "Chromium neugestartet" +fi + +# Prüfen, ob MYP Flask-Dienst läuft +if ! systemctl is-active --quiet myp.service; then + log "MYP Flask-Dienst ist nicht aktiv - starte neu" + + # Dienst neustarten + sudo systemctl restart myp.service + + log "MYP Flask-Dienst neugestartet" +fi + +exit 0 +``` + +Ausführbar machen und Cron-Job einrichten: + +```bash +chmod +x /home/pi/watchdog.sh +(crontab -l 2>/dev/null || echo "") | grep -v "watchdog.sh" | { cat; echo "*/5 * * * * /home/pi/watchdog.sh > /dev/null 2>&1"; } | crontab - +``` + +### 8. Automatischen Desktop-Login einschalten + +```bash +sudo raspi-config +# 1 System Options → S5 Boot/Auto Login → B4 Desktop Autologin +``` + +### 9. Bildschirm nie ausschalten + +```bash +sudo sed -i 's/#BLANK_TIME=.*/BLANK_TIME=0/' /etc/xdg/lxsession/LXDE-pi/autostart +``` + +## Ablauf beim Booten + +1. Der Raspberry Pi startet und fährt bis zum Multi-User-Target hoch +2. `myp.service` wird gestartet und das Flask-Backend sowie der Plug-Scheduler laufen +3. LightDM startet und meldet den Benutzer `pi` automatisch an +4. Nach dem Anmelden wird der User-Scope geladen und `kiosk.service` gestartet +5. `kiosk.sh` startet Chromium im Kiosk-Modus und öffnet die MYP-Oberfläche +6. Der Watchdog-Cron-Job überwacht alle 5 Minuten, ob alles läuft + +## Fehlerbehebung + +- **MYP startet nicht**: `systemctl status myp.service` zeigt den Status des Dienstes +- **Browser startet nicht**: `systemctl --user status kiosk.service` zeigt den Status des Kiosk-Dienstes +- **Watchdog-Logs**: In `/home/pi/myp-watchdog.log` werden Probleme und Neustarts protokolliert + +## Anpassung für andere Benutzer + +Falls ein anderer Benutzer als `pi` verwendet wird, müssen folgende Anpassungen vorgenommen werden: + +1. In `myp.service`: `User=` auf den entsprechenden Benutzer ändern +2. Pfade in `kiosk.sh` und `kiosk.service` anpassen +3. `loginctl enable-linger` für den entsprechenden Benutzer aktivieren \ No newline at end of file diff --git a/backend/docs/PROJEKTDOKUMENTATION.md b/backend/docs/PROJEKTDOKUMENTATION.md deleted file mode 100644 index 822af80e..00000000 --- a/backend/docs/PROJEKTDOKUMENTATION.md +++ /dev/null @@ -1,213 +0,0 @@ -# MYP - Projektdokumentation für das IHK-Abschlussprojekt - -## Projektübersicht - -**Projektname:** MYP (Manage Your Printer) -**Projekttyp:** IHK-Abschlussprojekt für Fachinformatiker für digitale Vernetzung -**Zeitraum:** [Dein Projektzeitraum] -**Team:** 2 Personen (Frontend- und Backend-Entwicklung) - -## Projektziel - -Das Ziel des Projektes ist die Entwicklung einer Reservierungs- und Steuerungsplattform für 3D-Drucker, die es Benutzern ermöglicht, Drucker zu reservieren und deren Stromversorgung automatisch über WLAN-Steckdosen (Tapo P115) zu steuern. Die Plattform soll eine einfache Verwaltung der Drucker und ihrer Auslastung bieten sowie den Stromverbrauch optimieren, indem Drucker nur während aktiver Reservierungen mit Strom versorgt werden. - -## Aufgabenbeschreibung - -Als Fachinformatiker für digitale Vernetzung besteht meine Aufgabe in der Entwicklung des Backend-Systems, das folgende Funktionen bereitstellt: - -1. **API-Backend für das Frontend**: Entwicklung einer RESTful API, die mit dem Frontend kommuniziert und alle notwendigen Daten bereitstellt. - -2. **Authentifizierungssystem**: Integration einer OAuth-Authentifizierung über GitHub, um Benutzer zu identifizieren und Zugriffskontrolle zu gewährleisten. - -3. **Datenbankverwaltung**: Erstellung und Verwaltung der Datenbankmodelle für Benutzer, Drucker und Reservierungen. - -4. **Steckdosensteuerung**: Implementierung einer Schnittstelle zu Tapo P115 WLAN-Steckdosen, um die Stromversorgung der Drucker basierend auf Reservierungen zu steuern. - -5. **Automatisierung**: Entwicklung von Mechanismen zur automatischen Überwachung von Reservierungen und Steuerung der Steckdosen. - -6. **Sicherheit**: Implementierung von Sicherheitsmaßnahmen zum Schutz der Anwendung und der Daten. - -7. **Dokumentation**: Erstellung einer umfassenden Dokumentation für Entwicklung, Installation und Nutzung des Systems. - -## Technische Umsetzung - -### Backend (Mein Verantwortungsbereich) - -#### Verwendete Technologien - -- **Programmiersprache**: Python 3.11 -- **Web-Framework**: Flask 2.3.3 -- **Datenbank-ORM**: SQLAlchemy 3.1.1 -- **Datenbank**: SQLite (für Entwicklung), erweiterbar auf PostgreSQL für Produktion -- **Authentifizierung**: Authlib für GitHub OAuth -- **Steckdosen-Steuerung**: Tapo Python Library -- **Container-Technologie**: Docker und Docker Compose - -#### Architektur - -Die Backend-Anwendung folgt einer klassischen dreischichtigen Architektur: - -1. **Datenmodell-Schicht**: SQLAlchemy ORM-Modelle für Benutzer, Sessions, Drucker und Druckaufträge -2. **Business-Logic-Schicht**: Implementierung der Geschäftslogik für Reservierungsverwaltung und Steckdosensteuerung -3. **API-Schicht**: RESTful API-Endpunkte, die vom Frontend konsumiert werden - -Zusätzlich wurden folgende Features implementiert: - -- **OAuth-Authentifizierung**: Implementierung einer sicheren Authentifizierung über GitHub -- **Session-Management**: Server-seitige Session-Verwaltung für Benutzerauthentifizierung -- **Steckdosensteuerung**: Asynchrone Steuerung der Tapo P115 WLAN-Steckdosen -- **CLI-Befehle**: Flask CLI-Befehle für automatisierte Aufgaben wie die Überprüfung abgelaufener Reservierungen - -#### Datenmodell - -Das Datenmodell besteht aus vier Hauptentitäten: - -1. **User**: Benutzer mit GitHub-Authentifizierung und Rollenverwaltung -2. **Session**: Sitzungsdaten für die Authentifizierung -3. **Printer**: Drucker mit Status und IP-Adresse der zugehörigen Steckdose -4. **PrintJob**: Reservierungen mit Start- und Endzeit, Dauer und Status - -#### API-Endpunkte - -Die API wurde speziell entwickelt, um nahtlos mit dem bestehenden Frontend zusammenzuarbeiten. Sie bietet Endpunkte für: - -- Authentifizierung und Benutzerverwaltung -- Druckerverwaltung -- Reservierungsverwaltung (Erstellen, Abbrechen, Verlängern) -- Statusinformationen wie verbleibende Zeit - -#### Steckdosensteuerung - -Die Steuerung der Tapo P115 WLAN-Steckdosen erfolgt über die Tapo Python Library. Das System: - -- Schaltet Steckdosen bei Erstellung einer Reservierung ein -- Schaltet Steckdosen bei Abbruch oder Beendigung einer Reservierung aus -- Überprüft regelmäßig abgelaufene Reservierungen und schaltet die entsprechenden Steckdosen aus - -#### Automatisierung - -Das System implementiert mehrere Automatisierungsmechanismen: - -- **Automatische Steckdosensteuerung**: Ein- und Ausschalten der Steckdosen basierend auf Reservierungsstatus -- **Job-Überprüfung**: CLI-Befehl `flask check-jobs` zur regelmäßigen Überprüfung abgelaufener Reservierungen -- **Logging**: Automatische Protokollierung aller Aktionen zur Fehlerdiagnose - -### Frontend (Verantwortungsbereich des Teampartners) - -Das Frontend wurde von meinem Teampartner entwickelt und besteht aus: - -- Next.js-Anwendung mit React-Komponenten -- Tailwind CSS für das Styling -- Serverless Functions für API-Integrationen -- Responsive Design für Desktop- und Mobile-Nutzung - -## Projektergebnisse - -Das Projekt hat erfolgreich eine funktionsfähige Reservierungs- und Steuerungsplattform für 3D-Drucker geschaffen, die es Benutzern ermöglicht: - -1. Sich über GitHub zu authentifizieren -2. Verfügbare Drucker zu sehen und zu reservieren -3. Ihre Reservierungen zu verwalten (verlängern, abbrechen, kommentieren) -4. Als Administrator Drucker und Benutzer zu verwalten - -Technische Errungenschaften: - -1. Nahtlose Integration mit dem Frontend -2. Erfolgreiche Implementierung der Steckdosensteuerung -3. Sichere Authentifizierung über GitHub OAuth -4. Optimierte Stromnutzung durch automatische Steckdosensteuerung - -## Herausforderungen und Lösungen - -### Herausforderung 1: GitHub OAuth-Integration - -Die Integration der GitHub-Authentifizierung, insbesondere mit GitHub Enterprise, erforderte eine sorgfältige Konfiguration der OAuth-Einstellungen und URL-Anpassungen. - -**Lösung:** Implementierung mit Authlib und anpassbaren Konfigurationsoptionen für verschiedene GitHub-Instanzen. - -### Herausforderung 2: Tapo P115 Steuerung - -Die Kommunikation mit den Tapo P115 WLAN-Steckdosen erforderte eine zuverlässige und asynchrone Implementierung. - -**Lösung:** Verwendung der Tapo Python Library mit asynchronem Handling und robusten Fehlerbehandlungsmechanismen. - -### Herausforderung 3: Kompatibilität mit bestehendem Frontend - -Das Backend musste mit dem bereits entwickelten Frontend kompatibel sein, was eine genaue Anpassung der API-Endpunkte und Datenstrukturen erforderte. - -**Lösung:** Sorgfältige Analyse des Frontend-Codes, um die erwarteten API-Strukturen zu verstehen und das Backend entsprechend zu implementieren. - -### Herausforderung 4: Automatische Steckdosensteuerung - -Die zuverlässige Steuerung der Steckdosen bei abgelaufenen Reservierungen war eine Herausforderung. - -**Lösung:** Implementierung eines CLI-Befehls, der regelmäßig durch Cron-Jobs ausgeführt werden kann, um abgelaufene Reservierungen zu überprüfen. - -## Fachliche Reflexion - -Das Projekt erforderte ein breites Spektrum an Fähigkeiten aus dem Bereich der digitalen Vernetzung: - -1. **Netzwerkkommunikation**: Implementierung der Kommunikation zwischen Backend, Frontend und WLAN-Steckdosen über verschiedene Protokolle. - -2. **Systemintegration**: Integration verschiedener Systeme (GitHub OAuth, Datenbank, Tapo-Steckdosen) zu einer kohärenten Anwendung. - -3. **API-Design**: Entwicklung einer RESTful API, die den Anforderungen des Frontends entspricht und zukunftssicher ist. - -4. **Datenbankentwurf**: Erstellung eines optimierten Datenbankschemas für die Anwendung. - -5. **Sicherheitskonzepte**: Implementierung von Sicherheitsmaßnahmen wie OAuth, Session-Management und Zugriffskontrollen. - -6. **Automatisierung**: Entwicklung von Automatisierungsprozessen für die Steckdosensteuerung und Job-Überwachung. - -Diese Aspekte entsprechen direkt den Kernkompetenzen des Berufsbildes "Fachinformatiker für digitale Vernetzung" und zeigen die praktische Anwendung dieser Fähigkeiten in einem realen Projekt. - -## Ausblick und Weiterentwicklung - -Das System bietet verschiedene Möglichkeiten zur Weiterentwicklung: - -1. **Erweiterung der Steckdosenunterstützung**: Integration weiterer Smart-Home-Geräte neben Tapo P115. - -2. **Benachrichtigungssystem**: Implementierung von E-Mail- oder Push-Benachrichtigungen für Reservierungserinnerungen. - -3. **Erweiterte Statistiken**: Detailliertere Nutzungsstatistiken und Visualisierungen für Administratoren. - -4. **Mobile App**: Entwicklung einer nativen mobilen App für iOS und Android. - -5. **Verbesserte Automatisierung**: Integration mit weiteren Systemen wie 3D-Drucker-APIs für direktes Monitoring des Druckstatus. - -## Fazit - -Das MYP-Projekt zeigt erfolgreich, wie moderne Webtechnologien und IoT-Geräte kombiniert werden können, um eine praktische Lösung für die Verwaltung von 3D-Druckern zu schaffen. - -Als angehender Fachinformatiker für digitale Vernetzung konnte ich meine Fähigkeiten in den Bereichen Programmierung, Systemintegration, Netzwerkkommunikation und Automatisierung anwenden und erweitern. - -Die Zusammenarbeit im Team mit klarer Aufgabenteilung (Frontend/Backend) hat zu einem erfolgreichen Projektergebnis geführt, das die gestellten Anforderungen erfüllt und einen praktischen Nutzen bietet. - ---- - -## Anhang - -### Installation und Einrichtung - -Detaillierte Anweisungen zur Installation und Einrichtung des Backend-Systems finden sich in der README.md-Datei. - -### Wichtige Konfigurationsparameter - -Die folgenden Umgebungsvariablen müssen konfiguriert werden: - -- `SECRET_KEY`: Geheimer Schlüssel für die Session-Verschlüsselung -- `DATABASE_URL`: URL zur Datenbank -- `OAUTH_CLIENT_ID`: GitHub OAuth Client ID -- `OAUTH_CLIENT_SECRET`: GitHub OAuth Client Secret -- `GITHUB_API_BASE_URL`, `GITHUB_AUTHORIZE_URL`, `GITHUB_TOKEN_URL`: URLs für GitHub OAuth -- `TAPO_USERNAME`: Benutzername für die Tapo-Steckdosen -- `TAPO_PASSWORD`: Passwort für die Tapo-Steckdosen -- `TAPO_DEVICES`: JSON-Objekt mit der Zuordnung von Drucker-IDs zu IP-Adressen - -### Cron-Job-Einrichtung - -Für die automatische Überprüfung abgelaufener Jobs kann folgender Cron-Job eingerichtet werden: - -``` -*/5 * * * * cd /pfad/zum/projekt && /pfad/zur/venv/bin/flask check-jobs >> /pfad/zum/projekt/logs/cron.log 2>&1 -``` \ No newline at end of file diff --git a/backend/docs/README.md b/backend/docs/README.md index e2a97714..5fa067ad 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -1,189 +1,118 @@ -# MYP Backend-Steuerungsplattform +# MYP - Manage Your Printer -Dies ist das Backend für das MYP (Manage Your Printer) Projekt, ein IHK-Abschlussprojekt für Fachinformatiker für digitale Vernetzung. Die Plattform ist mit Python und Flask implementiert und stellt eine RESTful API zur Verfügung, die es ermöglicht, 3D-Drucker zu verwalten, zu reservieren und über WLAN-Steckdosen (Tapo P115) zu steuern. +Ein System zur Verwaltung und Steuerung von 3D-Druckern über TP-Link Tapo P110 Smart Plugs. -## Funktionen +## Funktionsumfang -- Lokales Authentifizierungssystem (Offline-fähig) -- Rollen-basierte Zugriffskontrolle (Admin/User/Guest) -- Druckerverwaltung (Hinzufügen, Bearbeiten, Löschen) -- Reservierungsverwaltung (Erstellen, Abbrechen, Verlängern) -- Fernsteuerung von WLAN-Steckdosen (Tapo P115) -- Statistikerfassung und -anzeige -- RESTful API für die Kommunikation mit dem Frontend +- Benutzer- und Rechteverwaltung (Admin/User) +- Verwaltung von Druckern und Smart Plugs +- Reservierungssystem für Drucker (Zeitplanung) +- Automatisches Ein-/Ausschalten der Drucker über Smart Plugs +- Statistikerfassung für Druckaufträge +- **NEU**: Kiosk-Modus für automatischen Start auf Raspberry Pi -## Technologie-Stack +## Systemvoraussetzungen -- **Python**: Programmiersprache -- **Flask**: Web-Framework -- **SQLite**: Integrierte Datenbank (kann für Produktion durch PostgreSQL ersetzt werden) -- **PyP100**: Python-Bibliothek zur Steuerung der Tapo P115 WLAN-Steckdosen -- **Gunicorn**: WSGI HTTP Server für die Produktionsumgebung -- **Docker**: Containerisierung der Anwendung +- Raspberry Pi 4 (oder kompatibel) +- Python 3.11+ +- Internetzugang für die Installation (danach offline nutzbar) +- TP-Link Tapo P110 Smart Plugs im lokalen Netzwerk -## Projekt-Struktur +## Installation -- `app.py`: Hauptanwendungsdatei mit allen Routen und Modellen -- `requirements.txt`: Liste aller Python-Abhängigkeiten -- `Dockerfile`: Docker-Konfiguration -- `docker-compose.yml`: Docker Compose Konfiguration für einfaches Deployment -- `.env.example`: Beispiel für die Umgebungsvariablen -- `logs/`: Logdateien (automatisch erstellt) -- `instance/`: SQLite-Datenbank (automatisch erstellt) +### Standardinstallation -## Installation und Ausführung +1. Repository klonen oder Dateien auf den Raspberry Pi kopieren -### Lokal (Entwicklung) +2. Abhängigkeiten installieren: +```bash +pip install -r requirements.txt +``` -1. Python 3.8 oder höher installieren -2. Repository klonen -3. Ins Projektverzeichnis wechseln -4. Virtuelle Umgebung erstellen (optional, aber empfohlen) - ``` - python -m venv venv - source venv/bin/activate # Unter Windows: venv\Scripts\activate - ``` -5. Abhängigkeiten installieren - ``` - pip install -r requirements.txt - ``` -6. `.env.example` nach `.env` kopieren und anpassen - ``` - cp .env.example .env - ``` -7. Anwendung starten - ``` - python app.py - ``` +3. Anwendung starten: +```bash +python app.py +``` -Die Anwendung ist dann unter http://localhost:5000 erreichbar. +Die Anwendung läuft dann unter http://localhost:5000 oder unter der IP-Adresse des Raspberry Pi. -### Mit Docker +### Kiosk-Modus Installation -1. Docker und Docker Compose installieren -2. Ins Projektverzeichnis wechseln -3. `.env.example` nach `.env` kopieren und anpassen - ``` - cp .env.example .env - ``` -4. Anwendung starten - ``` - docker-compose up -d - ``` +Für den vollautomatischen Start im Kiosk-Modus (z.B. für IHK-Prüfungen): -Die Anwendung ist dann unter http://localhost:5000 erreichbar. +```bash +chmod +x setup.sh +./setup.sh +``` -## API-Endpunkte +Detaillierte Anweisungen finden sich in der [Kiosk-Setup Anleitung](KIOSK-SETUP.md). + +## Erster Start + +Beim ersten Start wird eine leere Datenbank angelegt. Es muss ein erster Administrator angelegt werden: + +``` +POST /api/create-initial-admin +``` + +Mit folgendem JSON-Body: +```json +{ + "email": "admin@example.com", + "password": "sicheres-passwort", + "name": "Administrator" +} +``` + +## API-Dokumentation + +Die Anwendung stellt folgende REST-API-Endpunkte bereit: ### Authentifizierung -- `POST /auth/register`: Neuen Benutzer registrieren -- `POST /auth/login`: Benutzer anmelden -- `POST /auth/logout`: Abmelden und Session beenden -- `POST /api/create-initial-admin`: Initialen Administrator erstellen -- `GET /api/me`: Aktuelle Benutzerinformationen abrufen - -### Benutzer - -- `GET /api/users`: Liste aller Benutzer (Admin) -- `GET /api/users/`: Details zu einem Benutzer (Admin) -- `PUT /api/users/`: Benutzer aktualisieren (Admin) -- `DELETE /api/users/`: Benutzer löschen (Admin) +- `POST /auth/register` - Neuen Benutzer registrieren +- `POST /auth/login` - Anmelden (erstellt eine Session für 7 Tage) ### Drucker -- `GET /api/printers`: Liste aller Drucker -- `POST /api/printers`: Drucker hinzufügen (Admin) -- `GET /api/printers/`: Details zu einem Drucker -- `PUT /api/printers/`: Drucker aktualisieren (Admin) -- `DELETE /api/printers/`: Drucker löschen (Admin) +- `GET /api/printers` - Alle Drucker auflisten +- `GET /api/printers/` - Einzelnen Drucker abrufen +- `POST /api/printers` - Neuen Drucker anlegen +- `DELETE /api/printers/` - Drucker löschen -### Druckaufträge +### Jobs/Reservierungen -- `GET /api/jobs`: Liste aller Druckaufträge (Admin) oder eigener Druckaufträge (Benutzer) -- `POST /api/jobs`: Druckauftrag erstellen -- `GET /api/jobs/`: Details zu einem Druckauftrag -- `POST /api/jobs//abort`: Druckauftrag abbrechen -- `POST /api/jobs//finish`: Druckauftrag vorzeitig beenden -- `POST /api/jobs//extend`: Druckauftrag verlängern -- `PUT /api/jobs//comments`: Kommentare aktualisieren -- `GET /api/job//remaining-time`: Verbleibende Zeit für einen aktiven Druckauftrag +- `GET /api/jobs` - Alle Reservierungen abrufen +- `POST /api/jobs` - Neue Reservierung anlegen +- `GET /api/jobs/` - Reservierungsdetails abrufen +- `POST /api/jobs//finish` - Job beenden (Plug ausschalten) +- `POST /api/jobs//abort` - Job abbrechen (Plug ausschalten) +- `POST /api/jobs//extend` - Endzeit verschieben +- `GET /api/jobs//status` - Aktuellen Plug-Status abrufen +- `GET /api/jobs//remaining-time` - Restzeit in Sekunden abrufen +- `DELETE /api/jobs/` - Job löschen -### Statistiken +### Benutzer -- `GET /api/stats`: Statistiken zu Druckern, Aufträgen und Benutzern (Admin) +- `GET /api/users` - Alle Benutzer auflisten (nur Admin) +- `GET /api/users/` - Einzelnen Benutzer abrufen +- `DELETE /api/users/` - Benutzer löschen (nur Admin) -## Datenmodell +### Sonstiges -### Benutzer (User) +- `GET /api/stats` - Globale Statistik (Druckzeit, etc.) +- `GET /api/test` - Health-Check -- id (String UUID, Primary Key) -- username (String, Unique) -- password_hash (String) -- display_name (String) -- email (String, Unique) -- role (String, 'admin', 'user' oder 'guest') +## Sicherheitshinweise -### Session +- Diese Anwendung speichert alle Zugangsdaten direkt im Code und in der Datenbank. +- Die Anwendung sollte ausschließlich in einem geschützten, lokalen Netzwerk betrieben werden. +- Es wird keine Verschlüsselung für die API-Kommunikation verwendet. -- id (String UUID, Primary Key) -- user_id (String UUID, Foreign Key zu User) -- expires_at (DateTime) +## Wartung und Troubleshooting -### Drucker (Printer) +- Die Logdatei `myp.log` enthält alle wichtigen Ereignisse und Fehler +- Die Datenbank wird in `database/myp.db` gespeichert +- Häufig auftretende Probleme und Lösungen finden sich in [COMMON_ERRORS.md](COMMON_ERRORS.md) +- Zukünftige Entwicklungspläne sind in [ROADMAP.md](ROADMAP.md) dokumentiert -- id (String UUID, Primary Key) -- name (String) -- description (Text) -- status (Integer, 0=available, 1=busy, 2=maintenance) -- ip_address (String, IP-Adresse der Tapo-Steckdose) - -### Druckauftrag (PrintJob) - -- id (String UUID, Primary Key) -- printer_id (String UUID, Foreign Key zu Printer) -- user_id (String UUID, Foreign Key zu User) -- start_at (DateTime) -- duration_in_minutes (Integer) -- comments (Text) -- aborted (Boolean) -- abort_reason (Text) - -## Steckdosensteuerung - -Die Anwendung steuert Tapo P115 WLAN-Steckdosen, um die Drucker basierend auf Reservierungen ein- und auszuschalten: - -- Bei Erstellung eines Druckauftrags wird die Steckdose des zugehörigen Druckers automatisch eingeschaltet -- Bei Abbruch oder vorzeitiger Beendigung eines Druckauftrags wird die Steckdose ausgeschaltet -- Nach Ablauf der Reservierungszeit wird die Steckdose automatisch ausgeschaltet -- Ein CLI-Befehl `flask check-jobs` überprüft regelmäßig abgelaufene Jobs und schaltet Steckdosen aus - -## Sicherheit - -- Die Anwendung verwendet ein lokales Authentifizierungssystem mit Passwort-Hashing -- Sitzungsdaten werden in Server-Side-Sessions gespeichert -- Zugriffskontrollen sind implementiert, um sicherzustellen, dass Benutzer nur auf ihre eigenen Daten zugreifen können -- Admin-Benutzer haben Zugriff auf alle Daten und können Systemkonfigurationen ändern -- Der erste registrierte Benutzer wird automatisch zum Administrator - -## Logging - -Die Anwendung protokolliert Aktivitäten in rotierenden Logdateien in einem `logs` Verzeichnis. Dies hilft bei der Fehlersuche und Überwachung der Anwendung im Betrieb. - -## Umgebungsvariablen - -Die folgenden Umgebungsvariablen müssen konfiguriert werden: - -- `SECRET_KEY`: Geheimer Schlüssel für die Session-Verschlüsselung -- `DATABASE_PATH`: Pfad zur Datenbank (Standard: SQLite-Datenbank im Instance-Verzeichnis) -- `TAPO_USERNAME`: Benutzername für die Tapo-Steckdosen -- `TAPO_PASSWORD`: Passwort für die Tapo-Steckdosen -- `PRINTERS`: JSON-Objekt mit der Zuordnung von Drucker-Namen zu IP-Adressen der Steckdosen im Format: `{"Printer 1": {"ip": "192.168.1.100"}, "Printer 2": {"ip": "192.168.1.101"}, ...}` - -## Automatisierung - -Die Anwendung beinhaltet einen CLI-Befehl `flask check-jobs`, der regelmäßig ausgeführt werden sollte (z.B. als Cron-Job), um abgelaufene Druckaufträge zu überprüfen und die zugehörigen Steckdosen auszuschalten. - -## Kompatibilität mit dem Frontend - -Das Backend wurde speziell für die Kompatibilität mit dem bestehenden Frontend entwickelt, welches in `/frontend` zu finden ist. Die API-Endpunkte und Datenstrukturen sind so gestaltet, dass sie nahtlos mit dem Frontend zusammenarbeiten. diff --git a/backend/docs/ROADMAP.md b/backend/docs/ROADMAP.md new file mode 100644 index 00000000..0c003275 --- /dev/null +++ b/backend/docs/ROADMAP.md @@ -0,0 +1,62 @@ +# MYP Entwicklungs-Roadmap + +Dieses Dokument skizziert die geplanten Entwicklungsschritte für MYP (Manage Your Printer). + +## Version 1.0 (aktuell) + +- [x] Benutzer- und Rechteverwaltung (Admin/User) +- [x] Verwaltung von Druckern und Smart Plugs +- [x] Reservierungssystem für Drucker (Zeitplanung) +- [x] Automatisches Ein-/Ausschalten der Drucker über Smart Plugs +- [x] Grundlegende Statistikerfassung +- [x] Kiosk-Modus für Raspberry Pi + +## Version 1.1 (nächstes Release) + +- [ ] Verbessertes Dashboard mit Echtzeit-Status aller Drucker +- [ ] Optimierte Benutzeroberfläche für Touchscreen-Bedienung +- [ ] Verbesserte Fehlerbehandlung und Selbstheilungsmechanismen +- [ ] Unterstützung für verschiedene Sprachen (Internationalisierung) +- [ ] Erweiterte Logging-Funktionalität + +## Version 1.2 (mittelfristig) + +- [ ] Unterstützung für weitere Smart-Plug-Typen (nicht nur TP-Link Tapo) +- [ ] Telegram- oder E-Mail-Benachrichtigungen bei Job-Ende/Problemen +- [ ] Verbesserte Statistik mit visuellen Darstellungen (Graphen, Charts) +- [ ] Exportfunktion für Nutzungsdaten (CSV, PDF) +- [ ] Einfache Nutzungsanleitungen direkt in der Web-Oberfläche + +## Version 2.0 (langfristig) + +- [ ] Direkte Integration mit OctoPrint für 3D-Drucker-Steuerung +- [ ] Kamera-Integration zur visuellen Überwachung der Drucker +- [ ] Materialverwaltung mit Bestandsführung +- [ ] Kostenberechnung für Druckaufträge +- [ ] Prognose der Energiekosten basierend auf Messwerten der P110-Plugs +- [ ] Mobile App für iOS/Android + +## Technische Schulden & Optimierungen + +- [ ] Umstellung auf asynchrone API mit FastAPI +- [ ] Frontend mit modernem Framework (Vue.js, React) +- [ ] API-Dokumentation mit Swagger/OpenAPI +- [ ] Verbessertes Datenbankschema für bessere Skalierbarkeit +- [ ] Unit- und Integrationstests +- [ ] Containerisierung mit Docker für einfachere Bereitstellung +- [ ] Upgrade auf Python 3.12+ und neuere Abhängigkeiten + +## Sicherheitsverbesserungen + +- [ ] HTTPS-Unterstützung +- [ ] Verbesserte Passwort-Richtlinien +- [ ] 2-Faktor-Authentifizierung für Administratoren +- [ ] Regelmäßige Sicherheits-Audits +- [ ] Sichere Speicherung von Zugangsdaten (nicht im Code) + +## Wartbarkeit + +- [ ] Umfassendere Dokumentation für Entwickler +- [ ] Code-Refactoring für bessere Lesbarkeit und Wartbarkeit +- [ ] Aufteilen von app.py in mehrere Module +- [ ] Verbessertes Fehlerhandling und Logging \ No newline at end of file diff --git a/backend/env.backend b/backend/env.backend deleted file mode 100644 index eae0871a..00000000 --- a/backend/env.backend +++ /dev/null @@ -1,86 +0,0 @@ -# MYP Backend - Standalone Server Konfiguration -# Umgebungsvariablen ausschließlich für den Backend-Server - -# === FLASK KONFIGURATION === -FLASK_APP=app.py -FLASK_ENV=production -PYTHONUNBUFFERED=1 - -# === DATENBANK KONFIGURATION === -# SQLite (Default) -DATABASE_PATH=instance/myp.db - -# PostgreSQL (Optional - für Produktionsumgebung empfohlen) -DB_NAME=myp_backend -DB_USER=myp_user -DB_PASSWORD=secure_backend_password -DB_HOST=localhost -DB_PORT=5432 - -# === SICHERHEIT === -SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -JWT_SECRET=secure-jwt-secret-backend-2024 -JWT_ACCESS_TOKEN_EXPIRES=3600 -JWT_REFRESH_TOKEN_EXPIRES=2592000 - -# === CORS KONFIGURATION === -# Erlaubte Frontend-Origins -CORS_ORIGINS=http://localhost:3000,https://frontend.myp.local,https://myp.frontend.local - -# === DRUCKER KONFIGURATION === -PRINTERS={"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}} - -# === TAPO SMART PLUG === -TAPO_USERNAME=till.tomczak@mercedes-benz.com -TAPO_PASSWORD=744563017196A - -# === NETZWERK KONFIGURATION === -HOST=0.0.0.0 -PORT=5000 -BACKEND_URL=http://localhost:5000 - -# === CACHE KONFIGURATION === -REDIS_PASSWORD=backend_cache_password -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_DB=0 - -# === LOGGING === -LOG_LEVEL=INFO -LOG_FILE=logs/backend.log -LOG_MAX_BYTES=10485760 -LOG_BACKUP_COUNT=5 - -# === PRODUKTIONS-KONFIGURATION === -# Gunicorn-Einstellungen -WORKERS=4 -BIND_ADDRESS=0.0.0.0:5000 -TIMEOUT=30 -KEEP_ALIVE=5 -MAX_REQUESTS=1000 -MAX_REQUESTS_JITTER=100 - -# === SICHERHEITS-EINSTELLUNGEN === -WTF_CSRF_ENABLED=true -FORCE_HTTPS=false -RATE_LIMIT_ENABLED=true -MAX_REQUESTS_PER_MINUTE=60 -RATE_LIMIT_WINDOW_MINUTES=15 - -# === MONITORING === -HEALTH_CHECK_INTERVAL=30 -METRICS_ENABLED=true -METRICS_PORT=9090 - -# === JOB-KONFIGURATION === -JOB_CHECK_INTERVAL=60 -SOCKET_CHECK_INTERVAL=120 - -# === DATEISYSTEM === -UPLOAD_FOLDER=uploads -MAX_CONTENT_LENGTH=16777216 - -# === ENTWICKLUNG === -DEBUG=false -TESTING=false -DEVELOPMENT=false \ No newline at end of file diff --git a/backend/env.example b/backend/env.example deleted file mode 100644 index eb625a69..00000000 --- a/backend/env.example +++ /dev/null @@ -1,68 +0,0 @@ -# MYP Backend - Umgebungsvariablen Konfiguration -# Kopiere diese Datei zu .env und passe die Werte an deine Umgebung an - -# === Flask-Konfiguration === -# Umgebung: development, production, testing -FLASK_ENV=production - -# Geheimer Schlüssel für Sessions und Tokens -# WICHTIG: Generiere einen sicheren Schlüssel für die Produktion! -# Beispiel: python -c "import secrets; print(secrets.token_hex(32))" -SECRET_KEY=your-super-secret-key-here - -# === Datenbank === -# Pfad zur SQLite-Datenbankdatei -DATABASE_PATH=instance/myp.db - -# === Job-Verwaltung === -# Intervall für Job-Überprüfung in Sekunden -JOB_CHECK_INTERVAL=60 - -# === Tapo Smart Plugs === -# Anmeldedaten für Tapo-Steckdosen -TAPO_USERNAME=your-tapo-email@example.com -TAPO_PASSWORD=your-tapo-password - -# Drucker-Konfiguration (JSON-Format) -# Beispiel: {"Drucker1":{"ip":"192.168.1.100"},"Drucker2":{"ip":"192.168.1.101"}} -PRINTERS={} - -# === Sicherheit === -# API-Schlüssel für externe Zugriffe (optional) -API_KEY= - -# Rate Limiting -MAX_REQUESTS_PER_MINUTE=60 -RATE_LIMIT_WINDOW_MINUTES=15 - -# HTTPS-Erzwingung (nur in Produktion mit SSL-Zertifikat) -FORCE_HTTPS=false - -# === Logging === -# Log-Level: DEBUG, INFO, WARNING, ERROR -LOG_LEVEL=INFO - -# Maximale Log-Dateigröße in Bytes (Standard: 10MB) -LOG_MAX_BYTES=10485760 - -# Anzahl der Log-Backup-Dateien -LOG_BACKUP_COUNT=10 - -# === Server-Konfiguration === -# Anzahl der Gunicorn-Worker-Prozesse -WORKERS=4 - -# Server-Adresse und Port -BIND_ADDRESS=0.0.0.0 -PORT=5000 - -# Request-Timeout in Sekunden -TIMEOUT=30 - -# === Monitoring === -# Aktiviere Metriken-Sammlung -METRICS_ENABLED=true - -# === Entwicklung (nur für FLASK_ENV=development) === -# Debug-Modus -DEBUG=false \ No newline at end of file diff --git a/backend/frontend_v2_routes.py b/backend/frontend_v2_routes.py deleted file mode 100644 index 61ce26bd..00000000 --- a/backend/frontend_v2_routes.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -Routing-Modul für die neue Frontend V2-Implementierung. -Stellt die notwendigen Endpunkte bereit, während die Original-API-Endpunkte intakt bleiben. -""" - -from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify, g, session -import datetime -import os -from functools import wraps -import logging -import jwt - -# Blueprint für Frontend V2 erstellen -frontend_v2 = Blueprint('frontend_v2', __name__, template_folder='frontend_v2/templates') - -# Logger konfigurieren -logger = logging.getLogger(__name__) - -# Importiere Funktionen aus dem Hauptmodul -# Diese werden während der Registrierung des Blueprints übergeben, -# um zirkuläre Importe zu vermeiden -get_current_user = None -get_user_by_id = None -get_socket_by_id = None -get_job_by_id = None -get_all_sockets = None -get_all_users = None -get_all_jobs = None -get_jobs_by_user = None -login_required = None -admin_required = None -delete_session = None -socket_to_dict = None -job_to_dict = None -user_to_dict = None - -def set_app_functions(app_functions): - """ - Setzt die importierten Funktionen aus dem Hauptmodul. - - Args: - app_functions: Ein Dictionary mit Funktionen aus dem Hauptmodul - """ - global get_current_user, get_user_by_id, get_socket_by_id, get_job_by_id - global get_all_sockets, get_all_users, get_all_jobs, get_jobs_by_user - global login_required, admin_required, delete_session - global socket_to_dict, job_to_dict, user_to_dict - - get_current_user = app_functions.get('get_current_user') - get_user_by_id = app_functions.get('get_user_by_id') - get_socket_by_id = app_functions.get('get_socket_by_id') - get_job_by_id = app_functions.get('get_job_by_id') - get_all_sockets = app_functions.get('get_all_sockets') - get_all_users = app_functions.get('get_all_users') - get_all_jobs = app_functions.get('get_all_jobs') - get_jobs_by_user = app_functions.get('get_jobs_by_user') - login_required = app_functions.get('login_required') - admin_required = app_functions.get('admin_required') - delete_session = app_functions.get('delete_session') - socket_to_dict = app_functions.get('socket_to_dict') - job_to_dict = app_functions.get('job_to_dict') - user_to_dict = app_functions.get('user_to_dict') - -# Wrapper für Login-Erfordernis im Frontend -def frontend_login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - user = get_current_user() - if not user: - flash('Bitte melden Sie sich an, um diese Seite zu besuchen.', 'error') - return redirect(url_for('frontend_v2.login')) - - g.current_user = user - return f(*args, **kwargs) - - return decorated - -# Wrapper für Admin-Erfordernis im Frontend -def frontend_admin_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if not g.get('current_user') or g.current_user['role'] != 'admin': - flash('Sie benötigen Administrator-Rechte, um diese Seite zu besuchen.', 'error') - return redirect(url_for('frontend_v2.dashboard')) - return f(*args, **kwargs) - - return decorated - -# Öffentliche Routen -@frontend_v2.route('/') -def index(): - current_user = get_current_user() - if current_user: - return redirect(url_for('frontend_v2.dashboard')) - return redirect(url_for('frontend_v2.login')) - -@frontend_v2.route('/login') -def login(): - current_user = get_current_user() - if current_user: - return redirect(url_for('frontend_v2.dashboard')) - return render_template('login.html', current_user=None, active_page='login') - -@frontend_v2.route('/register') -def register(): - current_user = get_current_user() - if current_user: - return redirect(url_for('frontend_v2.dashboard')) - return render_template('register.html', current_user=None, active_page='register') - -@frontend_v2.route('/logout') -def logout(): - session_id = session.get('session_id') - if session_id: - delete_session(session_id) - session.pop('session_id', None) - - flash('Sie wurden erfolgreich abgemeldet.', 'success') - return redirect(url_for('frontend_v2.login')) - -# Geschützte Routen -@frontend_v2.route('/dashboard') -@frontend_login_required -def dashboard(): - current_user = g.current_user - current_year = datetime.datetime.now().year - return render_template('dashboard.html', - current_user=user_to_dict(current_user), - current_year=current_year, - active_page='dashboard') - -@frontend_v2.route('/jobs') -@frontend_login_required -def jobs(): - current_user = g.current_user - - # Admins sehen alle Jobs, normale Benutzer nur ihre eigenen - if current_user['role'] == 'admin': - all_jobs = get_all_jobs() - else: - all_jobs = get_jobs_by_user(current_user['id']) - - jobs_list = [job_to_dict(job) for job in all_jobs] - - # Sortiere Jobs nach Startzeit (neueste zuerst) - jobs_list.sort(key=lambda x: x['startAt'], reverse=True) - - # Gruppiere Jobs nach Status (aktiv, abgebrochen, abgeschlossen) - active_jobs = [job for job in jobs_list if job['remainingMinutes'] > 0 and not job['aborted']] - completed_jobs = [job for job in jobs_list if job['remainingMinutes'] == 0 and not job['aborted']] - aborted_jobs = [job for job in jobs_list if job['aborted']] - - return render_template('jobs.html', - current_user=user_to_dict(current_user), - active_jobs=active_jobs, - completed_jobs=completed_jobs, - aborted_jobs=aborted_jobs, - active_page='jobs') - -@frontend_v2.route('/job/') -@frontend_login_required -def job_details(job_id): - current_user = g.current_user - - job = get_job_by_id(job_id) - if not job: - flash('Der angeforderte Job wurde nicht gefunden.', 'error') - return redirect(url_for('frontend_v2.jobs')) - - # Benutzer können nur ihre eigenen Jobs sehen, es sei denn, sie sind Admins - if current_user['role'] != 'admin' and job['user_id'] != current_user['id']: - flash('Sie haben keine Berechtigung, diesen Job anzusehen.', 'error') - return redirect(url_for('frontend_v2.jobs')) - - job_data = job_to_dict(job) - - # Holen Sie sich die Drucker-Informationen - printer = get_socket_by_id(job['socket_id']) - printer_data = socket_to_dict(printer) if printer else None - - # Benutzerinformationen abrufen - job_user = get_user_by_id(job['user_id']) - job_user_data = user_to_dict(job_user) if job_user else None - - return render_template('job_details.html', - current_user=user_to_dict(current_user), - job=job_data, - printer=printer_data, - job_user=job_user_data, - active_page='jobs') - -@frontend_v2.route('/profile') -@frontend_login_required -def profile(): - current_user = g.current_user - - # Benutzer-Jobs abrufen - user_jobs = get_jobs_by_user(current_user['id']) - jobs_list = [job_to_dict(job) for job in user_jobs] - - # Jobs nach Startzeit sortieren (neueste zuerst) - jobs_list.sort(key=lambda x: x['startAt'], reverse=True) - - # Gruppiere Jobs nach Status (aktiv, abgebrochen, abgeschlossen) - active_jobs = [job for job in jobs_list if job['remainingMinutes'] > 0 and not job['aborted']] - recent_jobs = jobs_list[:5] # Die 5 neuesten Jobs - - # Nutzungsstatistiken berechnen - total_jobs = len(jobs_list) - total_minutes_used = sum(job['durationInMinutes'] for job in jobs_list if not job['aborted']) - avg_duration = total_minutes_used // total_jobs if total_jobs > 0 else 0 - - return render_template('profile.html', - current_user=user_to_dict(current_user), - active_jobs=active_jobs, - recent_jobs=recent_jobs, - total_jobs=total_jobs, - total_minutes_used=total_minutes_used, - avg_duration=avg_duration, - active_page='profile') - -# Admin-Routen -@frontend_v2.route('/printers') -@frontend_login_required -@frontend_admin_required -def printers(): - current_user = g.current_user - - # Alle Drucker abrufen - all_sockets = get_all_sockets() - printers_list = [socket_to_dict(socket) for socket in all_sockets] - - return render_template('printers.html', - current_user=user_to_dict(current_user), - printers=printers_list, - active_page='printers') - -@frontend_v2.route('/printer/') -@frontend_login_required -@frontend_admin_required -def printer_details(printer_id): - current_user = g.current_user - - printer = get_socket_by_id(printer_id) - if not printer: - flash('Der angeforderte Drucker wurde nicht gefunden.', 'error') - return redirect(url_for('frontend_v2.printers')) - - printer_data = socket_to_dict(printer) - - return render_template('printer_details.html', - current_user=user_to_dict(current_user), - printer=printer_data, - active_page='printers') - -@frontend_v2.route('/users') -@frontend_login_required -@frontend_admin_required -def users(): - current_user = g.current_user - - # Alle Benutzer abrufen - all_users = get_all_users() - users_list = [user_to_dict(user) for user in all_users] - - return render_template('users.html', - current_user=user_to_dict(current_user), - users=users_list, - active_page='users') - -@frontend_v2.route('/user/') -@frontend_login_required -@frontend_admin_required -def user_details(user_id): - current_user = g.current_user - - user = get_user_by_id(user_id) - if not user: - flash('Der angeforderte Benutzer wurde nicht gefunden.', 'error') - return redirect(url_for('frontend_v2.users')) - - user_data = user_to_dict(user) - - # Benutzer-Jobs abrufen - user_jobs = get_jobs_by_user(user_id) - jobs_list = [job_to_dict(job) for job in user_jobs] - - # Jobs nach Startzeit sortieren (neueste zuerst) - jobs_list.sort(key=lambda x: x['startAt'], reverse=True) - - # Gruppiere Jobs nach Status - active_jobs = [job for job in jobs_list if job['remainingMinutes'] > 0 and not job['aborted']] - completed_jobs = [job for job in jobs_list if job['remainingMinutes'] == 0 and not job['aborted']] - aborted_jobs = [job for job in jobs_list if job['aborted']] - - # Nutzungsstatistiken berechnen - total_jobs = len(jobs_list) - total_minutes_used = sum(job['durationInMinutes'] for job in jobs_list if not job['aborted']) - avg_duration = total_minutes_used // total_jobs if total_jobs > 0 else 0 - - return render_template('user_details.html', - current_user=user_to_dict(current_user), - user=user_data, - active_jobs=active_jobs, - completed_jobs=completed_jobs, - aborted_jobs=aborted_jobs, - total_jobs=total_jobs, - total_minutes_used=total_minutes_used, - avg_duration=avg_duration, - active_page='users') - -@frontend_v2.route('/statistics') -@frontend_login_required -@frontend_admin_required -def statistics(): - current_user = g.current_user - - return render_template('statistics.html', - current_user=user_to_dict(current_user), - active_page='statistics') - -# Fehlerbehandlung -@frontend_v2.errorhandler(404) -def page_not_found(e): - current_user = get_current_user() - return render_template('error.html', - current_user=user_to_dict(current_user) if current_user else None, - error_code=404, - error_message='Die angeforderte Seite wurde nicht gefunden.'), 404 - -@frontend_v2.errorhandler(403) -def forbidden(e): - current_user = get_current_user() - return render_template('error.html', - current_user=user_to_dict(current_user) if current_user else None, - error_code=403, - error_message='Sie haben keine Berechtigung, auf diese Seite zuzugreifen.'), 403 - -@frontend_v2.errorhandler(500) -def server_error(e): - current_user = get_current_user() - logger.error(f'Serverfehler: {e}') - return render_template('error.html', - current_user=user_to_dict(current_user) if current_user else None, - error_code=500, - error_message='Ein interner Serverfehler ist aufgetreten.'), 500 \ No newline at end of file diff --git a/backend/install.ps1 b/backend/install.ps1 deleted file mode 100644 index c88bfb53..00000000 --- a/backend/install.ps1 +++ /dev/null @@ -1,284 +0,0 @@ -# MYP Backend - Windows PowerShell Installations-Skript -# Installiert das Backend für Produktionsbetrieb oder Entwicklung - -param( - [switch]$Production, - [switch]$Development, - [switch]$Clean, - [switch]$Logs, - [switch]$Help -) - -# Farben für PowerShell -$Red = "Red" -$Green = "Green" -$Yellow = "Yellow" -$Blue = "Cyan" - -function Write-Log { - param([string]$Message, [string]$Color = "White") - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - Write-Host "[$timestamp] $Message" -ForegroundColor $Color -} - -function Write-Success { - param([string]$Message) - Write-Log "SUCCESS: $Message" -Color $Green -} - -function Write-Warning { - param([string]$Message) - Write-Log "WARNING: $Message" -Color $Yellow -} - -function Write-Error { - param([string]$Message) - Write-Log "FEHLER: $Message" -Color $Red -} - -# Verbesserte Funktion zum Parsen der Umgebungsvariablen -function Set-EnvironmentFromFile { - param([string]$FilePath) - - if (-not (Test-Path $FilePath)) { - Write-Warning "$FilePath nicht gefunden" - return - } - - Write-Log "⚙️ Lade Umgebungsvariablen aus $FilePath..." -Color $Blue - - try { - $EnvContent = Get-Content $FilePath -Raw - $Lines = $EnvContent -split "`r?`n" - - foreach ($Line in $Lines) { - # Überspringe leere Zeilen und Kommentare - if ([string]::IsNullOrWhiteSpace($Line) -or $Line.TrimStart().StartsWith('#')) { - continue - } - - # Finde den ersten = Zeichen - $EqualIndex = $Line.IndexOf('=') - if ($EqualIndex -le 0) { - continue - } - - # Extrahiere Key und Value - $Key = $Line.Substring(0, $EqualIndex).Trim() - $Value = $Line.Substring($EqualIndex + 1).Trim() - - # Entferne umgebende Anführungszeichen, falls vorhanden - if (($Value.StartsWith('"') -and $Value.EndsWith('"')) -or - ($Value.StartsWith("'") -and $Value.EndsWith("'"))) { - $Value = $Value.Substring(1, $Value.Length - 2) - } - - # Setze Umgebungsvariable - if (-not [string]::IsNullOrWhiteSpace($Key)) { - [Environment]::SetEnvironmentVariable($Key, $Value, "Process") - Write-Log "Geladen: $Key" -Color $Blue - } - } - - Write-Success "Umgebungsvariablen erfolgreich geladen" - - } catch { - Write-Error "Fehler beim Laden der Umgebungsvariablen: $_" - Write-Warning "Verwende Standard-Umgebungsvariablen" - } -} - -# Banner -Write-Host "========================================" -ForegroundColor $Blue -Write-Host "🏭 MYP Backend - Windows Installation" -ForegroundColor $Blue -Write-Host "========================================" -ForegroundColor $Blue - -if ($Help) { - Write-Host @" -Verwendung: .\install.ps1 [OPTIONEN] - -OPTIONEN: - -Production Produktions-Installation - -Development Entwicklungs-Installation - -Clean Bereinige vorherige Installation - -Logs Zeige detaillierte Logs - -Help Zeige diese Hilfe - -BEISPIELE: - .\install.ps1 -Production - .\install.ps1 -Development -Logs - .\install.ps1 -Clean -"@ - exit 0 -} - -# Bestimme Installationsmodus -$InstallMode = "development" -if ($Production) { - $InstallMode = "production" - Write-Log "🏭 Produktions-Installation gestartet" -Color $Blue -} elseif ($Development) { - $InstallMode = "development" - Write-Log "🔧 Entwicklungs-Installation gestartet" -Color $Blue -} else { - Write-Log "🔧 Standard-Installation (Entwicklung)" -Color $Blue -} - -# Arbeitsverzeichnis prüfen -$CurrentDir = Get-Location -Write-Log "Arbeitsverzeichnis: $CurrentDir" - -if (-not (Test-Path "app.py")) { - Write-Error "app.py nicht gefunden! Bitte im Backend-Verzeichnis ausführen." - exit 1 -} - -# Python-Version prüfen -Write-Log "🐍 Prüfe Python-Installation..." -Color $Blue - -try { - $PythonVersion = python --version 2>&1 - Write-Log "Python-Version: $PythonVersion" - - # Prüfe Mindestversion (3.8+) - $VersionMatch = $PythonVersion -match "Python (\d+)\.(\d+)" - if ($VersionMatch) { - $Major = [int]$Matches[1] - $Minor = [int]$Matches[2] - - if ($Major -lt 3 -or ($Major -eq 3 -and $Minor -lt 8)) { - Write-Error "Python 3.8+ erforderlich, gefunden: $PythonVersion" - exit 1 - } - } - - Write-Success "Python-Version ist kompatibel" -} catch { - Write-Error "Python nicht gefunden! Bitte Python 3.8+ installieren." - exit 1 -} - -# Bereinigung (falls gewünscht) -if ($Clean) { - Write-Log "🧹 Bereinige vorherige Installation..." -Color $Yellow - - if (Test-Path "instance") { - Remove-Item -Recurse -Force "instance" - Write-Log "Datenbank-Verzeichnis entfernt" - } - - if (Test-Path "logs") { - Remove-Item -Recurse -Force "logs" - Write-Log "Log-Verzeichnis entfernt" - } - - Write-Success "Bereinigung abgeschlossen" -} - -# Erstelle erforderliche Verzeichnisse -Write-Log "📁 Erstelle Verzeichnisse..." -Color $Blue - -$Directories = @("instance", "logs", "uploads") -foreach ($Dir in $Directories) { - if (-not (Test-Path $Dir)) { - New-Item -ItemType Directory -Path $Dir | Out-Null - Write-Log "Verzeichnis erstellt: $Dir" - } else { - Write-Log "Verzeichnis existiert bereits: $Dir" - } -} - -# Installiere Python-Dependencies -Write-Log "📦 Installiere Python-Pakete..." -Color $Blue - -if (Test-Path "requirements.txt") { - try { - if ($Logs) { - pip install -r requirements.txt - } else { - pip install -r requirements.txt --quiet - } - Write-Success "Python-Pakete installiert" - } catch { - Write-Error "Fehler beim Installieren der Python-Pakete: $_" - exit 1 - } -} else { - Write-Warning "requirements.txt nicht gefunden" -} - -# Umgebungskonfiguration -Write-Log "⚙️ Konfiguriere Umgebung..." -Color $Blue - -# Lade Umgebungsvariablen für Tests -Set-EnvironmentFromFile "env.backend" - -# Datenbank initialisieren -Write-Log "🗄️ Initialisiere Datenbank..." -Color $Blue - -try { - $env:FLASK_APP = "app.py" - $env:FLASK_ENV = $InstallMode - - # Verwende das Test-Skript für die Datenbank-Initialisierung - Write-Log "Führe Datenbank-Initialisierung über Test-Skript aus..." - python test-backend-setup.py - - if ($LASTEXITCODE -eq 0) { - Write-Success "Datenbank erfolgreich initialisiert" - } else { - Write-Warning "Datenbank-Initialisierung mit Warnungen abgeschlossen" - } -} catch { - Write-Error "Fehler bei der Datenbank-Initialisierung: $_" - exit 1 -} - -# Konfigurationstest -Write-Log "🧪 Teste Konfiguration..." -Color $Blue - -try { - python test-backend-setup.py | Out-Null - $TestResult = $LASTEXITCODE - - if ($TestResult -eq 0) { - Write-Success "Alle Konfigurationstests bestanden" - } else { - Write-Warning "Einige Konfigurationstests fehlgeschlagen (Code: $TestResult)" - if ($Logs) { - Write-Log "Führe detaillierte Tests aus..." - python test-backend-setup.py - } - } -} catch { - Write-Warning "Konfigurationstest konnte nicht ausgeführt werden: $_" -} - -# Installation abgeschlossen -Write-Host "" -Write-Host "========================================" -ForegroundColor $Green -Write-Host "✅ MYP Backend Installation abgeschlossen!" -ForegroundColor $Green -Write-Host "========================================" -ForegroundColor $Green -Write-Host "" - -Write-Host "📋 Nächste Schritte:" -ForegroundColor $Blue -Write-Host "1. Backend starten:" -ForegroundColor $White -Write-Host " .\start-backend-server.ps1 -Development" -ForegroundColor $Yellow -Write-Host "" -Write-Host "2. Health-Check testen:" -ForegroundColor $White -Write-Host " curl http://localhost:5000/monitoring/health/simple" -ForegroundColor $Yellow -Write-Host "" -Write-Host "3. Logs überwachen:" -ForegroundColor $White -Write-Host " Get-Content logs\myp.log -Wait" -ForegroundColor $Yellow -Write-Host "" - -if ($InstallMode -eq "production") { - Write-Host "🏭 Produktions-Hinweise:" -ForegroundColor $Blue - Write-Host "- Verwende einen Reverse Proxy (nginx/Apache)" -ForegroundColor $White - Write-Host "- Konfiguriere SSL/TLS-Zertifikate" -ForegroundColor $White - Write-Host "- Überwache Logs und Metriken" -ForegroundColor $White - Write-Host "- Führe regelmäßige Backups durch" -ForegroundColor $White -} - -Write-Host "" -Write-Success "Installation erfolgreich abgeschlossen!" \ No newline at end of file diff --git a/backend/install.sh b/backend/install.sh deleted file mode 100644 index f096eb3c..00000000 --- a/backend/install.sh +++ /dev/null @@ -1,364 +0,0 @@ -#!/bin/bash - -# 🏭 MYP Backend - Installations-Skript -# Installiert das Backend für Produktionsbetrieb oder Entwicklung - -set -e - -# Farbcodes für Ausgabe -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Funktion zur Ausgabe mit Zeitstempel -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" -} - -success_log() { - echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] SUCCESS:${NC} $1" -} - -warning_log() { - echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] WARNING:${NC} $1" -} - -error_log() { - echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] FEHLER:${NC} $1" >&2 -} - -# Banner -echo "========================================" -echo "🏭 MYP Backend - Installation" -echo "========================================" -echo "" - -# Arbeitsverzeichnis -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPT_DIR" - -log "Arbeitsverzeichnis: $SCRIPT_DIR" - -# Installation-Modus bestimmen -INSTALL_MODE="development" -if [ "$1" = "--production" ]; then - INSTALL_MODE="production" - log "🚀 Produktions-Installation gewählt" -elif [ "$1" = "--development" ]; then - INSTALL_MODE="development" - log "🔧 Entwicklungs-Installation gewählt" -else - echo "Wählen Sie den Installationsmodus:" - echo "1) Entwicklung (empfohlen für lokale Tests)" - echo "2) Produktion (für Server-Deployment)" - read -p "Ihre Wahl (1/2): " choice - - case $choice in - 1) INSTALL_MODE="development" ;; - 2) INSTALL_MODE="production" ;; - *) log "Verwende Standard-Entwicklungsmodus" && INSTALL_MODE="development" ;; - esac -fi - -log "Installationsmodus: $INSTALL_MODE" - -# Bereinige vorherige Installation -cleanup_existing_installation() { - log "🧹 Bereinige vorherige Installation..." - - # Stoppe laufende Prozesse - if pgrep -f "flask run" > /dev/null; then - log "Stoppe laufende Flask-Prozesse..." - pkill -f "flask run" || true - fi - - if pgrep -f "gunicorn" > /dev/null; then - log "Stoppe laufende Gunicorn-Prozesse..." - pkill -f "gunicorn.*wsgi:application" || true - fi - - # Entferne alte virtuelle Umgebung - if [ -d "venv" ]; then - log "Entferne alte virtuelle Umgebung..." - rm -rf venv - fi - - success_log "Bereinigung abgeschlossen" -} - -# System-Dependencies prüfen und installieren -install_system_dependencies() { - log "🔧 Prüfe System-Dependencies..." - - # Betriebssystem erkennen - if [ -f /etc/os-release ]; then - . /etc/os-release - OS=$NAME - VER=$VERSION_ID - elif type lsb_release >/dev/null 2>&1; then - OS=$(lsb_release -si) - VER=$(lsb_release -sr) - else - OS=$(uname -s) - VER=$(uname -r) - fi - - log "Erkanntes System: $OS $VER" - - # Python 3 prüfen - if ! command -v python3 &> /dev/null; then - error_log "Python 3 ist nicht installiert!" - log "Installationsanleitung:" - if [[ "$OS" == *"Ubuntu"* ]] || [[ "$OS" == *"Debian"* ]]; then - log "sudo apt update && sudo apt install python3 python3-pip python3-venv" - elif [[ "$OS" == *"CentOS"* ]] || [[ "$OS" == *"Red Hat"* ]]; then - log "sudo yum install python3 python3-pip" - elif [[ "$OS" == *"Alpine"* ]]; then - log "sudo apk add python3 py3-pip" - else - log "Bitte installieren Sie Python 3 manuell für Ihr System" - fi - exit 1 - fi - - # Python-Version prüfen - PYTHON_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))") - log "Python-Version: $PYTHON_VERSION" - - if python3 -c "import sys; exit(0 if sys.version_info >= (3, 8) else 1)"; then - success_log "Python-Version ist kompatibel (>= 3.8)" - else - error_log "Python-Version ist zu alt! Benötigt wird mindestens Python 3.8" - exit 1 - fi - - # pip prüfen - if ! command -v pip3 &> /dev/null; then - error_log "pip3 ist nicht installiert!" - log "Installiere pip3..." - if [[ "$OS" == *"Ubuntu"* ]] || [[ "$OS" == *"Debian"* ]]; then - sudo apt install python3-pip - else - error_log "Bitte installieren Sie pip3 manuell" - exit 1 - fi -fi - - # Weitere notwendige System-Pakete prüfen - if [[ "$OS" == *"Ubuntu"* ]] || [[ "$OS" == *"Debian"* ]]; then - log "Prüfe System-Pakete für Ubuntu/Debian..." - - # Prüfe ob build-essential installiert ist (für Compilation von Python-Paketen) - if ! dpkg -l | grep -q build-essential; then - log "Installiere build-essential..." - sudo apt update - sudo apt install -y build-essential python3-dev - fi - - # Prüfe curl für Health-Checks - if ! command -v curl &> /dev/null; then - log "Installiere curl..." - sudo apt install -y curl - fi - fi - - success_log "System-Dependencies sind verfügbar" -} - -# Python virtuelle Umgebung erstellen -create_virtual_environment() { - log "🐍 Erstelle Python virtuelle Umgebung..." - - # Erstelle virtuelle Umgebung - python3 -m venv venv - - # Aktiviere virtuelle Umgebung - source venv/bin/activate - - # Upgrade pip in virtueller Umgebung - log "Aktualisiere pip..." - pip install --upgrade pip - - success_log "Virtuelle Umgebung erstellt und aktiviert" -} - -# Python-Dependencies installieren -install_python_dependencies() { - log "📦 Installiere Python-Dependencies..." - - # Aktiviere virtuelle Umgebung - source venv/bin/activate - - # Installiere Requirements - if [ -f "requirements.txt" ]; then - log "Installiere aus requirements.txt..." - pip install -r requirements.txt - else - error_log "requirements.txt nicht gefunden!" - exit 1 - fi - - # Produktions-spezifische Dependencies - if [ "$INSTALL_MODE" = "production" ]; then - log "Installiere Produktions-Dependencies..." - pip install gunicorn supervisor - fi - - success_log "Python-Dependencies installiert" -} - -# Konfiguration vorbereiten -prepare_configuration() { - log "⚙️ Bereite Konfiguration vor..." - - # Erstelle notwendige Verzeichnisse - mkdir -p instance logs migrations/versions uploads - - # Setze Standard-Umgebungsvariablen für die Installation - log "Setze Konfiguration (hardgecodet)..." - export FLASK_APP=app.py - export FLASK_ENV=$INSTALL_MODE - export SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F - export DATABASE_PATH=instance/myp.db - export LOG_LEVEL=INFO - export JOB_CHECK_INTERVAL=60 - export SOCKET_CHECK_INTERVAL=120 - export PRINTERS='{"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}}' - export TAPO_USERNAME=till.tomczak@mercedes-benz.com - export TAPO_PASSWORD=744563017196A - export HOST=0.0.0.0 - export PORT=5000 - export BACKEND_URL=http://localhost:5000 - export UPLOAD_FOLDER=uploads - export MAX_CONTENT_LENGTH=16777216 - export DEBUG=false - export TESTING=false - export DEVELOPMENT=false - - success_log "Konfiguration vorbereitet (hardgecodet)" -} - -# Datenbank initialisieren -initialize_database() { - log "🗄️ Initialisiere Datenbank..." - - # Aktiviere virtuelle Umgebung - source venv/bin/activate - - # Umgebungsvariablen sind bereits in prepare_configuration() gesetzt - log "Verwende hardgecodete Konfiguration..." - - # Setze Flask-App - export FLASK_APP=app.py - export FLASK_ENV=$INSTALL_MODE - - # Initialisiere Datenbank - python3 -c " -from app import create_app, init_db -app = create_app('$INSTALL_MODE') -with app.app_context(): - init_db() - print('✅ Datenbank initialisiert') -" - - success_log "Datenbank initialisiert" -} - -# Systemd-Service für Produktion erstellen -create_systemd_service() { - if [ "$INSTALL_MODE" = "production" ]; then - log "🔧 Erstelle systemd-Service für Produktion..." - - SERVICE_FILE="/etc/systemd/system/myp-backend.service" - - sudo tee $SERVICE_FILE > /dev/null << EOF -[Unit] -Description=MYP Backend Flask Application -After=network.target - -[Service] -Type=exec -User=$USER -Group=$USER -WorkingDirectory=$SCRIPT_DIR -Environment=PATH=$SCRIPT_DIR/venv/bin -Environment=FLASK_APP=app.py -Environment=FLASK_ENV=production -Environment=SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -Environment=DATABASE_PATH=instance/myp.db -Environment=LOG_LEVEL=INFO -Environment=JOB_CHECK_INTERVAL=60 -Environment=SOCKET_CHECK_INTERVAL=120 -Environment=PRINTERS={"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}} -Environment=TAPO_USERNAME=till.tomczak@mercedes-benz.com -Environment=TAPO_PASSWORD=744563017196A -Environment=HOST=0.0.0.0 -Environment=PORT=5000 -Environment=BACKEND_URL=http://localhost:5000 -Environment=UPLOAD_FOLDER=uploads -Environment=MAX_CONTENT_LENGTH=16777216 -Environment=DEBUG=false -Environment=TESTING=false -Environment=DEVELOPMENT=false -ExecStart=$SCRIPT_DIR/venv/bin/gunicorn --bind 0.0.0.0:5000 --workers 4 wsgi:application -ExecReload=/bin/kill -s HUP \$MAINPID -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target -EOF - - sudo systemctl daemon-reload - sudo systemctl enable myp-backend - - success_log "Systemd-Service erstellt: myp-backend.service" - log "Starten mit: sudo systemctl start myp-backend" - log "Status prüfen mit: sudo systemctl status myp-backend" - fi -} - -# Hauptinstallation -main() { - cleanup_existing_installation - install_system_dependencies - create_virtual_environment - install_python_dependencies - prepare_configuration - initialize_database - create_systemd_service - - echo "" - echo "========================================" - success_log "🎉 Installation erfolgreich abgeschlossen!" - echo "========================================" - echo "" - - log "Nächste Schritte:" - echo "" - - if [ "$INSTALL_MODE" = "production" ]; then - echo "📋 Produktionsbetrieb:" - echo " 1. Service starten: sudo systemctl start myp-backend" - echo " 2. Service prüfen: sudo systemctl status myp-backend" - echo " 3. Oder manuell: ./start-production.sh" - echo " 4. Logs anzeigen: sudo journalctl -u myp-backend -f" - else - echo "🔧 Entwicklungsbetrieb:" - echo " 1. Server starten: ./start-backend-server.sh" - echo " 2. Development-Server: ./start-backend-server.sh --development" - echo " 3. Debug-Modus: ./start-debug-server.sh" - fi - - echo "" - echo "📡 Backend wird verfügbar sein unter:" - echo " - API: http://localhost:5000" - echo " - Health-Check: http://localhost:5000/health" - echo " - Test: http://localhost:5000/api/test" - echo "" -} - -# Installation starten -main "$@" \ No newline at end of file diff --git a/backend/install/kiosk.service b/backend/install/kiosk.service new file mode 100644 index 00000000..ec526cb9 --- /dev/null +++ b/backend/install/kiosk.service @@ -0,0 +1,11 @@ +[Unit] +Description=Chromium Kiosk +PartOf=graphical-session.target + +[Service] +Type=forking +ExecStart=/home/pi/kiosk.sh +Restart=on-abort + +[Install] +WantedBy=xsession.target \ No newline at end of file diff --git a/backend/install/kiosk.sh b/backend/install/kiosk.sh new file mode 100755 index 00000000..96baf297 --- /dev/null +++ b/backend/install/kiosk.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Bildschirm-Blanking verhindern +xset s off +xset s noblank +xset -dpms + +# Mauszeiger ausblenden +unclutter -idle 0.5 -root & + +# Chromium-Crash-Dialoge unterdrücken +sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' \ + "$HOME/.config/chromium/Default/Preferences" 2>/dev/null || true +sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' \ + "$HOME/.config/chromium/Default/Preferences" 2>/dev/null || true + +# Browser starten +chromium-browser --kiosk --noerrdialogs --disable-infobars \ + --window-position=0,0 --app=http://localhost:5000/ & \ No newline at end of file diff --git a/backend/install/myp.service b/backend/install/myp.service new file mode 100644 index 00000000..be681031 --- /dev/null +++ b/backend/install/myp.service @@ -0,0 +1,14 @@ +[Unit] +Description=MYP Flask Backend +After=network-online.target +Wants=network-online.target + +[Service] +User=pi +WorkingDirectory=/opt/myp +ExecStart=/opt/myp/.venv/bin/python /opt/myp/app.py +Restart=always +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/backend/install/requirements.txt b/backend/install/requirements.txt new file mode 100644 index 00000000..f48b76f4 --- /dev/null +++ b/backend/install/requirements.txt @@ -0,0 +1,6 @@ +flask==2.3.3 +flask-login==0.6.2 +sqlalchemy==2.0.20 +PyP100==0.1.1 +bcrypt==4.0.1 +gunicorn==21.2.0 \ No newline at end of file diff --git a/backend/install/setup.sh b/backend/install/setup.sh new file mode 100755 index 00000000..1f77ac7e --- /dev/null +++ b/backend/install/setup.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# MYP Kiosk-Modus Einrichtungsskript + +# Fehlerabbruch aktivieren +set -e + +echo "===== MYP Kiosk-Modus Einrichtungsskript =====" +echo "Dieses Skript richtet MYP für den automatischen Start im Kiosk-Modus ein." +echo "" + +# 1. Benötigte Pakete installieren +echo "1. Installiere benötigte Pakete..." +sudo apt update +sudo apt install -y python3 python3-pip python3-venv chromium-browser \ + unclutter xdotool xscreensaver git + +# 2. Verzeichnis für MYP erstellen und Projekt kopieren +echo "2. Kopiere MYP nach /opt/myp..." +sudo mkdir -p /opt/myp +sudo chown $USER:$USER /opt/myp + +# Aktuelle Verzeichnisstruktur ermitteln +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +echo "Kopiere Dateien von $SCRIPT_DIR nach /opt/myp..." +cp -r "$SCRIPT_DIR"/* /opt/myp/ + +# 3. Python-Umgebung und Abhängigkeiten einrichten +echo "3. Richte Python-Umgebung ein..." +cd /opt/myp +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 4. Systemd-Dienst für Flask-Backend einrichten +echo "4. Richte Flask-Backend-Dienst ein..." +sudo cp /opt/myp/myp.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable myp.service +sudo systemctl start myp.service + +# 5. Kiosk-Script einrichten +echo "5. Richte Kiosk-Script ein..." +cp /opt/myp/kiosk.sh /home/pi/ +chmod +x /home/pi/kiosk.sh + +# 6. Systemd-User-Dienst für Kiosk einrichten +echo "6. Richte Kiosk-Dienst ein..." +mkdir -p /home/pi/.config/systemd/user +cp /opt/myp/kiosk.service /home/pi/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable kiosk.service + +# 7. Linger für den pi-Benutzer aktivieren +echo "7. Aktiviere User-Linger für pi-Benutzer..." +sudo loginctl enable-linger pi + +# 8. Watchdog-Script einrichten +echo "8. Richte Watchdog-Script ein..." +cp /opt/myp/watchdog.sh /home/pi/ +chmod +x /home/pi/watchdog.sh + +# 9. Cron-Job für Watchdog einrichten +echo "9. Richte Cron-Job für Watchdog ein..." +(crontab -l 2>/dev/null || echo "") | grep -v "watchdog.sh" | { cat; echo "*/5 * * * * /home/pi/watchdog.sh > /dev/null 2>&1"; } | crontab - + +# 10. Automatischen Login einrichten +echo "10. Automatischer Login wird manuell über raspi-config eingerichtet" +echo " Führe 'sudo raspi-config' aus und wähle:" +echo " 1 System Options → S5 Boot/Auto Login → B4 Desktop Autologin" + +# 11. Bildschirm nie ausschalten +echo "11. Deaktiviere Bildschirmschoner..." +sudo sed -i 's/#BLANK_TIME=.*/BLANK_TIME=0/' /etc/xdg/lxsession/LXDE-pi/autostart + +echo "" +echo "===== Installation abgeschlossen =====" +echo "Um die Einrichtung zu vervollständigen, führe 'sudo raspi-config' aus" +echo "und aktiviere den automatischen Login: " +echo "1 System Options → S5 Boot/Auto Login → B4 Desktop Autologin" +echo "" +echo "Nach einem Neustart sollte der Raspberry Pi automatisch:" +echo "1. Die MYP-Flask-Anwendung starten" +echo "2. Den Chromium-Browser im Kiosk-Modus öffnen" +echo "" +echo "MYP ist erreichbar unter: http://localhost:5000/" +echo "" +echo "Ein Watchdog-Script überwacht alle 5 Minuten, ob Chromium und der MYP-Dienst" +echo "noch laufen und startet sie bei Bedarf neu." +echo "" +echo "Starte den Raspberry Pi neu mit 'sudo reboot'" \ No newline at end of file diff --git a/backend/install/watchdog.sh b/backend/install/watchdog.sh new file mode 100755 index 00000000..cf28986c --- /dev/null +++ b/backend/install/watchdog.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# MYP Watchdog für Chromium Browser +# Empfohlene Ausführung über crontab: */5 * * * * /home/pi/watchdog.sh > /dev/null 2>&1 + +# Funktion zum Loggen von Nachrichten +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> /home/pi/myp-watchdog.log +} + +# Prüfen, ob Chromium läuft +if ! pgrep -x "chromium-browse" > /dev/null; then + log "Chromium nicht gefunden - starte neu" + + # Alle eventuell noch vorhandenen Chromium-Prozesse beenden + pkill -f chromium || true + + # Warten bis alle Prozesse beendet sind + sleep 2 + + # Kiosk-Script neu starten + /home/pi/kiosk.sh + + log "Chromium neugestartet" +else + # Optional: Nur für Debug-Zwecke + # log "Chromium läuft normal" + : +fi + +# Prüfen, ob MYP Flask-Dienst läuft +if ! systemctl is-active --quiet myp.service; then + log "MYP Flask-Dienst ist nicht aktiv - starte neu" + + # Dienst neustarten + sudo systemctl restart myp.service + + log "MYP Flask-Dienst neugestartet" +fi + +exit 0 \ No newline at end of file diff --git a/backend/log.txt b/backend/log.txt deleted file mode 100644 index 38d23d67..00000000 --- a/backend/log.txt +++ /dev/null @@ -1,89 +0,0 @@ -root@raspberrypi:/home/user/Projektarbeit-MYP/backend# python3 app.py -[2025-03-24 09:38:15,229] INFO in app: MYP Backend starting up -[2025-03-24 09:38:15,338] INFO in app: Initialisiere Drucker aus Umgebungsvariablen -[2025-03-24 09:38:15,353] INFO in app: Neuer Drucker angelegt: Printer 1 mit IP 192.168.0.100 -[2025-03-24 09:38:16,197] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.100: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:16,197] INFO in app: Neue Steckdose mit IP 192.168.0.100 wurde beim Start ausgeschaltet -[2025-03-24 09:38:16,209] INFO in app: Neuer Drucker angelegt: Printer 2 mit IP 192.168.0.101 -[2025-03-24 09:38:16,521] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.101: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:16,522] INFO in app: Neue Steckdose mit IP 192.168.0.101 wurde beim Start ausgeschaltet -[2025-03-24 09:38:16,536] INFO in app: Neuer Drucker angelegt: Printer 3 mit IP 192.168.0.102 -[2025-03-24 09:38:17,082] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.102: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:17,083] INFO in app: Neue Steckdose mit IP 192.168.0.102 wurde beim Start ausgeschaltet -[2025-03-24 09:38:17,096] INFO in app: Neuer Drucker angelegt: Printer 4 mit IP 192.168.0.103 -[2025-03-24 09:38:18,248] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.103: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:18,249] INFO in app: Neue Steckdose mit IP 192.168.0.103 wurde beim Start ausgeschaltet -[2025-03-24 09:38:18,263] INFO in app: Neuer Drucker angelegt: Printer 5 mit IP 192.168.0.104 -[2025-03-24 09:38:18,635] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.104: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:18,636] INFO in app: Neue Steckdose mit IP 192.168.0.104 wurde beim Start ausgeschaltet -[2025-03-24 09:38:18,650] INFO in app: Neuer Drucker angelegt: Printer 6 mit IP 192.168.0.106 -[2025-03-24 09:38:21,004] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.106: HTTPConnectionPool(host='192.168.0.106', port=80): Max retries exceeded with url: /app (Caused by ConnectTimeoutError(, 'Connection to 192.168.0.106 timed out. (connect timeout=2)')) -[2025-03-24 09:38:21,006] INFO in app: Neue Steckdose mit IP 192.168.0.106 wurde beim Start ausgeschaltet -[2025-03-24 09:38:21,007] INFO in app: Starte Hintergrund-Thread für Job-Überprüfung und Steckdosen-Monitoring -[2025-03-24 09:38:21,008] INFO in app: Hintergrund-Thread für Job-Überprüfung gestartet -[2025-03-24 09:38:21,014] INFO in app: 0 abgelaufene Jobs überprüft, 0 Steckdosen aktualisiert. - * Serving Flask app 'app' - * Debug mode: on -[2025-03-24 09:38:21,023] INFO in app: Überprüfe Verbindungsstatus von 6 Steckdosen -WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. - * Running on all addresses (0.0.0.0) - * Running on http://127.0.0.1:5000 - * Running on http://192.168.0.105:5000 -Press CTRL+C to quit - * Restarting with stat -[2025-03-24 09:38:21,810] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.100: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:21,826] INFO in app: Verbindungsstatus für Steckdose 80c65076-acdb-4448-ac6e-05a44b35f5b2 geändert: offline -[2025-03-24 09:38:21,845] WARNING in app: Steckdose Printer 1 (192.168.0.100) ist nicht erreichbar -[2025-03-24 09:38:21,913] INFO in app: MYP Backend starting up -[2025-03-24 09:38:21,968] INFO in app: Initialisiere Drucker aus Umgebungsvariablen -[2025-03-24 09:38:21,969] INFO in app: Drucker mit IP 192.168.0.100 existiert bereits in der Datenbank -[2025-03-24 09:38:22,109] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.101: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:22,120] INFO in app: Verbindungsstatus für Steckdose 19e70cd5-5fdb-439b-80e3-807015c7cb15 geändert: offline -[2025-03-24 09:38:22,134] WARNING in app: Steckdose Printer 2 (192.168.0.101) ist nicht erreichbar -[2025-03-24 09:38:22,666] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.100: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:22,667] INFO in app: Steckdose mit IP 192.168.0.100 wurde beim Start ausgeschaltet -[2025-03-24 09:38:22,668] INFO in app: Drucker mit IP 192.168.0.101 existiert bereits in der Datenbank -[2025-03-24 09:38:22,806] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.102: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:22,819] INFO in app: Verbindungsstatus für Steckdose 7cdc29a8-3593-4666-8419-070914c6d6c5 geändert: offline -[2025-03-24 09:38:22,831] WARNING in app: Steckdose Printer 3 (192.168.0.102) ist nicht erreichbar -[2025-03-24 09:38:23,222] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.101: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:23,223] INFO in app: Steckdose mit IP 192.168.0.101 wurde beim Start ausgeschaltet -[2025-03-24 09:38:23,223] INFO in app: Drucker mit IP 192.168.0.102 existiert bereits in der Datenbank -[2025-03-24 09:38:23,228] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.103: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:23,243] INFO in app: Verbindungsstatus für Steckdose 69be8092-0eea-4797-a940-51bdec244cf7 geändert: offline -[2025-03-24 09:38:23,256] WARNING in app: Steckdose Printer 4 (192.168.0.103) ist nicht erreichbar -[2025-03-24 09:38:23,458] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.104: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:23,476] INFO in app: Verbindungsstatus für Steckdose 90caa30e-adaf-44ec-a680-6beea72a570a geändert: offline -[2025-03-24 09:38:23,489] WARNING in app: Steckdose Printer 5 (192.168.0.104) ist nicht erreichbar -[2025-03-24 09:38:23,492] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.102: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:23,493] INFO in app: Steckdose mit IP 192.168.0.102 wurde beim Start ausgeschaltet -[2025-03-24 09:38:23,493] INFO in app: Drucker mit IP 192.168.0.103 existiert bereits in der Datenbank -[2025-03-24 09:38:24,058] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.103: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:24,058] INFO in app: Steckdose mit IP 192.168.0.103 wurde beim Start ausgeschaltet -[2025-03-24 09:38:24,059] INFO in app: Drucker mit IP 192.168.0.104 existiert bereits in der Datenbank -[2025-03-24 09:38:24,610] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.104: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:24,611] INFO in app: Steckdose mit IP 192.168.0.104 wurde beim Start ausgeschaltet -[2025-03-24 09:38:24,612] INFO in app: Drucker mit IP 192.168.0.106 existiert bereits in der Datenbank -[2025-03-24 09:38:26,344] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.106: HTTPConnectionPool(host='192.168.0.106', port=80): Max retries exceeded with url: /app (Caused by ConnectTimeoutError(, 'Connection to 192.168.0.106 timed out. (connect timeout=2)')) -[2025-03-24 09:38:26,357] INFO in app: Verbindungsstatus für Steckdose 2b6b9831-e4c1-4f60-8107-69cbc8b58e2c geändert: offline -[2025-03-24 09:38:26,370] WARNING in app: Steckdose Printer 6 (192.168.0.106) ist nicht erreichbar -[2025-03-24 09:38:26,371] INFO in app: Verbindungsüberprüfung abgeschlossen: 0 online, 6 offline, 0 übersprungen -[2025-03-24 09:38:26,371] INFO in app: Nächste Socket-Überprüfung in 120 Sekunden -[2025-03-24 09:38:26,775] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.106: HTTPConnectionPool(host='192.168.0.106', port=80): Max retries exceeded with url: /app (Caused by ConnectTimeoutError(, 'Connection to 192.168.0.106 timed out. (connect timeout=2)')) -[2025-03-24 09:38:26,776] INFO in app: Steckdose mit IP 192.168.0.106 wurde beim Start ausgeschaltet -[2025-03-24 09:38:26,776] INFO in app: Starte Hintergrund-Thread für Job-Überprüfung und Steckdosen-Monitoring -[2025-03-24 09:38:26,777] INFO in app: Hintergrund-Thread für Job-Überprüfung gestartet -[2025-03-24 09:38:26,780] INFO in app: 0 abgelaufene Jobs überprüft, 0 Steckdosen aktualisiert. -[2025-03-24 09:38:26,784] INFO in app: Überprüfe Verbindungsstatus von 6 Steckdosen - * Debugger is active! - * Debugger PIN: 101-484-383 -[2025-03-24 09:38:27,279] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.100: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:27,280] WARNING in app: Steckdose Printer 1 (192.168.0.100) ist nicht erreichbar -[2025-03-24 09:38:27,719] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.101: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:27,720] WARNING in app: Steckdose Printer 2 (192.168.0.101) ist nicht erreichbar -[2025-03-24 09:38:28,073] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.102: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:28,074] WARNING in app: Steckdose Printer 3 (192.168.0.102) ist nicht erreichbar -[2025-03-24 09:38:28,887] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.103: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:28,887] WARNING in app: Steckdose Printer 4 (192.168.0.103) ist nicht erreichbar -[2025-03-24 09:38:29,312] ERROR in app: Fehler bei der Anmeldung an P100-Gerät 192.168.0.104: Expecting value: line 1 column 1 (char 0) -[2025-03-24 09:38:29,312] WARNING in app: Steckdose Printer 5 (192.168.0.104) ist nicht erreichbar diff --git a/backend/migrations/README b/backend/migrations/README deleted file mode 100644 index 0e048441..00000000 --- a/backend/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini deleted file mode 100644 index ec9d45c2..00000000 --- a/backend/migrations/alembic.ini +++ /dev/null @@ -1,50 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,flask_migrate - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py deleted file mode 100644 index 4c970927..00000000 --- a/backend/migrations/env.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from logging.config import fileConfig - -from flask import current_app - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') - - -def get_engine(): - try: - # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() - except (TypeError, AttributeError): - # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine - - -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') - except AttributeError: - return str(get_engine().url).replace('%', '%%') - - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def get_metadata(): - if hasattr(target_db, 'metadatas'): - return target_db.metadatas[None] - return target_db.metadata - - -def run_migrations_offline(): - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online(): - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - # this callback is used to prevent an auto-migration from being generated - # when there are no changes to the schema - # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info('No changes in schema detected.') - - conf_args = current_app.extensions['migrate'].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() - - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako deleted file mode 100644 index 2c015630..00000000 --- a/backend/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/add_waiting_approval.py b/backend/migrations/versions/add_waiting_approval.py deleted file mode 100644 index 523c8a59..00000000 --- a/backend/migrations/versions/add_waiting_approval.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Add waiting_approval column to job table - -Revision ID: add_waiting_approval -Revises: af3faaa3844c -Create Date: 2025-03-12 14:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'add_waiting_approval' -down_revision = 'af3faaa3844c' -branch_labels = None -depends_on = None - - -def upgrade(): - # Füge die neue Spalte waiting_approval zur job-Tabelle hinzu - with op.batch_alter_table('job', schema=None) as batch_op: - batch_op.add_column(sa.Column('waiting_approval', sa.Integer(), server_default='0', nullable=False)) - - # SQLite-kompatible Migration für die print_job-Tabelle, falls diese existiert - try: - with op.batch_alter_table('print_job', schema=None) as batch_op: - batch_op.add_column(sa.Column('waiting_approval', sa.Boolean(), server_default='0', nullable=False)) - except Exception as e: - print(f"Migration für print_job-Tabelle übersprungen: {e}") - - -def downgrade(): - # Entferne die waiting_approval-Spalte aus der job-Tabelle - with op.batch_alter_table('job', schema=None) as batch_op: - batch_op.drop_column('waiting_approval') - - # SQLite-kompatible Migration für die print_job-Tabelle, falls diese existiert - try: - with op.batch_alter_table('print_job', schema=None) as batch_op: - batch_op.drop_column('waiting_approval') - except Exception as e: - print(f"Downgrade für print_job-Tabelle übersprungen: {e}") \ No newline at end of file diff --git a/backend/migrations/versions/af3faaa3844c_.py b/backend/migrations/versions/af3faaa3844c_.py deleted file mode 100644 index f9f2c6d5..00000000 --- a/backend/migrations/versions/af3faaa3844c_.py +++ /dev/null @@ -1,81 +0,0 @@ -"""empty message - -Revision ID: af3faaa3844c -Revises: -Create Date: 2025-03-11 11:16:04.961964 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'af3faaa3844c' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('printer', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('status', sa.Integer(), nullable=True), - sa.Column('ip_address', sa.String(length=15), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('printer', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_printer_name'), ['name'], unique=False) - - op.create_table('user', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('username', sa.String(length=64), nullable=True), - sa.Column('password_hash', sa.String(length=128), nullable=True), - sa.Column('display_name', sa.String(length=100), nullable=True), - sa.Column('email', sa.String(length=120), nullable=True), - sa.Column('role', sa.String(length=20), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('user', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) - batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) - - op.create_table('print_job', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('printer_id', sa.String(length=36), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=False), - sa.Column('start_at', sa.DateTime(), nullable=True), - sa.Column('duration_in_minutes', sa.Integer(), nullable=False), - sa.Column('comments', sa.Text(), nullable=True), - sa.Column('aborted', sa.Boolean(), nullable=True), - sa.Column('abort_reason', sa.Text(), nullable=True), - sa.ForeignKeyConstraint(['printer_id'], ['printer.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('session', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('session') - op.drop_table('print_job') - with op.batch_alter_table('user', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_user_username')) - batch_op.drop_index(batch_op.f('ix_user_email')) - - op.drop_table('user') - with op.batch_alter_table('printer', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_printer_name')) - - op.drop_table('printer') - # ### end Alembic commands ### diff --git a/backend/monitoring.py b/backend/monitoring.py deleted file mode 100644 index 04296365..00000000 --- a/backend/monitoring.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -Monitoring und Health Check Module für die MYP Flask-Anwendung. -Bietet Endpunkte für Systemüberwachung und Performance-Metriken. -""" - -from flask import Blueprint, jsonify, current_app -import psutil -import os -import sqlite3 -import datetime -import threading -import time -from collections import defaultdict - -# Blueprint für Monitoring-Endpunkte -monitoring_bp = Blueprint('monitoring', __name__, url_prefix='/monitoring') - -# Metriken-Speicher -metrics = { - 'requests_total': defaultdict(int), - 'request_duration': defaultdict(list), - 'database_queries': 0, - 'active_jobs': 0, - 'error_count': defaultdict(int), - 'startup_time': datetime.datetime.now() -} - -class HealthCheck: - """Klasse für System-Health-Checks.""" - - @staticmethod - def check_database(): - """ - Überprüft die Datenbankverbindung. - - Returns: - dict: Status und Details der Datenbankverbindung - """ - try: - db_path = current_app.config.get('DATABASE', 'instance/myp.db') - - # Bei In-Memory-DB für Tests - if db_path == ':memory:': - return {'status': 'healthy', 'message': 'In-Memory-Datenbank aktiv'} - - # Datei-basierte Datenbank prüfen - if not os.path.exists(db_path): - return {'status': 'unhealthy', 'message': 'Datenbankdatei nicht gefunden'} - - # Verbindung testen - conn = sqlite3.connect(db_path, timeout=5) - cursor = conn.cursor() - cursor.execute('SELECT 1') - conn.close() - - # Dateigröße ermitteln - db_size = os.path.getsize(db_path) - - return { - 'status': 'healthy', - 'message': 'Datenbankverbindung erfolgreich', - 'database_path': db_path, - 'database_size_bytes': db_size - } - - except Exception as e: - return { - 'status': 'unhealthy', - 'message': f'Datenbankfehler: {str(e)}' - } - - @staticmethod - def check_disk_space(): - """ - Überprüft den verfügbaren Festplattenspeicher. - - Returns: - dict: Status und Details des Festplattenspeichers - """ - try: - disk_usage = psutil.disk_usage('.') - free_gb = disk_usage.free / (1024**3) - total_gb = disk_usage.total / (1024**3) - used_percent = (disk_usage.used / disk_usage.total) * 100 - - status = 'healthy' - if used_percent > 90: - status = 'critical' - elif used_percent > 80: - status = 'warning' - - return { - 'status': status, - 'free_gb': round(free_gb, 2), - 'total_gb': round(total_gb, 2), - 'used_percent': round(used_percent, 2) - } - - except Exception as e: - return { - 'status': 'unhealthy', - 'message': f'Festplattenfehler: {str(e)}' - } - - @staticmethod - def check_memory(): - """ - Überprüft die Speichernutzung. - - Returns: - dict: Status und Details der Speichernutzung - """ - try: - memory = psutil.virtual_memory() - - status = 'healthy' - if memory.percent > 90: - status = 'critical' - elif memory.percent > 80: - status = 'warning' - - return { - 'status': status, - 'total_gb': round(memory.total / (1024**3), 2), - 'available_gb': round(memory.available / (1024**3), 2), - 'used_percent': round(memory.percent, 2) - } - - except Exception as e: - return { - 'status': 'unhealthy', - 'message': f'Speicherfehler: {str(e)}' - } - - @staticmethod - def check_background_threads(): - """ - Überprüft die Hintergrund-Threads. - - Returns: - dict: Status der Hintergrund-Threads - """ - try: - active_threads = [t.name for t in threading.enumerate() if t.is_alive()] - job_checker_running = any('job_checker' in name for name in active_threads) - - return { - 'status': 'healthy' if job_checker_running else 'warning', - 'job_checker_running': job_checker_running, - 'active_threads': active_threads, - 'thread_count': len(active_threads) - } - - except Exception as e: - return { - 'status': 'unhealthy', - 'message': f'Thread-Fehler: {str(e)}' - } - -@monitoring_bp.route('/health') -def health_check(): - """ - Umfassender Health Check aller Systemkomponenten. - - Returns: - JSON: Status aller Systemkomponenten - """ - checks = { - 'database': HealthCheck.check_database(), - 'disk_space': HealthCheck.check_disk_space(), - 'memory': HealthCheck.check_memory(), - 'background_threads': HealthCheck.check_background_threads() - } - - # Gesamtstatus bestimmen - overall_status = 'healthy' - for check in checks.values(): - if check['status'] == 'unhealthy': - overall_status = 'unhealthy' - break - elif check['status'] in ['warning', 'critical']: - overall_status = 'degraded' - - response = { - 'status': overall_status, - 'timestamp': datetime.datetime.now().isoformat(), - 'checks': checks - } - - status_code = 200 if overall_status == 'healthy' else 503 - return jsonify(response), status_code - -@monitoring_bp.route('/health/simple') -def simple_health_check(): - """ - Einfacher Health Check für Load Balancer. - - Returns: - JSON: Einfacher Status - """ - return jsonify({'status': 'ok', 'timestamp': datetime.datetime.now().isoformat()}) - -@monitoring_bp.route('/metrics') -def get_metrics(): - """ - Sammelt und gibt Performance-Metriken zurück. - - Returns: - JSON: System- und Anwendungsmetriken - """ - try: - # System-Metriken - cpu_percent = psutil.cpu_percent(interval=1) - memory = psutil.virtual_memory() - disk = psutil.disk_usage('.') - - # Uptime berechnen - uptime = datetime.datetime.now() - metrics['startup_time'] - - # Anwendungsmetriken - app_metrics = { - 'system': { - 'cpu_percent': cpu_percent, - 'memory_percent': memory.percent, - 'disk_percent': (disk.used / disk.total) * 100, - 'uptime_seconds': uptime.total_seconds() - }, - 'application': { - 'requests_total': dict(metrics['requests_total']), - 'database_queries_total': metrics['database_queries'], - 'active_jobs': metrics['active_jobs'], - 'error_count': dict(metrics['error_count']), - 'startup_time': metrics['startup_time'].isoformat() - } - } - - return jsonify(app_metrics) - - except Exception as e: - current_app.logger.error(f"Fehler beim Sammeln der Metriken: {e}") - return jsonify({'error': 'Metriken nicht verfügbar'}), 500 - -@monitoring_bp.route('/info') -def get_info(): - """ - Gibt allgemeine Informationen über die Anwendung zurück. - - Returns: - JSON: Anwendungsinformationen - """ - return jsonify({ - 'application': 'MYP Backend', - 'version': '2.0.0', - 'flask_env': current_app.config.get('FLASK_ENV', 'unknown'), - 'debug': current_app.debug, - 'startup_time': metrics['startup_time'].isoformat(), - 'python_version': os.sys.version, - 'config': { - 'database': current_app.config.get('DATABASE'), - 'job_check_interval': current_app.config.get('JOB_CHECK_INTERVAL'), - 'security_enabled': current_app.config.get('SECURITY_ENABLED', False), - 'rate_limit_enabled': current_app.config.get('RATE_LIMIT_ENABLED', False) - } - }) - -def record_request_metric(endpoint, method, status_code, duration): - """ - Zeichnet Request-Metriken auf. - - Args: - endpoint: API-Endpunkt - method: HTTP-Methode - status_code: HTTP-Status-Code - duration: Request-Dauer in Sekunden - """ - key = f"{method}_{endpoint}" - metrics['requests_total'][key] += 1 - metrics['request_duration'][key].append(duration) - - if status_code >= 400: - metrics['error_count'][str(status_code)] += 1 - -def record_database_query(): - """Zeichnet eine Datenbankabfrage auf.""" - metrics['database_queries'] += 1 - -def update_active_jobs(count): - """ - Aktualisiert die Anzahl aktiver Jobs. - - Args: - count: Anzahl aktiver Jobs - """ - metrics['active_jobs'] = count - -class RequestMetricsMiddleware: - """Middleware für automatisches Request-Tracking.""" - - def __init__(self, app=None): - self.app = app - if app is not None: - self.init_app(app) - - def init_app(self, app): - """Initialisiert die Middleware mit der Flask-App.""" - app.before_request(self.before_request) - app.after_request(self.after_request) - - def before_request(self): - """Startet die Zeitmessung für den Request.""" - from flask import g - g.start_time = time.time() - - def after_request(self, response): - """Zeichnet Metriken nach dem Request auf.""" - from flask import g, request - - if hasattr(g, 'start_time'): - duration = time.time() - g.start_time - record_request_metric( - request.endpoint or 'unknown', - request.method, - response.status_code, - duration - ) - - return response - -# Globale Middleware-Instanz -request_metrics = RequestMetricsMiddleware() \ No newline at end of file diff --git a/backend/myp-backend.service b/backend/myp-backend.service deleted file mode 100644 index 0caff33f..00000000 --- a/backend/myp-backend.service +++ /dev/null @@ -1,36 +0,0 @@ -[Unit] -Description=MYP Backend Flask Application -Documentation=https://github.com/your-org/myp -After=network.target - -[Service] -Type=notify -User=myp -Group=myp -WorkingDirectory=/opt/myp/backend -Environment=PATH=/opt/myp/venv/bin -Environment=FLASK_ENV=production -ExecStart=/opt/myp/venv/bin/gunicorn --workers=4 --worker-class=sync --bind=0.0.0.0:5000 --timeout=30 --keep-alive=5 --max-requests=1000 --max-requests-jitter=100 --preload --access-logfile=logs/access.log --error-logfile=logs/error.log --log-level=info --capture-output --enable-stdio-inheritance wsgi:application -ExecReload=/bin/kill -s HUP $MAINPID -KillMode=mixed -TimeoutStopSec=5 -PrivateTmp=true -Restart=on-failure -RestartSec=10 - -# Security settings -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/opt/myp/backend/logs /opt/myp/backend/instance -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true - -# Logging -StandardOutput=journal -StandardError=journal -SyslogIdentifier=myp-backend - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/backend/network_config.py b/backend/network_config.py deleted file mode 100644 index 8432e94b..00000000 --- a/backend/network_config.py +++ /dev/null @@ -1,185 +0,0 @@ -import os -import json -import socket -import subprocess -import platform -import netifaces -import requests -from datetime import datetime -import logging - -class NetworkConfig: - """Verwaltet die Netzwerkkonfiguration für das MYP-System.""" - - CONFIG_FILE = 'instance/network_config.json' - DEFAULT_CONFIG = { - 'backend_hostname': '192.168.0.5', - 'backend_port': 5000, - 'frontend_hostname': '192.168.0.106', - 'frontend_port': 3000 - } - - def __init__(self, app=None): - """Initialisierung der Netzwerkkonfiguration.""" - self.logger = logging.getLogger('myp.network') - self.config = self.DEFAULT_CONFIG.copy() - self.last_check = None - self.backend_status = "Nicht überprüft" - self.frontend_status = "Nicht überprüft" - - # Stelle sicher, dass das Verzeichnis existiert - os.makedirs(os.path.dirname(self.CONFIG_FILE), exist_ok=True) - - # Lade gespeicherte Konfiguration, falls vorhanden - self.load_config() - - if app: - self.init_app(app) - - def init_app(self, app): - """Initialisiert die Anwendung mit dieser Konfiguration.""" - app.network_config = self - - # Registriere Route für Netzwerkkonfiguration - @app.route('/admin/network-config', methods=['GET', 'POST']) - def network_config(): - from flask import request, render_template, flash, redirect, url_for - - # Prüfe aktuelle Status - self.check_connection_statuses() - - if request.method == 'POST': - # Aktualisiere Konfiguration - self.config['backend_hostname'] = request.form.get('backend_hostname', self.DEFAULT_CONFIG['backend_hostname']) - self.config['backend_port'] = int(request.form.get('backend_port', self.DEFAULT_CONFIG['backend_port'])) - self.config['frontend_hostname'] = request.form.get('frontend_hostname', self.DEFAULT_CONFIG['frontend_hostname']) - self.config['frontend_port'] = int(request.form.get('frontend_port', self.DEFAULT_CONFIG['frontend_port'])) - - # Speichere Konfiguration - self.save_config() - - # Teste die neue Konfiguration - self.check_connection_statuses() - - flash('Netzwerkkonfiguration erfolgreich gespeichert!', 'success') - return redirect(url_for('network_config')) - - # Ermittle Netzwerkschnittstellen - network_interfaces = self.get_network_interfaces() - - return render_template('network_config.html', - config=self.config, - backend_status=self.backend_status, - frontend_status=self.frontend_status, - last_check=self.last_check, - network_interfaces=network_interfaces, - message=request.args.get('message'), - message_type=request.args.get('message_type', 'info')) - - def load_config(self): - """Lädt die gespeicherte Konfiguration.""" - try: - if os.path.exists(self.CONFIG_FILE): - with open(self.CONFIG_FILE, 'r') as f: - saved_config = json.load(f) - self.config.update(saved_config) - self.logger.info(f"Netzwerkkonfiguration geladen: {self.config}") - else: - self.logger.info(f"Keine gespeicherte Konfiguration gefunden, verwende Standardwerte: {self.config}") - except Exception as e: - self.logger.error(f"Fehler beim Laden der Netzwerkkonfiguration: {e}") - - def save_config(self): - """Speichert die aktuelle Konfiguration.""" - try: - with open(self.CONFIG_FILE, 'w') as f: - json.dump(self.config, f, indent=4) - self.logger.info(f"Netzwerkkonfiguration gespeichert: {self.config}") - return True - except Exception as e: - self.logger.error(f"Fehler beim Speichern der Netzwerkkonfiguration: {e}") - return False - - def get_backend_url(self): - """Gibt die Backend-URL zurück.""" - return f"http://{self.config['backend_hostname']}:{self.config['backend_port']}" - - def get_frontend_url(self): - """Gibt die Frontend-URL zurück.""" - return f"http://{self.config['frontend_hostname']}:{self.config['frontend_port']}" - - def check_connection_statuses(self): - """Überprüft den Verbindungsstatus zu Backend und Frontend.""" - self.last_check = datetime.now().strftime("%d.%m.%Y %H:%M:%S") - - # Prüfe Backend-Verbindung - backend_url = self.get_backend_url() - try: - response = requests.get(f"{backend_url}/api/test", timeout=3) - if response.status_code == 200: - self.backend_status = "Verbunden" - else: - self.backend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - self.backend_status = f"Nicht erreichbar: {str(e)}" - - # Prüfe Frontend-Verbindung - frontend_url = self.get_frontend_url() - try: - response = requests.get(frontend_url, timeout=3) - if response.status_code == 200: - self.frontend_status = "Verbunden" - else: - self.frontend_status = f"Fehler: HTTP {response.status_code}" - except requests.exceptions.RequestException as e: - self.frontend_status = f"Nicht erreichbar: {str(e)}" - - self.logger.info(f"Verbindungsstatus - Backend: {self.backend_status}, Frontend: {self.frontend_status}") - - def get_network_interfaces(self): - """Gibt Informationen zu allen Netzwerkschnittstellen zurück.""" - interfaces = [] - - try: - for interface in netifaces.interfaces(): - if interface.startswith(('lo', 'docker', 'br-')): - continue # Ignoriere Loopback und Docker-Interfaces - - addresses = [] - try: - addrs = netifaces.ifaddresses(interface) - if netifaces.AF_INET in addrs: - for addr in addrs[netifaces.AF_INET]: - if 'addr' in addr: - addresses.append(addr['addr']) - except Exception as e: - self.logger.error(f"Fehler beim Ermitteln der Adresse für Interface {interface}: {e}") - - if addresses: - interfaces.append({ - 'name': interface, - 'address': ', '.join(addresses) - }) - except Exception as e: - self.logger.error(f"Fehler beim Ermitteln der Netzwerkschnittstellen: {e}") - - return interfaces - -# Helper-Funktion zum Testen von Netzwerkverbindungen -def test_connection(host, port, timeout=2): - """Testet eine TCP-Verbindung zu einem Host und Port.""" - try: - socket.setdefaulttimeout(timeout) - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect((host, port)) - s.close() - return True - except Exception as e: - return False - -# Helper-Funktion zum Ping eines Hosts -def ping_host(host, count=1): - """Pingt einen Host an.""" - param = '-n' if platform.system().lower() == 'windows' else '-c' - command = ['ping', param, str(count), host] - return subprocess.call(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index bd080b30..59110ad8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,86 +1,25 @@ -# === MYP Backend Dependencies === +# MYP V2 - Python Dependencies +# Installiere mit: pip3.11 install -r requirements.txt -# Core Flask und Extensions -Flask>=2.0.0,<3.0.0 -Flask-SQLAlchemy>=3.0.0 -Flask-CORS>=4.0.0 -Flask-JWT-Extended>=4.4.0 -Flask-Login>=0.6.0 -Flask-Migrate>=4.0.0 -Flask-Assets>=2.1.0 +# Flask Framework und Extensions +Flask==3.0.0 +Flask-Login==0.6.3 # Datenbank -SQLAlchemy>=2.0.0 +SQLAlchemy==2.0.23 -# API und Serialisierung -Flask-RESTful>=0.3.10 -marshmallow>=3.19.0 -flask-marshmallow>=0.15.0 -marshmallow-sqlalchemy>=0.29.0 +# Smart Plug Steuerung +PyP100==0.1.4 -# HTTP Requests -requests>=2.31.0 -urllib3>=2.0.0 - -# Smart Home Integration (Tapo) -PyP100>=0.1.2 - -# System und Netzwerk Monitoring -psutil>=5.9.0 -netifaces>=0.11.0 -ping3>=4.0.0 -speedtest-cli>=2.1.3 - -# Konfiguration und Umgebung -python-dotenv>=1.0.0 - -# Sicherheit -PyJWT>=2.8.0 -bcrypt>=4.0.0 -cryptography>=41.0.0 - -# CLI und Tools -click>=8.1.0 - -# Utilities -validators>=0.20.0 -python-dateutil>=2.8.0 -pytz>=2023.3 -schedule>=1.2.0 -watchdog>=3.0.0 -filelock>=3.12.0 - -# Caching -redis>=4.6.0 -cachetools>=5.3.0 - -# JSON und Data Processing -jsonschema>=4.19.0 -pydantic>=2.4.0 - -# Production Server (optional) -gunicorn>=21.2.0 -gevent>=23.7.0 -supervisor>=4.2.0 +# Passwort-Hashing (bereits in Flask enthalten, aber explizit für Klarheit) +Werkzeug==3.0.1 # Entwicklung und Testing (optional) -pytest>=7.4.0 -pytest-flask>=1.2.0 -coverage>=7.3.0 -pytest-cov>=4.1.0 +pytest==7.4.3 +pytest-cov==4.1.0 -# Logging und Monitoring -structlog>=23.1.0 -colorlog>=6.7.0 +# Produktions-Server (optional) +gunicorn==21.2.0 -# Performance und Memory -memory-profiler>=0.61.0 -line-profiler>=4.1.0 - -# WebSockets (für Real-time Updates) -Flask-SocketIO>=5.3.0 -python-socketio>=5.8.0 - -# Task Queue (optional für Background Jobs) -celery>=5.3.0 -kombu>=5.3.0 \ No newline at end of file +# Monitoring und Logging (optional) +psutil==5.9.6 \ No newline at end of file diff --git a/backend/security.py b/backend/security.py deleted file mode 100644 index 83b0c7da..00000000 --- a/backend/security.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Sicherheitsmodule und Middleware für die MYP Flask-Anwendung. -Implementiert CSRF-Schutz, Content Security Policy und weitere Sicherheitsmaßnahmen. -""" - -from flask import request, jsonify, current_app -from flask_talisman import Talisman -from functools import wraps -import time -import hashlib -import hmac -from collections import defaultdict, deque -from datetime import datetime, timedelta - -class SecurityMiddleware: - """Zentrale Sicherheits-Middleware für die Anwendung.""" - - def __init__(self, app=None): - self.app = app - self.rate_limits = defaultdict(lambda: deque()) - self.failed_attempts = defaultdict(int) - self.blocked_ips = set() - - if app is not None: - self.init_app(app) - - def init_app(self, app): - """Initialisiert die Sicherheits-Middleware mit der Flask-App.""" - self.app = app - - # Talisman für Content Security Policy und HTTPS-Enforcement - if not app.debug: - Talisman( - app, - force_https=False, # In Produktion auf True setzen, wenn HTTPS verfügbar - strict_transport_security=True, - content_security_policy={ - 'default-src': "'self'", - 'script-src': "'self' 'unsafe-inline'", - 'style-src': "'self' 'unsafe-inline'", - 'img-src': "'self' data:", - 'font-src': "'self'", - 'connect-src': "'self'", - 'form-action': "'self'" - } - ) - - # Request-Hooks registrieren - app.before_request(self.before_request_security_check) - app.after_request(self.after_request_security_headers) - - def before_request_security_check(self): - """Sicherheitsüberprüfungen vor jeder Anfrage.""" - client_ip = self.get_client_ip() - - # Blocked IPs prüfen - if client_ip in self.blocked_ips: - current_app.logger.warning(f"Blockierte IP-Adresse versucht Zugriff: {client_ip}") - return jsonify({'message': 'Zugriff verweigert'}), 403 - - # Rate Limiting - if self.is_rate_limited(client_ip): - current_app.logger.warning(f"Rate Limit überschritten für IP: {client_ip}") - return jsonify({'message': 'Zu viele Anfragen'}), 429 - - # Content-Length prüfen (Schutz vor großen Payloads) - if request.content_length and request.content_length > 10 * 1024 * 1024: # 10MB - current_app.logger.warning(f"Payload zu groß von IP: {client_ip}") - return jsonify({'message': 'Payload zu groß'}), 413 - - def after_request_security_headers(self, response): - """Fügt Sicherheits-Header zu jeder Antwort hinzu.""" - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['X-Frame-Options'] = 'DENY' - response.headers['X-XSS-Protection'] = '1; mode=block' - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' - - # Cache-Control für statische Ressourcen - if request.endpoint and 'static' in request.endpoint: - response.headers['Cache-Control'] = 'public, max-age=3600' - else: - response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' - - return response - - def get_client_ip(self): - """Ermittelt die Client-IP-Adresse.""" - if request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - elif request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - else: - return request.remote_addr - - def is_rate_limited(self, ip, max_requests=100, window_minutes=15): - """ - Überprüft Rate Limiting für eine IP-Adresse. - - Args: - ip: Client-IP-Adresse - max_requests: Maximale Anzahl Requests pro Zeitfenster - window_minutes: Zeitfenster in Minuten - - Returns: - bool: True wenn Rate Limit überschritten - """ - now = datetime.now() - window_start = now - timedelta(minutes=window_minutes) - - # Alte Einträge entfernen - while self.rate_limits[ip] and self.rate_limits[ip][0] < window_start: - self.rate_limits[ip].popleft() - - # Neue Anfrage hinzufügen - self.rate_limits[ip].append(now) - - # Rate Limit prüfen - if len(self.rate_limits[ip]) > max_requests: - return True - - return False - - def record_failed_login(self, ip): - """ - Zeichnet fehlgeschlagene Login-Versuche auf. - - Args: - ip: Client-IP-Adresse - """ - self.failed_attempts[ip] += 1 - - # Nach 5 fehlgeschlagenen Versuchen temporär blockieren - if self.failed_attempts[ip] >= 5: - self.blocked_ips.add(ip) - current_app.logger.warning(f"IP-Adresse blockiert nach zu vielen fehlgeschlagenen Login-Versuchen: {ip}") - - # Automatisches Entsperren nach 1 Stunde - def unblock_ip(): - time.sleep(3600) # 1 Stunde - if ip in self.blocked_ips: - self.blocked_ips.remove(ip) - self.failed_attempts[ip] = 0 - current_app.logger.info(f"IP-Adresse automatisch entsperrt: {ip}") - - import threading - threading.Thread(target=unblock_ip, daemon=True).start() - - def clear_failed_attempts(self, ip): - """ - Löscht fehlgeschlagene Login-Versuche für eine IP. - - Args: - ip: Client-IP-Adresse - """ - if ip in self.failed_attempts: - self.failed_attempts[ip] = 0 - -def require_api_key(f): - """ - Decorator für API-Endpunkte, die einen API-Key erfordern. - - Args: - f: Zu schützende Funktion - - Returns: - Geschützte Funktion - """ - @wraps(f) - def decorated(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - expected_key = current_app.config.get('API_KEY') - - if not expected_key: - # Kein API-Key konfiguriert, Zugriff erlauben - return f(*args, **kwargs) - - if not api_key: - return jsonify({'message': 'API-Key erforderlich'}), 401 - - # Sichere Vergleichsfunktion verwenden - if not hmac.compare_digest(api_key, expected_key): - current_app.logger.warning(f"Ungültiger API-Key von IP: {request.remote_addr}") - return jsonify({'message': 'Ungültiger API-Key'}), 401 - - return f(*args, **kwargs) - - return decorated - -def validate_csrf_token(): - """ - Validiert CSRF-Token für POST/PUT/DELETE-Requests. - - Returns: - bool: True wenn Token gültig ist - """ - if request.method in ['GET', 'HEAD', 'OPTIONS']: - return True - - token = request.headers.get('X-CSRF-Token') or request.form.get('csrf_token') - session_token = request.cookies.get('csrf_token') - - if not token or not session_token: - return False - - return hmac.compare_digest(token, session_token) - -def generate_csrf_token(): - """ - Generiert ein neues CSRF-Token. - - Returns: - str: CSRF-Token - """ - import secrets - return secrets.token_hex(32) - -# Globale Sicherheits-Middleware-Instanz -security_middleware = SecurityMiddleware() \ No newline at end of file diff --git a/backend/setup_myp.sh b/backend/setup_myp.sh new file mode 100755 index 00000000..d1d4cbe0 --- /dev/null +++ b/backend/setup_myp.sh @@ -0,0 +1,806 @@ +#!/usr/bin/env bash +# MYP V2 - Command Center +# Ein umfassendes Verwaltungsskript für MYP V2 (Manage Your Printer) + +# Fehlerabbruch aktivieren +set -e + +# Farben für bessere Lesbarkeit +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Funktion für Titel +print_header() { + clear + echo -e "${BLUE}================================================================${NC}" + echo -e "${BLUE} MYP V2 - Manage Your Printer ${NC}" + echo -e "${BLUE} Command Center ${NC}" + echo -e "${BLUE}================================================================${NC}" + echo "" +} + +# Funktion zum Überprüfen, ob der Benutzer Root-Rechte hat +check_root() { + if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Dieses Skript muss mit Root-Rechten ausgeführt werden.${NC}" + echo -e "${YELLOW}Bitte mit 'sudo ./setup_myp.sh' neu starten.${NC}" + exit 1 + fi +} + +# Funktion zum Überprüfen, ob MYP installiert ist +is_myp_installed() { + if [ -d "/opt/myp" ]; then + return 0 # true + else + return 1 # false + fi +} + +# Hauptmenü +show_main_menu() { + print_header + echo -e "${GREEN}Willkommen zum MYP V2 Command Center${NC}" + echo "" + echo -e "Bitte wählen Sie eine Option:" + echo "" + echo -e "${YELLOW}INSTALLATION & KONFIGURATION${NC}" + echo "1) MYP V2 Standardinstallation" + echo "2) MYP V2 Kiosk-Modus Installation" + echo "3) MYP V2 deinstallieren" + echo "" + echo -e "${YELLOW}NETZWERK & SYSTEM${NC}" + echo "4) Netzwerkeinstellungen konfigurieren" + echo "5) DNS-Server konfigurieren" + echo "6) System-Status anzeigen" + echo "" + echo -e "${YELLOW}WARTUNG & HILFE${NC}" + echo "7) MYP-Dienst starten/stoppen/neustarten" + echo "8) Logs anzeigen" + echo "9) Dokumentation anzeigen" + echo "" + echo "q) Beenden" + echo "" + read -p "Ihre Auswahl: " main_option + process_main_menu "$main_option" +} + +# Verarbeiten der Hauptmenü-Auswahl +process_main_menu() { + case $1 in + 1) + standard_installation + ;; + 2) + kiosk_installation + ;; + 3) + uninstall_myp + ;; + 4) + network_configuration + ;; + 5) + dns_configuration + ;; + 6) + system_status + ;; + 7) + service_management + ;; + 8) + show_logs + ;; + 9) + show_documentation + ;; + q|Q) + echo -e "${GREEN}Auf Wiedersehen!${NC}" + exit 0 + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + sleep 2 + show_main_menu + ;; + esac +} + +# 1) MYP V2 Standardinstallation +standard_installation() { + print_header + echo -e "${GREEN}MYP V2 Standardinstallation${NC}" + echo "" + + if is_myp_installed; then + echo -e "${YELLOW}MYP ist bereits installiert!${NC}" + read -p "Möchten Sie die Installation aktualisieren? (j/n): " update_option + if [[ "$update_option" != "j" && "$update_option" != "J" ]]; then + show_main_menu + return + fi + fi + + echo "Installiere MYP V2..." + + # Benötigte System-Pakete installieren + echo "Installiere System-Abhängigkeiten..." + apt update + apt install -y python3.11 python3.11-pip python3.11-venv python3.11-dev \ + build-essential git curl + + # Verzeichnis für MYP erstellen/aktualisieren + mkdir -p /opt/myp + + # Basispfad ermitteln + SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + + # Komplette Projektstruktur kopieren + echo "Kopiere Projektdateien..." + cp -r "$SCRIPT_DIR/app" /opt/myp/ + cp -r "$SCRIPT_DIR/docs" /opt/myp/ + cp -r "$SCRIPT_DIR/install" /opt/myp/ + cp "$SCRIPT_DIR/requirements.txt" /opt/myp/ + cp "$SCRIPT_DIR/README.md" /opt/myp/ + cp "$SCRIPT_DIR/ROADMAP.md" /opt/myp/ + cp "$SCRIPT_DIR/COMMON_ERRORS.md" /opt/myp/ + + # Log-Verzeichnisse erstellen + echo "Erstelle Log-Verzeichnisse..." + mkdir -p /opt/myp/logs/{app,auth,jobs,printers,scheduler} + + # Datenbank-Verzeichnis erstellen + mkdir -p /opt/myp/data + + # Python-Umgebung und Abhängigkeiten einrichten + echo "Richte Python-Umgebung ein..." + cd /opt/myp + if [ ! -d ".venv" ]; then + python3.11 -m venv .venv + fi + source .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + + # Berechtigungen setzen + echo "Setze Berechtigungen..." + chown -R www-data:www-data /opt/myp + chmod -R 755 /opt/myp + chmod -R 775 /opt/myp/logs + chmod -R 775 /opt/myp/data + + echo -e "${GREEN}Installation abgeschlossen.${NC}" + echo "" + echo -e "${YELLOW}Nächste Schritte:${NC}" + echo "1. Konfiguration anpassen: /opt/myp/app/config/settings.py" + echo "2. MYP starten:" + echo " cd /opt/myp && source .venv/bin/activate && python3.11 app/app.py" + echo "" + echo -e "${BLUE}Oder verwenden Sie Option 7 für Dienst-Management${NC}" + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 2) MYP V2 Kiosk-Modus Installation +kiosk_installation() { + print_header + echo -e "${GREEN}MYP V2 Kiosk-Modus Installation${NC}" + echo "" + + # Benötigte Pakete installieren + echo "Installiere benötigte Pakete..." + apt update + apt install -y python3.11 python3.11-pip python3.11-venv python3.11-dev \ + build-essential chromium-browser unclutter xdotool \ + xscreensaver git curl nginx + + # Verzeichnis für MYP erstellen und Projekt kopieren + echo "Kopiere MYP V2 nach /opt/myp..." + mkdir -p /opt/myp + + # Basispfad ermitteln + SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + + # Komplette Projektstruktur kopieren + cp -r "$SCRIPT_DIR/app" /opt/myp/ + cp -r "$SCRIPT_DIR/docs" /opt/myp/ + cp -r "$SCRIPT_DIR/install" /opt/myp/ + cp "$SCRIPT_DIR/requirements.txt" /opt/myp/ + cp "$SCRIPT_DIR/README.md" /opt/myp/ + cp "$SCRIPT_DIR/ROADMAP.md" /opt/myp/ + cp "$SCRIPT_DIR/COMMON_ERRORS.md" /opt/myp/ + + # Log-Verzeichnisse erstellen + mkdir -p /opt/myp/logs/{app,auth,jobs,printers,scheduler} + + # Datenbank-Verzeichnis erstellen + mkdir -p /opt/myp/data + + # Python-Umgebung und Abhängigkeiten einrichten + echo "Richte Python-Umgebung ein..." + cd /opt/myp + python3.11 -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + pip install gunicorn # Für Produktionsserver + + # Systemd-Dienst für Flask-Backend einrichten + echo "Richte Flask-Backend-Dienst ein..." + + # Service-File erstellen + cat > /etc/systemd/system/myp.service << EOF +[Unit] +Description=MYP V2 Flask Application +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/myp +Environment=PATH=/opt/myp/.venv/bin +ExecStart=/opt/myp/.venv/bin/python3.11 /opt/myp/app/app.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + + # Berechtigungen setzen + chown -R www-data:www-data /opt/myp + chmod -R 755 /opt/myp + chmod -R 775 /opt/myp/logs + chmod -R 775 /opt/myp/data + + systemctl daemon-reload + systemctl enable myp.service + systemctl start myp.service + + # Nginx-Konfiguration für Reverse Proxy + echo "Konfiguriere Nginx..." + cat > /etc/nginx/sites-available/myp << EOF +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + location /static { + alias /opt/myp/app/static; + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +EOF + + ln -sf /etc/nginx/sites-available/myp /etc/nginx/sites-enabled/ + rm -f /etc/nginx/sites-enabled/default + systemctl restart nginx + + # Kiosk-Script einrichten + echo "Richte Kiosk-Script ein..." + + cat > /opt/myp/kiosk.sh << 'EOF' +#!/bin/bash +# MYP V2 Kiosk-Modus Script + +# Bildschirmschoner deaktivieren +xset s off +xset -dpms +xset s noblank + +# Mauszeiger verstecken +unclutter -idle 0.5 -root & + +# Chromium im Kiosk-Modus starten +chromium-browser \ + --noerrdialogs \ + --disable-infobars \ + --kiosk \ + --disable-session-crashed-bubble \ + --disable-restore-session-state \ + --disable-background-timer-throttling \ + --disable-backgrounding-occluded-windows \ + --disable-renderer-backgrounding \ + --disable-features=TranslateUI \ + --disable-ipc-flooding-protection \ + --disable-background-networking \ + --disable-sync \ + --disable-default-apps \ + --no-first-run \ + --fast \ + --fast-start \ + --disable-gpu \ + --no-sandbox \ + http://localhost/ +EOF + + chmod +x /opt/myp/kiosk.sh + + # Autostart für Kiosk-Modus einrichten + mkdir -p /home/pi/.config/autostart + cat > /home/pi/.config/autostart/myp-kiosk.desktop << EOF +[Desktop Entry] +Type=Application +Name=MYP Kiosk +Exec=/opt/myp/kiosk.sh +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +EOF + + chown -R pi:pi /home/pi/.config + + echo -e "${GREEN}Kiosk-Installation abgeschlossen.${NC}" + echo "" + echo -e "${YELLOW}Das System wird beim nächsten Start automatisch im Kiosk-Modus starten.${NC}" + echo -e "${BLUE}MYP V2 ist unter http://localhost erreichbar${NC}" + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 3) MYP V2 deinstallieren +uninstall_myp() { + print_header + echo -e "${RED}MYP V2 deinstallieren${NC}" + echo "" + + if ! is_myp_installed; then + echo -e "${YELLOW}MYP ist nicht installiert.${NC}" + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu + return + fi + + read -p "Sind Sie sicher, dass Sie MYP komplett deinstallieren möchten? (j/n): " confirm + if [[ "$confirm" != "j" && "$confirm" != "J" ]]; then + show_main_menu + return + fi + + echo "Stoppe MYP-Dienste..." + systemctl stop myp.service 2>/dev/null || true + systemctl disable myp.service 2>/dev/null || true + rm -f /etc/systemd/system/myp.service + systemctl daemon-reload + + echo "Entferne Kiosk-Modus Komponenten..." + rm -f /home/pi/kiosk.sh + rm -f /home/pi/.config/autostart/myp-kiosk.desktop + + echo "Entferne MYP-Dateien..." + rm -rf /opt/myp + + echo -e "${GREEN}MYP wurde vollständig deinstalliert.${NC}" + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 4) Netzwerkeinstellungen konfigurieren +network_configuration() { + print_header + echo -e "${GREEN}Netzwerkeinstellungen konfigurieren${NC}" + echo "" + + # Netzwerkschnittstellen anzeigen + echo -e "${YELLOW}Verfügbare Netzwerkschnittstellen:${NC}" + ip -o link show | awk -F': ' '{print $2}' + echo "" + + read -p "Welche Schnittstelle möchten Sie konfigurieren? (z.B. eth0, wlan0): " interface + + # Prüfen, ob die Schnittstelle existiert + if ! ip link show "$interface" &>/dev/null; then + echo -e "${RED}Die angegebene Schnittstelle existiert nicht.${NC}" + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu + return + fi + + echo "" + echo -e "${YELLOW}Konfiguration für $interface:${NC}" + echo "1) DHCP (automatische IP-Adresse)" + echo "2) Statische IP-Adresse" + echo "3) WLAN konfigurieren (für wlan0)" + echo "4) Zurück zum Hauptmenü" + echo "" + read -p "Ihre Auswahl: " network_option + + case $network_option in + 1) + # DHCP konfigurieren + echo "Konfiguriere DHCP für $interface..." + cat > "/etc/network/interfaces.d/$interface" << EOF +auto $interface +iface $interface inet dhcp +EOF + if [ "$interface" == "wlan0" ]; then + echo "Für WLAN bitte auch SSID und Passwort konfigurieren." + network_configuration + return + fi + systemctl restart networking + echo -e "${GREEN}DHCP wurde für $interface konfiguriert.${NC}" + ;; + 2) + # Statische IP-Adresse konfigurieren + read -p "IP-Adresse (z.B. 192.168.1.100): " ip_address + read -p "Netzmaske (z.B. 255.255.255.0): " netmask + read -p "Gateway (z.B. 192.168.1.1): " gateway + + cat > "/etc/network/interfaces.d/$interface" << EOF +auto $interface +iface $interface inet static + address $ip_address + netmask $netmask + gateway $gateway +EOF + systemctl restart networking + echo -e "${GREEN}Statische IP-Adresse wurde für $interface konfiguriert.${NC}" + ;; + 3) + # WLAN konfigurieren + if [ "$interface" != "wlan0" ]; then + echo -e "${RED}Diese Option ist nur für wlan0 verfügbar.${NC}" + network_configuration + return + fi + + read -p "SSID (Netzwerkname): " ssid + read -s -p "Passwort: " password + echo "" + + # wpa_supplicant.conf erstellen/aktualisieren + wpa_passphrase "$ssid" "$password" > /etc/wpa_supplicant/wpa_supplicant.conf + + # Netzwerk-Interface konfigurieren + cat > "/etc/network/interfaces.d/wlan0" << EOF +auto wlan0 +allow-hotplug wlan0 +iface wlan0 inet dhcp + wpa-conf /etc/wpa_supplicant/wpa_supplicant.conf +EOF + + # WLAN neustarten + systemctl restart networking + wpa_cli -i wlan0 reconfigure + + echo -e "${GREEN}WLAN wurde konfiguriert und verbunden.${NC}" + ;; + 4) + show_main_menu + return + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + network_configuration + return + ;; + esac + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 5) DNS-Server konfigurieren +dns_configuration() { + print_header + echo -e "${GREEN}DNS-Server konfigurieren${NC}" + echo "" + + # Aktuelle DNS-Server anzeigen + echo -e "${YELLOW}Aktuelle DNS-Server:${NC}" + if [ -f "/etc/resolv.conf" ]; then + grep "nameserver" /etc/resolv.conf || echo "Keine DNS-Server konfiguriert." + else + echo "Die Datei /etc/resolv.conf existiert nicht." + fi + echo "" + + echo "1) DNS-Server automatisch beziehen (über DHCP)" + echo "2) Manuelle DNS-Server konfigurieren" + echo "3) Zurück zum Hauptmenü" + echo "" + read -p "Ihre Auswahl: " dns_option + + case $dns_option in + 1) + # Automatische DNS-Konfiguration + if [ -f "/etc/dhcp/dhclient.conf" ]; then + sed -i 's/^prepend domain-name-servers.*//' /etc/dhcp/dhclient.conf + echo -e "${GREEN}DNS-Server werden jetzt automatisch über DHCP bezogen.${NC}" + systemctl restart networking + else + echo -e "${RED}Die Datei /etc/dhcp/dhclient.conf existiert nicht.${NC}" + fi + ;; + 2) + # Manuelle DNS-Konfiguration + read -p "Primärer DNS-Server (z.B. 8.8.8.8): " primary_dns + read -p "Sekundärer DNS-Server (z.B. 8.8.4.4, leer lassen wenn nicht benötigt): " secondary_dns + + # resolv.conf direkt bearbeiten + echo "nameserver $primary_dns" > /etc/resolv.conf + if [ -n "$secondary_dns" ]; then + echo "nameserver $secondary_dns" >> /etc/resolv.conf + fi + + # Für persistente Konfiguration + if [ -f "/etc/dhcp/dhclient.conf" ]; then + if [ -n "$secondary_dns" ]; then + sed -i "s/^prepend domain-name-servers.*/prepend domain-name-servers $primary_dns, $secondary_dns;/" /etc/dhcp/dhclient.conf + if ! grep -q "prepend domain-name-servers" /etc/dhcp/dhclient.conf; then + echo "prepend domain-name-servers $primary_dns, $secondary_dns;" >> /etc/dhcp/dhclient.conf + fi + else + sed -i "s/^prepend domain-name-servers.*/prepend domain-name-servers $primary_dns;/" /etc/dhcp/dhclient.conf + if ! grep -q "prepend domain-name-servers" /etc/dhcp/dhclient.conf; then + echo "prepend domain-name-servers $primary_dns;" >> /etc/dhcp/dhclient.conf + fi + fi + fi + + echo -e "${GREEN}DNS-Server wurden konfiguriert.${NC}" + ;; + 3) + show_main_menu + return + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + dns_configuration + return + ;; + esac + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 6) System-Status anzeigen +system_status() { + print_header + echo -e "${GREEN}System-Status${NC}" + echo "" + + # MYP-Status + echo -e "${YELLOW}MYP-Status:${NC}" + if is_myp_installed; then + echo "MYP ist installiert in /opt/myp" + if systemctl is-active --quiet myp.service; then + echo -e "MYP-Dienst: ${GREEN}Aktiv${NC}" + else + echo -e "MYP-Dienst: ${RED}Inaktiv${NC}" + fi + else + echo -e "MYP ist ${RED}nicht installiert${NC}" + fi + echo "" + + # Netzwerkstatus + echo -e "${YELLOW}Netzwerkstatus:${NC}" + ip -o addr show | awk '$3 == "inet" {print $2 ": " $4}' + echo "" + + # DNS-Server + echo -e "${YELLOW}DNS-Server:${NC}" + grep "nameserver" /etc/resolv.conf 2>/dev/null || echo "Keine DNS-Server konfiguriert." + echo "" + + # Systemressourcen + echo -e "${YELLOW}Systemressourcen:${NC}" + echo "CPU-Auslastung:" + top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4 "% genutzt"}' + echo "Speichernutzung:" + free -h | grep "Mem:" | awk '{print $3 " von " $2 " genutzt"}' + echo "Festplattenbelegung:" + df -h / | grep -v "Filesystem" | awk '{print $3 " von " $2 " genutzt (" $5 ")"}' + echo "" + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 7) MYP-Dienst starten/stoppen/neustarten +service_management() { + print_header + echo -e "${GREEN}MYP-Dienst verwalten${NC}" + echo "" + + if ! is_myp_installed; then + echo -e "${YELLOW}MYP ist nicht installiert.${NC}" + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu + return + fi + + # Aktuellen Dienststatus anzeigen + echo -e "${YELLOW}Aktueller Status:${NC}" + if systemctl is-active --quiet myp.service; then + echo -e "MYP-Dienst: ${GREEN}Aktiv${NC}" + else + echo -e "MYP-Dienst: ${RED}Inaktiv${NC}" + fi + echo "" + + echo "1) MYP-Dienst starten" + echo "2) MYP-Dienst stoppen" + echo "3) MYP-Dienst neustarten" + echo "4) Zurück zum Hauptmenü" + echo "" + read -p "Ihre Auswahl: " service_option + + case $service_option in + 1) + echo "Starte MYP-Dienst..." + systemctl start myp.service + echo -e "${GREEN}MYP-Dienst wurde gestartet.${NC}" + ;; + 2) + echo "Stoppe MYP-Dienst..." + systemctl stop myp.service + echo -e "${GREEN}MYP-Dienst wurde gestoppt.${NC}" + ;; + 3) + echo "Starte MYP-Dienst neu..." + systemctl restart myp.service + echo -e "${GREEN}MYP-Dienst wurde neugestartet.${NC}" + ;; + 4) + show_main_menu + return + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + service_management + return + ;; + esac + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 8) Logs anzeigen +show_logs() { + print_header + echo -e "${GREEN}Logs anzeigen${NC}" + echo "" + + echo "1) MYP-Anwendungslogs" + echo "2) Systemd-Dienstlogs" + echo "3) Watchdog-Logs" + echo "4) Zurück zum Hauptmenü" + echo "" + read -p "Ihre Auswahl: " logs_option + + case $logs_option in + 1) + if [ -f "/opt/myp/myp.log" ]; then + echo -e "${YELLOW}MYP-Anwendungslogs (letzte 20 Zeilen):${NC}" + tail -n 20 /opt/myp/myp.log + else + echo -e "${RED}MYP-Logdatei nicht gefunden.${NC}" + fi + ;; + 2) + echo -e "${YELLOW}Systemd-Dienstlogs für MYP:${NC}" + journalctl -u myp.service -n 20 --no-pager + ;; + 3) + if [ -f "/home/pi/myp-watchdog.log" ]; then + echo -e "${YELLOW}Watchdog-Logs (letzte 20 Zeilen):${NC}" + tail -n 20 /home/pi/myp-watchdog.log + else + echo -e "${RED}Watchdog-Logdatei nicht gefunden.${NC}" + fi + ;; + 4) + show_main_menu + return + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + show_logs + return + ;; + esac + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# 9) Dokumentation anzeigen +show_documentation() { + print_header + echo -e "${GREEN}Dokumentation${NC}" + echo "" + + SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + + echo "Verfügbare Dokumentation:" + echo "1) Allgemeine README" + echo "2) Kiosk-Modus Anleitung" + echo "3) Fehlerbehebung" + echo "4) Entwicklungsplan" + echo "5) Zurück zum Hauptmenü" + echo "" + read -p "Ihre Auswahl: " doc_option + + case $doc_option in + 1) + if [ -f "$SCRIPT_DIR/docs/README.md" ]; then + if command -v less &>/dev/null; then + less "$SCRIPT_DIR/docs/README.md" + else + cat "$SCRIPT_DIR/docs/README.md" + fi + else + echo -e "${RED}README nicht gefunden.${NC}" + fi + ;; + 2) + if [ -f "$SCRIPT_DIR/docs/KIOSK-SETUP.md" ]; then + if command -v less &>/dev/null; then + less "$SCRIPT_DIR/docs/KIOSK-SETUP.md" + else + cat "$SCRIPT_DIR/docs/KIOSK-SETUP.md" + fi + else + echo -e "${RED}Kiosk-Anleitung nicht gefunden.${NC}" + fi + ;; + 3) + if [ -f "$SCRIPT_DIR/docs/COMMON_ERRORS.md" ]; then + if command -v less &>/dev/null; then + less "$SCRIPT_DIR/docs/COMMON_ERRORS.md" + else + cat "$SCRIPT_DIR/docs/COMMON_ERRORS.md" + fi + else + echo -e "${RED}Fehlerbehebungsdokumentation nicht gefunden.${NC}" + fi + ;; + 4) + if [ -f "$SCRIPT_DIR/docs/ROADMAP.md" ]; then + if command -v less &>/dev/null; then + less "$SCRIPT_DIR/docs/ROADMAP.md" + else + cat "$SCRIPT_DIR/docs/ROADMAP.md" + fi + else + echo -e "${RED}Entwicklungsplan nicht gefunden.${NC}" + fi + ;; + 5) + show_main_menu + return + ;; + *) + echo -e "${RED}Ungültige Option.${NC}" + show_documentation + return + ;; + esac + + read -p "Drücken Sie eine Taste, um zum Hauptmenü zurückzukehren..." + show_main_menu +} + +# Hauptprogramm +check_root +show_main_menu \ No newline at end of file diff --git a/backend/start-backend-server.ps1 b/backend/start-backend-server.ps1 deleted file mode 100644 index e6baccd0..00000000 --- a/backend/start-backend-server.ps1 +++ /dev/null @@ -1,372 +0,0 @@ -# MYP Backend - Windows PowerShell Server Start -# Startet den Backend-Server vollstaendig unabhaengig vom Frontend - -param( - [switch]$Production, - [switch]$Development, - [switch]$Logs, - [switch]$Help -) - -# Farben fuer PowerShell -$Red = "Red" -$Green = "Green" -$Yellow = "Yellow" -$Blue = "Cyan" - -function Write-Log { - param([string]$Message, [string]$Color = "White") - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - Write-Host "[$timestamp] $Message" -ForegroundColor $Color -} - -function Write-Success { - param([string]$Message) - Write-Log "SUCCESS: $Message" -Color $Green -} - -function Write-Warning { - param([string]$Message) - Write-Log "WARNING: $Message" -Color $Yellow -} - -function Write-Error { - param([string]$Message) - Write-Log "FEHLER: $Message" -Color $Red -} - -# Banner -Write-Host "========================================" -ForegroundColor $Blue -Write-Host "MYP Backend - Standalone Server Start" -ForegroundColor $Blue -Write-Host "========================================" -ForegroundColor $Blue - -if ($Help) { - Write-Host "Verwendung: .\start-backend-server.ps1 [OPTIONEN]" - Write-Host "" - Write-Host "OPTIONEN:" - Write-Host " -Production Produktionsmodus (Gunicorn)" - Write-Host " -Development Entwicklungsmodus (Flask Dev Server)" - Write-Host " -Logs Zeige Live-Logs" - Write-Host " -Help Zeige diese Hilfe" - Write-Host "" - Write-Host "BEISPIELE:" - Write-Host " .\start-backend-server.ps1 -Development" - Write-Host " .\start-backend-server.ps1 -Production" - Write-Host " .\start-backend-server.ps1 -Development -Logs" - exit 0 -} - -# Bestimme Ausfuehrungsmodus -$RunMode = "development" -if ($Production) { - $RunMode = "production" - Write-Log "Produktionsmodus aktiviert" -Color $Blue -} elseif ($Development) { - $RunMode = "development" - Write-Log "Entwicklungsmodus aktiviert" -Color $Blue -} else { - $RunMode = "development" - Write-Log "Standard-Entwicklungsmodus aktiviert" -Color $Blue -} - -# Arbeitsverzeichnis pruefen -$CurrentDir = Get-Location -Write-Log "Arbeitsverzeichnis: $CurrentDir" - -if (-not (Test-Path "app.py")) { - Write-Error "app.py nicht gefunden! Bitte im Backend-Verzeichnis ausfuehren." - exit 1 -} - -# Python-Installation pruefen -Write-Log "Pruefe Python-Installation..." -Color $Blue - -try { - $PythonVersion = python --version 2>&1 - Write-Log "Python-Version: $PythonVersion" - Write-Success "Python-Installation verifiziert" -} catch { - Write-Error "Python ist nicht installiert oder nicht im PATH!" - exit 1 -} - -# Verbesserte Funktion zum Parsen der Umgebungsvariablen -function Set-EnvironmentFromFile { - param([string]$FilePath) - - if (-not (Test-Path $FilePath)) { - Write-Warning "$FilePath nicht gefunden" - return - } - - Write-Log "Lade Backend-Umgebungsvariablen aus $FilePath..." -Color $Blue - - try { - $EnvContent = Get-Content $FilePath -Raw - $Lines = $EnvContent -split "`r?`n" - - foreach ($Line in $Lines) { - # Ueberspringe leere Zeilen und Kommentare - if ([string]::IsNullOrWhiteSpace($Line) -or $Line.TrimStart().StartsWith('#')) { - continue - } - - # Finde den ersten = Zeichen - $EqualIndex = $Line.IndexOf('=') - if ($EqualIndex -le 0) { - continue - } - - # Extrahiere Key und Value - $Key = $Line.Substring(0, $EqualIndex).Trim() - $Value = $Line.Substring($EqualIndex + 1).Trim() - - # Entferne umgebende Anfuehrungszeichen, falls vorhanden - if (($Value.StartsWith('"') -and $Value.EndsWith('"')) -or - ($Value.StartsWith("'") -and $Value.EndsWith("'"))) { - $Value = $Value.Substring(1, $Value.Length - 2) - } - - # Setze Umgebungsvariable - if (-not [string]::IsNullOrWhiteSpace($Key)) { - [Environment]::SetEnvironmentVariable($Key, $Value, "Process") - Write-Log "Geladen: $Key" -Color $Blue - } - } - - # Ueberschreibe FLASK_ENV mit dem gewaehlten Modus - [Environment]::SetEnvironmentVariable("FLASK_ENV", $RunMode, "Process") - Write-Success "Umgebungsvariablen erfolgreich geladen" - - } catch { - Write-Error "Fehler beim Laden der Umgebungsvariablen: $_" - Write-Warning "Verwende Standard-Umgebungsvariablen" - } -} - -# Umgebungsvariablen laden -Set-EnvironmentFromFile "env.backend" - -# Notwendige Verzeichnisse erstellen -Write-Log "Pruefe Verzeichnisse..." -Color $Blue - -$Directories = @("instance", "logs", "uploads") -foreach ($Dir in $Directories) { - if (-not (Test-Path $Dir)) { - New-Item -ItemType Directory -Path $Dir | Out-Null - Write-Log "Verzeichnis erstellt: $Dir" - } -} - -# Dependencies pruefen -Write-Log "Pruefe Python-Dependencies..." -Color $Blue - -if (Test-Path "requirements.txt") { - try { - pip install -r requirements.txt --quiet - Write-Success "Dependencies aktualisiert" - } catch { - Write-Warning "Fehler beim Aktualisieren der Dependencies: $_" - } -} else { - Write-Warning "requirements.txt nicht gefunden" -} - -# Datenbank initialisieren -Write-Log "Initialisiere Datenbank..." -Color $Blue - -try { - $env:FLASK_APP = "app.py" - - # Erstelle temporaere Python-Datei fuer Datenbank-Initialisierung - $TempInitFile = "temp_init.py" - $InitCode = "from app import create_app, init_db`n" - $InitCode += "app = create_app('$RunMode')`n" - $InitCode += "with app.app_context():`n" - $InitCode += " init_db()`n" - $InitCode += " print('Datenbank initialisiert')" - - $InitCode | Out-File -FilePath $TempInitFile -Encoding UTF8 - python $TempInitFile - Remove-Item $TempInitFile -Force - - Write-Success "Datenbank initialisiert" -} catch { - Write-Error "Fehler bei der Datenbank-Initialisierung: $_" - if (Test-Path $TempInitFile) { - Remove-Item $TempInitFile -Force - } - exit 1 -} - -# Port pruefen -$Port = 5000 -if ($env:PORT) { - $Port = [int]$env:PORT -} - -$PortInUse = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue -if ($PortInUse) { - Write-Warning "Port $Port ist bereits belegt!" - $Port = 5001 - Write-Log "Verwende alternativen Port $Port..." -} - -# Server starten basierend auf Modus -if ($RunMode -eq "production") { - Write-Log "Starte Backend-Server im Produktionsmodus..." -Color $Blue - - # Pruefe Gunicorn - try { - gunicorn --version | Out-Null - Write-Log "Verwende Gunicorn fuer Produktionsbetrieb" - } catch { - Write-Error "Gunicorn ist nicht installiert! Installiere mit: pip install gunicorn" - exit 1 - } - - # Gunicorn Konfiguration - $Workers = if ($env:WORKERS) { $env:WORKERS } else { 4 } - $BindAddress = if ($env:BIND_ADDRESS) { $env:BIND_ADDRESS } else { "0.0.0.0:$Port" } - $Timeout = if ($env:TIMEOUT) { $env:TIMEOUT } else { 30 } - - Write-Log "Backend-Server startet auf $BindAddress mit $Workers Workern..." - - # Starte Gunicorn - $GunicornCmd = "gunicorn --bind $BindAddress --workers $Workers --timeout $Timeout wsgi:application" - - if ($Logs) { - Write-Log "Starte mit Live-Logs..." -Color $Blue - Invoke-Expression $GunicornCmd - } else { - Write-Log "Starte im Hintergrund..." -Color $Blue - Start-Process -FilePath "gunicorn" -ArgumentList "--bind", $BindAddress, "--workers", $Workers, "--timeout", $Timeout, "wsgi:application" -NoNewWindow - - # Warte auf Server-Start - Write-Log "Warte auf Backend-Service..." -Color $Blue - $Counter = 0 - $TimeoutSeconds = 60 - - do { - Start-Sleep -Seconds 1 - $Counter++ - - try { - $Response = Invoke-WebRequest -Uri "http://localhost:$Port/monitoring/health/simple" -Method GET -TimeoutSec 5 -UseBasicParsing - if ($Response.StatusCode -eq 200) { - Write-Success "Backend-Server ist bereit!" - break - } - } catch { - # Ignoriere Fehler waehrend der Startphase - } - - if ($Counter % 10 -eq 0) { - $StatusMessage = "Warte auf Backend-Service... ($Counter/$TimeoutSeconds Sekunden)" - Write-Log $StatusMessage - } - } while ($Counter -lt $TimeoutSeconds) - - if ($Counter -eq $TimeoutSeconds) { - Write-Error "Backend-Service konnte nicht gestartet werden!" - exit 1 - } - } - -} else { - Write-Log "Starte Backend-Server im Entwicklungsmodus..." -Color $Blue - - # Flask-Entwicklungsserver - $env:FLASK_APP = "app.py" - $env:FLASK_ENV = "development" - $env:FLASK_DEBUG = "1" - - Write-Log "Backend-Server startet auf Port $Port..." - - if ($Logs) { - Write-Log "Starte mit Live-Logs..." -Color $Blue - python -m flask run --host=0.0.0.0 --port=$Port - } else { - Write-Log "Starte im Hintergrund..." -Color $Blue - $FlaskProcess = Start-Process -FilePath "python" -ArgumentList "-m", "flask", "run", "--host=0.0.0.0", "--port=$Port" -NoNewWindow -PassThru - - # Warte auf Server-Start - Write-Log "Warte auf Backend-Service..." -Color $Blue - $Counter = 0 - $TimeoutSeconds = 60 - - do { - Start-Sleep -Seconds 1 - $Counter++ - - try { - $Response = Invoke-WebRequest -Uri "http://localhost:$Port/monitoring/health/simple" -Method GET -TimeoutSec 5 -UseBasicParsing - if ($Response.StatusCode -eq 200) { - Write-Success "Backend-Server ist bereit!" - break - } - } catch { - # Ignoriere Fehler waehrend der Startphase - } - - if ($Counter % 10 -eq 0) { - $StatusMessage = "Warte auf Backend-Service... ($Counter/$TimeoutSeconds Sekunden)" - Write-Log $StatusMessage - } - } while ($Counter -lt $TimeoutSeconds) - - if ($Counter -eq $TimeoutSeconds) { - Write-Error "Backend-Service konnte nicht gestartet werden!" - if ($FlaskProcess -and !$FlaskProcess.HasExited) { - $FlaskProcess.Kill() - } - exit 1 - } - } -} - -# URLs anzeigen -Write-Host "" -Write-Success "Backend-Server erfolgreich gestartet!" -Write-Host "" -Write-Host "Backend-API: http://localhost:$Port" -ForegroundColor $Green -Write-Host "Backend-Health: http://localhost:$Port/monitoring/health/simple" -ForegroundColor $Green -Write-Host "Backend-Test: http://localhost:$Port/api/test" -ForegroundColor $Green -Write-Host "" - -if ($RunMode -eq "development") { - Write-Host "Entwicklungsmodus aktiv:" -ForegroundColor $Blue - Write-Host "- Debug-Modus ist aktiviert" -ForegroundColor $White - Write-Host "- Automatisches Neuladen bei Aenderungen" -ForegroundColor $White - Write-Host "- Detaillierte Fehlermeldungen" -ForegroundColor $White -} else { - Write-Host "Produktionsmodus aktiv:" -ForegroundColor $Blue - Write-Host "- Gunicorn mit $Workers Workern" -ForegroundColor $White - Write-Host "- Optimiert fuer Performance" -ForegroundColor $White - Write-Host "- Logging aktiviert" -ForegroundColor $White -} - -Write-Host "" - -# Logs anzeigen (optional) -if ($Logs -and $RunMode -eq "development") { - Write-Log "Zeige Backend-Logs (Strg+C zum Beenden):" -Color $Blue - if (Test-Path "logs\myp.log") { - Get-Content "logs\myp.log" -Wait - } else { - Write-Warning "Keine Log-Datei gefunden" - } -} - -if (!$Logs) { - Write-Log "Verwende Strg+C um den Server zu stoppen" -Color $Blue - - # Warte auf Benutzereingabe - try { - while ($true) { - Start-Sleep -Seconds 1 - } - } catch { - Write-Log "Server wird beendet..." -Color $Yellow - } -} \ No newline at end of file diff --git a/backend/start-backend-server.sh b/backend/start-backend-server.sh deleted file mode 100644 index 81bc7a31..00000000 --- a/backend/start-backend-server.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/bin/bash - -# 🚀 MYP Backend - Entwicklungs-Startskript -# Startet den Backend-Server mit hardgecodeter Konfiguration - -set -e - -echo "=== MYP Backend - Entwicklungsstart ===" - -# Wechsel ins Backend-Verzeichnis -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPT_DIR" - -# Hardgecodete Umgebungsvariablen setzen -export FLASK_APP=app.py -export FLASK_ENV=development -export SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -export DATABASE_PATH=instance/myp.db -export LOG_LEVEL=INFO -export JOB_CHECK_INTERVAL=60 -export SOCKET_CHECK_INTERVAL=120 -export PRINTERS='{"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}}' -export TAPO_USERNAME=till.tomczak@mercedes-benz.com -export TAPO_PASSWORD=744563017196A -export HOST=0.0.0.0 -export PORT=5000 -export BACKEND_URL=http://localhost:5000 -export UPLOAD_FOLDER=uploads -export MAX_CONTENT_LENGTH=16777216 -export DEBUG=true -export TESTING=false -export DEVELOPMENT=true -export PYTHONPATH=${PYTHONPATH}:$(pwd) - -# Prüfe Entwicklungsmodus Parameter -if [ "$1" = "--production" ]; then - export FLASK_ENV=production - export DEBUG=false - export DEVELOPMENT=false - echo "🚀 Produktions-Modus aktiviert" -elif [ "$1" = "--debug" ]; then - export FLASK_ENV=development - export DEBUG=true - export DEVELOPMENT=true - echo "🔧 Debug-Modus aktiviert" -else - echo "🔧 Standard-Entwicklungsmodus" -fi - -echo "Konfiguration:" -echo " - Flask Environment: $FLASK_ENV" -echo " - Debug Modus: $DEBUG" -echo " - Host: $HOST" -echo " - Port: $PORT" -echo " - Database: $DATABASE_PATH" -echo "" - -# Erstelle notwendige Verzeichnisse -mkdir -p instance logs uploads - -# Aktiviere virtuelle Umgebung falls vorhanden -if [ -d "venv" ]; then - echo "Aktiviere virtuelle Umgebung..." - source venv/bin/activate -else - echo "WARNUNG: Keine virtuelle Umgebung gefunden. Verwende System-Python." -fi - -# Prüfe ob App-Datei existiert -if [ ! -f "app.py" ]; then - echo "FEHLER: app.py nicht gefunden!" - exit 1 -fi - -# Starte Flask Development Server -echo "Starte Flask Development Server..." -echo "Backend verfügbar unter: http://localhost:$PORT" -echo "Health-Check: http://localhost:$PORT/health" -echo "API-Test: http://localhost:$PORT/api/test" -echo "" -echo "Zum Stoppen: Ctrl+C" -echo "" - -# Starte den Server -if [ "$FLASK_ENV" = "production" ]; then - # Produktionsmodus mit Gunicorn - echo "Starte mit Gunicorn (Produktionsmodus)..." - exec gunicorn \ - --bind=$HOST:$PORT \ - --workers=2 \ - --worker-class=sync \ - --timeout=30 \ - --keep-alive=5 \ - --reload \ - --access-logfile=logs/access.log \ - --error-logfile=logs/error.log \ - --log-level=info \ - wsgi:application -else - # Entwicklungsmodus mit Flask - echo "Starte mit Flask (Entwicklungsmodus)..." - exec python3 -m flask run --host=$HOST --port=$PORT --debug -fi \ No newline at end of file diff --git a/backend/start-debug-server.bat b/backend/start-debug-server.bat deleted file mode 100644 index f41614a4..00000000 --- a/backend/start-debug-server.bat +++ /dev/null @@ -1,36 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -echo [%date% %time%] Starte Backend-Debug-Server... - -REM Pfad zum Debug-Server ermitteln -set "SCRIPT_DIR=%~dp0" -set "DEBUG_SERVER_DIR=%SCRIPT_DIR%debug-server" - -REM Prüfe, ob das Debug-Server-Verzeichnis existiert -if not exist "%DEBUG_SERVER_DIR%" ( - echo [%date% %time%] FEHLER: Debug-Server-Verzeichnis nicht gefunden: %DEBUG_SERVER_DIR% - exit /b 1 -) - -REM Prüfe, ob Python installiert ist -where python >nul 2>&1 -if %ERRORLEVEL% neq 0 ( - echo [%date% %time%] FEHLER: Python nicht gefunden. Bitte installieren Sie Python. - exit /b 1 -) - -REM Wechsle ins Debug-Server-Verzeichnis -cd "%DEBUG_SERVER_DIR%" - -REM Installiere Abhängigkeiten, falls nötig -if exist "requirements.txt" ( - echo [%date% %time%] Installiere Abhängigkeiten... - pip install -r requirements.txt -) - -REM Starte den Debug-Server -echo [%date% %time%] Starte Backend-Debug-Server... -python app.py - -endlocal \ No newline at end of file diff --git a/backend/start-debug-server.sh b/backend/start-debug-server.sh deleted file mode 100644 index a6435713..00000000 --- a/backend/start-debug-server.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# 🔧 MYP Backend - Debug-Startskript -# Startet den Backend-Server im Debug-Modus mit maximaler Verbosity - -set -e - -echo "=== MYP Backend - Debug-Modus ===" - -# Wechsel ins Backend-Verzeichnis -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPT_DIR" - -# Hardgecodete Debug-Umgebungsvariablen -export FLASK_APP=app.py -export FLASK_ENV=development -export FLASK_DEBUG=1 -export SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -export DATABASE_PATH=instance/myp.db -export LOG_LEVEL=DEBUG -export JOB_CHECK_INTERVAL=30 -export SOCKET_CHECK_INTERVAL=60 -export PRINTERS='{"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}}' -export TAPO_USERNAME=till.tomczak@mercedes-benz.com -export TAPO_PASSWORD=744563017196A -export HOST=0.0.0.0 -export PORT=5000 -export BACKEND_URL=http://localhost:5000 -export UPLOAD_FOLDER=uploads -export MAX_CONTENT_LENGTH=16777216 -export DEBUG=true -export TESTING=false -export DEVELOPMENT=true -export PYTHONPATH=${PYTHONPATH}:$(pwd) - -echo "🔧 Debug-Konfiguration:" -echo " - Flask Environment: $FLASK_ENV" -echo " - Debug Modus: $FLASK_DEBUG" -echo " - Log Level: $LOG_LEVEL" -echo " - Host: $HOST" -echo " - Port: $PORT" -echo " - Database: $DATABASE_PATH" -echo " - Job Check Interval: $JOB_CHECK_INTERVAL Sekunden" -echo "" - -# Erstelle notwendige Verzeichnisse -mkdir -p instance logs uploads - -# Aktiviere virtuelle Umgebung falls vorhanden -if [ -d "venv" ]; then - echo "Aktiviere virtuelle Umgebung..." - source venv/bin/activate -else - echo "WARNUNG: Keine virtuelle Umgebung gefunden. Verwende System-Python." -fi - -# Prüfe ob App-Datei existiert -if [ ! -f "app.py" ]; then - echo "FEHLER: app.py nicht gefunden!" - exit 1 -fi - -# Starte Flask im Debug-Modus -echo "🚀 Starte Flask Debug-Server..." -echo "Backend verfügbar unter: http://localhost:$PORT" -echo "Health-Check: http://localhost:$PORT/health" -echo "API-Test: http://localhost:$PORT/api/test" -echo "" -echo "🔧 Debug-Features aktiviert:" -echo " - Auto-Reload bei Code-Änderungen" -echo " - Detaillierte Error-Pages" -echo " - Erweiterte Logging-Ausgabe" -echo " - Schnellere Job-Check-Intervalle" -echo "" -echo "Zum Stoppen: Ctrl+C" -echo "" - -# Starte Flask mit Debug-Optionen -exec python3 -m flask run \ - --host=$HOST \ - --port=$PORT \ - --debug \ - --reload \ - --debugger \ No newline at end of file diff --git a/backend/start-production.bat b/backend/start-production.bat deleted file mode 100644 index d13a7883..00000000 --- a/backend/start-production.bat +++ /dev/null @@ -1,38 +0,0 @@ -@echo off -REM MYP Backend - Produktions-Startskript für Windows -REM Startet die Flask-Anwendung mit Waitress für den Produktionsbetrieb - -echo === MYP Backend - Produktionsstart === - -REM Konfiguration -if not defined WORKERS set WORKERS=4 -if not defined BIND_ADDRESS set BIND_ADDRESS=0.0.0.0 -if not defined PORT set PORT=5000 -if not defined THREADS set THREADS=6 - -REM Umgebungsvariablen -set FLASK_ENV=production -set PYTHONPATH=%PYTHONPATH%;%cd% - -REM Log-Verzeichnis erstellen -if not exist logs mkdir logs - -REM Prüfe, ob SECRET_KEY gesetzt ist -if not defined SECRET_KEY ( - echo WARNUNG: SECRET_KEY ist nicht gesetzt. Verwende einen generierten Schlüssel. - for /f %%i in ('python -c "import secrets; print(secrets.token_hex(32))"') do set SECRET_KEY=%%i -) - -REM Produktionsparameter ausgeben -echo Konfiguration: -echo - Host: %BIND_ADDRESS% -echo - Port: %PORT% -echo - Threads: %THREADS% -echo - Environment: %FLASK_ENV% -echo. - -REM Waitress starten (besser für Windows als Gunicorn) -echo Starte Waitress-Server... -python -m waitress --host=%BIND_ADDRESS% --port=%PORT% --threads=%THREADS% --call wsgi:application - -pause \ No newline at end of file diff --git a/backend/start-production.sh b/backend/start-production.sh deleted file mode 100644 index 8f5cc0c0..00000000 --- a/backend/start-production.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash - -# MYP Backend - Produktions-Startskript -# Startet die Flask-Anwendung mit Gunicorn für den Produktionsbetrieb - -set -e - -echo "=== MYP Backend - Produktionsstart ===" - -# Wechsel ins Backend-Verzeichnis -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPT_DIR" - -# Hardgecodete Produktions-Umgebungsvariablen -export FLASK_APP=app.py -export FLASK_ENV=production -export SECRET_KEY=7445630171969DFAC92C53CEC92E67A9CB2E00B3CB2F -export DATABASE_PATH=instance/myp.db -export LOG_LEVEL=INFO -export JOB_CHECK_INTERVAL=60 -export SOCKET_CHECK_INTERVAL=120 -export PRINTERS='{"Drucker 1": {"ip": "192.168.0.100"}, "Drucker 2": {"ip": "192.168.0.101"}, "Drucker 3": {"ip": "192.168.0.102"}, "Drucker 4": {"ip": "192.168.0.103"}, "Drucker 5": {"ip": "192.168.0.104"}, "Drucker 6": {"ip": "192.168.0.106"}}' -export TAPO_USERNAME=till.tomczak@mercedes-benz.com -export TAPO_PASSWORD=744563017196A -export HOST=0.0.0.0 -export PORT=5000 -export BACKEND_URL=http://localhost:5000 -export UPLOAD_FOLDER=uploads -export MAX_CONTENT_LENGTH=16777216 -export DEBUG=false -export TESTING=false -export DEVELOPMENT=false -export PYTHONPATH=${PYTHONPATH}:$(pwd) - -# Konfiguration -WORKERS=${WORKERS:-4} -BIND_ADDRESS=${BIND_ADDRESS:-"0.0.0.0:5000"} -TIMEOUT=${TIMEOUT:-30} -KEEP_ALIVE=${KEEP_ALIVE:-5} -MAX_REQUESTS=${MAX_REQUESTS:-1000} -MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} - -# Log-Verzeichnis erstellen -mkdir -p logs - -# Produktionsparameter ausgeben -echo "Konfiguration:" -echo " - Workers: $WORKERS" -echo " - Bind: $BIND_ADDRESS" -echo " - Timeout: $TIMEOUT Sekunden" -echo " - Max Requests: $MAX_REQUESTS" -echo " - Environment: $FLASK_ENV" -echo " - Database: $DATABASE_PATH" -echo " - Log Level: $LOG_LEVEL" -echo "" - -# Aktiviere virtuelle Umgebung falls vorhanden -if [ -d "venv" ]; then - echo "Aktiviere virtuelle Umgebung..." - source venv/bin/activate -else - echo "WARNUNG: Keine virtuelle Umgebung gefunden. Verwende System-Python." -fi - -# Gunicorn starten -echo "Starte Gunicorn-Server..." -exec gunicorn \ - --workers=$WORKERS \ - --worker-class=sync \ - --bind=$BIND_ADDRESS \ - --timeout=$TIMEOUT \ - --keep-alive=$KEEP_ALIVE \ - --max-requests=$MAX_REQUESTS \ - --max-requests-jitter=$MAX_REQUESTS_JITTER \ - --preload \ - --access-logfile=logs/access.log \ - --error-logfile=logs/error.log \ - --log-level=info \ - --capture-output \ - --enable-stdio-inheritance \ - wsgi:application \ No newline at end of file diff --git a/backend/static/css/bootstrap.css b/backend/static/css/bootstrap.css deleted file mode 100644 index 30aae55e..00000000 --- a/backend/static/css/bootstrap.css +++ /dev/null @@ -1,12068 +0,0 @@ -@charset "UTF-8"; -/*! - * Bootstrap v5.3.2 (https://getbootstrap.com/) - * Copyright 2011-2023 The Bootstrap Authors - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -:root, -[data-bs-theme=light] { - --bs-blue: #0d6efd; - --bs-indigo: #6610f2; - --bs-purple: #6f42c1; - --bs-pink: #d63384; - --bs-red: #dc3545; - --bs-orange: #fd7e14; - --bs-yellow: #ffc107; - --bs-green: #198754; - --bs-teal: #20c997; - --bs-cyan: #0dcaf0; - --bs-black: #000; - --bs-white: #fff; - --bs-gray: #6c757d; - --bs-gray-dark: #343a40; - --bs-gray-100: #f8f9fa; - --bs-gray-200: #e9ecef; - --bs-gray-300: #dee2e6; - --bs-gray-400: #ced4da; - --bs-gray-500: #adb5bd; - --bs-gray-600: #6c757d; - --bs-gray-700: #495057; - --bs-gray-800: #343a40; - --bs-gray-900: #212529; - --bs-primary: #0d6efd; - --bs-secondary: #6c757d; - --bs-success: #198754; - --bs-info: #0dcaf0; - --bs-warning: #ffc107; - --bs-danger: #dc3545; - --bs-light: #f8f9fa; - --bs-dark: #212529; - --bs-primary-rgb: 13, 110, 253; - --bs-secondary-rgb: 108, 117, 125; - --bs-success-rgb: 25, 135, 84; - --bs-info-rgb: 13, 202, 240; - --bs-warning-rgb: 255, 193, 7; - --bs-danger-rgb: 220, 53, 69; - --bs-light-rgb: 248, 249, 250; - --bs-dark-rgb: 33, 37, 41; - --bs-primary-text-emphasis: #052c65; - --bs-secondary-text-emphasis: #2b2f32; - --bs-success-text-emphasis: #0a3622; - --bs-info-text-emphasis: #055160; - --bs-warning-text-emphasis: #664d03; - --bs-danger-text-emphasis: #58151c; - --bs-light-text-emphasis: #495057; - --bs-dark-text-emphasis: #495057; - --bs-primary-bg-subtle: #cfe2ff; - --bs-secondary-bg-subtle: #e2e3e5; - --bs-success-bg-subtle: #d1e7dd; - --bs-info-bg-subtle: #cff4fc; - --bs-warning-bg-subtle: #fff3cd; - --bs-danger-bg-subtle: #f8d7da; - --bs-light-bg-subtle: #fcfcfd; - --bs-dark-bg-subtle: #ced4da; - --bs-primary-border-subtle: #9ec5fe; - --bs-secondary-border-subtle: #c4c8cb; - --bs-success-border-subtle: #a3cfbb; - --bs-info-border-subtle: #9eeaf9; - --bs-warning-border-subtle: #ffe69c; - --bs-danger-border-subtle: #f1aeb5; - --bs-light-border-subtle: #e9ecef; - --bs-dark-border-subtle: #adb5bd; - --bs-white-rgb: 255, 255, 255; - --bs-black-rgb: 0, 0, 0; - --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); - --bs-body-font-family: var(--bs-font-sans-serif); - --bs-body-font-size: 1rem; - --bs-body-font-weight: 400; - --bs-body-line-height: 1.5; - --bs-body-color: #212529; - --bs-body-color-rgb: 33, 37, 41; - --bs-body-bg: #fff; - --bs-body-bg-rgb: 255, 255, 255; - --bs-emphasis-color: #000; - --bs-emphasis-color-rgb: 0, 0, 0; - --bs-secondary-color: rgba(33, 37, 41, 0.75); - --bs-secondary-color-rgb: 33, 37, 41; - --bs-secondary-bg: #e9ecef; - --bs-secondary-bg-rgb: 233, 236, 239; - --bs-tertiary-color: rgba(33, 37, 41, 0.5); - --bs-tertiary-color-rgb: 33, 37, 41; - --bs-tertiary-bg: #f8f9fa; - --bs-tertiary-bg-rgb: 248, 249, 250; - --bs-heading-color: inherit; - --bs-link-color: #0d6efd; - --bs-link-color-rgb: 13, 110, 253; - --bs-link-decoration: underline; - --bs-link-hover-color: #0a58ca; - --bs-link-hover-color-rgb: 10, 88, 202; - --bs-code-color: #d63384; - --bs-highlight-color: #212529; - --bs-highlight-bg: #fff3cd; - --bs-border-width: 1px; - --bs-border-style: solid; - --bs-border-color: #dee2e6; - --bs-border-color-translucent: rgba(0, 0, 0, 0.175); - --bs-border-radius: 0.375rem; - --bs-border-radius-sm: 0.25rem; - --bs-border-radius-lg: 0.5rem; - --bs-border-radius-xl: 1rem; - --bs-border-radius-xxl: 2rem; - --bs-border-radius-2xl: var(--bs-border-radius-xxl); - --bs-border-radius-pill: 50rem; - --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); - --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); - --bs-focus-ring-width: 0.25rem; - --bs-focus-ring-opacity: 0.25; - --bs-focus-ring-color: rgba(13, 110, 253, 0.25); - --bs-form-valid-color: #198754; - --bs-form-valid-border-color: #198754; - --bs-form-invalid-color: #dc3545; - --bs-form-invalid-border-color: #dc3545; -} - -[data-bs-theme=dark] { - color-scheme: dark; - --bs-body-color: #dee2e6; - --bs-body-color-rgb: 222, 226, 230; - --bs-body-bg: #212529; - --bs-body-bg-rgb: 33, 37, 41; - --bs-emphasis-color: #fff; - --bs-emphasis-color-rgb: 255, 255, 255; - --bs-secondary-color: rgba(222, 226, 230, 0.75); - --bs-secondary-color-rgb: 222, 226, 230; - --bs-secondary-bg: #343a40; - --bs-secondary-bg-rgb: 52, 58, 64; - --bs-tertiary-color: rgba(222, 226, 230, 0.5); - --bs-tertiary-color-rgb: 222, 226, 230; - --bs-tertiary-bg: #2b3035; - --bs-tertiary-bg-rgb: 43, 48, 53; - --bs-primary-text-emphasis: #6ea8fe; - --bs-secondary-text-emphasis: #a7acb1; - --bs-success-text-emphasis: #75b798; - --bs-info-text-emphasis: #6edff6; - --bs-warning-text-emphasis: #ffda6a; - --bs-danger-text-emphasis: #ea868f; - --bs-light-text-emphasis: #f8f9fa; - --bs-dark-text-emphasis: #dee2e6; - --bs-primary-bg-subtle: #031633; - --bs-secondary-bg-subtle: #161719; - --bs-success-bg-subtle: #051b11; - --bs-info-bg-subtle: #032830; - --bs-warning-bg-subtle: #332701; - --bs-danger-bg-subtle: #2c0b0e; - --bs-light-bg-subtle: #343a40; - --bs-dark-bg-subtle: #1a1d20; - --bs-primary-border-subtle: #084298; - --bs-secondary-border-subtle: #41464b; - --bs-success-border-subtle: #0f5132; - --bs-info-border-subtle: #087990; - --bs-warning-border-subtle: #997404; - --bs-danger-border-subtle: #842029; - --bs-light-border-subtle: #495057; - --bs-dark-border-subtle: #343a40; - --bs-heading-color: inherit; - --bs-link-color: #6ea8fe; - --bs-link-hover-color: #8bb9fe; - --bs-link-color-rgb: 110, 168, 254; - --bs-link-hover-color-rgb: 139, 185, 254; - --bs-code-color: #e685b5; - --bs-highlight-color: #dee2e6; - --bs-highlight-bg: #664d03; - --bs-border-color: #495057; - --bs-border-color-translucent: rgba(255, 255, 255, 0.15); - --bs-form-valid-color: #75b798; - --bs-form-valid-border-color: #75b798; - --bs-form-invalid-color: #ea868f; - --bs-form-invalid-border-color: #ea868f; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -@media (prefers-reduced-motion: no-preference) { - :root { - scroll-behavior: smooth; - } -} - -body { - margin: 0; - font-family: var(--bs-body-font-family); - font-size: var(--bs-body-font-size); - font-weight: var(--bs-body-font-weight); - line-height: var(--bs-body-line-height); - color: var(--bs-body-color); - text-align: var(--bs-body-text-align); - background-color: var(--bs-body-bg); - -webkit-text-size-adjust: 100%; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -} - -hr { - margin: 1rem 0; - color: inherit; - border: 0; - border-top: var(--bs-border-width) solid; - opacity: 0.25; -} - -h6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 { - margin-top: 0; - margin-bottom: 0.5rem; - font-weight: 500; - line-height: 1.2; - color: var(--bs-heading-color); -} - -h1, .h1 { - font-size: calc(1.375rem + 1.5vw); -} -@media (min-width: 1200px) { - h1, .h1 { - font-size: 2.5rem; - } -} - -h2, .h2 { - font-size: calc(1.325rem + 0.9vw); -} -@media (min-width: 1200px) { - h2, .h2 { - font-size: 2rem; - } -} - -h3, .h3 { - font-size: calc(1.3rem + 0.6vw); -} -@media (min-width: 1200px) { - h3, .h3 { - font-size: 1.75rem; - } -} - -h4, .h4 { - font-size: calc(1.275rem + 0.3vw); -} -@media (min-width: 1200px) { - h4, .h4 { - font-size: 1.5rem; - } -} - -h5, .h5 { - font-size: 1.25rem; -} - -h6, .h6 { - font-size: 1rem; -} - -p { - margin-top: 0; - margin-bottom: 1rem; -} - -abbr[title] { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - cursor: help; - -webkit-text-decoration-skip-ink: none; - text-decoration-skip-ink: none; -} - -address { - margin-bottom: 1rem; - font-style: normal; - line-height: inherit; -} - -ol, -ul { - padding-left: 2rem; -} - -ol, -ul, -dl { - margin-top: 0; - margin-bottom: 1rem; -} - -ol ol, -ul ul, -ol ul, -ul ol { - margin-bottom: 0; -} - -dt { - font-weight: 700; -} - -dd { - margin-bottom: 0.5rem; - margin-left: 0; -} - -blockquote { - margin: 0 0 1rem; -} - -b, -strong { - font-weight: bolder; -} - -small, .small { - font-size: 0.875em; -} - -mark, .mark { - padding: 0.1875em; - color: var(--bs-highlight-color); - background-color: var(--bs-highlight-bg); -} - -sub, -sup { - position: relative; - font-size: 0.75em; - line-height: 0; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -a { - color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); - text-decoration: underline; -} -a:hover { - --bs-link-color-rgb: var(--bs-link-hover-color-rgb); -} - -a:not([href]):not([class]), a:not([href]):not([class]):hover { - color: inherit; - text-decoration: none; -} - -pre, -code, -kbd, -samp { - font-family: var(--bs-font-monospace); - font-size: 1em; -} - -pre { - display: block; - margin-top: 0; - margin-bottom: 1rem; - overflow: auto; - font-size: 0.875em; -} -pre code { - font-size: inherit; - color: inherit; - word-break: normal; -} - -code { - font-size: 0.875em; - color: var(--bs-code-color); - word-wrap: break-word; -} -a > code { - color: inherit; -} - -kbd { - padding: 0.1875rem 0.375rem; - font-size: 0.875em; - color: var(--bs-body-bg); - background-color: var(--bs-body-color); - border-radius: 0.25rem; -} -kbd kbd { - padding: 0; - font-size: 1em; -} - -figure { - margin: 0 0 1rem; -} - -img, -svg { - vertical-align: middle; -} - -table { - caption-side: bottom; - border-collapse: collapse; -} - -caption { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - color: var(--bs-secondary-color); - text-align: left; -} - -th { - text-align: inherit; - text-align: -webkit-match-parent; -} - -thead, -tbody, -tfoot, -tr, -td, -th { - border-color: inherit; - border-style: solid; - border-width: 0; -} - -label { - display: inline-block; -} - -button { - border-radius: 0; -} - -button:focus:not(:focus-visible) { - outline: 0; -} - -input, -button, -select, -optgroup, -textarea { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - -button, -select { - text-transform: none; -} - -[role=button] { - cursor: pointer; -} - -select { - word-wrap: normal; -} -select:disabled { - opacity: 1; -} - -[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator { - display: none !important; -} - -button, -[type=button], -[type=reset], -[type=submit] { - -webkit-appearance: button; -} -button:not(:disabled), -[type=button]:not(:disabled), -[type=reset]:not(:disabled), -[type=submit]:not(:disabled) { - cursor: pointer; -} - -::-moz-focus-inner { - padding: 0; - border-style: none; -} - -textarea { - resize: vertical; -} - -fieldset { - min-width: 0; - padding: 0; - margin: 0; - border: 0; -} - -legend { - float: left; - width: 100%; - padding: 0; - margin-bottom: 0.5rem; - font-size: calc(1.275rem + 0.3vw); - line-height: inherit; -} -@media (min-width: 1200px) { - legend { - font-size: 1.5rem; - } -} -legend + * { - clear: left; -} - -::-webkit-datetime-edit-fields-wrapper, -::-webkit-datetime-edit-text, -::-webkit-datetime-edit-minute, -::-webkit-datetime-edit-hour-field, -::-webkit-datetime-edit-day-field, -::-webkit-datetime-edit-month-field, -::-webkit-datetime-edit-year-field { - padding: 0; -} - -::-webkit-inner-spin-button { - height: auto; -} - -[type=search] { - -webkit-appearance: textfield; - outline-offset: -2px; -} - -/* rtl:raw: -[type="tel"], -[type="url"], -[type="email"], -[type="number"] { - direction: ltr; -} -*/ -::-webkit-search-decoration { - -webkit-appearance: none; -} - -::-webkit-color-swatch-wrapper { - padding: 0; -} - -::-webkit-file-upload-button { - font: inherit; - -webkit-appearance: button; -} - -::file-selector-button { - font: inherit; - -webkit-appearance: button; -} - -output { - display: inline-block; -} - -iframe { - border: 0; -} - -summary { - display: list-item; - cursor: pointer; -} - -progress { - vertical-align: baseline; -} - -[hidden] { - display: none !important; -} - -.lead { - font-size: 1.25rem; - font-weight: 300; -} - -.display-1 { - font-size: calc(1.625rem + 4.5vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-1 { - font-size: 5rem; - } -} - -.display-2 { - font-size: calc(1.575rem + 3.9vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-2 { - font-size: 4.5rem; - } -} - -.display-3 { - font-size: calc(1.525rem + 3.3vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-3 { - font-size: 4rem; - } -} - -.display-4 { - font-size: calc(1.475rem + 2.7vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-4 { - font-size: 3.5rem; - } -} - -.display-5 { - font-size: calc(1.425rem + 2.1vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-5 { - font-size: 3rem; - } -} - -.display-6 { - font-size: calc(1.375rem + 1.5vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-6 { - font-size: 2.5rem; - } -} - -.list-unstyled { - padding-left: 0; - list-style: none; -} - -.list-inline { - padding-left: 0; - list-style: none; -} - -.list-inline-item { - display: inline-block; -} -.list-inline-item:not(:last-child) { - margin-right: 0.5rem; -} - -.initialism { - font-size: 0.875em; - text-transform: uppercase; -} - -.blockquote { - margin-bottom: 1rem; - font-size: 1.25rem; -} -.blockquote > :last-child { - margin-bottom: 0; -} - -.blockquote-footer { - margin-top: -1rem; - margin-bottom: 1rem; - font-size: 0.875em; - color: #6c757d; -} -.blockquote-footer::before { - content: "— "; -} - -.img-fluid { - max-width: 100%; - height: auto; -} - -.img-thumbnail { - padding: 0.25rem; - background-color: var(--bs-body-bg); - border: var(--bs-border-width) solid var(--bs-border-color); - border-radius: var(--bs-border-radius); - max-width: 100%; - height: auto; -} - -.figure { - display: inline-block; -} - -.figure-img { - margin-bottom: 0.5rem; - line-height: 1; -} - -.figure-caption { - font-size: 0.875em; - color: var(--bs-secondary-color); -} - -.container, -.container-fluid, -.container-xxl, -.container-xl, -.container-lg, -.container-md, -.container-sm { - --bs-gutter-x: 1.5rem; - --bs-gutter-y: 0; - width: 100%; - padding-right: calc(var(--bs-gutter-x) * 0.5); - padding-left: calc(var(--bs-gutter-x) * 0.5); - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - .container-sm, .container { - max-width: 540px; - } -} -@media (min-width: 768px) { - .container-md, .container-sm, .container { - max-width: 720px; - } -} -@media (min-width: 992px) { - .container-lg, .container-md, .container-sm, .container { - max-width: 960px; - } -} -@media (min-width: 1200px) { - .container-xl, .container-lg, .container-md, .container-sm, .container { - max-width: 1140px; - } -} -@media (min-width: 1400px) { - .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { - max-width: 1320px; - } -} -:root { - --bs-breakpoint-xs: 0; - --bs-breakpoint-sm: 576px; - --bs-breakpoint-md: 768px; - --bs-breakpoint-lg: 992px; - --bs-breakpoint-xl: 1200px; - --bs-breakpoint-xxl: 1400px; -} - -.row { - --bs-gutter-x: 1.5rem; - --bs-gutter-y: 0; - display: flex; - flex-wrap: wrap; - margin-top: calc(-1 * var(--bs-gutter-y)); - margin-right: calc(-0.5 * var(--bs-gutter-x)); - margin-left: calc(-0.5 * var(--bs-gutter-x)); -} -.row > * { - flex-shrink: 0; - width: 100%; - max-width: 100%; - padding-right: calc(var(--bs-gutter-x) * 0.5); - padding-left: calc(var(--bs-gutter-x) * 0.5); - margin-top: var(--bs-gutter-y); -} - -.col { - flex: 1 0 0%; -} - -.row-cols-auto > * { - flex: 0 0 auto; - width: auto; -} - -.row-cols-1 > * { - flex: 0 0 auto; - width: 100%; -} - -.row-cols-2 > * { - flex: 0 0 auto; - width: 50%; -} - -.row-cols-3 > * { - flex: 0 0 auto; - width: 33.33333333%; -} - -.row-cols-4 > * { - flex: 0 0 auto; - width: 25%; -} - -.row-cols-5 > * { - flex: 0 0 auto; - width: 20%; -} - -.row-cols-6 > * { - flex: 0 0 auto; - width: 16.66666667%; -} - -.col-auto { - flex: 0 0 auto; - width: auto; -} - -.col-1 { - flex: 0 0 auto; - width: 8.33333333%; -} - -.col-2 { - flex: 0 0 auto; - width: 16.66666667%; -} - -.col-3 { - flex: 0 0 auto; - width: 25%; -} - -.col-4 { - flex: 0 0 auto; - width: 33.33333333%; -} - -.col-5 { - flex: 0 0 auto; - width: 41.66666667%; -} - -.col-6 { - flex: 0 0 auto; - width: 50%; -} - -.col-7 { - flex: 0 0 auto; - width: 58.33333333%; -} - -.col-8 { - flex: 0 0 auto; - width: 66.66666667%; -} - -.col-9 { - flex: 0 0 auto; - width: 75%; -} - -.col-10 { - flex: 0 0 auto; - width: 83.33333333%; -} - -.col-11 { - flex: 0 0 auto; - width: 91.66666667%; -} - -.col-12 { - flex: 0 0 auto; - width: 100%; -} - -.offset-1 { - margin-left: 8.33333333%; -} - -.offset-2 { - margin-left: 16.66666667%; -} - -.offset-3 { - margin-left: 25%; -} - -.offset-4 { - margin-left: 33.33333333%; -} - -.offset-5 { - margin-left: 41.66666667%; -} - -.offset-6 { - margin-left: 50%; -} - -.offset-7 { - margin-left: 58.33333333%; -} - -.offset-8 { - margin-left: 66.66666667%; -} - -.offset-9 { - margin-left: 75%; -} - -.offset-10 { - margin-left: 83.33333333%; -} - -.offset-11 { - margin-left: 91.66666667%; -} - -.g-0, -.gx-0 { - --bs-gutter-x: 0; -} - -.g-0, -.gy-0 { - --bs-gutter-y: 0; -} - -.g-1, -.gx-1 { - --bs-gutter-x: 0.25rem; -} - -.g-1, -.gy-1 { - --bs-gutter-y: 0.25rem; -} - -.g-2, -.gx-2 { - --bs-gutter-x: 0.5rem; -} - -.g-2, -.gy-2 { - --bs-gutter-y: 0.5rem; -} - -.g-3, -.gx-3 { - --bs-gutter-x: 1rem; -} - -.g-3, -.gy-3 { - --bs-gutter-y: 1rem; -} - -.g-4, -.gx-4 { - --bs-gutter-x: 1.5rem; -} - -.g-4, -.gy-4 { - --bs-gutter-y: 1.5rem; -} - -.g-5, -.gx-5 { - --bs-gutter-x: 3rem; -} - -.g-5, -.gy-5 { - --bs-gutter-y: 3rem; -} - -@media (min-width: 576px) { - .col-sm { - flex: 1 0 0%; - } - .row-cols-sm-auto > * { - flex: 0 0 auto; - width: auto; - } - .row-cols-sm-1 > * { - flex: 0 0 auto; - width: 100%; - } - .row-cols-sm-2 > * { - flex: 0 0 auto; - width: 50%; - } - .row-cols-sm-3 > * { - flex: 0 0 auto; - width: 33.33333333%; - } - .row-cols-sm-4 > * { - flex: 0 0 auto; - width: 25%; - } - .row-cols-sm-5 > * { - flex: 0 0 auto; - width: 20%; - } - .row-cols-sm-6 > * { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-sm-auto { - flex: 0 0 auto; - width: auto; - } - .col-sm-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - .col-sm-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-sm-3 { - flex: 0 0 auto; - width: 25%; - } - .col-sm-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - .col-sm-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - .col-sm-6 { - flex: 0 0 auto; - width: 50%; - } - .col-sm-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - .col-sm-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - .col-sm-9 { - flex: 0 0 auto; - width: 75%; - } - .col-sm-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - .col-sm-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - .col-sm-12 { - flex: 0 0 auto; - width: 100%; - } - .offset-sm-0 { - margin-left: 0; - } - .offset-sm-1 { - margin-left: 8.33333333%; - } - .offset-sm-2 { - margin-left: 16.66666667%; - } - .offset-sm-3 { - margin-left: 25%; - } - .offset-sm-4 { - margin-left: 33.33333333%; - } - .offset-sm-5 { - margin-left: 41.66666667%; - } - .offset-sm-6 { - margin-left: 50%; - } - .offset-sm-7 { - margin-left: 58.33333333%; - } - .offset-sm-8 { - margin-left: 66.66666667%; - } - .offset-sm-9 { - margin-left: 75%; - } - .offset-sm-10 { - margin-left: 83.33333333%; - } - .offset-sm-11 { - margin-left: 91.66666667%; - } - .g-sm-0, - .gx-sm-0 { - --bs-gutter-x: 0; - } - .g-sm-0, - .gy-sm-0 { - --bs-gutter-y: 0; - } - .g-sm-1, - .gx-sm-1 { - --bs-gutter-x: 0.25rem; - } - .g-sm-1, - .gy-sm-1 { - --bs-gutter-y: 0.25rem; - } - .g-sm-2, - .gx-sm-2 { - --bs-gutter-x: 0.5rem; - } - .g-sm-2, - .gy-sm-2 { - --bs-gutter-y: 0.5rem; - } - .g-sm-3, - .gx-sm-3 { - --bs-gutter-x: 1rem; - } - .g-sm-3, - .gy-sm-3 { - --bs-gutter-y: 1rem; - } - .g-sm-4, - .gx-sm-4 { - --bs-gutter-x: 1.5rem; - } - .g-sm-4, - .gy-sm-4 { - --bs-gutter-y: 1.5rem; - } - .g-sm-5, - .gx-sm-5 { - --bs-gutter-x: 3rem; - } - .g-sm-5, - .gy-sm-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 768px) { - .col-md { - flex: 1 0 0%; - } - .row-cols-md-auto > * { - flex: 0 0 auto; - width: auto; - } - .row-cols-md-1 > * { - flex: 0 0 auto; - width: 100%; - } - .row-cols-md-2 > * { - flex: 0 0 auto; - width: 50%; - } - .row-cols-md-3 > * { - flex: 0 0 auto; - width: 33.33333333%; - } - .row-cols-md-4 > * { - flex: 0 0 auto; - width: 25%; - } - .row-cols-md-5 > * { - flex: 0 0 auto; - width: 20%; - } - .row-cols-md-6 > * { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-md-auto { - flex: 0 0 auto; - width: auto; - } - .col-md-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - .col-md-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-md-3 { - flex: 0 0 auto; - width: 25%; - } - .col-md-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - .col-md-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - .col-md-6 { - flex: 0 0 auto; - width: 50%; - } - .col-md-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - .col-md-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - .col-md-9 { - flex: 0 0 auto; - width: 75%; - } - .col-md-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - .col-md-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - .col-md-12 { - flex: 0 0 auto; - width: 100%; - } - .offset-md-0 { - margin-left: 0; - } - .offset-md-1 { - margin-left: 8.33333333%; - } - .offset-md-2 { - margin-left: 16.66666667%; - } - .offset-md-3 { - margin-left: 25%; - } - .offset-md-4 { - margin-left: 33.33333333%; - } - .offset-md-5 { - margin-left: 41.66666667%; - } - .offset-md-6 { - margin-left: 50%; - } - .offset-md-7 { - margin-left: 58.33333333%; - } - .offset-md-8 { - margin-left: 66.66666667%; - } - .offset-md-9 { - margin-left: 75%; - } - .offset-md-10 { - margin-left: 83.33333333%; - } - .offset-md-11 { - margin-left: 91.66666667%; - } - .g-md-0, - .gx-md-0 { - --bs-gutter-x: 0; - } - .g-md-0, - .gy-md-0 { - --bs-gutter-y: 0; - } - .g-md-1, - .gx-md-1 { - --bs-gutter-x: 0.25rem; - } - .g-md-1, - .gy-md-1 { - --bs-gutter-y: 0.25rem; - } - .g-md-2, - .gx-md-2 { - --bs-gutter-x: 0.5rem; - } - .g-md-2, - .gy-md-2 { - --bs-gutter-y: 0.5rem; - } - .g-md-3, - .gx-md-3 { - --bs-gutter-x: 1rem; - } - .g-md-3, - .gy-md-3 { - --bs-gutter-y: 1rem; - } - .g-md-4, - .gx-md-4 { - --bs-gutter-x: 1.5rem; - } - .g-md-4, - .gy-md-4 { - --bs-gutter-y: 1.5rem; - } - .g-md-5, - .gx-md-5 { - --bs-gutter-x: 3rem; - } - .g-md-5, - .gy-md-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 992px) { - .col-lg { - flex: 1 0 0%; - } - .row-cols-lg-auto > * { - flex: 0 0 auto; - width: auto; - } - .row-cols-lg-1 > * { - flex: 0 0 auto; - width: 100%; - } - .row-cols-lg-2 > * { - flex: 0 0 auto; - width: 50%; - } - .row-cols-lg-3 > * { - flex: 0 0 auto; - width: 33.33333333%; - } - .row-cols-lg-4 > * { - flex: 0 0 auto; - width: 25%; - } - .row-cols-lg-5 > * { - flex: 0 0 auto; - width: 20%; - } - .row-cols-lg-6 > * { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-lg-auto { - flex: 0 0 auto; - width: auto; - } - .col-lg-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - .col-lg-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-lg-3 { - flex: 0 0 auto; - width: 25%; - } - .col-lg-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - .col-lg-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - .col-lg-6 { - flex: 0 0 auto; - width: 50%; - } - .col-lg-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - .col-lg-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - .col-lg-9 { - flex: 0 0 auto; - width: 75%; - } - .col-lg-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - .col-lg-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - .col-lg-12 { - flex: 0 0 auto; - width: 100%; - } - .offset-lg-0 { - margin-left: 0; - } - .offset-lg-1 { - margin-left: 8.33333333%; - } - .offset-lg-2 { - margin-left: 16.66666667%; - } - .offset-lg-3 { - margin-left: 25%; - } - .offset-lg-4 { - margin-left: 33.33333333%; - } - .offset-lg-5 { - margin-left: 41.66666667%; - } - .offset-lg-6 { - margin-left: 50%; - } - .offset-lg-7 { - margin-left: 58.33333333%; - } - .offset-lg-8 { - margin-left: 66.66666667%; - } - .offset-lg-9 { - margin-left: 75%; - } - .offset-lg-10 { - margin-left: 83.33333333%; - } - .offset-lg-11 { - margin-left: 91.66666667%; - } - .g-lg-0, - .gx-lg-0 { - --bs-gutter-x: 0; - } - .g-lg-0, - .gy-lg-0 { - --bs-gutter-y: 0; - } - .g-lg-1, - .gx-lg-1 { - --bs-gutter-x: 0.25rem; - } - .g-lg-1, - .gy-lg-1 { - --bs-gutter-y: 0.25rem; - } - .g-lg-2, - .gx-lg-2 { - --bs-gutter-x: 0.5rem; - } - .g-lg-2, - .gy-lg-2 { - --bs-gutter-y: 0.5rem; - } - .g-lg-3, - .gx-lg-3 { - --bs-gutter-x: 1rem; - } - .g-lg-3, - .gy-lg-3 { - --bs-gutter-y: 1rem; - } - .g-lg-4, - .gx-lg-4 { - --bs-gutter-x: 1.5rem; - } - .g-lg-4, - .gy-lg-4 { - --bs-gutter-y: 1.5rem; - } - .g-lg-5, - .gx-lg-5 { - --bs-gutter-x: 3rem; - } - .g-lg-5, - .gy-lg-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 1200px) { - .col-xl { - flex: 1 0 0%; - } - .row-cols-xl-auto > * { - flex: 0 0 auto; - width: auto; - } - .row-cols-xl-1 > * { - flex: 0 0 auto; - width: 100%; - } - .row-cols-xl-2 > * { - flex: 0 0 auto; - width: 50%; - } - .row-cols-xl-3 > * { - flex: 0 0 auto; - width: 33.33333333%; - } - .row-cols-xl-4 > * { - flex: 0 0 auto; - width: 25%; - } - .row-cols-xl-5 > * { - flex: 0 0 auto; - width: 20%; - } - .row-cols-xl-6 > * { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-xl-auto { - flex: 0 0 auto; - width: auto; - } - .col-xl-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - .col-xl-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-xl-3 { - flex: 0 0 auto; - width: 25%; - } - .col-xl-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - .col-xl-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - .col-xl-6 { - flex: 0 0 auto; - width: 50%; - } - .col-xl-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - .col-xl-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - .col-xl-9 { - flex: 0 0 auto; - width: 75%; - } - .col-xl-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - .col-xl-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - .col-xl-12 { - flex: 0 0 auto; - width: 100%; - } - .offset-xl-0 { - margin-left: 0; - } - .offset-xl-1 { - margin-left: 8.33333333%; - } - .offset-xl-2 { - margin-left: 16.66666667%; - } - .offset-xl-3 { - margin-left: 25%; - } - .offset-xl-4 { - margin-left: 33.33333333%; - } - .offset-xl-5 { - margin-left: 41.66666667%; - } - .offset-xl-6 { - margin-left: 50%; - } - .offset-xl-7 { - margin-left: 58.33333333%; - } - .offset-xl-8 { - margin-left: 66.66666667%; - } - .offset-xl-9 { - margin-left: 75%; - } - .offset-xl-10 { - margin-left: 83.33333333%; - } - .offset-xl-11 { - margin-left: 91.66666667%; - } - .g-xl-0, - .gx-xl-0 { - --bs-gutter-x: 0; - } - .g-xl-0, - .gy-xl-0 { - --bs-gutter-y: 0; - } - .g-xl-1, - .gx-xl-1 { - --bs-gutter-x: 0.25rem; - } - .g-xl-1, - .gy-xl-1 { - --bs-gutter-y: 0.25rem; - } - .g-xl-2, - .gx-xl-2 { - --bs-gutter-x: 0.5rem; - } - .g-xl-2, - .gy-xl-2 { - --bs-gutter-y: 0.5rem; - } - .g-xl-3, - .gx-xl-3 { - --bs-gutter-x: 1rem; - } - .g-xl-3, - .gy-xl-3 { - --bs-gutter-y: 1rem; - } - .g-xl-4, - .gx-xl-4 { - --bs-gutter-x: 1.5rem; - } - .g-xl-4, - .gy-xl-4 { - --bs-gutter-y: 1.5rem; - } - .g-xl-5, - .gx-xl-5 { - --bs-gutter-x: 3rem; - } - .g-xl-5, - .gy-xl-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 1400px) { - .col-xxl { - flex: 1 0 0%; - } - .row-cols-xxl-auto > * { - flex: 0 0 auto; - width: auto; - } - .row-cols-xxl-1 > * { - flex: 0 0 auto; - width: 100%; - } - .row-cols-xxl-2 > * { - flex: 0 0 auto; - width: 50%; - } - .row-cols-xxl-3 > * { - flex: 0 0 auto; - width: 33.33333333%; - } - .row-cols-xxl-4 > * { - flex: 0 0 auto; - width: 25%; - } - .row-cols-xxl-5 > * { - flex: 0 0 auto; - width: 20%; - } - .row-cols-xxl-6 > * { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-xxl-auto { - flex: 0 0 auto; - width: auto; - } - .col-xxl-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - .col-xxl-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - .col-xxl-3 { - flex: 0 0 auto; - width: 25%; - } - .col-xxl-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - .col-xxl-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - .col-xxl-6 { - flex: 0 0 auto; - width: 50%; - } - .col-xxl-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - .col-xxl-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - .col-xxl-9 { - flex: 0 0 auto; - width: 75%; - } - .col-xxl-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - .col-xxl-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - .col-xxl-12 { - flex: 0 0 auto; - width: 100%; - } - .offset-xxl-0 { - margin-left: 0; - } - .offset-xxl-1 { - margin-left: 8.33333333%; - } - .offset-xxl-2 { - margin-left: 16.66666667%; - } - .offset-xxl-3 { - margin-left: 25%; - } - .offset-xxl-4 { - margin-left: 33.33333333%; - } - .offset-xxl-5 { - margin-left: 41.66666667%; - } - .offset-xxl-6 { - margin-left: 50%; - } - .offset-xxl-7 { - margin-left: 58.33333333%; - } - .offset-xxl-8 { - margin-left: 66.66666667%; - } - .offset-xxl-9 { - margin-left: 75%; - } - .offset-xxl-10 { - margin-left: 83.33333333%; - } - .offset-xxl-11 { - margin-left: 91.66666667%; - } - .g-xxl-0, - .gx-xxl-0 { - --bs-gutter-x: 0; - } - .g-xxl-0, - .gy-xxl-0 { - --bs-gutter-y: 0; - } - .g-xxl-1, - .gx-xxl-1 { - --bs-gutter-x: 0.25rem; - } - .g-xxl-1, - .gy-xxl-1 { - --bs-gutter-y: 0.25rem; - } - .g-xxl-2, - .gx-xxl-2 { - --bs-gutter-x: 0.5rem; - } - .g-xxl-2, - .gy-xxl-2 { - --bs-gutter-y: 0.5rem; - } - .g-xxl-3, - .gx-xxl-3 { - --bs-gutter-x: 1rem; - } - .g-xxl-3, - .gy-xxl-3 { - --bs-gutter-y: 1rem; - } - .g-xxl-4, - .gx-xxl-4 { - --bs-gutter-x: 1.5rem; - } - .g-xxl-4, - .gy-xxl-4 { - --bs-gutter-y: 1.5rem; - } - .g-xxl-5, - .gx-xxl-5 { - --bs-gutter-x: 3rem; - } - .g-xxl-5, - .gy-xxl-5 { - --bs-gutter-y: 3rem; - } -} -.table { - --bs-table-color-type: initial; - --bs-table-bg-type: initial; - --bs-table-color-state: initial; - --bs-table-bg-state: initial; - --bs-table-color: var(--bs-emphasis-color); - --bs-table-bg: var(--bs-body-bg); - --bs-table-border-color: var(--bs-border-color); - --bs-table-accent-bg: transparent; - --bs-table-striped-color: var(--bs-emphasis-color); - --bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05); - --bs-table-active-color: var(--bs-emphasis-color); - --bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1); - --bs-table-hover-color: var(--bs-emphasis-color); - --bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075); - width: 100%; - margin-bottom: 1rem; - vertical-align: top; - border-color: var(--bs-table-border-color); -} -.table > :not(caption) > * > * { - padding: 0.5rem 0.5rem; - color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color))); - background-color: var(--bs-table-bg); - border-bottom-width: var(--bs-border-width); - box-shadow: inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg))); -} -.table > tbody { - vertical-align: inherit; -} -.table > thead { - vertical-align: bottom; -} - -.table-group-divider { - border-top: calc(var(--bs-border-width) * 2) solid currentcolor; -} - -.caption-top { - caption-side: top; -} - -.table-sm > :not(caption) > * > * { - padding: 0.25rem 0.25rem; -} - -.table-bordered > :not(caption) > * { - border-width: var(--bs-border-width) 0; -} -.table-bordered > :not(caption) > * > * { - border-width: 0 var(--bs-border-width); -} - -.table-borderless > :not(caption) > * > * { - border-bottom-width: 0; -} -.table-borderless > :not(:first-child) { - border-top-width: 0; -} - -.table-striped > tbody > tr:nth-of-type(odd) > * { - --bs-table-color-type: var(--bs-table-striped-color); - --bs-table-bg-type: var(--bs-table-striped-bg); -} - -.table-striped-columns > :not(caption) > tr > :nth-child(even) { - --bs-table-color-type: var(--bs-table-striped-color); - --bs-table-bg-type: var(--bs-table-striped-bg); -} - -.table-active { - --bs-table-color-state: var(--bs-table-active-color); - --bs-table-bg-state: var(--bs-table-active-bg); -} - -.table-hover > tbody > tr:hover > * { - --bs-table-color-state: var(--bs-table-hover-color); - --bs-table-bg-state: var(--bs-table-hover-bg); -} - -.table-primary { - --bs-table-color: #000; - --bs-table-bg: #cfe2ff; - --bs-table-border-color: #a6b5cc; - --bs-table-striped-bg: #c5d7f2; - --bs-table-striped-color: #000; - --bs-table-active-bg: #bacbe6; - --bs-table-active-color: #000; - --bs-table-hover-bg: #bfd1ec; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-secondary { - --bs-table-color: #000; - --bs-table-bg: #e2e3e5; - --bs-table-border-color: #b5b6b7; - --bs-table-striped-bg: #d7d8da; - --bs-table-striped-color: #000; - --bs-table-active-bg: #cbccce; - --bs-table-active-color: #000; - --bs-table-hover-bg: #d1d2d4; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-success { - --bs-table-color: #000; - --bs-table-bg: #d1e7dd; - --bs-table-border-color: #a7b9b1; - --bs-table-striped-bg: #c7dbd2; - --bs-table-striped-color: #000; - --bs-table-active-bg: #bcd0c7; - --bs-table-active-color: #000; - --bs-table-hover-bg: #c1d6cc; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-info { - --bs-table-color: #000; - --bs-table-bg: #cff4fc; - --bs-table-border-color: #a6c3ca; - --bs-table-striped-bg: #c5e8ef; - --bs-table-striped-color: #000; - --bs-table-active-bg: #badce3; - --bs-table-active-color: #000; - --bs-table-hover-bg: #bfe2e9; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-warning { - --bs-table-color: #000; - --bs-table-bg: #fff3cd; - --bs-table-border-color: #ccc2a4; - --bs-table-striped-bg: #f2e7c3; - --bs-table-striped-color: #000; - --bs-table-active-bg: #e6dbb9; - --bs-table-active-color: #000; - --bs-table-hover-bg: #ece1be; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-danger { - --bs-table-color: #000; - --bs-table-bg: #f8d7da; - --bs-table-border-color: #c6acae; - --bs-table-striped-bg: #eccccf; - --bs-table-striped-color: #000; - --bs-table-active-bg: #dfc2c4; - --bs-table-active-color: #000; - --bs-table-hover-bg: #e5c7ca; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-light { - --bs-table-color: #000; - --bs-table-bg: #f8f9fa; - --bs-table-border-color: #c6c7c8; - --bs-table-striped-bg: #ecedee; - --bs-table-striped-color: #000; - --bs-table-active-bg: #dfe0e1; - --bs-table-active-color: #000; - --bs-table-hover-bg: #e5e6e7; - --bs-table-hover-color: #000; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-dark { - --bs-table-color: #fff; - --bs-table-bg: #212529; - --bs-table-border-color: #4d5154; - --bs-table-striped-bg: #2c3034; - --bs-table-striped-color: #fff; - --bs-table-active-bg: #373b3e; - --bs-table-active-color: #fff; - --bs-table-hover-bg: #323539; - --bs-table-hover-color: #fff; - color: var(--bs-table-color); - border-color: var(--bs-table-border-color); -} - -.table-responsive { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -@media (max-width: 575.98px) { - .table-responsive-sm { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 767.98px) { - .table-responsive-md { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 991.98px) { - .table-responsive-lg { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 1199.98px) { - .table-responsive-xl { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 1399.98px) { - .table-responsive-xxl { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -.form-label { - margin-bottom: 0.5rem; -} - -.col-form-label { - padding-top: calc(0.375rem + var(--bs-border-width)); - padding-bottom: calc(0.375rem + var(--bs-border-width)); - margin-bottom: 0; - font-size: inherit; - line-height: 1.5; -} - -.col-form-label-lg { - padding-top: calc(0.5rem + var(--bs-border-width)); - padding-bottom: calc(0.5rem + var(--bs-border-width)); - font-size: 1.25rem; -} - -.col-form-label-sm { - padding-top: calc(0.25rem + var(--bs-border-width)); - padding-bottom: calc(0.25rem + var(--bs-border-width)); - font-size: 0.875rem; -} - -.form-text { - margin-top: 0.25rem; - font-size: 0.875em; - color: var(--bs-secondary-color); -} - -.form-control { - display: block; - width: 100%; - padding: 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: var(--bs-body-color); - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: var(--bs-body-bg); - background-clip: padding-box; - border: var(--bs-border-width) solid var(--bs-border-color); - border-radius: var(--bs-border-radius); - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-control { - transition: none; - } -} -.form-control[type=file] { - overflow: hidden; -} -.form-control[type=file]:not(:disabled):not([readonly]) { - cursor: pointer; -} -.form-control:focus { - color: var(--bs-body-color); - background-color: var(--bs-body-bg); - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-control::-webkit-date-and-time-value { - min-width: 85px; - height: 1.5em; - margin: 0; -} -.form-control::-webkit-datetime-edit { - display: block; - padding: 0; -} -.form-control::-moz-placeholder { - color: var(--bs-secondary-color); - opacity: 1; -} -.form-control::placeholder { - color: var(--bs-secondary-color); - opacity: 1; -} -.form-control:disabled { - background-color: var(--bs-secondary-bg); - opacity: 1; -} -.form-control::-webkit-file-upload-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - -webkit-margin-end: 0.75rem; - margin-inline-end: 0.75rem; - color: var(--bs-body-color); - background-color: var(--bs-tertiary-bg); - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: var(--bs-border-width); - border-radius: 0; - -webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -.form-control::file-selector-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - -webkit-margin-end: 0.75rem; - margin-inline-end: 0.75rem; - color: var(--bs-body-color); - background-color: var(--bs-tertiary-bg); - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: var(--bs-border-width); - border-radius: 0; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-control::-webkit-file-upload-button { - -webkit-transition: none; - transition: none; - } - .form-control::file-selector-button { - transition: none; - } -} -.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button { - background-color: var(--bs-secondary-bg); -} -.form-control:hover:not(:disabled):not([readonly])::file-selector-button { - background-color: var(--bs-secondary-bg); -} - -.form-control-plaintext { - display: block; - width: 100%; - padding: 0.375rem 0; - margin-bottom: 0; - line-height: 1.5; - color: var(--bs-body-color); - background-color: transparent; - border: solid transparent; - border-width: var(--bs-border-width) 0; -} -.form-control-plaintext:focus { - outline: 0; -} -.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { - padding-right: 0; - padding-left: 0; -} - -.form-control-sm { - min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: var(--bs-border-radius-sm); -} -.form-control-sm::-webkit-file-upload-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - -webkit-margin-end: 0.5rem; - margin-inline-end: 0.5rem; -} -.form-control-sm::file-selector-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - -webkit-margin-end: 0.5rem; - margin-inline-end: 0.5rem; -} - -.form-control-lg { - min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: var(--bs-border-radius-lg); -} -.form-control-lg::-webkit-file-upload-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - -webkit-margin-end: 1rem; - margin-inline-end: 1rem; -} -.form-control-lg::file-selector-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - -webkit-margin-end: 1rem; - margin-inline-end: 1rem; -} - -textarea.form-control { - min-height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2)); -} -textarea.form-control-sm { - min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); -} -textarea.form-control-lg { - min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); -} - -.form-control-color { - width: 3rem; - height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2)); - padding: 0.375rem; -} -.form-control-color:not(:disabled):not([readonly]) { - cursor: pointer; -} -.form-control-color::-moz-color-swatch { - border: 0 !important; - border-radius: var(--bs-border-radius); -} -.form-control-color::-webkit-color-swatch { - border: 0 !important; - border-radius: var(--bs-border-radius); -} -.form-control-color.form-control-sm { - height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); -} -.form-control-color.form-control-lg { - height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); -} - -.form-select { - --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); - display: block; - width: 100%; - padding: 0.375rem 2.25rem 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: var(--bs-body-color); - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: var(--bs-body-bg); - background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none); - background-repeat: no-repeat; - background-position: right 0.75rem center; - background-size: 16px 12px; - border: var(--bs-border-width) solid var(--bs-border-color); - border-radius: var(--bs-border-radius); - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-select { - transition: none; - } -} -.form-select:focus { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-select[multiple], .form-select[size]:not([size="1"]) { - padding-right: 0.75rem; - background-image: none; -} -.form-select:disabled { - background-color: var(--bs-secondary-bg); -} -.form-select:-moz-focusring { - color: transparent; - text-shadow: 0 0 0 var(--bs-body-color); -} - -.form-select-sm { - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - font-size: 0.875rem; - border-radius: var(--bs-border-radius-sm); -} - -.form-select-lg { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 1rem; - font-size: 1.25rem; - border-radius: var(--bs-border-radius-lg); -} - -[data-bs-theme=dark] .form-select { - --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); -} - -.form-check { - display: block; - min-height: 1.5rem; - padding-left: 1.5em; - margin-bottom: 0.125rem; -} -.form-check .form-check-input { - float: left; - margin-left: -1.5em; -} - -.form-check-reverse { - padding-right: 1.5em; - padding-left: 0; - text-align: right; -} -.form-check-reverse .form-check-input { - float: right; - margin-right: -1.5em; - margin-left: 0; -} - -.form-check-input { - --bs-form-check-bg: var(--bs-body-bg); - flex-shrink: 0; - width: 1em; - height: 1em; - margin-top: 0.25em; - vertical-align: top; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: var(--bs-form-check-bg); - background-image: var(--bs-form-check-bg-image); - background-repeat: no-repeat; - background-position: center; - background-size: contain; - border: var(--bs-border-width) solid var(--bs-border-color); - -webkit-print-color-adjust: exact; - color-adjust: exact; - print-color-adjust: exact; -} -.form-check-input[type=checkbox] { - border-radius: 0.25em; -} -.form-check-input[type=radio] { - border-radius: 50%; -} -.form-check-input:active { - filter: brightness(90%); -} -.form-check-input:focus { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-check-input:checked { - background-color: #0d6efd; - border-color: #0d6efd; -} -.form-check-input:checked[type=checkbox] { - --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); -} -.form-check-input:checked[type=radio] { - --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); -} -.form-check-input[type=checkbox]:indeterminate { - background-color: #0d6efd; - border-color: #0d6efd; - --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); -} -.form-check-input:disabled { - pointer-events: none; - filter: none; - opacity: 0.5; -} -.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label { - cursor: default; - opacity: 0.5; -} - -.form-switch { - padding-left: 2.5em; -} -.form-switch .form-check-input { - --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); - width: 2em; - margin-left: -2.5em; - background-image: var(--bs-form-switch-bg); - background-position: left center; - border-radius: 2em; - transition: background-position 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-switch .form-check-input { - transition: none; - } -} -.form-switch .form-check-input:focus { - --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e"); -} -.form-switch .form-check-input:checked { - background-position: right center; - --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); -} -.form-switch.form-check-reverse { - padding-right: 2.5em; - padding-left: 0; -} -.form-switch.form-check-reverse .form-check-input { - margin-right: -2.5em; - margin-left: 0; -} - -.form-check-inline { - display: inline-block; - margin-right: 1rem; -} - -.btn-check { - position: absolute; - clip: rect(0, 0, 0, 0); - pointer-events: none; -} -.btn-check[disabled] + .btn, .btn-check:disabled + .btn { - pointer-events: none; - filter: none; - opacity: 0.65; -} - -[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus) { - --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e"); -} - -.form-range { - width: 100%; - height: 1.5rem; - padding: 0; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: transparent; -} -.form-range:focus { - outline: 0; -} -.form-range:focus::-webkit-slider-thumb { - box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-range:focus::-moz-range-thumb { - box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-range::-moz-focus-outer { - border: 0; -} -.form-range::-webkit-slider-thumb { - width: 1rem; - height: 1rem; - margin-top: -0.25rem; - -webkit-appearance: none; - appearance: none; - background-color: #0d6efd; - border: 0; - border-radius: 1rem; - -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-range::-webkit-slider-thumb { - -webkit-transition: none; - transition: none; - } -} -.form-range::-webkit-slider-thumb:active { - background-color: #b6d4fe; -} -.form-range::-webkit-slider-runnable-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: var(--bs-secondary-bg); - border-color: transparent; - border-radius: 1rem; -} -.form-range::-moz-range-thumb { - width: 1rem; - height: 1rem; - -moz-appearance: none; - appearance: none; - background-color: #0d6efd; - border: 0; - border-radius: 1rem; - -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-range::-moz-range-thumb { - -moz-transition: none; - transition: none; - } -} -.form-range::-moz-range-thumb:active { - background-color: #b6d4fe; -} -.form-range::-moz-range-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: var(--bs-secondary-bg); - border-color: transparent; - border-radius: 1rem; -} -.form-range:disabled { - pointer-events: none; -} -.form-range:disabled::-webkit-slider-thumb { - background-color: var(--bs-secondary-color); -} -.form-range:disabled::-moz-range-thumb { - background-color: var(--bs-secondary-color); -} - -.form-floating { - position: relative; -} -.form-floating > .form-control, -.form-floating > .form-control-plaintext, -.form-floating > .form-select { - height: calc(3.5rem + calc(var(--bs-border-width) * 2)); - min-height: calc(3.5rem + calc(var(--bs-border-width) * 2)); - line-height: 1.25; -} -.form-floating > label { - position: absolute; - top: 0; - left: 0; - z-index: 2; - height: 100%; - padding: 1rem 0.75rem; - overflow: hidden; - text-align: start; - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - border: var(--bs-border-width) solid transparent; - transform-origin: 0 0; - transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-floating > label { - transition: none; - } -} -.form-floating > .form-control, -.form-floating > .form-control-plaintext { - padding: 1rem 0.75rem; -} -.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder { - color: transparent; -} -.form-floating > .form-control::placeholder, -.form-floating > .form-control-plaintext::placeholder { - color: transparent; -} -.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .form-control-plaintext:not(:-moz-placeholder-shown) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown), -.form-floating > .form-control-plaintext:focus, -.form-floating > .form-control-plaintext:not(:placeholder-shown) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:-webkit-autofill, -.form-floating > .form-control-plaintext:-webkit-autofill { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-select { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label { - color: rgba(var(--bs-body-color-rgb), 0.65); - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control:focus ~ label, -.form-floating > .form-control:not(:placeholder-shown) ~ label, -.form-floating > .form-control-plaintext ~ label, -.form-floating > .form-select ~ label { - color: rgba(var(--bs-body-color-rgb), 0.65); - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after { - position: absolute; - inset: 1rem 0.375rem; - z-index: -1; - height: 1.5em; - content: ""; - background-color: var(--bs-body-bg); - border-radius: var(--bs-border-radius); -} -.form-floating > .form-control:focus ~ label::after, -.form-floating > .form-control:not(:placeholder-shown) ~ label::after, -.form-floating > .form-control-plaintext ~ label::after, -.form-floating > .form-select ~ label::after { - position: absolute; - inset: 1rem 0.375rem; - z-index: -1; - height: 1.5em; - content: ""; - background-color: var(--bs-body-bg); - border-radius: var(--bs-border-radius); -} -.form-floating > .form-control:-webkit-autofill ~ label { - color: rgba(var(--bs-body-color-rgb), 0.65); - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control-plaintext ~ label { - border-width: var(--bs-border-width) 0; -} -.form-floating > :disabled ~ label, -.form-floating > .form-control:disabled ~ label { - color: #6c757d; -} -.form-floating > :disabled ~ label::after, -.form-floating > .form-control:disabled ~ label::after { - background-color: var(--bs-secondary-bg); -} - -.input-group { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: stretch; - width: 100%; -} -.input-group > .form-control, -.input-group > .form-select, -.input-group > .form-floating { - position: relative; - flex: 1 1 auto; - width: 1%; - min-width: 0; -} -.input-group > .form-control:focus, -.input-group > .form-select:focus, -.input-group > .form-floating:focus-within { - z-index: 5; -} -.input-group .btn { - position: relative; - z-index: 2; -} -.input-group .btn:focus { - z-index: 5; -} - -.input-group-text { - display: flex; - align-items: center; - padding: 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: var(--bs-body-color); - text-align: center; - white-space: nowrap; - background-color: var(--bs-tertiary-bg); - border: var(--bs-border-width) solid var(--bs-border-color); - border-radius: var(--bs-border-radius); -} - -.input-group-lg > .form-control, -.input-group-lg > .form-select, -.input-group-lg > .input-group-text, -.input-group-lg > .btn { - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: var(--bs-border-radius-lg); -} - -.input-group-sm > .form-control, -.input-group-sm > .form-select, -.input-group-sm > .input-group-text, -.input-group-sm > .btn { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: var(--bs-border-radius-sm); -} - -.input-group-lg > .form-select, -.input-group-sm > .form-select { - padding-right: 3rem; -} - -.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), -.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3), -.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control, -.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), -.input-group.has-validation > .dropdown-toggle:nth-last-child(n+4), -.input-group.has-validation > .form-floating:nth-last-child(n+3) > .form-control, -.input-group.has-validation > .form-floating:nth-last-child(n+3) > .form-select { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) { - margin-left: calc(var(--bs-border-width) * -1); - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} -.input-group > .form-floating:not(:first-child) > .form-control, -.input-group > .form-floating:not(:first-child) > .form-select { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.valid-feedback { - display: none; - width: 100%; - margin-top: 0.25rem; - font-size: 0.875em; - color: var(--bs-form-valid-color); -} - -.valid-tooltip { - position: absolute; - top: 100%; - z-index: 5; - display: none; - max-width: 100%; - padding: 0.25rem 0.5rem; - margin-top: 0.1rem; - font-size: 0.875rem; - color: #fff; - background-color: var(--bs-success); - border-radius: var(--bs-border-radius); -} - -.was-validated :valid ~ .valid-feedback, -.was-validated :valid ~ .valid-tooltip, -.is-valid ~ .valid-feedback, -.is-valid ~ .valid-tooltip { - display: block; -} - -.was-validated .form-control:valid, .form-control.is-valid { - border-color: var(--bs-form-valid-border-color); - padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-control:valid:focus, .form-control.is-valid:focus { - border-color: var(--bs-form-valid-border-color); - box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); -} - -.was-validated textarea.form-control:valid, textarea.form-control.is-valid { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - -.was-validated .form-select:valid, .form-select.is-valid { - border-color: var(--bs-form-valid-border-color); -} -.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] { - --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - padding-right: 4.125rem; - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-select:valid:focus, .form-select.is-valid:focus { - border-color: var(--bs-form-valid-border-color); - box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); -} - -.was-validated .form-control-color:valid, .form-control-color.is-valid { - width: calc(3rem + calc(1.5em + 0.75rem)); -} - -.was-validated .form-check-input:valid, .form-check-input.is-valid { - border-color: var(--bs-form-valid-border-color); -} -.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked { - background-color: var(--bs-form-valid-color); -} -.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus { - box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); -} -.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { - color: var(--bs-form-valid-color); -} - -.form-check-inline .form-check-input ~ .valid-feedback { - margin-left: 0.5em; -} - -.was-validated .input-group > .form-control:not(:focus):valid, .input-group > .form-control:not(:focus).is-valid, -.was-validated .input-group > .form-select:not(:focus):valid, -.input-group > .form-select:not(:focus).is-valid, -.was-validated .input-group > .form-floating:not(:focus-within):valid, -.input-group > .form-floating:not(:focus-within).is-valid { - z-index: 3; -} - -.invalid-feedback { - display: none; - width: 100%; - margin-top: 0.25rem; - font-size: 0.875em; - color: var(--bs-form-invalid-color); -} - -.invalid-tooltip { - position: absolute; - top: 100%; - z-index: 5; - display: none; - max-width: 100%; - padding: 0.25rem 0.5rem; - margin-top: 0.1rem; - font-size: 0.875rem; - color: #fff; - background-color: var(--bs-danger); - border-radius: var(--bs-border-radius); -} - -.was-validated :invalid ~ .invalid-feedback, -.was-validated :invalid ~ .invalid-tooltip, -.is-invalid ~ .invalid-feedback, -.is-invalid ~ .invalid-tooltip { - display: block; -} - -.was-validated .form-control:invalid, .form-control.is-invalid { - border-color: var(--bs-form-invalid-border-color); - padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus { - border-color: var(--bs-form-invalid-border-color); - box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); -} - -.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - -.was-validated .form-select:invalid, .form-select.is-invalid { - border-color: var(--bs-form-invalid-border-color); -} -.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] { - --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - padding-right: 4.125rem; - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-select:invalid:focus, .form-select.is-invalid:focus { - border-color: var(--bs-form-invalid-border-color); - box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); -} - -.was-validated .form-control-color:invalid, .form-control-color.is-invalid { - width: calc(3rem + calc(1.5em + 0.75rem)); -} - -.was-validated .form-check-input:invalid, .form-check-input.is-invalid { - border-color: var(--bs-form-invalid-border-color); -} -.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked { - background-color: var(--bs-form-invalid-color); -} -.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus { - box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); -} -.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { - color: var(--bs-form-invalid-color); -} - -.form-check-inline .form-check-input ~ .invalid-feedback { - margin-left: 0.5em; -} - -.was-validated .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid, -.was-validated .input-group > .form-select:not(:focus):invalid, -.input-group > .form-select:not(:focus).is-invalid, -.was-validated .input-group > .form-floating:not(:focus-within):invalid, -.input-group > .form-floating:not(:focus-within).is-invalid { - z-index: 4; -} - -.btn { - --bs-btn-padding-x: 0.75rem; - --bs-btn-padding-y: 0.375rem; - --bs-btn-font-family: ; - --bs-btn-font-size: 1rem; - --bs-btn-font-weight: 400; - --bs-btn-line-height: 1.5; - --bs-btn-color: var(--bs-body-color); - --bs-btn-bg: transparent; - --bs-btn-border-width: var(--bs-border-width); - --bs-btn-border-color: transparent; - --bs-btn-border-radius: var(--bs-border-radius); - --bs-btn-hover-border-color: transparent; - --bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); - --bs-btn-disabled-opacity: 0.65; - --bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5); - display: inline-block; - padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x); - font-family: var(--bs-btn-font-family); - font-size: var(--bs-btn-font-size); - font-weight: var(--bs-btn-font-weight); - line-height: var(--bs-btn-line-height); - color: var(--bs-btn-color); - text-align: center; - text-decoration: none; - vertical-align: middle; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - border: var(--bs-btn-border-width) solid var(--bs-btn-border-color); - border-radius: var(--bs-btn-border-radius); - background-color: var(--bs-btn-bg); - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .btn { - transition: none; - } -} -.btn:hover { - color: var(--bs-btn-hover-color); - background-color: var(--bs-btn-hover-bg); - border-color: var(--bs-btn-hover-border-color); -} -.btn-check + .btn:hover { - color: var(--bs-btn-color); - background-color: var(--bs-btn-bg); - border-color: var(--bs-btn-border-color); -} -.btn:focus-visible { - color: var(--bs-btn-hover-color); - background-color: var(--bs-btn-hover-bg); - border-color: var(--bs-btn-hover-border-color); - outline: 0; - box-shadow: var(--bs-btn-focus-box-shadow); -} -.btn-check:focus-visible + .btn { - border-color: var(--bs-btn-hover-border-color); - outline: 0; - box-shadow: var(--bs-btn-focus-box-shadow); -} -.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show { - color: var(--bs-btn-active-color); - background-color: var(--bs-btn-active-bg); - border-color: var(--bs-btn-active-border-color); -} -.btn-check:checked + .btn:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible { - box-shadow: var(--bs-btn-focus-box-shadow); -} -.btn:disabled, .btn.disabled, fieldset:disabled .btn { - color: var(--bs-btn-disabled-color); - pointer-events: none; - background-color: var(--bs-btn-disabled-bg); - border-color: var(--bs-btn-disabled-border-color); - opacity: var(--bs-btn-disabled-opacity); -} - -.btn-primary { - --bs-btn-color: #fff; - --bs-btn-bg: #0d6efd; - --bs-btn-border-color: #0d6efd; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #0b5ed7; - --bs-btn-hover-border-color: #0a58ca; - --bs-btn-focus-shadow-rgb: 49, 132, 253; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #0a58ca; - --bs-btn-active-border-color: #0a53be; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: #0d6efd; - --bs-btn-disabled-border-color: #0d6efd; -} - -.btn-secondary { - --bs-btn-color: #fff; - --bs-btn-bg: #6c757d; - --bs-btn-border-color: #6c757d; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #5c636a; - --bs-btn-hover-border-color: #565e64; - --bs-btn-focus-shadow-rgb: 130, 138, 145; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #565e64; - --bs-btn-active-border-color: #51585e; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: #6c757d; - --bs-btn-disabled-border-color: #6c757d; -} - -.btn-success { - --bs-btn-color: #fff; - --bs-btn-bg: #198754; - --bs-btn-border-color: #198754; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #157347; - --bs-btn-hover-border-color: #146c43; - --bs-btn-focus-shadow-rgb: 60, 153, 110; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #146c43; - --bs-btn-active-border-color: #13653f; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: #198754; - --bs-btn-disabled-border-color: #198754; -} - -.btn-info { - --bs-btn-color: #000; - --bs-btn-bg: #0dcaf0; - --bs-btn-border-color: #0dcaf0; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #31d2f2; - --bs-btn-hover-border-color: #25cff2; - --bs-btn-focus-shadow-rgb: 11, 172, 204; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #3dd5f3; - --bs-btn-active-border-color: #25cff2; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #000; - --bs-btn-disabled-bg: #0dcaf0; - --bs-btn-disabled-border-color: #0dcaf0; -} - -.btn-warning { - --bs-btn-color: #000; - --bs-btn-bg: #ffc107; - --bs-btn-border-color: #ffc107; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #ffca2c; - --bs-btn-hover-border-color: #ffc720; - --bs-btn-focus-shadow-rgb: 217, 164, 6; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #ffcd39; - --bs-btn-active-border-color: #ffc720; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #000; - --bs-btn-disabled-bg: #ffc107; - --bs-btn-disabled-border-color: #ffc107; -} - -.btn-danger { - --bs-btn-color: #fff; - --bs-btn-bg: #dc3545; - --bs-btn-border-color: #dc3545; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #bb2d3b; - --bs-btn-hover-border-color: #b02a37; - --bs-btn-focus-shadow-rgb: 225, 83, 97; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #b02a37; - --bs-btn-active-border-color: #a52834; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: #dc3545; - --bs-btn-disabled-border-color: #dc3545; -} - -.btn-light { - --bs-btn-color: #000; - --bs-btn-bg: #f8f9fa; - --bs-btn-border-color: #f8f9fa; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #d3d4d5; - --bs-btn-hover-border-color: #c6c7c8; - --bs-btn-focus-shadow-rgb: 211, 212, 213; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #c6c7c8; - --bs-btn-active-border-color: #babbbc; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #000; - --bs-btn-disabled-bg: #f8f9fa; - --bs-btn-disabled-border-color: #f8f9fa; -} - -.btn-dark { - --bs-btn-color: #fff; - --bs-btn-bg: #212529; - --bs-btn-border-color: #212529; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #424649; - --bs-btn-hover-border-color: #373b3e; - --bs-btn-focus-shadow-rgb: 66, 70, 73; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #4d5154; - --bs-btn-active-border-color: #373b3e; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: #212529; - --bs-btn-disabled-border-color: #212529; -} - -.btn-outline-primary { - --bs-btn-color: #0d6efd; - --bs-btn-border-color: #0d6efd; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #0d6efd; - --bs-btn-hover-border-color: #0d6efd; - --bs-btn-focus-shadow-rgb: 13, 110, 253; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #0d6efd; - --bs-btn-active-border-color: #0d6efd; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #0d6efd; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #0d6efd; - --bs-gradient: none; -} - -.btn-outline-secondary { - --bs-btn-color: #6c757d; - --bs-btn-border-color: #6c757d; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #6c757d; - --bs-btn-hover-border-color: #6c757d; - --bs-btn-focus-shadow-rgb: 108, 117, 125; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #6c757d; - --bs-btn-active-border-color: #6c757d; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #6c757d; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #6c757d; - --bs-gradient: none; -} - -.btn-outline-success { - --bs-btn-color: #198754; - --bs-btn-border-color: #198754; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #198754; - --bs-btn-hover-border-color: #198754; - --bs-btn-focus-shadow-rgb: 25, 135, 84; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #198754; - --bs-btn-active-border-color: #198754; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #198754; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #198754; - --bs-gradient: none; -} - -.btn-outline-info { - --bs-btn-color: #0dcaf0; - --bs-btn-border-color: #0dcaf0; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #0dcaf0; - --bs-btn-hover-border-color: #0dcaf0; - --bs-btn-focus-shadow-rgb: 13, 202, 240; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #0dcaf0; - --bs-btn-active-border-color: #0dcaf0; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #0dcaf0; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #0dcaf0; - --bs-gradient: none; -} - -.btn-outline-warning { - --bs-btn-color: #ffc107; - --bs-btn-border-color: #ffc107; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #ffc107; - --bs-btn-hover-border-color: #ffc107; - --bs-btn-focus-shadow-rgb: 255, 193, 7; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #ffc107; - --bs-btn-active-border-color: #ffc107; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #ffc107; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #ffc107; - --bs-gradient: none; -} - -.btn-outline-danger { - --bs-btn-color: #dc3545; - --bs-btn-border-color: #dc3545; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #dc3545; - --bs-btn-hover-border-color: #dc3545; - --bs-btn-focus-shadow-rgb: 220, 53, 69; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #dc3545; - --bs-btn-active-border-color: #dc3545; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #dc3545; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #dc3545; - --bs-gradient: none; -} - -.btn-outline-light { - --bs-btn-color: #f8f9fa; - --bs-btn-border-color: #f8f9fa; - --bs-btn-hover-color: #000; - --bs-btn-hover-bg: #f8f9fa; - --bs-btn-hover-border-color: #f8f9fa; - --bs-btn-focus-shadow-rgb: 248, 249, 250; - --bs-btn-active-color: #000; - --bs-btn-active-bg: #f8f9fa; - --bs-btn-active-border-color: #f8f9fa; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #f8f9fa; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #f8f9fa; - --bs-gradient: none; -} - -.btn-outline-dark { - --bs-btn-color: #212529; - --bs-btn-border-color: #212529; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #212529; - --bs-btn-hover-border-color: #212529; - --bs-btn-focus-shadow-rgb: 33, 37, 41; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #212529; - --bs-btn-active-border-color: #212529; - --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-disabled-color: #212529; - --bs-btn-disabled-bg: transparent; - --bs-btn-disabled-border-color: #212529; - --bs-gradient: none; -} - -.btn-link { - --bs-btn-font-weight: 400; - --bs-btn-color: var(--bs-link-color); - --bs-btn-bg: transparent; - --bs-btn-border-color: transparent; - --bs-btn-hover-color: var(--bs-link-hover-color); - --bs-btn-hover-border-color: transparent; - --bs-btn-active-color: var(--bs-link-hover-color); - --bs-btn-active-border-color: transparent; - --bs-btn-disabled-color: #6c757d; - --bs-btn-disabled-border-color: transparent; - --bs-btn-box-shadow: 0 0 0 #000; - --bs-btn-focus-shadow-rgb: 49, 132, 253; - text-decoration: underline; -} -.btn-link:focus-visible { - color: var(--bs-btn-color); -} -.btn-link:hover { - color: var(--bs-btn-hover-color); -} - -.btn-lg, .btn-group-lg > .btn { - --bs-btn-padding-y: 0.5rem; - --bs-btn-padding-x: 1rem; - --bs-btn-font-size: 1.25rem; - --bs-btn-border-radius: var(--bs-border-radius-lg); -} - -.btn-sm, .btn-group-sm > .btn { - --bs-btn-padding-y: 0.25rem; - --bs-btn-padding-x: 0.5rem; - --bs-btn-font-size: 0.875rem; - --bs-btn-border-radius: var(--bs-border-radius-sm); -} - -.fade { - transition: opacity 0.15s linear; -} -@media (prefers-reduced-motion: reduce) { - .fade { - transition: none; - } -} -.fade:not(.show) { - opacity: 0; -} - -.collapse:not(.show) { - display: none; -} - -.collapsing { - height: 0; - overflow: hidden; - transition: height 0.35s ease; -} -@media (prefers-reduced-motion: reduce) { - .collapsing { - transition: none; - } -} -.collapsing.collapse-horizontal { - width: 0; - height: auto; - transition: width 0.35s ease; -} -@media (prefers-reduced-motion: reduce) { - .collapsing.collapse-horizontal { - transition: none; - } -} - -.dropup, -.dropend, -.dropdown, -.dropstart, -.dropup-center, -.dropdown-center { - position: relative; -} - -.dropdown-toggle { - white-space: nowrap; -} -.dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} -.dropdown-toggle:empty::after { - margin-left: 0; -} - -.dropdown-menu { - --bs-dropdown-zindex: 1000; - --bs-dropdown-min-width: 10rem; - --bs-dropdown-padding-x: 0; - --bs-dropdown-padding-y: 0.5rem; - --bs-dropdown-spacer: 0.125rem; - --bs-dropdown-font-size: 1rem; - --bs-dropdown-color: var(--bs-body-color); - --bs-dropdown-bg: var(--bs-body-bg); - --bs-dropdown-border-color: var(--bs-border-color-translucent); - --bs-dropdown-border-radius: var(--bs-border-radius); - --bs-dropdown-border-width: var(--bs-border-width); - --bs-dropdown-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width)); - --bs-dropdown-divider-bg: var(--bs-border-color-translucent); - --bs-dropdown-divider-margin-y: 0.5rem; - --bs-dropdown-box-shadow: var(--bs-box-shadow); - --bs-dropdown-link-color: var(--bs-body-color); - --bs-dropdown-link-hover-color: var(--bs-body-color); - --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg); - --bs-dropdown-link-active-color: #fff; - --bs-dropdown-link-active-bg: #0d6efd; - --bs-dropdown-link-disabled-color: var(--bs-tertiary-color); - --bs-dropdown-item-padding-x: 1rem; - --bs-dropdown-item-padding-y: 0.25rem; - --bs-dropdown-header-color: #6c757d; - --bs-dropdown-header-padding-x: 1rem; - --bs-dropdown-header-padding-y: 0.5rem; - position: absolute; - z-index: var(--bs-dropdown-zindex); - display: none; - min-width: var(--bs-dropdown-min-width); - padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x); - margin: 0; - font-size: var(--bs-dropdown-font-size); - color: var(--bs-dropdown-color); - text-align: left; - list-style: none; - background-color: var(--bs-dropdown-bg); - background-clip: padding-box; - border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); - border-radius: var(--bs-dropdown-border-radius); -} -.dropdown-menu[data-bs-popper] { - top: 100%; - left: 0; - margin-top: var(--bs-dropdown-spacer); -} - -.dropdown-menu-start { - --bs-position: start; -} -.dropdown-menu-start[data-bs-popper] { - right: auto; - left: 0; -} - -.dropdown-menu-end { - --bs-position: end; -} -.dropdown-menu-end[data-bs-popper] { - right: 0; - left: auto; -} - -@media (min-width: 576px) { - .dropdown-menu-sm-start { - --bs-position: start; - } - .dropdown-menu-sm-start[data-bs-popper] { - right: auto; - left: 0; - } - .dropdown-menu-sm-end { - --bs-position: end; - } - .dropdown-menu-sm-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 768px) { - .dropdown-menu-md-start { - --bs-position: start; - } - .dropdown-menu-md-start[data-bs-popper] { - right: auto; - left: 0; - } - .dropdown-menu-md-end { - --bs-position: end; - } - .dropdown-menu-md-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 992px) { - .dropdown-menu-lg-start { - --bs-position: start; - } - .dropdown-menu-lg-start[data-bs-popper] { - right: auto; - left: 0; - } - .dropdown-menu-lg-end { - --bs-position: end; - } - .dropdown-menu-lg-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 1200px) { - .dropdown-menu-xl-start { - --bs-position: start; - } - .dropdown-menu-xl-start[data-bs-popper] { - right: auto; - left: 0; - } - .dropdown-menu-xl-end { - --bs-position: end; - } - .dropdown-menu-xl-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 1400px) { - .dropdown-menu-xxl-start { - --bs-position: start; - } - .dropdown-menu-xxl-start[data-bs-popper] { - right: auto; - left: 0; - } - .dropdown-menu-xxl-end { - --bs-position: end; - } - .dropdown-menu-xxl-end[data-bs-popper] { - right: 0; - left: auto; - } -} -.dropup .dropdown-menu[data-bs-popper] { - top: auto; - bottom: 100%; - margin-top: 0; - margin-bottom: var(--bs-dropdown-spacer); -} -.dropup .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0; - border-right: 0.3em solid transparent; - border-bottom: 0.3em solid; - border-left: 0.3em solid transparent; -} -.dropup .dropdown-toggle:empty::after { - margin-left: 0; -} - -.dropend .dropdown-menu[data-bs-popper] { - top: 0; - right: auto; - left: 100%; - margin-top: 0; - margin-left: var(--bs-dropdown-spacer); -} -.dropend .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid transparent; - border-right: 0; - border-bottom: 0.3em solid transparent; - border-left: 0.3em solid; -} -.dropend .dropdown-toggle:empty::after { - margin-left: 0; -} -.dropend .dropdown-toggle::after { - vertical-align: 0; -} - -.dropstart .dropdown-menu[data-bs-popper] { - top: 0; - right: 100%; - left: auto; - margin-top: 0; - margin-right: var(--bs-dropdown-spacer); -} -.dropstart .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; -} -.dropstart .dropdown-toggle::after { - display: none; -} -.dropstart .dropdown-toggle::before { - display: inline-block; - margin-right: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid transparent; - border-right: 0.3em solid; - border-bottom: 0.3em solid transparent; -} -.dropstart .dropdown-toggle:empty::after { - margin-left: 0; -} -.dropstart .dropdown-toggle::before { - vertical-align: 0; -} - -.dropdown-divider { - height: 0; - margin: var(--bs-dropdown-divider-margin-y) 0; - overflow: hidden; - border-top: 1px solid var(--bs-dropdown-divider-bg); - opacity: 1; -} - -.dropdown-item { - display: block; - width: 100%; - padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); - clear: both; - font-weight: 400; - color: var(--bs-dropdown-link-color); - text-align: inherit; - text-decoration: none; - white-space: nowrap; - background-color: transparent; - border: 0; - border-radius: var(--bs-dropdown-item-border-radius, 0); -} -.dropdown-item:hover, .dropdown-item:focus { - color: var(--bs-dropdown-link-hover-color); - background-color: var(--bs-dropdown-link-hover-bg); -} -.dropdown-item.active, .dropdown-item:active { - color: var(--bs-dropdown-link-active-color); - text-decoration: none; - background-color: var(--bs-dropdown-link-active-bg); -} -.dropdown-item.disabled, .dropdown-item:disabled { - color: var(--bs-dropdown-link-disabled-color); - pointer-events: none; - background-color: transparent; -} - -.dropdown-menu.show { - display: block; -} - -.dropdown-header { - display: block; - padding: var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x); - margin-bottom: 0; - font-size: 0.875rem; - color: var(--bs-dropdown-header-color); - white-space: nowrap; -} - -.dropdown-item-text { - display: block; - padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); - color: var(--bs-dropdown-link-color); -} - -.dropdown-menu-dark { - --bs-dropdown-color: #dee2e6; - --bs-dropdown-bg: #343a40; - --bs-dropdown-border-color: var(--bs-border-color-translucent); - --bs-dropdown-box-shadow: ; - --bs-dropdown-link-color: #dee2e6; - --bs-dropdown-link-hover-color: #fff; - --bs-dropdown-divider-bg: var(--bs-border-color-translucent); - --bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15); - --bs-dropdown-link-active-color: #fff; - --bs-dropdown-link-active-bg: #0d6efd; - --bs-dropdown-link-disabled-color: #adb5bd; - --bs-dropdown-header-color: #adb5bd; -} - -.btn-group, -.btn-group-vertical { - position: relative; - display: inline-flex; - vertical-align: middle; -} -.btn-group > .btn, -.btn-group-vertical > .btn { - position: relative; - flex: 1 1 auto; -} -.btn-group > .btn-check:checked + .btn, -.btn-group > .btn-check:focus + .btn, -.btn-group > .btn:hover, -.btn-group > .btn:focus, -.btn-group > .btn:active, -.btn-group > .btn.active, -.btn-group-vertical > .btn-check:checked + .btn, -.btn-group-vertical > .btn-check:focus + .btn, -.btn-group-vertical > .btn:hover, -.btn-group-vertical > .btn:focus, -.btn-group-vertical > .btn:active, -.btn-group-vertical > .btn.active { - z-index: 1; -} - -.btn-toolbar { - display: flex; - flex-wrap: wrap; - justify-content: flex-start; -} -.btn-toolbar .input-group { - width: auto; -} - -.btn-group { - border-radius: var(--bs-border-radius); -} -.btn-group > :not(.btn-check:first-child) + .btn, -.btn-group > .btn-group:not(:first-child) { - margin-left: calc(var(--bs-border-width) * -1); -} -.btn-group > .btn:not(:last-child):not(.dropdown-toggle), -.btn-group > .btn.dropdown-toggle-split:first-child, -.btn-group > .btn-group:not(:last-child) > .btn { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.btn-group > .btn:nth-child(n+3), -.btn-group > :not(.btn-check) + .btn, -.btn-group > .btn-group:not(:first-child) > .btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.dropdown-toggle-split { - padding-right: 0.5625rem; - padding-left: 0.5625rem; -} -.dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after { - margin-left: 0; -} -.dropstart .dropdown-toggle-split::before { - margin-right: 0; -} - -.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { - padding-right: 0.375rem; - padding-left: 0.375rem; -} - -.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { - padding-right: 0.75rem; - padding-left: 0.75rem; -} - -.btn-group-vertical { - flex-direction: column; - align-items: flex-start; - justify-content: center; -} -.btn-group-vertical > .btn, -.btn-group-vertical > .btn-group { - width: 100%; -} -.btn-group-vertical > .btn:not(:first-child), -.btn-group-vertical > .btn-group:not(:first-child) { - margin-top: calc(var(--bs-border-width) * -1); -} -.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), -.btn-group-vertical > .btn-group:not(:last-child) > .btn { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} -.btn-group-vertical > .btn ~ .btn, -.btn-group-vertical > .btn-group:not(:first-child) > .btn { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.nav { - --bs-nav-link-padding-x: 1rem; - --bs-nav-link-padding-y: 0.5rem; - --bs-nav-link-font-weight: ; - --bs-nav-link-color: var(--bs-link-color); - --bs-nav-link-hover-color: var(--bs-link-hover-color); - --bs-nav-link-disabled-color: var(--bs-secondary-color); - display: flex; - flex-wrap: wrap; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} - -.nav-link { - display: block; - padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x); - font-size: var(--bs-nav-link-font-size); - font-weight: var(--bs-nav-link-font-weight); - color: var(--bs-nav-link-color); - text-decoration: none; - background: none; - border: 0; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .nav-link { - transition: none; - } -} -.nav-link:hover, .nav-link:focus { - color: var(--bs-nav-link-hover-color); -} -.nav-link:focus-visible { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.nav-link.disabled, .nav-link:disabled { - color: var(--bs-nav-link-disabled-color); - pointer-events: none; - cursor: default; -} - -.nav-tabs { - --bs-nav-tabs-border-width: var(--bs-border-width); - --bs-nav-tabs-border-color: var(--bs-border-color); - --bs-nav-tabs-border-radius: var(--bs-border-radius); - --bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color); - --bs-nav-tabs-link-active-color: var(--bs-emphasis-color); - --bs-nav-tabs-link-active-bg: var(--bs-body-bg); - --bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg); - border-bottom: var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color); -} -.nav-tabs .nav-link { - margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width)); - border: var(--bs-nav-tabs-border-width) solid transparent; - border-top-left-radius: var(--bs-nav-tabs-border-radius); - border-top-right-radius: var(--bs-nav-tabs-border-radius); -} -.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { - isolation: isolate; - border-color: var(--bs-nav-tabs-link-hover-border-color); -} -.nav-tabs .nav-link.active, -.nav-tabs .nav-item.show .nav-link { - color: var(--bs-nav-tabs-link-active-color); - background-color: var(--bs-nav-tabs-link-active-bg); - border-color: var(--bs-nav-tabs-link-active-border-color); -} -.nav-tabs .dropdown-menu { - margin-top: calc(-1 * var(--bs-nav-tabs-border-width)); - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.nav-pills { - --bs-nav-pills-border-radius: var(--bs-border-radius); - --bs-nav-pills-link-active-color: #fff; - --bs-nav-pills-link-active-bg: #0d6efd; -} -.nav-pills .nav-link { - border-radius: var(--bs-nav-pills-border-radius); -} -.nav-pills .nav-link.active, -.nav-pills .show > .nav-link { - color: var(--bs-nav-pills-link-active-color); - background-color: var(--bs-nav-pills-link-active-bg); -} - -.nav-underline { - --bs-nav-underline-gap: 1rem; - --bs-nav-underline-border-width: 0.125rem; - --bs-nav-underline-link-active-color: var(--bs-emphasis-color); - gap: var(--bs-nav-underline-gap); -} -.nav-underline .nav-link { - padding-right: 0; - padding-left: 0; - border-bottom: var(--bs-nav-underline-border-width) solid transparent; -} -.nav-underline .nav-link:hover, .nav-underline .nav-link:focus { - border-bottom-color: currentcolor; -} -.nav-underline .nav-link.active, -.nav-underline .show > .nav-link { - font-weight: 700; - color: var(--bs-nav-underline-link-active-color); - border-bottom-color: currentcolor; -} - -.nav-fill > .nav-link, -.nav-fill .nav-item { - flex: 1 1 auto; - text-align: center; -} - -.nav-justified > .nav-link, -.nav-justified .nav-item { - flex-basis: 0; - flex-grow: 1; - text-align: center; -} - -.nav-fill .nav-item .nav-link, -.nav-justified .nav-item .nav-link { - width: 100%; -} - -.tab-content > .tab-pane { - display: none; -} -.tab-content > .active { - display: block; -} - -.navbar { - --bs-navbar-padding-x: 0; - --bs-navbar-padding-y: 0.5rem; - --bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65); - --bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8); - --bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3); - --bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1); - --bs-navbar-brand-padding-y: 0.3125rem; - --bs-navbar-brand-margin-end: 1rem; - --bs-navbar-brand-font-size: 1.25rem; - --bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1); - --bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1); - --bs-navbar-nav-link-padding-x: 0.5rem; - --bs-navbar-toggler-padding-y: 0.25rem; - --bs-navbar-toggler-padding-x: 0.75rem; - --bs-navbar-toggler-font-size: 1.25rem; - --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); - --bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15); - --bs-navbar-toggler-border-radius: var(--bs-border-radius); - --bs-navbar-toggler-focus-width: 0.25rem; - --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out; - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding: var(--bs-navbar-padding-y) var(--bs-navbar-padding-x); -} -.navbar > .container, -.navbar > .container-fluid, -.navbar > .container-sm, -.navbar > .container-md, -.navbar > .container-lg, -.navbar > .container-xl, -.navbar > .container-xxl { - display: flex; - flex-wrap: inherit; - align-items: center; - justify-content: space-between; -} -.navbar-brand { - padding-top: var(--bs-navbar-brand-padding-y); - padding-bottom: var(--bs-navbar-brand-padding-y); - margin-right: var(--bs-navbar-brand-margin-end); - font-size: var(--bs-navbar-brand-font-size); - color: var(--bs-navbar-brand-color); - text-decoration: none; - white-space: nowrap; -} -.navbar-brand:hover, .navbar-brand:focus { - color: var(--bs-navbar-brand-hover-color); -} - -.navbar-nav { - --bs-nav-link-padding-x: 0; - --bs-nav-link-padding-y: 0.5rem; - --bs-nav-link-font-weight: ; - --bs-nav-link-color: var(--bs-navbar-color); - --bs-nav-link-hover-color: var(--bs-navbar-hover-color); - --bs-nav-link-disabled-color: var(--bs-navbar-disabled-color); - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar-nav .nav-link.active, .navbar-nav .nav-link.show { - color: var(--bs-navbar-active-color); -} -.navbar-nav .dropdown-menu { - position: static; -} - -.navbar-text { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - color: var(--bs-navbar-color); -} -.navbar-text a, -.navbar-text a:hover, -.navbar-text a:focus { - color: var(--bs-navbar-active-color); -} - -.navbar-collapse { - flex-basis: 100%; - flex-grow: 1; - align-items: center; -} - -.navbar-toggler { - padding: var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x); - font-size: var(--bs-navbar-toggler-font-size); - line-height: 1; - color: var(--bs-navbar-color); - background-color: transparent; - border: var(--bs-border-width) solid var(--bs-navbar-toggler-border-color); - border-radius: var(--bs-navbar-toggler-border-radius); - transition: var(--bs-navbar-toggler-transition); -} -@media (prefers-reduced-motion: reduce) { - .navbar-toggler { - transition: none; - } -} -.navbar-toggler:hover { - text-decoration: none; -} -.navbar-toggler:focus { - text-decoration: none; - outline: 0; - box-shadow: 0 0 0 var(--bs-navbar-toggler-focus-width); -} - -.navbar-toggler-icon { - display: inline-block; - width: 1.5em; - height: 1.5em; - vertical-align: middle; - background-image: var(--bs-navbar-toggler-icon-bg); - background-repeat: no-repeat; - background-position: center; - background-size: 100%; -} - -.navbar-nav-scroll { - max-height: var(--bs-scroll-height, 75vh); - overflow-y: auto; -} - -@media (min-width: 576px) { - .navbar-expand-sm { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-sm .navbar-nav { - flex-direction: row; - } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-sm .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); - } - .navbar-expand-sm .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-sm .navbar-toggler { - display: none; - } - .navbar-expand-sm .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; - } - .navbar-expand-sm .offcanvas .offcanvas-header { - display: none; - } - .navbar-expand-sm .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 768px) { - .navbar-expand-md { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-md .navbar-nav { - flex-direction: row; - } - .navbar-expand-md .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-md .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); - } - .navbar-expand-md .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-md .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-md .navbar-toggler { - display: none; - } - .navbar-expand-md .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; - } - .navbar-expand-md .offcanvas .offcanvas-header { - display: none; - } - .navbar-expand-md .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 992px) { - .navbar-expand-lg { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-lg .navbar-nav { - flex-direction: row; - } - .navbar-expand-lg .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-lg .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); - } - .navbar-expand-lg .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-lg .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-lg .navbar-toggler { - display: none; - } - .navbar-expand-lg .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; - } - .navbar-expand-lg .offcanvas .offcanvas-header { - display: none; - } - .navbar-expand-lg .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 1200px) { - .navbar-expand-xl { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-xl .navbar-nav { - flex-direction: row; - } - .navbar-expand-xl .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-xl .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); - } - .navbar-expand-xl .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-xl .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-xl .navbar-toggler { - display: none; - } - .navbar-expand-xl .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; - } - .navbar-expand-xl .offcanvas .offcanvas-header { - display: none; - } - .navbar-expand-xl .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 1400px) { - .navbar-expand-xxl { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-xxl .navbar-nav { - flex-direction: row; - } - .navbar-expand-xxl .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-xxl .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); - } - .navbar-expand-xxl .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-xxl .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-xxl .navbar-toggler { - display: none; - } - .navbar-expand-xxl .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; - } - .navbar-expand-xxl .offcanvas .offcanvas-header { - display: none; - } - .navbar-expand-xxl .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -.navbar-expand { - flex-wrap: nowrap; - justify-content: flex-start; -} -.navbar-expand .navbar-nav { - flex-direction: row; -} -.navbar-expand .navbar-nav .dropdown-menu { - position: absolute; -} -.navbar-expand .navbar-nav .nav-link { - padding-right: var(--bs-navbar-nav-link-padding-x); - padding-left: var(--bs-navbar-nav-link-padding-x); -} -.navbar-expand .navbar-nav-scroll { - overflow: visible; -} -.navbar-expand .navbar-collapse { - display: flex !important; - flex-basis: auto; -} -.navbar-expand .navbar-toggler { - display: none; -} -.navbar-expand .offcanvas { - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - transition: none; -} -.navbar-expand .offcanvas .offcanvas-header { - display: none; -} -.navbar-expand .offcanvas .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; -} - -.navbar-dark, -.navbar[data-bs-theme=dark] { - --bs-navbar-color: rgba(255, 255, 255, 0.55); - --bs-navbar-hover-color: rgba(255, 255, 255, 0.75); - --bs-navbar-disabled-color: rgba(255, 255, 255, 0.25); - --bs-navbar-active-color: #fff; - --bs-navbar-brand-color: #fff; - --bs-navbar-brand-hover-color: #fff; - --bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1); - --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} - -[data-bs-theme=dark] .navbar-toggler-icon { - --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} - -.card { - --bs-card-spacer-y: 1rem; - --bs-card-spacer-x: 1rem; - --bs-card-title-spacer-y: 0.5rem; - --bs-card-title-color: ; - --bs-card-subtitle-color: ; - --bs-card-border-width: var(--bs-border-width); - --bs-card-border-color: var(--bs-border-color-translucent); - --bs-card-border-radius: var(--bs-border-radius); - --bs-card-box-shadow: ; - --bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width))); - --bs-card-cap-padding-y: 0.5rem; - --bs-card-cap-padding-x: 1rem; - --bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03); - --bs-card-cap-color: ; - --bs-card-height: ; - --bs-card-color: ; - --bs-card-bg: var(--bs-body-bg); - --bs-card-img-overlay-padding: 1rem; - --bs-card-group-margin: 0.75rem; - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - height: var(--bs-card-height); - color: var(--bs-body-color); - word-wrap: break-word; - background-color: var(--bs-card-bg); - background-clip: border-box; - border: var(--bs-card-border-width) solid var(--bs-card-border-color); - border-radius: var(--bs-card-border-radius); -} -.card > hr { - margin-right: 0; - margin-left: 0; -} -.card > .list-group { - border-top: inherit; - border-bottom: inherit; -} -.card > .list-group:first-child { - border-top-width: 0; - border-top-left-radius: var(--bs-card-inner-border-radius); - border-top-right-radius: var(--bs-card-inner-border-radius); -} -.card > .list-group:last-child { - border-bottom-width: 0; - border-bottom-right-radius: var(--bs-card-inner-border-radius); - border-bottom-left-radius: var(--bs-card-inner-border-radius); -} -.card > .card-header + .list-group, -.card > .list-group + .card-footer { - border-top: 0; -} - -.card-body { - flex: 1 1 auto; - padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x); - color: var(--bs-card-color); -} - -.card-title { - margin-bottom: var(--bs-card-title-spacer-y); - color: var(--bs-card-title-color); -} - -.card-subtitle { - margin-top: calc(-0.5 * var(--bs-card-title-spacer-y)); - margin-bottom: 0; - color: var(--bs-card-subtitle-color); -} - -.card-text:last-child { - margin-bottom: 0; -} - -.card-link + .card-link { - margin-left: var(--bs-card-spacer-x); -} - -.card-header { - padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); - margin-bottom: 0; - color: var(--bs-card-cap-color); - background-color: var(--bs-card-cap-bg); - border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color); -} -.card-header:first-child { - border-radius: var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0; -} - -.card-footer { - padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); - color: var(--bs-card-cap-color); - background-color: var(--bs-card-cap-bg); - border-top: var(--bs-card-border-width) solid var(--bs-card-border-color); -} -.card-footer:last-child { - border-radius: 0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius); -} - -.card-header-tabs { - margin-right: calc(-0.5 * var(--bs-card-cap-padding-x)); - margin-bottom: calc(-1 * var(--bs-card-cap-padding-y)); - margin-left: calc(-0.5 * var(--bs-card-cap-padding-x)); - border-bottom: 0; -} -.card-header-tabs .nav-link.active { - background-color: var(--bs-card-bg); - border-bottom-color: var(--bs-card-bg); -} - -.card-header-pills { - margin-right: calc(-0.5 * var(--bs-card-cap-padding-x)); - margin-left: calc(-0.5 * var(--bs-card-cap-padding-x)); -} - -.card-img-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - padding: var(--bs-card-img-overlay-padding); - border-radius: var(--bs-card-inner-border-radius); -} - -.card-img, -.card-img-top, -.card-img-bottom { - width: 100%; -} - -.card-img, -.card-img-top { - border-top-left-radius: var(--bs-card-inner-border-radius); - border-top-right-radius: var(--bs-card-inner-border-radius); -} - -.card-img, -.card-img-bottom { - border-bottom-right-radius: var(--bs-card-inner-border-radius); - border-bottom-left-radius: var(--bs-card-inner-border-radius); -} - -.card-group > .card { - margin-bottom: var(--bs-card-group-margin); -} -@media (min-width: 576px) { - .card-group { - display: flex; - flex-flow: row wrap; - } - .card-group > .card { - flex: 1 0 0%; - margin-bottom: 0; - } - .card-group > .card + .card { - margin-left: 0; - border-left: 0; - } - .card-group > .card:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - .card-group > .card:not(:last-child) .card-img-top, - .card-group > .card:not(:last-child) .card-header { - border-top-right-radius: 0; - } - .card-group > .card:not(:last-child) .card-img-bottom, - .card-group > .card:not(:last-child) .card-footer { - border-bottom-right-radius: 0; - } - .card-group > .card:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - .card-group > .card:not(:first-child) .card-img-top, - .card-group > .card:not(:first-child) .card-header { - border-top-left-radius: 0; - } - .card-group > .card:not(:first-child) .card-img-bottom, - .card-group > .card:not(:first-child) .card-footer { - border-bottom-left-radius: 0; - } -} - -.accordion { - --bs-accordion-color: var(--bs-body-color); - --bs-accordion-bg: var(--bs-body-bg); - --bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease; - --bs-accordion-border-color: var(--bs-border-color); - --bs-accordion-border-width: var(--bs-border-width); - --bs-accordion-border-radius: var(--bs-border-radius); - --bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width))); - --bs-accordion-btn-padding-x: 1.25rem; - --bs-accordion-btn-padding-y: 1rem; - --bs-accordion-btn-color: var(--bs-body-color); - --bs-accordion-btn-bg: var(--bs-accordion-bg); - --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - --bs-accordion-btn-icon-width: 1.25rem; - --bs-accordion-btn-icon-transform: rotate(-180deg); - --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out; - --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23052c65'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - --bs-accordion-btn-focus-border-color: #86b7fe; - --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); - --bs-accordion-body-padding-x: 1.25rem; - --bs-accordion-body-padding-y: 1rem; - --bs-accordion-active-color: var(--bs-primary-text-emphasis); - --bs-accordion-active-bg: var(--bs-primary-bg-subtle); -} - -.accordion-button { - position: relative; - display: flex; - align-items: center; - width: 100%; - padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x); - font-size: 1rem; - color: var(--bs-accordion-btn-color); - text-align: left; - background-color: var(--bs-accordion-btn-bg); - border: 0; - border-radius: 0; - overflow-anchor: none; - transition: var(--bs-accordion-transition); -} -@media (prefers-reduced-motion: reduce) { - .accordion-button { - transition: none; - } -} -.accordion-button:not(.collapsed) { - color: var(--bs-accordion-active-color); - background-color: var(--bs-accordion-active-bg); - box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color); -} -.accordion-button:not(.collapsed)::after { - background-image: var(--bs-accordion-btn-active-icon); - transform: var(--bs-accordion-btn-icon-transform); -} -.accordion-button::after { - flex-shrink: 0; - width: var(--bs-accordion-btn-icon-width); - height: var(--bs-accordion-btn-icon-width); - margin-left: auto; - content: ""; - background-image: var(--bs-accordion-btn-icon); - background-repeat: no-repeat; - background-size: var(--bs-accordion-btn-icon-width); - transition: var(--bs-accordion-btn-icon-transition); -} -@media (prefers-reduced-motion: reduce) { - .accordion-button::after { - transition: none; - } -} -.accordion-button:hover { - z-index: 2; -} -.accordion-button:focus { - z-index: 3; - border-color: var(--bs-accordion-btn-focus-border-color); - outline: 0; - box-shadow: var(--bs-accordion-btn-focus-box-shadow); -} - -.accordion-header { - margin-bottom: 0; -} - -.accordion-item { - color: var(--bs-accordion-color); - background-color: var(--bs-accordion-bg); - border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color); -} -.accordion-item:first-of-type { - border-top-left-radius: var(--bs-accordion-border-radius); - border-top-right-radius: var(--bs-accordion-border-radius); -} -.accordion-item:first-of-type .accordion-button { - border-top-left-radius: var(--bs-accordion-inner-border-radius); - border-top-right-radius: var(--bs-accordion-inner-border-radius); -} -.accordion-item:not(:first-of-type) { - border-top: 0; -} -.accordion-item:last-of-type { - border-bottom-right-radius: var(--bs-accordion-border-radius); - border-bottom-left-radius: var(--bs-accordion-border-radius); -} -.accordion-item:last-of-type .accordion-button.collapsed { - border-bottom-right-radius: var(--bs-accordion-inner-border-radius); - border-bottom-left-radius: var(--bs-accordion-inner-border-radius); -} -.accordion-item:last-of-type .accordion-collapse { - border-bottom-right-radius: var(--bs-accordion-border-radius); - border-bottom-left-radius: var(--bs-accordion-border-radius); -} - -.accordion-body { - padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x); -} - -.accordion-flush .accordion-collapse { - border-width: 0; -} -.accordion-flush .accordion-item { - border-right: 0; - border-left: 0; - border-radius: 0; -} -.accordion-flush .accordion-item:first-child { - border-top: 0; -} -.accordion-flush .accordion-item:last-child { - border-bottom: 0; -} -.accordion-flush .accordion-item .accordion-button, .accordion-flush .accordion-item .accordion-button.collapsed { - border-radius: 0; -} - -[data-bs-theme=dark] .accordion-button::after { - --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); -} - -.breadcrumb { - --bs-breadcrumb-padding-x: 0; - --bs-breadcrumb-padding-y: 0; - --bs-breadcrumb-margin-bottom: 1rem; - --bs-breadcrumb-bg: ; - --bs-breadcrumb-border-radius: ; - --bs-breadcrumb-divider-color: var(--bs-secondary-color); - --bs-breadcrumb-item-padding-x: 0.5rem; - --bs-breadcrumb-item-active-color: var(--bs-secondary-color); - display: flex; - flex-wrap: wrap; - padding: var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x); - margin-bottom: var(--bs-breadcrumb-margin-bottom); - font-size: var(--bs-breadcrumb-font-size); - list-style: none; - background-color: var(--bs-breadcrumb-bg); - border-radius: var(--bs-breadcrumb-border-radius); -} - -.breadcrumb-item + .breadcrumb-item { - padding-left: var(--bs-breadcrumb-item-padding-x); -} -.breadcrumb-item + .breadcrumb-item::before { - float: left; - padding-right: var(--bs-breadcrumb-item-padding-x); - color: var(--bs-breadcrumb-divider-color); - content: var(--bs-breadcrumb-divider, "/") /* rtl: var(--bs-breadcrumb-divider, "/") */; -} -.breadcrumb-item.active { - color: var(--bs-breadcrumb-item-active-color); -} - -.pagination { - --bs-pagination-padding-x: 0.75rem; - --bs-pagination-padding-y: 0.375rem; - --bs-pagination-font-size: 1rem; - --bs-pagination-color: var(--bs-link-color); - --bs-pagination-bg: var(--bs-body-bg); - --bs-pagination-border-width: var(--bs-border-width); - --bs-pagination-border-color: var(--bs-border-color); - --bs-pagination-border-radius: var(--bs-border-radius); - --bs-pagination-hover-color: var(--bs-link-hover-color); - --bs-pagination-hover-bg: var(--bs-tertiary-bg); - --bs-pagination-hover-border-color: var(--bs-border-color); - --bs-pagination-focus-color: var(--bs-link-hover-color); - --bs-pagination-focus-bg: var(--bs-secondary-bg); - --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); - --bs-pagination-active-color: #fff; - --bs-pagination-active-bg: #0d6efd; - --bs-pagination-active-border-color: #0d6efd; - --bs-pagination-disabled-color: var(--bs-secondary-color); - --bs-pagination-disabled-bg: var(--bs-secondary-bg); - --bs-pagination-disabled-border-color: var(--bs-border-color); - display: flex; - padding-left: 0; - list-style: none; -} - -.page-link { - position: relative; - display: block; - padding: var(--bs-pagination-padding-y) var(--bs-pagination-padding-x); - font-size: var(--bs-pagination-font-size); - color: var(--bs-pagination-color); - text-decoration: none; - background-color: var(--bs-pagination-bg); - border: var(--bs-pagination-border-width) solid var(--bs-pagination-border-color); - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .page-link { - transition: none; - } -} -.page-link:hover { - z-index: 2; - color: var(--bs-pagination-hover-color); - background-color: var(--bs-pagination-hover-bg); - border-color: var(--bs-pagination-hover-border-color); -} -.page-link:focus { - z-index: 3; - color: var(--bs-pagination-focus-color); - background-color: var(--bs-pagination-focus-bg); - outline: 0; - box-shadow: var(--bs-pagination-focus-box-shadow); -} -.page-link.active, .active > .page-link { - z-index: 3; - color: var(--bs-pagination-active-color); - background-color: var(--bs-pagination-active-bg); - border-color: var(--bs-pagination-active-border-color); -} -.page-link.disabled, .disabled > .page-link { - color: var(--bs-pagination-disabled-color); - pointer-events: none; - background-color: var(--bs-pagination-disabled-bg); - border-color: var(--bs-pagination-disabled-border-color); -} - -.page-item:not(:first-child) .page-link { - margin-left: calc(var(--bs-border-width) * -1); -} -.page-item:first-child .page-link { - border-top-left-radius: var(--bs-pagination-border-radius); - border-bottom-left-radius: var(--bs-pagination-border-radius); -} -.page-item:last-child .page-link { - border-top-right-radius: var(--bs-pagination-border-radius); - border-bottom-right-radius: var(--bs-pagination-border-radius); -} - -.pagination-lg { - --bs-pagination-padding-x: 1.5rem; - --bs-pagination-padding-y: 0.75rem; - --bs-pagination-font-size: 1.25rem; - --bs-pagination-border-radius: var(--bs-border-radius-lg); -} - -.pagination-sm { - --bs-pagination-padding-x: 0.5rem; - --bs-pagination-padding-y: 0.25rem; - --bs-pagination-font-size: 0.875rem; - --bs-pagination-border-radius: var(--bs-border-radius-sm); -} - -.badge { - --bs-badge-padding-x: 0.65em; - --bs-badge-padding-y: 0.35em; - --bs-badge-font-size: 0.75em; - --bs-badge-font-weight: 700; - --bs-badge-color: #fff; - --bs-badge-border-radius: var(--bs-border-radius); - display: inline-block; - padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x); - font-size: var(--bs-badge-font-size); - font-weight: var(--bs-badge-font-weight); - line-height: 1; - color: var(--bs-badge-color); - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: var(--bs-badge-border-radius); -} -.badge:empty { - display: none; -} - -.btn .badge { - position: relative; - top: -1px; -} - -.alert { - --bs-alert-bg: transparent; - --bs-alert-padding-x: 1rem; - --bs-alert-padding-y: 1rem; - --bs-alert-margin-bottom: 1rem; - --bs-alert-color: inherit; - --bs-alert-border-color: transparent; - --bs-alert-border: var(--bs-border-width) solid var(--bs-alert-border-color); - --bs-alert-border-radius: var(--bs-border-radius); - --bs-alert-link-color: inherit; - position: relative; - padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x); - margin-bottom: var(--bs-alert-margin-bottom); - color: var(--bs-alert-color); - background-color: var(--bs-alert-bg); - border: var(--bs-alert-border); - border-radius: var(--bs-alert-border-radius); -} - -.alert-heading { - color: inherit; -} - -.alert-link { - font-weight: 700; - color: var(--bs-alert-link-color); -} - -.alert-dismissible { - padding-right: 3rem; -} -.alert-dismissible .btn-close { - position: absolute; - top: 0; - right: 0; - z-index: 2; - padding: 1.25rem 1rem; -} - -.alert-primary { - --bs-alert-color: var(--bs-primary-text-emphasis); - --bs-alert-bg: var(--bs-primary-bg-subtle); - --bs-alert-border-color: var(--bs-primary-border-subtle); - --bs-alert-link-color: var(--bs-primary-text-emphasis); -} - -.alert-secondary { - --bs-alert-color: var(--bs-secondary-text-emphasis); - --bs-alert-bg: var(--bs-secondary-bg-subtle); - --bs-alert-border-color: var(--bs-secondary-border-subtle); - --bs-alert-link-color: var(--bs-secondary-text-emphasis); -} - -.alert-success { - --bs-alert-color: var(--bs-success-text-emphasis); - --bs-alert-bg: var(--bs-success-bg-subtle); - --bs-alert-border-color: var(--bs-success-border-subtle); - --bs-alert-link-color: var(--bs-success-text-emphasis); -} - -.alert-info { - --bs-alert-color: var(--bs-info-text-emphasis); - --bs-alert-bg: var(--bs-info-bg-subtle); - --bs-alert-border-color: var(--bs-info-border-subtle); - --bs-alert-link-color: var(--bs-info-text-emphasis); -} - -.alert-warning { - --bs-alert-color: var(--bs-warning-text-emphasis); - --bs-alert-bg: var(--bs-warning-bg-subtle); - --bs-alert-border-color: var(--bs-warning-border-subtle); - --bs-alert-link-color: var(--bs-warning-text-emphasis); -} - -.alert-danger { - --bs-alert-color: var(--bs-danger-text-emphasis); - --bs-alert-bg: var(--bs-danger-bg-subtle); - --bs-alert-border-color: var(--bs-danger-border-subtle); - --bs-alert-link-color: var(--bs-danger-text-emphasis); -} - -.alert-light { - --bs-alert-color: var(--bs-light-text-emphasis); - --bs-alert-bg: var(--bs-light-bg-subtle); - --bs-alert-border-color: var(--bs-light-border-subtle); - --bs-alert-link-color: var(--bs-light-text-emphasis); -} - -.alert-dark { - --bs-alert-color: var(--bs-dark-text-emphasis); - --bs-alert-bg: var(--bs-dark-bg-subtle); - --bs-alert-border-color: var(--bs-dark-border-subtle); - --bs-alert-link-color: var(--bs-dark-text-emphasis); -} - -@keyframes progress-bar-stripes { - 0% { - background-position-x: 1rem; - } -} -.progress, -.progress-stacked { - --bs-progress-height: 1rem; - --bs-progress-font-size: 0.75rem; - --bs-progress-bg: var(--bs-secondary-bg); - --bs-progress-border-radius: var(--bs-border-radius); - --bs-progress-box-shadow: var(--bs-box-shadow-inset); - --bs-progress-bar-color: #fff; - --bs-progress-bar-bg: #0d6efd; - --bs-progress-bar-transition: width 0.6s ease; - display: flex; - height: var(--bs-progress-height); - overflow: hidden; - font-size: var(--bs-progress-font-size); - background-color: var(--bs-progress-bg); - border-radius: var(--bs-progress-border-radius); -} - -.progress-bar { - display: flex; - flex-direction: column; - justify-content: center; - overflow: hidden; - color: var(--bs-progress-bar-color); - text-align: center; - white-space: nowrap; - background-color: var(--bs-progress-bar-bg); - transition: var(--bs-progress-bar-transition); -} -@media (prefers-reduced-motion: reduce) { - .progress-bar { - transition: none; - } -} - -.progress-bar-striped { - background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-size: var(--bs-progress-height) var(--bs-progress-height); -} - -.progress-stacked > .progress { - overflow: visible; -} - -.progress-stacked > .progress > .progress-bar { - width: 100%; -} - -.progress-bar-animated { - animation: 1s linear infinite progress-bar-stripes; -} -@media (prefers-reduced-motion: reduce) { - .progress-bar-animated { - animation: none; - } -} - -.list-group { - --bs-list-group-color: var(--bs-body-color); - --bs-list-group-bg: var(--bs-body-bg); - --bs-list-group-border-color: var(--bs-border-color); - --bs-list-group-border-width: var(--bs-border-width); - --bs-list-group-border-radius: var(--bs-border-radius); - --bs-list-group-item-padding-x: 1rem; - --bs-list-group-item-padding-y: 0.5rem; - --bs-list-group-action-color: var(--bs-secondary-color); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-tertiary-bg); - --bs-list-group-action-active-color: var(--bs-body-color); - --bs-list-group-action-active-bg: var(--bs-secondary-bg); - --bs-list-group-disabled-color: var(--bs-secondary-color); - --bs-list-group-disabled-bg: var(--bs-body-bg); - --bs-list-group-active-color: #fff; - --bs-list-group-active-bg: #0d6efd; - --bs-list-group-active-border-color: #0d6efd; - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - border-radius: var(--bs-list-group-border-radius); -} - -.list-group-numbered { - list-style-type: none; - counter-reset: section; -} -.list-group-numbered > .list-group-item::before { - content: counters(section, ".") ". "; - counter-increment: section; -} - -.list-group-item-action { - width: 100%; - color: var(--bs-list-group-action-color); - text-align: inherit; -} -.list-group-item-action:hover, .list-group-item-action:focus { - z-index: 1; - color: var(--bs-list-group-action-hover-color); - text-decoration: none; - background-color: var(--bs-list-group-action-hover-bg); -} -.list-group-item-action:active { - color: var(--bs-list-group-action-active-color); - background-color: var(--bs-list-group-action-active-bg); -} - -.list-group-item { - position: relative; - display: block; - padding: var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x); - color: var(--bs-list-group-color); - text-decoration: none; - background-color: var(--bs-list-group-bg); - border: var(--bs-list-group-border-width) solid var(--bs-list-group-border-color); -} -.list-group-item:first-child { - border-top-left-radius: inherit; - border-top-right-radius: inherit; -} -.list-group-item:last-child { - border-bottom-right-radius: inherit; - border-bottom-left-radius: inherit; -} -.list-group-item.disabled, .list-group-item:disabled { - color: var(--bs-list-group-disabled-color); - pointer-events: none; - background-color: var(--bs-list-group-disabled-bg); -} -.list-group-item.active { - z-index: 2; - color: var(--bs-list-group-active-color); - background-color: var(--bs-list-group-active-bg); - border-color: var(--bs-list-group-active-border-color); -} -.list-group-item + .list-group-item { - border-top-width: 0; -} -.list-group-item + .list-group-item.active { - margin-top: calc(-1 * var(--bs-list-group-border-width)); - border-top-width: var(--bs-list-group-border-width); -} - -.list-group-horizontal { - flex-direction: row; -} -.list-group-horizontal > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; -} -.list-group-horizontal > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; -} -.list-group-horizontal > .list-group-item.active { - margin-top: 0; -} -.list-group-horizontal > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; -} -.list-group-horizontal > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); -} - -@media (min-width: 576px) { - .list-group-horizontal-sm { - flex-direction: row; - } - .list-group-horizontal-sm > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; - } - .list-group-horizontal-sm > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; - } - .list-group-horizontal-sm > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-sm > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; - } - .list-group-horizontal-sm > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); - } -} -@media (min-width: 768px) { - .list-group-horizontal-md { - flex-direction: row; - } - .list-group-horizontal-md > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; - } - .list-group-horizontal-md > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; - } - .list-group-horizontal-md > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-md > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; - } - .list-group-horizontal-md > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); - } -} -@media (min-width: 992px) { - .list-group-horizontal-lg { - flex-direction: row; - } - .list-group-horizontal-lg > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; - } - .list-group-horizontal-lg > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; - } - .list-group-horizontal-lg > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-lg > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; - } - .list-group-horizontal-lg > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); - } -} -@media (min-width: 1200px) { - .list-group-horizontal-xl { - flex-direction: row; - } - .list-group-horizontal-xl > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; - } - .list-group-horizontal-xl > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; - } - .list-group-horizontal-xl > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-xl > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; - } - .list-group-horizontal-xl > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); - } -} -@media (min-width: 1400px) { - .list-group-horizontal-xxl { - flex-direction: row; - } - .list-group-horizontal-xxl > .list-group-item:first-child:not(:last-child) { - border-bottom-left-radius: var(--bs-list-group-border-radius); - border-top-right-radius: 0; - } - .list-group-horizontal-xxl > .list-group-item:last-child:not(:first-child) { - border-top-right-radius: var(--bs-list-group-border-radius); - border-bottom-left-radius: 0; - } - .list-group-horizontal-xxl > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-xxl > .list-group-item + .list-group-item { - border-top-width: var(--bs-list-group-border-width); - border-left-width: 0; - } - .list-group-horizontal-xxl > .list-group-item + .list-group-item.active { - margin-left: calc(-1 * var(--bs-list-group-border-width)); - border-left-width: var(--bs-list-group-border-width); - } -} -.list-group-flush { - border-radius: 0; -} -.list-group-flush > .list-group-item { - border-width: 0 0 var(--bs-list-group-border-width); -} -.list-group-flush > .list-group-item:last-child { - border-bottom-width: 0; -} - -.list-group-item-primary { - --bs-list-group-color: var(--bs-primary-text-emphasis); - --bs-list-group-bg: var(--bs-primary-bg-subtle); - --bs-list-group-border-color: var(--bs-primary-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-primary-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-primary-border-subtle); - --bs-list-group-active-color: var(--bs-primary-bg-subtle); - --bs-list-group-active-bg: var(--bs-primary-text-emphasis); - --bs-list-group-active-border-color: var(--bs-primary-text-emphasis); -} - -.list-group-item-secondary { - --bs-list-group-color: var(--bs-secondary-text-emphasis); - --bs-list-group-bg: var(--bs-secondary-bg-subtle); - --bs-list-group-border-color: var(--bs-secondary-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-secondary-border-subtle); - --bs-list-group-active-color: var(--bs-secondary-bg-subtle); - --bs-list-group-active-bg: var(--bs-secondary-text-emphasis); - --bs-list-group-active-border-color: var(--bs-secondary-text-emphasis); -} - -.list-group-item-success { - --bs-list-group-color: var(--bs-success-text-emphasis); - --bs-list-group-bg: var(--bs-success-bg-subtle); - --bs-list-group-border-color: var(--bs-success-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-success-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-success-border-subtle); - --bs-list-group-active-color: var(--bs-success-bg-subtle); - --bs-list-group-active-bg: var(--bs-success-text-emphasis); - --bs-list-group-active-border-color: var(--bs-success-text-emphasis); -} - -.list-group-item-info { - --bs-list-group-color: var(--bs-info-text-emphasis); - --bs-list-group-bg: var(--bs-info-bg-subtle); - --bs-list-group-border-color: var(--bs-info-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-info-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-info-border-subtle); - --bs-list-group-active-color: var(--bs-info-bg-subtle); - --bs-list-group-active-bg: var(--bs-info-text-emphasis); - --bs-list-group-active-border-color: var(--bs-info-text-emphasis); -} - -.list-group-item-warning { - --bs-list-group-color: var(--bs-warning-text-emphasis); - --bs-list-group-bg: var(--bs-warning-bg-subtle); - --bs-list-group-border-color: var(--bs-warning-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-warning-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-warning-border-subtle); - --bs-list-group-active-color: var(--bs-warning-bg-subtle); - --bs-list-group-active-bg: var(--bs-warning-text-emphasis); - --bs-list-group-active-border-color: var(--bs-warning-text-emphasis); -} - -.list-group-item-danger { - --bs-list-group-color: var(--bs-danger-text-emphasis); - --bs-list-group-bg: var(--bs-danger-bg-subtle); - --bs-list-group-border-color: var(--bs-danger-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-danger-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-danger-border-subtle); - --bs-list-group-active-color: var(--bs-danger-bg-subtle); - --bs-list-group-active-bg: var(--bs-danger-text-emphasis); - --bs-list-group-active-border-color: var(--bs-danger-text-emphasis); -} - -.list-group-item-light { - --bs-list-group-color: var(--bs-light-text-emphasis); - --bs-list-group-bg: var(--bs-light-bg-subtle); - --bs-list-group-border-color: var(--bs-light-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-light-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-light-border-subtle); - --bs-list-group-active-color: var(--bs-light-bg-subtle); - --bs-list-group-active-bg: var(--bs-light-text-emphasis); - --bs-list-group-active-border-color: var(--bs-light-text-emphasis); -} - -.list-group-item-dark { - --bs-list-group-color: var(--bs-dark-text-emphasis); - --bs-list-group-bg: var(--bs-dark-bg-subtle); - --bs-list-group-border-color: var(--bs-dark-border-subtle); - --bs-list-group-action-hover-color: var(--bs-emphasis-color); - --bs-list-group-action-hover-bg: var(--bs-dark-border-subtle); - --bs-list-group-action-active-color: var(--bs-emphasis-color); - --bs-list-group-action-active-bg: var(--bs-dark-border-subtle); - --bs-list-group-active-color: var(--bs-dark-bg-subtle); - --bs-list-group-active-bg: var(--bs-dark-text-emphasis); - --bs-list-group-active-border-color: var(--bs-dark-text-emphasis); -} - -.btn-close { - --bs-btn-close-color: #000; - --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); - --bs-btn-close-opacity: 0.5; - --bs-btn-close-hover-opacity: 0.75; - --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); - --bs-btn-close-focus-opacity: 1; - --bs-btn-close-disabled-opacity: 0.25; - --bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); - box-sizing: content-box; - width: 1em; - height: 1em; - padding: 0.25em 0.25em; - color: var(--bs-btn-close-color); - background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; - border: 0; - border-radius: 0.375rem; - opacity: var(--bs-btn-close-opacity); -} -.btn-close:hover { - color: var(--bs-btn-close-color); - text-decoration: none; - opacity: var(--bs-btn-close-hover-opacity); -} -.btn-close:focus { - outline: 0; - box-shadow: var(--bs-btn-close-focus-shadow); - opacity: var(--bs-btn-close-focus-opacity); -} -.btn-close:disabled, .btn-close.disabled { - pointer-events: none; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - opacity: var(--bs-btn-close-disabled-opacity); -} - -.btn-close-white { - filter: var(--bs-btn-close-white-filter); -} - -[data-bs-theme=dark] .btn-close { - filter: var(--bs-btn-close-white-filter); -} - -.toast { - --bs-toast-zindex: 1090; - --bs-toast-padding-x: 0.75rem; - --bs-toast-padding-y: 0.5rem; - --bs-toast-spacing: 1.5rem; - --bs-toast-max-width: 350px; - --bs-toast-font-size: 0.875rem; - --bs-toast-color: ; - --bs-toast-bg: rgba(var(--bs-body-bg-rgb), 0.85); - --bs-toast-border-width: var(--bs-border-width); - --bs-toast-border-color: var(--bs-border-color-translucent); - --bs-toast-border-radius: var(--bs-border-radius); - --bs-toast-box-shadow: var(--bs-box-shadow); - --bs-toast-header-color: var(--bs-secondary-color); - --bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), 0.85); - --bs-toast-header-border-color: var(--bs-border-color-translucent); - width: var(--bs-toast-max-width); - max-width: 100%; - font-size: var(--bs-toast-font-size); - color: var(--bs-toast-color); - pointer-events: auto; - background-color: var(--bs-toast-bg); - background-clip: padding-box; - border: var(--bs-toast-border-width) solid var(--bs-toast-border-color); - box-shadow: var(--bs-toast-box-shadow); - border-radius: var(--bs-toast-border-radius); -} -.toast.showing { - opacity: 0; -} -.toast:not(.show) { - display: none; -} - -.toast-container { - --bs-toast-zindex: 1090; - position: absolute; - z-index: var(--bs-toast-zindex); - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 100%; - pointer-events: none; -} -.toast-container > :not(:last-child) { - margin-bottom: var(--bs-toast-spacing); -} - -.toast-header { - display: flex; - align-items: center; - padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x); - color: var(--bs-toast-header-color); - background-color: var(--bs-toast-header-bg); - background-clip: padding-box; - border-bottom: var(--bs-toast-border-width) solid var(--bs-toast-header-border-color); - border-top-left-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width)); - border-top-right-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width)); -} -.toast-header .btn-close { - margin-right: calc(-0.5 * var(--bs-toast-padding-x)); - margin-left: var(--bs-toast-padding-x); -} - -.toast-body { - padding: var(--bs-toast-padding-x); - word-wrap: break-word; -} - -.modal { - --bs-modal-zindex: 1055; - --bs-modal-width: 500px; - --bs-modal-padding: 1rem; - --bs-modal-margin: 0.5rem; - --bs-modal-color: ; - --bs-modal-bg: var(--bs-body-bg); - --bs-modal-border-color: var(--bs-border-color-translucent); - --bs-modal-border-width: var(--bs-border-width); - --bs-modal-border-radius: var(--bs-border-radius-lg); - --bs-modal-box-shadow: var(--bs-box-shadow-sm); - --bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width))); - --bs-modal-header-padding-x: 1rem; - --bs-modal-header-padding-y: 1rem; - --bs-modal-header-padding: 1rem 1rem; - --bs-modal-header-border-color: var(--bs-border-color); - --bs-modal-header-border-width: var(--bs-border-width); - --bs-modal-title-line-height: 1.5; - --bs-modal-footer-gap: 0.5rem; - --bs-modal-footer-bg: ; - --bs-modal-footer-border-color: var(--bs-border-color); - --bs-modal-footer-border-width: var(--bs-border-width); - position: fixed; - top: 0; - left: 0; - z-index: var(--bs-modal-zindex); - display: none; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: auto; - outline: 0; -} - -.modal-dialog { - position: relative; - width: auto; - margin: var(--bs-modal-margin); - pointer-events: none; -} -.modal.fade .modal-dialog { - transition: transform 0.3s ease-out; - transform: translate(0, -50px); -} -@media (prefers-reduced-motion: reduce) { - .modal.fade .modal-dialog { - transition: none; - } -} -.modal.show .modal-dialog { - transform: none; -} -.modal.modal-static .modal-dialog { - transform: scale(1.02); -} - -.modal-dialog-scrollable { - height: calc(100% - var(--bs-modal-margin) * 2); -} -.modal-dialog-scrollable .modal-content { - max-height: 100%; - overflow: hidden; -} -.modal-dialog-scrollable .modal-body { - overflow-y: auto; -} - -.modal-dialog-centered { - display: flex; - align-items: center; - min-height: calc(100% - var(--bs-modal-margin) * 2); -} - -.modal-content { - position: relative; - display: flex; - flex-direction: column; - width: 100%; - color: var(--bs-modal-color); - pointer-events: auto; - background-color: var(--bs-modal-bg); - background-clip: padding-box; - border: var(--bs-modal-border-width) solid var(--bs-modal-border-color); - border-radius: var(--bs-modal-border-radius); - outline: 0; -} - -.modal-backdrop { - --bs-backdrop-zindex: 1050; - --bs-backdrop-bg: #000; - --bs-backdrop-opacity: 0.5; - position: fixed; - top: 0; - left: 0; - z-index: var(--bs-backdrop-zindex); - width: 100vw; - height: 100vh; - background-color: var(--bs-backdrop-bg); -} -.modal-backdrop.fade { - opacity: 0; -} -.modal-backdrop.show { - opacity: var(--bs-backdrop-opacity); -} - -.modal-header { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: space-between; - padding: var(--bs-modal-header-padding); - border-bottom: var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color); - border-top-left-radius: var(--bs-modal-inner-border-radius); - border-top-right-radius: var(--bs-modal-inner-border-radius); -} -.modal-header .btn-close { - padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5); - margin: calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x)) calc(-0.5 * var(--bs-modal-header-padding-y)) auto; -} - -.modal-title { - margin-bottom: 0; - line-height: var(--bs-modal-title-line-height); -} - -.modal-body { - position: relative; - flex: 1 1 auto; - padding: var(--bs-modal-padding); -} - -.modal-footer { - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - padding: calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * 0.5); - background-color: var(--bs-modal-footer-bg); - border-top: var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color); - border-bottom-right-radius: var(--bs-modal-inner-border-radius); - border-bottom-left-radius: var(--bs-modal-inner-border-radius); -} -.modal-footer > * { - margin: calc(var(--bs-modal-footer-gap) * 0.5); -} - -@media (min-width: 576px) { - .modal { - --bs-modal-margin: 1.75rem; - --bs-modal-box-shadow: var(--bs-box-shadow); - } - .modal-dialog { - max-width: var(--bs-modal-width); - margin-right: auto; - margin-left: auto; - } - .modal-sm { - --bs-modal-width: 300px; - } -} -@media (min-width: 992px) { - .modal-lg, - .modal-xl { - --bs-modal-width: 800px; - } -} -@media (min-width: 1200px) { - .modal-xl { - --bs-modal-width: 1140px; - } -} -.modal-fullscreen { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; -} -.modal-fullscreen .modal-content { - height: 100%; - border: 0; - border-radius: 0; -} -.modal-fullscreen .modal-header, -.modal-fullscreen .modal-footer { - border-radius: 0; -} -.modal-fullscreen .modal-body { - overflow-y: auto; -} - -@media (max-width: 575.98px) { - .modal-fullscreen-sm-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-sm-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-sm-down .modal-header, - .modal-fullscreen-sm-down .modal-footer { - border-radius: 0; - } - .modal-fullscreen-sm-down .modal-body { - overflow-y: auto; - } -} -@media (max-width: 767.98px) { - .modal-fullscreen-md-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-md-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-md-down .modal-header, - .modal-fullscreen-md-down .modal-footer { - border-radius: 0; - } - .modal-fullscreen-md-down .modal-body { - overflow-y: auto; - } -} -@media (max-width: 991.98px) { - .modal-fullscreen-lg-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-lg-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-lg-down .modal-header, - .modal-fullscreen-lg-down .modal-footer { - border-radius: 0; - } - .modal-fullscreen-lg-down .modal-body { - overflow-y: auto; - } -} -@media (max-width: 1199.98px) { - .modal-fullscreen-xl-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-xl-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-xl-down .modal-header, - .modal-fullscreen-xl-down .modal-footer { - border-radius: 0; - } - .modal-fullscreen-xl-down .modal-body { - overflow-y: auto; - } -} -@media (max-width: 1399.98px) { - .modal-fullscreen-xxl-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-xxl-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-xxl-down .modal-header, - .modal-fullscreen-xxl-down .modal-footer { - border-radius: 0; - } - .modal-fullscreen-xxl-down .modal-body { - overflow-y: auto; - } -} -.tooltip { - --bs-tooltip-zindex: 1080; - --bs-tooltip-max-width: 200px; - --bs-tooltip-padding-x: 0.5rem; - --bs-tooltip-padding-y: 0.25rem; - --bs-tooltip-margin: ; - --bs-tooltip-font-size: 0.875rem; - --bs-tooltip-color: var(--bs-body-bg); - --bs-tooltip-bg: var(--bs-emphasis-color); - --bs-tooltip-border-radius: var(--bs-border-radius); - --bs-tooltip-opacity: 0.9; - --bs-tooltip-arrow-width: 0.8rem; - --bs-tooltip-arrow-height: 0.4rem; - z-index: var(--bs-tooltip-zindex); - display: block; - margin: var(--bs-tooltip-margin); - font-family: var(--bs-font-sans-serif); - font-style: normal; - font-weight: 400; - line-height: 1.5; - text-align: left; - text-align: start; - text-decoration: none; - text-shadow: none; - text-transform: none; - letter-spacing: normal; - word-break: normal; - white-space: normal; - word-spacing: normal; - line-break: auto; - font-size: var(--bs-tooltip-font-size); - word-wrap: break-word; - opacity: 0; -} -.tooltip.show { - opacity: var(--bs-tooltip-opacity); -} -.tooltip .tooltip-arrow { - display: block; - width: var(--bs-tooltip-arrow-width); - height: var(--bs-tooltip-arrow-height); -} -.tooltip .tooltip-arrow::before { - position: absolute; - content: ""; - border-color: transparent; - border-style: solid; -} - -.bs-tooltip-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow { - bottom: calc(-1 * var(--bs-tooltip-arrow-height)); -} -.bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before { - top: -1px; - border-width: var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0; - border-top-color: var(--bs-tooltip-bg); -} - -/* rtl:begin:ignore */ -.bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow { - left: calc(-1 * var(--bs-tooltip-arrow-height)); - width: var(--bs-tooltip-arrow-height); - height: var(--bs-tooltip-arrow-width); -} -.bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before { - right: -1px; - border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0; - border-right-color: var(--bs-tooltip-bg); -} - -/* rtl:end:ignore */ -.bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow { - top: calc(-1 * var(--bs-tooltip-arrow-height)); -} -.bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before { - bottom: -1px; - border-width: 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height); - border-bottom-color: var(--bs-tooltip-bg); -} - -/* rtl:begin:ignore */ -.bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow { - right: calc(-1 * var(--bs-tooltip-arrow-height)); - width: var(--bs-tooltip-arrow-height); - height: var(--bs-tooltip-arrow-width); -} -.bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before { - left: -1px; - border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height); - border-left-color: var(--bs-tooltip-bg); -} - -/* rtl:end:ignore */ -.tooltip-inner { - max-width: var(--bs-tooltip-max-width); - padding: var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x); - color: var(--bs-tooltip-color); - text-align: center; - background-color: var(--bs-tooltip-bg); - border-radius: var(--bs-tooltip-border-radius); -} - -.popover { - --bs-popover-zindex: 1070; - --bs-popover-max-width: 276px; - --bs-popover-font-size: 0.875rem; - --bs-popover-bg: var(--bs-body-bg); - --bs-popover-border-width: var(--bs-border-width); - --bs-popover-border-color: var(--bs-border-color-translucent); - --bs-popover-border-radius: var(--bs-border-radius-lg); - --bs-popover-inner-border-radius: calc(var(--bs-border-radius-lg) - var(--bs-border-width)); - --bs-popover-box-shadow: var(--bs-box-shadow); - --bs-popover-header-padding-x: 1rem; - --bs-popover-header-padding-y: 0.5rem; - --bs-popover-header-font-size: 1rem; - --bs-popover-header-color: inherit; - --bs-popover-header-bg: var(--bs-secondary-bg); - --bs-popover-body-padding-x: 1rem; - --bs-popover-body-padding-y: 1rem; - --bs-popover-body-color: var(--bs-body-color); - --bs-popover-arrow-width: 1rem; - --bs-popover-arrow-height: 0.5rem; - --bs-popover-arrow-border: var(--bs-popover-border-color); - z-index: var(--bs-popover-zindex); - display: block; - max-width: var(--bs-popover-max-width); - font-family: var(--bs-font-sans-serif); - font-style: normal; - font-weight: 400; - line-height: 1.5; - text-align: left; - text-align: start; - text-decoration: none; - text-shadow: none; - text-transform: none; - letter-spacing: normal; - word-break: normal; - white-space: normal; - word-spacing: normal; - line-break: auto; - font-size: var(--bs-popover-font-size); - word-wrap: break-word; - background-color: var(--bs-popover-bg); - background-clip: padding-box; - border: var(--bs-popover-border-width) solid var(--bs-popover-border-color); - border-radius: var(--bs-popover-border-radius); -} -.popover .popover-arrow { - display: block; - width: var(--bs-popover-arrow-width); - height: var(--bs-popover-arrow-height); -} -.popover .popover-arrow::before, .popover .popover-arrow::after { - position: absolute; - display: block; - content: ""; - border-color: transparent; - border-style: solid; - border-width: 0; -} - -.bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow { - bottom: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); -} -.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before, .bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after { - border-width: var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0; -} -.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before { - bottom: 0; - border-top-color: var(--bs-popover-arrow-border); -} -.bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after { - bottom: var(--bs-popover-border-width); - border-top-color: var(--bs-popover-bg); -} - -/* rtl:begin:ignore */ -.bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow { - left: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); - width: var(--bs-popover-arrow-height); - height: var(--bs-popover-arrow-width); -} -.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before, .bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after { - border-width: calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0; -} -.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before { - left: 0; - border-right-color: var(--bs-popover-arrow-border); -} -.bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after { - left: var(--bs-popover-border-width); - border-right-color: var(--bs-popover-bg); -} - -/* rtl:end:ignore */ -.bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow { - top: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); -} -.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before, .bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after { - border-width: 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height); -} -.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before { - top: 0; - border-bottom-color: var(--bs-popover-arrow-border); -} -.bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after { - top: var(--bs-popover-border-width); - border-bottom-color: var(--bs-popover-bg); -} -.bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^=bottom] .popover-header::before { - position: absolute; - top: 0; - left: 50%; - display: block; - width: var(--bs-popover-arrow-width); - margin-left: calc(-0.5 * var(--bs-popover-arrow-width)); - content: ""; - border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-header-bg); -} - -/* rtl:begin:ignore */ -.bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow { - right: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); - width: var(--bs-popover-arrow-height); - height: var(--bs-popover-arrow-width); -} -.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before, .bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after { - border-width: calc(var(--bs-popover-arrow-width) * 0.5) 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height); -} -.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before { - right: 0; - border-left-color: var(--bs-popover-arrow-border); -} -.bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after { - right: var(--bs-popover-border-width); - border-left-color: var(--bs-popover-bg); -} - -/* rtl:end:ignore */ -.popover-header { - padding: var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x); - margin-bottom: 0; - font-size: var(--bs-popover-header-font-size); - color: var(--bs-popover-header-color); - background-color: var(--bs-popover-header-bg); - border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-border-color); - border-top-left-radius: var(--bs-popover-inner-border-radius); - border-top-right-radius: var(--bs-popover-inner-border-radius); -} -.popover-header:empty { - display: none; -} - -.popover-body { - padding: var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x); - color: var(--bs-popover-body-color); -} - -.carousel { - position: relative; -} - -.carousel.pointer-event { - touch-action: pan-y; -} - -.carousel-inner { - position: relative; - width: 100%; - overflow: hidden; -} -.carousel-inner::after { - display: block; - clear: both; - content: ""; -} - -.carousel-item { - position: relative; - display: none; - float: left; - width: 100%; - margin-right: -100%; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - transition: transform 0.6s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .carousel-item { - transition: none; - } -} - -.carousel-item.active, -.carousel-item-next, -.carousel-item-prev { - display: block; -} - -.carousel-item-next:not(.carousel-item-start), -.active.carousel-item-end { - transform: translateX(100%); -} - -.carousel-item-prev:not(.carousel-item-end), -.active.carousel-item-start { - transform: translateX(-100%); -} - -.carousel-fade .carousel-item { - opacity: 0; - transition-property: opacity; - transform: none; -} -.carousel-fade .carousel-item.active, -.carousel-fade .carousel-item-next.carousel-item-start, -.carousel-fade .carousel-item-prev.carousel-item-end { - z-index: 1; - opacity: 1; -} -.carousel-fade .active.carousel-item-start, -.carousel-fade .active.carousel-item-end { - z-index: 0; - opacity: 0; - transition: opacity 0s 0.6s; -} -@media (prefers-reduced-motion: reduce) { - .carousel-fade .active.carousel-item-start, - .carousel-fade .active.carousel-item-end { - transition: none; - } -} - -.carousel-control-prev, -.carousel-control-next { - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 15%; - padding: 0; - color: #fff; - text-align: center; - background: none; - border: 0; - opacity: 0.5; - transition: opacity 0.15s ease; -} -@media (prefers-reduced-motion: reduce) { - .carousel-control-prev, - .carousel-control-next { - transition: none; - } -} -.carousel-control-prev:hover, .carousel-control-prev:focus, -.carousel-control-next:hover, -.carousel-control-next:focus { - color: #fff; - text-decoration: none; - outline: 0; - opacity: 0.9; -} - -.carousel-control-prev { - left: 0; -} - -.carousel-control-next { - right: 0; -} - -.carousel-control-prev-icon, -.carousel-control-next-icon { - display: inline-block; - width: 2rem; - height: 2rem; - background-repeat: no-repeat; - background-position: 50%; - background-size: 100% 100%; -} - -/* rtl:options: { - "autoRename": true, - "stringMap":[ { - "name" : "prev-next", - "search" : "prev", - "replace" : "next" - } ] -} */ -.carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e"); -} - -.carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); -} - -.carousel-indicators { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 2; - display: flex; - justify-content: center; - padding: 0; - margin-right: 15%; - margin-bottom: 1rem; - margin-left: 15%; -} -.carousel-indicators [data-bs-target] { - box-sizing: content-box; - flex: 0 1 auto; - width: 30px; - height: 3px; - padding: 0; - margin-right: 3px; - margin-left: 3px; - text-indent: -999px; - cursor: pointer; - background-color: #fff; - background-clip: padding-box; - border: 0; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - opacity: 0.5; - transition: opacity 0.6s ease; -} -@media (prefers-reduced-motion: reduce) { - .carousel-indicators [data-bs-target] { - transition: none; - } -} -.carousel-indicators .active { - opacity: 1; -} - -.carousel-caption { - position: absolute; - right: 15%; - bottom: 1.25rem; - left: 15%; - padding-top: 1.25rem; - padding-bottom: 1.25rem; - color: #fff; - text-align: center; -} - -.carousel-dark .carousel-control-prev-icon, -.carousel-dark .carousel-control-next-icon { - filter: invert(1) grayscale(100); -} -.carousel-dark .carousel-indicators [data-bs-target] { - background-color: #000; -} -.carousel-dark .carousel-caption { - color: #000; -} - -[data-bs-theme=dark] .carousel .carousel-control-prev-icon, -[data-bs-theme=dark] .carousel .carousel-control-next-icon, [data-bs-theme=dark].carousel .carousel-control-prev-icon, -[data-bs-theme=dark].carousel .carousel-control-next-icon { - filter: invert(1) grayscale(100); -} -[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target], [data-bs-theme=dark].carousel .carousel-indicators [data-bs-target] { - background-color: #000; -} -[data-bs-theme=dark] .carousel .carousel-caption, [data-bs-theme=dark].carousel .carousel-caption { - color: #000; -} - -.spinner-grow, -.spinner-border { - display: inline-block; - width: var(--bs-spinner-width); - height: var(--bs-spinner-height); - vertical-align: var(--bs-spinner-vertical-align); - border-radius: 50%; - animation: var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name); -} - -@keyframes spinner-border { - to { - transform: rotate(360deg) /* rtl:ignore */; - } -} -.spinner-border { - --bs-spinner-width: 2rem; - --bs-spinner-height: 2rem; - --bs-spinner-vertical-align: -0.125em; - --bs-spinner-border-width: 0.25em; - --bs-spinner-animation-speed: 0.75s; - --bs-spinner-animation-name: spinner-border; - border: var(--bs-spinner-border-width) solid currentcolor; - border-right-color: transparent; -} - -.spinner-border-sm { - --bs-spinner-width: 1rem; - --bs-spinner-height: 1rem; - --bs-spinner-border-width: 0.2em; -} - -@keyframes spinner-grow { - 0% { - transform: scale(0); - } - 50% { - opacity: 1; - transform: none; - } -} -.spinner-grow { - --bs-spinner-width: 2rem; - --bs-spinner-height: 2rem; - --bs-spinner-vertical-align: -0.125em; - --bs-spinner-animation-speed: 0.75s; - --bs-spinner-animation-name: spinner-grow; - background-color: currentcolor; - opacity: 0; -} - -.spinner-grow-sm { - --bs-spinner-width: 1rem; - --bs-spinner-height: 1rem; -} - -@media (prefers-reduced-motion: reduce) { - .spinner-border, - .spinner-grow { - --bs-spinner-animation-speed: 1.5s; - } -} -.offcanvas, .offcanvas-xxl, .offcanvas-xl, .offcanvas-lg, .offcanvas-md, .offcanvas-sm { - --bs-offcanvas-zindex: 1045; - --bs-offcanvas-width: 400px; - --bs-offcanvas-height: 30vh; - --bs-offcanvas-padding-x: 1rem; - --bs-offcanvas-padding-y: 1rem; - --bs-offcanvas-color: var(--bs-body-color); - --bs-offcanvas-bg: var(--bs-body-bg); - --bs-offcanvas-border-width: var(--bs-border-width); - --bs-offcanvas-border-color: var(--bs-border-color-translucent); - --bs-offcanvas-box-shadow: var(--bs-box-shadow-sm); - --bs-offcanvas-transition: transform 0.3s ease-in-out; - --bs-offcanvas-title-line-height: 1.5; -} - -@media (max-width: 575.98px) { - .offcanvas-sm { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); - } -} -@media (max-width: 575.98px) and (prefers-reduced-motion: reduce) { - .offcanvas-sm { - transition: none; - } -} -@media (max-width: 575.98px) { - .offcanvas-sm.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); - } - .offcanvas-sm.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); - } - .offcanvas-sm.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); - } - .offcanvas-sm.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); - } - .offcanvas-sm.showing, .offcanvas-sm.show:not(.hiding) { - transform: none; - } - .offcanvas-sm.showing, .offcanvas-sm.hiding, .offcanvas-sm.show { - visibility: visible; - } -} -@media (min-width: 576px) { - .offcanvas-sm { - --bs-offcanvas-height: auto; - --bs-offcanvas-border-width: 0; - background-color: transparent !important; - } - .offcanvas-sm .offcanvas-header { - display: none; - } - .offcanvas-sm .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - background-color: transparent !important; - } -} - -@media (max-width: 767.98px) { - .offcanvas-md { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); - } -} -@media (max-width: 767.98px) and (prefers-reduced-motion: reduce) { - .offcanvas-md { - transition: none; - } -} -@media (max-width: 767.98px) { - .offcanvas-md.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); - } - .offcanvas-md.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); - } - .offcanvas-md.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); - } - .offcanvas-md.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); - } - .offcanvas-md.showing, .offcanvas-md.show:not(.hiding) { - transform: none; - } - .offcanvas-md.showing, .offcanvas-md.hiding, .offcanvas-md.show { - visibility: visible; - } -} -@media (min-width: 768px) { - .offcanvas-md { - --bs-offcanvas-height: auto; - --bs-offcanvas-border-width: 0; - background-color: transparent !important; - } - .offcanvas-md .offcanvas-header { - display: none; - } - .offcanvas-md .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - background-color: transparent !important; - } -} - -@media (max-width: 991.98px) { - .offcanvas-lg { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); - } -} -@media (max-width: 991.98px) and (prefers-reduced-motion: reduce) { - .offcanvas-lg { - transition: none; - } -} -@media (max-width: 991.98px) { - .offcanvas-lg.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); - } - .offcanvas-lg.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); - } - .offcanvas-lg.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); - } - .offcanvas-lg.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); - } - .offcanvas-lg.showing, .offcanvas-lg.show:not(.hiding) { - transform: none; - } - .offcanvas-lg.showing, .offcanvas-lg.hiding, .offcanvas-lg.show { - visibility: visible; - } -} -@media (min-width: 992px) { - .offcanvas-lg { - --bs-offcanvas-height: auto; - --bs-offcanvas-border-width: 0; - background-color: transparent !important; - } - .offcanvas-lg .offcanvas-header { - display: none; - } - .offcanvas-lg .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - background-color: transparent !important; - } -} - -@media (max-width: 1199.98px) { - .offcanvas-xl { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); - } -} -@media (max-width: 1199.98px) and (prefers-reduced-motion: reduce) { - .offcanvas-xl { - transition: none; - } -} -@media (max-width: 1199.98px) { - .offcanvas-xl.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); - } - .offcanvas-xl.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); - } - .offcanvas-xl.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); - } - .offcanvas-xl.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); - } - .offcanvas-xl.showing, .offcanvas-xl.show:not(.hiding) { - transform: none; - } - .offcanvas-xl.showing, .offcanvas-xl.hiding, .offcanvas-xl.show { - visibility: visible; - } -} -@media (min-width: 1200px) { - .offcanvas-xl { - --bs-offcanvas-height: auto; - --bs-offcanvas-border-width: 0; - background-color: transparent !important; - } - .offcanvas-xl .offcanvas-header { - display: none; - } - .offcanvas-xl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - background-color: transparent !important; - } -} - -@media (max-width: 1399.98px) { - .offcanvas-xxl { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); - } -} -@media (max-width: 1399.98px) and (prefers-reduced-motion: reduce) { - .offcanvas-xxl { - transition: none; - } -} -@media (max-width: 1399.98px) { - .offcanvas-xxl.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); - } - .offcanvas-xxl.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); - } - .offcanvas-xxl.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); - } - .offcanvas-xxl.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); - } - .offcanvas-xxl.showing, .offcanvas-xxl.show:not(.hiding) { - transform: none; - } - .offcanvas-xxl.showing, .offcanvas-xxl.hiding, .offcanvas-xxl.show { - visibility: visible; - } -} -@media (min-width: 1400px) { - .offcanvas-xxl { - --bs-offcanvas-height: auto; - --bs-offcanvas-border-width: 0; - background-color: transparent !important; - } - .offcanvas-xxl .offcanvas-header { - display: none; - } - .offcanvas-xxl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - background-color: transparent !important; - } -} - -.offcanvas { - position: fixed; - bottom: 0; - z-index: var(--bs-offcanvas-zindex); - display: flex; - flex-direction: column; - max-width: 100%; - color: var(--bs-offcanvas-color); - visibility: hidden; - background-color: var(--bs-offcanvas-bg); - background-clip: padding-box; - outline: 0; - transition: var(--bs-offcanvas-transition); -} -@media (prefers-reduced-motion: reduce) { - .offcanvas { - transition: none; - } -} -.offcanvas.offcanvas-start { - top: 0; - left: 0; - width: var(--bs-offcanvas-width); - border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(-100%); -} -.offcanvas.offcanvas-end { - top: 0; - right: 0; - width: var(--bs-offcanvas-width); - border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateX(100%); -} -.offcanvas.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(-100%); -} -.offcanvas.offcanvas-bottom { - right: 0; - left: 0; - height: var(--bs-offcanvas-height); - max-height: 100%; - border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); - transform: translateY(100%); -} -.offcanvas.showing, .offcanvas.show:not(.hiding) { - transform: none; -} -.offcanvas.showing, .offcanvas.hiding, .offcanvas.show { - visibility: visible; -} - -.offcanvas-backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 1040; - width: 100vw; - height: 100vh; - background-color: #000; -} -.offcanvas-backdrop.fade { - opacity: 0; -} -.offcanvas-backdrop.show { - opacity: 0.5; -} - -.offcanvas-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); -} -.offcanvas-header .btn-close { - padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5); - margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y)); - margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x)); - margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y)); -} - -.offcanvas-title { - margin-bottom: 0; - line-height: var(--bs-offcanvas-title-line-height); -} - -.offcanvas-body { - flex-grow: 1; - padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); - overflow-y: auto; -} - -.placeholder { - display: inline-block; - min-height: 1em; - vertical-align: middle; - cursor: wait; - background-color: currentcolor; - opacity: 0.5; -} -.placeholder.btn::before { - display: inline-block; - content: ""; -} - -.placeholder-xs { - min-height: 0.6em; -} - -.placeholder-sm { - min-height: 0.8em; -} - -.placeholder-lg { - min-height: 1.2em; -} - -.placeholder-glow .placeholder { - animation: placeholder-glow 2s ease-in-out infinite; -} - -@keyframes placeholder-glow { - 50% { - opacity: 0.2; - } -} -.placeholder-wave { - -webkit-mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); - mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); - -webkit-mask-size: 200% 100%; - mask-size: 200% 100%; - animation: placeholder-wave 2s linear infinite; -} - -@keyframes placeholder-wave { - 100% { - -webkit-mask-position: -200% 0%; - mask-position: -200% 0%; - } -} -.clearfix::after { - display: block; - clear: both; - content: ""; -} - -.text-bg-primary { - color: #fff !important; - background-color: RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-secondary { - color: #fff !important; - background-color: RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-success { - color: #fff !important; - background-color: RGBA(var(--bs-success-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-info { - color: #000 !important; - background-color: RGBA(var(--bs-info-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-warning { - color: #000 !important; - background-color: RGBA(var(--bs-warning-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-danger { - color: #fff !important; - background-color: RGBA(var(--bs-danger-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-light { - color: #000 !important; - background-color: RGBA(var(--bs-light-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.text-bg-dark { - color: #fff !important; - background-color: RGBA(var(--bs-dark-rgb), var(--bs-bg-opacity, 1)) !important; -} - -.link-primary { - color: RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-primary:hover, .link-primary:focus { - color: RGBA(10, 88, 202, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(10, 88, 202, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(10, 88, 202, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-secondary { - color: RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-secondary:hover, .link-secondary:focus { - color: RGBA(86, 94, 100, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(86, 94, 100, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(86, 94, 100, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-success { - color: RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-success:hover, .link-success:focus { - color: RGBA(20, 108, 67, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(20, 108, 67, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(20, 108, 67, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-info { - color: RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-info:hover, .link-info:focus { - color: RGBA(61, 213, 243, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(61, 213, 243, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(61, 213, 243, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-warning { - color: RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-warning:hover, .link-warning:focus { - color: RGBA(255, 205, 57, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(255, 205, 57, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(255, 205, 57, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-danger { - color: RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-danger:hover, .link-danger:focus { - color: RGBA(176, 42, 55, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(176, 42, 55, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(176, 42, 55, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-light { - color: RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-light:hover, .link-light:focus { - color: RGBA(249, 250, 251, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(249, 250, 251, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(249, 250, 251, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-dark { - color: RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-dark:hover, .link-dark:focus { - color: RGBA(26, 30, 33, var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(26, 30, 33, var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(26, 30, 33, var(--bs-link-underline-opacity, 1)) !important; -} - -.link-body-emphasis { - color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 1)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important; -} -.link-body-emphasis:hover, .link-body-emphasis:focus { - color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 0.75)) !important; - -webkit-text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important; - text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important; -} - -.focus-ring:focus { - outline: 0; - box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color); -} - -.icon-link { - display: inline-flex; - gap: 0.375rem; - align-items: center; - -webkit-text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5)); - text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5)); - text-underline-offset: 0.25em; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; -} -.icon-link > .bi { - flex-shrink: 0; - width: 1em; - height: 1em; - fill: currentcolor; - transition: 0.2s ease-in-out transform; -} -@media (prefers-reduced-motion: reduce) { - .icon-link > .bi { - transition: none; - } -} - -.icon-link-hover:hover > .bi, .icon-link-hover:focus-visible > .bi { - transform: var(--bs-icon-link-transform, translate3d(0.25em, 0, 0)); -} - -.ratio { - position: relative; - width: 100%; -} -.ratio::before { - display: block; - padding-top: var(--bs-aspect-ratio); - content: ""; -} -.ratio > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -.ratio-1x1 { - --bs-aspect-ratio: 100%; -} - -.ratio-4x3 { - --bs-aspect-ratio: 75%; -} - -.ratio-16x9 { - --bs-aspect-ratio: 56.25%; -} - -.ratio-21x9 { - --bs-aspect-ratio: 42.8571428571%; -} - -.fixed-top { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; -} - -.fixed-bottom { - position: fixed; - right: 0; - bottom: 0; - left: 0; - z-index: 1030; -} - -.sticky-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; -} - -.sticky-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; -} - -@media (min-width: 576px) { - .sticky-sm-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } - .sticky-sm-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; - } -} -@media (min-width: 768px) { - .sticky-md-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } - .sticky-md-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; - } -} -@media (min-width: 992px) { - .sticky-lg-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } - .sticky-lg-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; - } -} -@media (min-width: 1200px) { - .sticky-xl-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } - .sticky-xl-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; - } -} -@media (min-width: 1400px) { - .sticky-xxl-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } - .sticky-xxl-bottom { - position: -webkit-sticky; - position: sticky; - bottom: 0; - z-index: 1020; - } -} -.hstack { - display: flex; - flex-direction: row; - align-items: center; - align-self: stretch; -} - -.vstack { - display: flex; - flex: 1 1 auto; - flex-direction: column; - align-self: stretch; -} - -.visually-hidden, -.visually-hidden-focusable:not(:focus):not(:focus-within) { - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} -.visually-hidden:not(caption), -.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) { - position: absolute !important; -} - -.stretched-link::after { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 1; - content: ""; -} - -.text-truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.vr { - display: inline-block; - align-self: stretch; - width: var(--bs-border-width); - min-height: 1em; - background-color: currentcolor; - opacity: 0.25; -} - -.align-baseline { - vertical-align: baseline !important; -} - -.align-top { - vertical-align: top !important; -} - -.align-middle { - vertical-align: middle !important; -} - -.align-bottom { - vertical-align: bottom !important; -} - -.align-text-bottom { - vertical-align: text-bottom !important; -} - -.align-text-top { - vertical-align: text-top !important; -} - -.float-start { - float: left !important; -} - -.float-end { - float: right !important; -} - -.float-none { - float: none !important; -} - -.object-fit-contain { - -o-object-fit: contain !important; - object-fit: contain !important; -} - -.object-fit-cover { - -o-object-fit: cover !important; - object-fit: cover !important; -} - -.object-fit-fill { - -o-object-fit: fill !important; - object-fit: fill !important; -} - -.object-fit-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; -} - -.object-fit-none { - -o-object-fit: none !important; - object-fit: none !important; -} - -.opacity-0 { - opacity: 0 !important; -} - -.opacity-25 { - opacity: 0.25 !important; -} - -.opacity-50 { - opacity: 0.5 !important; -} - -.opacity-75 { - opacity: 0.75 !important; -} - -.opacity-100 { - opacity: 1 !important; -} - -.overflow-auto { - overflow: auto !important; -} - -.overflow-hidden { - overflow: hidden !important; -} - -.overflow-visible { - overflow: visible !important; -} - -.overflow-scroll { - overflow: scroll !important; -} - -.overflow-x-auto { - overflow-x: auto !important; -} - -.overflow-x-hidden { - overflow-x: hidden !important; -} - -.overflow-x-visible { - overflow-x: visible !important; -} - -.overflow-x-scroll { - overflow-x: scroll !important; -} - -.overflow-y-auto { - overflow-y: auto !important; -} - -.overflow-y-hidden { - overflow-y: hidden !important; -} - -.overflow-y-visible { - overflow-y: visible !important; -} - -.overflow-y-scroll { - overflow-y: scroll !important; -} - -.d-inline { - display: inline !important; -} - -.d-inline-block { - display: inline-block !important; -} - -.d-block { - display: block !important; -} - -.d-grid { - display: grid !important; -} - -.d-inline-grid { - display: inline-grid !important; -} - -.d-table { - display: table !important; -} - -.d-table-row { - display: table-row !important; -} - -.d-table-cell { - display: table-cell !important; -} - -.d-flex { - display: flex !important; -} - -.d-inline-flex { - display: inline-flex !important; -} - -.d-none { - display: none !important; -} - -.shadow { - box-shadow: var(--bs-box-shadow) !important; -} - -.shadow-sm { - box-shadow: var(--bs-box-shadow-sm) !important; -} - -.shadow-lg { - box-shadow: var(--bs-box-shadow-lg) !important; -} - -.shadow-none { - box-shadow: none !important; -} - -.focus-ring-primary { - --bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-secondary { - --bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-success { - --bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-info { - --bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-warning { - --bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-danger { - --bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-light { - --bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity)); -} - -.focus-ring-dark { - --bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity)); -} - -.position-static { - position: static !important; -} - -.position-relative { - position: relative !important; -} - -.position-absolute { - position: absolute !important; -} - -.position-fixed { - position: fixed !important; -} - -.position-sticky { - position: -webkit-sticky !important; - position: sticky !important; -} - -.top-0 { - top: 0 !important; -} - -.top-50 { - top: 50% !important; -} - -.top-100 { - top: 100% !important; -} - -.bottom-0 { - bottom: 0 !important; -} - -.bottom-50 { - bottom: 50% !important; -} - -.bottom-100 { - bottom: 100% !important; -} - -.start-0 { - left: 0 !important; -} - -.start-50 { - left: 50% !important; -} - -.start-100 { - left: 100% !important; -} - -.end-0 { - right: 0 !important; -} - -.end-50 { - right: 50% !important; -} - -.end-100 { - right: 100% !important; -} - -.translate-middle { - transform: translate(-50%, -50%) !important; -} - -.translate-middle-x { - transform: translateX(-50%) !important; -} - -.translate-middle-y { - transform: translateY(-50%) !important; -} - -.border { - border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; -} - -.border-0 { - border: 0 !important; -} - -.border-top { - border-top: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; -} - -.border-top-0 { - border-top: 0 !important; -} - -.border-end { - border-right: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; -} - -.border-end-0 { - border-right: 0 !important; -} - -.border-bottom { - border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; -} - -.border-bottom-0 { - border-bottom: 0 !important; -} - -.border-start { - border-left: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; -} - -.border-start-0 { - border-left: 0 !important; -} - -.border-primary { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important; -} - -.border-secondary { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important; -} - -.border-success { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important; -} - -.border-info { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important; -} - -.border-warning { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important; -} - -.border-danger { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important; -} - -.border-light { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important; -} - -.border-dark { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important; -} - -.border-black { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important; -} - -.border-white { - --bs-border-opacity: 1; - border-color: rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important; -} - -.border-primary-subtle { - border-color: var(--bs-primary-border-subtle) !important; -} - -.border-secondary-subtle { - border-color: var(--bs-secondary-border-subtle) !important; -} - -.border-success-subtle { - border-color: var(--bs-success-border-subtle) !important; -} - -.border-info-subtle { - border-color: var(--bs-info-border-subtle) !important; -} - -.border-warning-subtle { - border-color: var(--bs-warning-border-subtle) !important; -} - -.border-danger-subtle { - border-color: var(--bs-danger-border-subtle) !important; -} - -.border-light-subtle { - border-color: var(--bs-light-border-subtle) !important; -} - -.border-dark-subtle { - border-color: var(--bs-dark-border-subtle) !important; -} - -.border-1 { - border-width: 1px !important; -} - -.border-2 { - border-width: 2px !important; -} - -.border-3 { - border-width: 3px !important; -} - -.border-4 { - border-width: 4px !important; -} - -.border-5 { - border-width: 5px !important; -} - -.border-opacity-10 { - --bs-border-opacity: 0.1; -} - -.border-opacity-25 { - --bs-border-opacity: 0.25; -} - -.border-opacity-50 { - --bs-border-opacity: 0.5; -} - -.border-opacity-75 { - --bs-border-opacity: 0.75; -} - -.border-opacity-100 { - --bs-border-opacity: 1; -} - -.w-25 { - width: 25% !important; -} - -.w-50 { - width: 50% !important; -} - -.w-75 { - width: 75% !important; -} - -.w-100 { - width: 100% !important; -} - -.w-auto { - width: auto !important; -} - -.mw-100 { - max-width: 100% !important; -} - -.vw-100 { - width: 100vw !important; -} - -.min-vw-100 { - min-width: 100vw !important; -} - -.h-25 { - height: 25% !important; -} - -.h-50 { - height: 50% !important; -} - -.h-75 { - height: 75% !important; -} - -.h-100 { - height: 100% !important; -} - -.h-auto { - height: auto !important; -} - -.mh-100 { - max-height: 100% !important; -} - -.vh-100 { - height: 100vh !important; -} - -.min-vh-100 { - min-height: 100vh !important; -} - -.flex-fill { - flex: 1 1 auto !important; -} - -.flex-row { - flex-direction: row !important; -} - -.flex-column { - flex-direction: column !important; -} - -.flex-row-reverse { - flex-direction: row-reverse !important; -} - -.flex-column-reverse { - flex-direction: column-reverse !important; -} - -.flex-grow-0 { - flex-grow: 0 !important; -} - -.flex-grow-1 { - flex-grow: 1 !important; -} - -.flex-shrink-0 { - flex-shrink: 0 !important; -} - -.flex-shrink-1 { - flex-shrink: 1 !important; -} - -.flex-wrap { - flex-wrap: wrap !important; -} - -.flex-nowrap { - flex-wrap: nowrap !important; -} - -.flex-wrap-reverse { - flex-wrap: wrap-reverse !important; -} - -.justify-content-start { - justify-content: flex-start !important; -} - -.justify-content-end { - justify-content: flex-end !important; -} - -.justify-content-center { - justify-content: center !important; -} - -.justify-content-between { - justify-content: space-between !important; -} - -.justify-content-around { - justify-content: space-around !important; -} - -.justify-content-evenly { - justify-content: space-evenly !important; -} - -.align-items-start { - align-items: flex-start !important; -} - -.align-items-end { - align-items: flex-end !important; -} - -.align-items-center { - align-items: center !important; -} - -.align-items-baseline { - align-items: baseline !important; -} - -.align-items-stretch { - align-items: stretch !important; -} - -.align-content-start { - align-content: flex-start !important; -} - -.align-content-end { - align-content: flex-end !important; -} - -.align-content-center { - align-content: center !important; -} - -.align-content-between { - align-content: space-between !important; -} - -.align-content-around { - align-content: space-around !important; -} - -.align-content-stretch { - align-content: stretch !important; -} - -.align-self-auto { - align-self: auto !important; -} - -.align-self-start { - align-self: flex-start !important; -} - -.align-self-end { - align-self: flex-end !important; -} - -.align-self-center { - align-self: center !important; -} - -.align-self-baseline { - align-self: baseline !important; -} - -.align-self-stretch { - align-self: stretch !important; -} - -.order-first { - order: -1 !important; -} - -.order-0 { - order: 0 !important; -} - -.order-1 { - order: 1 !important; -} - -.order-2 { - order: 2 !important; -} - -.order-3 { - order: 3 !important; -} - -.order-4 { - order: 4 !important; -} - -.order-5 { - order: 5 !important; -} - -.order-last { - order: 6 !important; -} - -.m-0 { - margin: 0 !important; -} - -.m-1 { - margin: 0.25rem !important; -} - -.m-2 { - margin: 0.5rem !important; -} - -.m-3 { - margin: 1rem !important; -} - -.m-4 { - margin: 1.5rem !important; -} - -.m-5 { - margin: 3rem !important; -} - -.m-auto { - margin: auto !important; -} - -.mx-0 { - margin-right: 0 !important; - margin-left: 0 !important; -} - -.mx-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; -} - -.mx-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; -} - -.mx-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; -} - -.mx-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; -} - -.mx-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; -} - -.mx-auto { - margin-right: auto !important; - margin-left: auto !important; -} - -.my-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.my-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; -} - -.my-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; -} - -.my-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; -} - -.my-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; -} - -.my-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; -} - -.my-auto { - margin-top: auto !important; - margin-bottom: auto !important; -} - -.mt-0 { - margin-top: 0 !important; -} - -.mt-1 { - margin-top: 0.25rem !important; -} - -.mt-2 { - margin-top: 0.5rem !important; -} - -.mt-3 { - margin-top: 1rem !important; -} - -.mt-4 { - margin-top: 1.5rem !important; -} - -.mt-5 { - margin-top: 3rem !important; -} - -.mt-auto { - margin-top: auto !important; -} - -.me-0 { - margin-right: 0 !important; -} - -.me-1 { - margin-right: 0.25rem !important; -} - -.me-2 { - margin-right: 0.5rem !important; -} - -.me-3 { - margin-right: 1rem !important; -} - -.me-4 { - margin-right: 1.5rem !important; -} - -.me-5 { - margin-right: 3rem !important; -} - -.me-auto { - margin-right: auto !important; -} - -.mb-0 { - margin-bottom: 0 !important; -} - -.mb-1 { - margin-bottom: 0.25rem !important; -} - -.mb-2 { - margin-bottom: 0.5rem !important; -} - -.mb-3 { - margin-bottom: 1rem !important; -} - -.mb-4 { - margin-bottom: 1.5rem !important; -} - -.mb-5 { - margin-bottom: 3rem !important; -} - -.mb-auto { - margin-bottom: auto !important; -} - -.ms-0 { - margin-left: 0 !important; -} - -.ms-1 { - margin-left: 0.25rem !important; -} - -.ms-2 { - margin-left: 0.5rem !important; -} - -.ms-3 { - margin-left: 1rem !important; -} - -.ms-4 { - margin-left: 1.5rem !important; -} - -.ms-5 { - margin-left: 3rem !important; -} - -.ms-auto { - margin-left: auto !important; -} - -.p-0 { - padding: 0 !important; -} - -.p-1 { - padding: 0.25rem !important; -} - -.p-2 { - padding: 0.5rem !important; -} - -.p-3 { - padding: 1rem !important; -} - -.p-4 { - padding: 1.5rem !important; -} - -.p-5 { - padding: 3rem !important; -} - -.px-0 { - padding-right: 0 !important; - padding-left: 0 !important; -} - -.px-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; -} - -.px-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; -} - -.px-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; -} - -.px-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; -} - -.px-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; -} - -.py-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - -.py-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; -} - -.py-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; -} - -.py-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; -} - -.py-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; -} - -.py-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; -} - -.pt-0 { - padding-top: 0 !important; -} - -.pt-1 { - padding-top: 0.25rem !important; -} - -.pt-2 { - padding-top: 0.5rem !important; -} - -.pt-3 { - padding-top: 1rem !important; -} - -.pt-4 { - padding-top: 1.5rem !important; -} - -.pt-5 { - padding-top: 3rem !important; -} - -.pe-0 { - padding-right: 0 !important; -} - -.pe-1 { - padding-right: 0.25rem !important; -} - -.pe-2 { - padding-right: 0.5rem !important; -} - -.pe-3 { - padding-right: 1rem !important; -} - -.pe-4 { - padding-right: 1.5rem !important; -} - -.pe-5 { - padding-right: 3rem !important; -} - -.pb-0 { - padding-bottom: 0 !important; -} - -.pb-1 { - padding-bottom: 0.25rem !important; -} - -.pb-2 { - padding-bottom: 0.5rem !important; -} - -.pb-3 { - padding-bottom: 1rem !important; -} - -.pb-4 { - padding-bottom: 1.5rem !important; -} - -.pb-5 { - padding-bottom: 3rem !important; -} - -.ps-0 { - padding-left: 0 !important; -} - -.ps-1 { - padding-left: 0.25rem !important; -} - -.ps-2 { - padding-left: 0.5rem !important; -} - -.ps-3 { - padding-left: 1rem !important; -} - -.ps-4 { - padding-left: 1.5rem !important; -} - -.ps-5 { - padding-left: 3rem !important; -} - -.gap-0 { - gap: 0 !important; -} - -.gap-1 { - gap: 0.25rem !important; -} - -.gap-2 { - gap: 0.5rem !important; -} - -.gap-3 { - gap: 1rem !important; -} - -.gap-4 { - gap: 1.5rem !important; -} - -.gap-5 { - gap: 3rem !important; -} - -.row-gap-0 { - row-gap: 0 !important; -} - -.row-gap-1 { - row-gap: 0.25rem !important; -} - -.row-gap-2 { - row-gap: 0.5rem !important; -} - -.row-gap-3 { - row-gap: 1rem !important; -} - -.row-gap-4 { - row-gap: 1.5rem !important; -} - -.row-gap-5 { - row-gap: 3rem !important; -} - -.column-gap-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; -} - -.column-gap-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; -} - -.column-gap-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; -} - -.column-gap-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; -} - -.column-gap-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; -} - -.column-gap-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; -} - -.font-monospace { - font-family: var(--bs-font-monospace) !important; -} - -.fs-1 { - font-size: calc(1.375rem + 1.5vw) !important; -} - -.fs-2 { - font-size: calc(1.325rem + 0.9vw) !important; -} - -.fs-3 { - font-size: calc(1.3rem + 0.6vw) !important; -} - -.fs-4 { - font-size: calc(1.275rem + 0.3vw) !important; -} - -.fs-5 { - font-size: 1.25rem !important; -} - -.fs-6 { - font-size: 1rem !important; -} - -.fst-italic { - font-style: italic !important; -} - -.fst-normal { - font-style: normal !important; -} - -.fw-lighter { - font-weight: lighter !important; -} - -.fw-light { - font-weight: 300 !important; -} - -.fw-normal { - font-weight: 400 !important; -} - -.fw-medium { - font-weight: 500 !important; -} - -.fw-semibold { - font-weight: 600 !important; -} - -.fw-bold { - font-weight: 700 !important; -} - -.fw-bolder { - font-weight: bolder !important; -} - -.lh-1 { - line-height: 1 !important; -} - -.lh-sm { - line-height: 1.25 !important; -} - -.lh-base { - line-height: 1.5 !important; -} - -.lh-lg { - line-height: 2 !important; -} - -.text-start { - text-align: left !important; -} - -.text-end { - text-align: right !important; -} - -.text-center { - text-align: center !important; -} - -.text-decoration-none { - text-decoration: none !important; -} - -.text-decoration-underline { - text-decoration: underline !important; -} - -.text-decoration-line-through { - text-decoration: line-through !important; -} - -.text-lowercase { - text-transform: lowercase !important; -} - -.text-uppercase { - text-transform: uppercase !important; -} - -.text-capitalize { - text-transform: capitalize !important; -} - -.text-wrap { - white-space: normal !important; -} - -.text-nowrap { - white-space: nowrap !important; -} - -/* rtl:begin:remove */ -.text-break { - word-wrap: break-word !important; - word-break: break-word !important; -} - -/* rtl:end:remove */ -.text-primary { - --bs-text-opacity: 1; - color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important; -} - -.text-secondary { - --bs-text-opacity: 1; - color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important; -} - -.text-success { - --bs-text-opacity: 1; - color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important; -} - -.text-info { - --bs-text-opacity: 1; - color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important; -} - -.text-warning { - --bs-text-opacity: 1; - color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important; -} - -.text-danger { - --bs-text-opacity: 1; - color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important; -} - -.text-light { - --bs-text-opacity: 1; - color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important; -} - -.text-dark { - --bs-text-opacity: 1; - color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important; -} - -.text-black { - --bs-text-opacity: 1; - color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important; -} - -.text-white { - --bs-text-opacity: 1; - color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important; -} - -.text-body { - --bs-text-opacity: 1; - color: rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important; -} - -.text-muted { - --bs-text-opacity: 1; - color: var(--bs-secondary-color) !important; -} - -.text-black-50 { - --bs-text-opacity: 1; - color: rgba(0, 0, 0, 0.5) !important; -} - -.text-white-50 { - --bs-text-opacity: 1; - color: rgba(255, 255, 255, 0.5) !important; -} - -.text-body-secondary { - --bs-text-opacity: 1; - color: var(--bs-secondary-color) !important; -} - -.text-body-tertiary { - --bs-text-opacity: 1; - color: var(--bs-tertiary-color) !important; -} - -.text-body-emphasis { - --bs-text-opacity: 1; - color: var(--bs-emphasis-color) !important; -} - -.text-reset { - --bs-text-opacity: 1; - color: inherit !important; -} - -.text-opacity-25 { - --bs-text-opacity: 0.25; -} - -.text-opacity-50 { - --bs-text-opacity: 0.5; -} - -.text-opacity-75 { - --bs-text-opacity: 0.75; -} - -.text-opacity-100 { - --bs-text-opacity: 1; -} - -.text-primary-emphasis { - color: var(--bs-primary-text-emphasis) !important; -} - -.text-secondary-emphasis { - color: var(--bs-secondary-text-emphasis) !important; -} - -.text-success-emphasis { - color: var(--bs-success-text-emphasis) !important; -} - -.text-info-emphasis { - color: var(--bs-info-text-emphasis) !important; -} - -.text-warning-emphasis { - color: var(--bs-warning-text-emphasis) !important; -} - -.text-danger-emphasis { - color: var(--bs-danger-text-emphasis) !important; -} - -.text-light-emphasis { - color: var(--bs-light-text-emphasis) !important; -} - -.text-dark-emphasis { - color: var(--bs-dark-text-emphasis) !important; -} - -.link-opacity-10 { - --bs-link-opacity: 0.1; -} - -.link-opacity-10-hover:hover { - --bs-link-opacity: 0.1; -} - -.link-opacity-25 { - --bs-link-opacity: 0.25; -} - -.link-opacity-25-hover:hover { - --bs-link-opacity: 0.25; -} - -.link-opacity-50 { - --bs-link-opacity: 0.5; -} - -.link-opacity-50-hover:hover { - --bs-link-opacity: 0.5; -} - -.link-opacity-75 { - --bs-link-opacity: 0.75; -} - -.link-opacity-75-hover:hover { - --bs-link-opacity: 0.75; -} - -.link-opacity-100 { - --bs-link-opacity: 1; -} - -.link-opacity-100-hover:hover { - --bs-link-opacity: 1; -} - -.link-offset-1 { - text-underline-offset: 0.125em !important; -} - -.link-offset-1-hover:hover { - text-underline-offset: 0.125em !important; -} - -.link-offset-2 { - text-underline-offset: 0.25em !important; -} - -.link-offset-2-hover:hover { - text-underline-offset: 0.25em !important; -} - -.link-offset-3 { - text-underline-offset: 0.375em !important; -} - -.link-offset-3-hover:hover { - text-underline-offset: 0.375em !important; -} - -.link-underline-primary { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-secondary { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-success { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-info { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-warning { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-danger { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-light { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline-dark { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important; - text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important; -} - -.link-underline { - --bs-link-underline-opacity: 1; - -webkit-text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important; - text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important; -} - -.link-underline-opacity-0 { - --bs-link-underline-opacity: 0; -} - -.link-underline-opacity-0-hover:hover { - --bs-link-underline-opacity: 0; -} - -.link-underline-opacity-10 { - --bs-link-underline-opacity: 0.1; -} - -.link-underline-opacity-10-hover:hover { - --bs-link-underline-opacity: 0.1; -} - -.link-underline-opacity-25 { - --bs-link-underline-opacity: 0.25; -} - -.link-underline-opacity-25-hover:hover { - --bs-link-underline-opacity: 0.25; -} - -.link-underline-opacity-50 { - --bs-link-underline-opacity: 0.5; -} - -.link-underline-opacity-50-hover:hover { - --bs-link-underline-opacity: 0.5; -} - -.link-underline-opacity-75 { - --bs-link-underline-opacity: 0.75; -} - -.link-underline-opacity-75-hover:hover { - --bs-link-underline-opacity: 0.75; -} - -.link-underline-opacity-100 { - --bs-link-underline-opacity: 1; -} - -.link-underline-opacity-100-hover:hover { - --bs-link-underline-opacity: 1; -} - -.bg-primary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-secondary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-success { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-info { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-warning { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-danger { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-light { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-dark { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-black { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-white { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-body { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-transparent { - --bs-bg-opacity: 1; - background-color: transparent !important; -} - -.bg-body-secondary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-body-tertiary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-opacity-10 { - --bs-bg-opacity: 0.1; -} - -.bg-opacity-25 { - --bs-bg-opacity: 0.25; -} - -.bg-opacity-50 { - --bs-bg-opacity: 0.5; -} - -.bg-opacity-75 { - --bs-bg-opacity: 0.75; -} - -.bg-opacity-100 { - --bs-bg-opacity: 1; -} - -.bg-primary-subtle { - background-color: var(--bs-primary-bg-subtle) !important; -} - -.bg-secondary-subtle { - background-color: var(--bs-secondary-bg-subtle) !important; -} - -.bg-success-subtle { - background-color: var(--bs-success-bg-subtle) !important; -} - -.bg-info-subtle { - background-color: var(--bs-info-bg-subtle) !important; -} - -.bg-warning-subtle { - background-color: var(--bs-warning-bg-subtle) !important; -} - -.bg-danger-subtle { - background-color: var(--bs-danger-bg-subtle) !important; -} - -.bg-light-subtle { - background-color: var(--bs-light-bg-subtle) !important; -} - -.bg-dark-subtle { - background-color: var(--bs-dark-bg-subtle) !important; -} - -.bg-gradient { - background-image: var(--bs-gradient) !important; -} - -.user-select-all { - -webkit-user-select: all !important; - -moz-user-select: all !important; - user-select: all !important; -} - -.user-select-auto { - -webkit-user-select: auto !important; - -moz-user-select: auto !important; - user-select: auto !important; -} - -.user-select-none { - -webkit-user-select: none !important; - -moz-user-select: none !important; - user-select: none !important; -} - -.pe-none { - pointer-events: none !important; -} - -.pe-auto { - pointer-events: auto !important; -} - -.rounded { - border-radius: var(--bs-border-radius) !important; -} - -.rounded-0 { - border-radius: 0 !important; -} - -.rounded-1 { - border-radius: var(--bs-border-radius-sm) !important; -} - -.rounded-2 { - border-radius: var(--bs-border-radius) !important; -} - -.rounded-3 { - border-radius: var(--bs-border-radius-lg) !important; -} - -.rounded-4 { - border-radius: var(--bs-border-radius-xl) !important; -} - -.rounded-5 { - border-radius: var(--bs-border-radius-xxl) !important; -} - -.rounded-circle { - border-radius: 50% !important; -} - -.rounded-pill { - border-radius: var(--bs-border-radius-pill) !important; -} - -.rounded-top { - border-top-left-radius: var(--bs-border-radius) !important; - border-top-right-radius: var(--bs-border-radius) !important; -} - -.rounded-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; -} - -.rounded-top-1 { - border-top-left-radius: var(--bs-border-radius-sm) !important; - border-top-right-radius: var(--bs-border-radius-sm) !important; -} - -.rounded-top-2 { - border-top-left-radius: var(--bs-border-radius) !important; - border-top-right-radius: var(--bs-border-radius) !important; -} - -.rounded-top-3 { - border-top-left-radius: var(--bs-border-radius-lg) !important; - border-top-right-radius: var(--bs-border-radius-lg) !important; -} - -.rounded-top-4 { - border-top-left-radius: var(--bs-border-radius-xl) !important; - border-top-right-radius: var(--bs-border-radius-xl) !important; -} - -.rounded-top-5 { - border-top-left-radius: var(--bs-border-radius-xxl) !important; - border-top-right-radius: var(--bs-border-radius-xxl) !important; -} - -.rounded-top-circle { - border-top-left-radius: 50% !important; - border-top-right-radius: 50% !important; -} - -.rounded-top-pill { - border-top-left-radius: var(--bs-border-radius-pill) !important; - border-top-right-radius: var(--bs-border-radius-pill) !important; -} - -.rounded-end { - border-top-right-radius: var(--bs-border-radius) !important; - border-bottom-right-radius: var(--bs-border-radius) !important; -} - -.rounded-end-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; -} - -.rounded-end-1 { - border-top-right-radius: var(--bs-border-radius-sm) !important; - border-bottom-right-radius: var(--bs-border-radius-sm) !important; -} - -.rounded-end-2 { - border-top-right-radius: var(--bs-border-radius) !important; - border-bottom-right-radius: var(--bs-border-radius) !important; -} - -.rounded-end-3 { - border-top-right-radius: var(--bs-border-radius-lg) !important; - border-bottom-right-radius: var(--bs-border-radius-lg) !important; -} - -.rounded-end-4 { - border-top-right-radius: var(--bs-border-radius-xl) !important; - border-bottom-right-radius: var(--bs-border-radius-xl) !important; -} - -.rounded-end-5 { - border-top-right-radius: var(--bs-border-radius-xxl) !important; - border-bottom-right-radius: var(--bs-border-radius-xxl) !important; -} - -.rounded-end-circle { - border-top-right-radius: 50% !important; - border-bottom-right-radius: 50% !important; -} - -.rounded-end-pill { - border-top-right-radius: var(--bs-border-radius-pill) !important; - border-bottom-right-radius: var(--bs-border-radius-pill) !important; -} - -.rounded-bottom { - border-bottom-right-radius: var(--bs-border-radius) !important; - border-bottom-left-radius: var(--bs-border-radius) !important; -} - -.rounded-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; -} - -.rounded-bottom-1 { - border-bottom-right-radius: var(--bs-border-radius-sm) !important; - border-bottom-left-radius: var(--bs-border-radius-sm) !important; -} - -.rounded-bottom-2 { - border-bottom-right-radius: var(--bs-border-radius) !important; - border-bottom-left-radius: var(--bs-border-radius) !important; -} - -.rounded-bottom-3 { - border-bottom-right-radius: var(--bs-border-radius-lg) !important; - border-bottom-left-radius: var(--bs-border-radius-lg) !important; -} - -.rounded-bottom-4 { - border-bottom-right-radius: var(--bs-border-radius-xl) !important; - border-bottom-left-radius: var(--bs-border-radius-xl) !important; -} - -.rounded-bottom-5 { - border-bottom-right-radius: var(--bs-border-radius-xxl) !important; - border-bottom-left-radius: var(--bs-border-radius-xxl) !important; -} - -.rounded-bottom-circle { - border-bottom-right-radius: 50% !important; - border-bottom-left-radius: 50% !important; -} - -.rounded-bottom-pill { - border-bottom-right-radius: var(--bs-border-radius-pill) !important; - border-bottom-left-radius: var(--bs-border-radius-pill) !important; -} - -.rounded-start { - border-bottom-left-radius: var(--bs-border-radius) !important; - border-top-left-radius: var(--bs-border-radius) !important; -} - -.rounded-start-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; -} - -.rounded-start-1 { - border-bottom-left-radius: var(--bs-border-radius-sm) !important; - border-top-left-radius: var(--bs-border-radius-sm) !important; -} - -.rounded-start-2 { - border-bottom-left-radius: var(--bs-border-radius) !important; - border-top-left-radius: var(--bs-border-radius) !important; -} - -.rounded-start-3 { - border-bottom-left-radius: var(--bs-border-radius-lg) !important; - border-top-left-radius: var(--bs-border-radius-lg) !important; -} - -.rounded-start-4 { - border-bottom-left-radius: var(--bs-border-radius-xl) !important; - border-top-left-radius: var(--bs-border-radius-xl) !important; -} - -.rounded-start-5 { - border-bottom-left-radius: var(--bs-border-radius-xxl) !important; - border-top-left-radius: var(--bs-border-radius-xxl) !important; -} - -.rounded-start-circle { - border-bottom-left-radius: 50% !important; - border-top-left-radius: 50% !important; -} - -.rounded-start-pill { - border-bottom-left-radius: var(--bs-border-radius-pill) !important; - border-top-left-radius: var(--bs-border-radius-pill) !important; -} - -.visible { - visibility: visible !important; -} - -.invisible { - visibility: hidden !important; -} - -.z-n1 { - z-index: -1 !important; -} - -.z-0 { - z-index: 0 !important; -} - -.z-1 { - z-index: 1 !important; -} - -.z-2 { - z-index: 2 !important; -} - -.z-3 { - z-index: 3 !important; -} - -@media (min-width: 576px) { - .float-sm-start { - float: left !important; - } - .float-sm-end { - float: right !important; - } - .float-sm-none { - float: none !important; - } - .object-fit-sm-contain { - -o-object-fit: contain !important; - object-fit: contain !important; - } - .object-fit-sm-cover { - -o-object-fit: cover !important; - object-fit: cover !important; - } - .object-fit-sm-fill { - -o-object-fit: fill !important; - object-fit: fill !important; - } - .object-fit-sm-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; - } - .object-fit-sm-none { - -o-object-fit: none !important; - object-fit: none !important; - } - .d-sm-inline { - display: inline !important; - } - .d-sm-inline-block { - display: inline-block !important; - } - .d-sm-block { - display: block !important; - } - .d-sm-grid { - display: grid !important; - } - .d-sm-inline-grid { - display: inline-grid !important; - } - .d-sm-table { - display: table !important; - } - .d-sm-table-row { - display: table-row !important; - } - .d-sm-table-cell { - display: table-cell !important; - } - .d-sm-flex { - display: flex !important; - } - .d-sm-inline-flex { - display: inline-flex !important; - } - .d-sm-none { - display: none !important; - } - .flex-sm-fill { - flex: 1 1 auto !important; - } - .flex-sm-row { - flex-direction: row !important; - } - .flex-sm-column { - flex-direction: column !important; - } - .flex-sm-row-reverse { - flex-direction: row-reverse !important; - } - .flex-sm-column-reverse { - flex-direction: column-reverse !important; - } - .flex-sm-grow-0 { - flex-grow: 0 !important; - } - .flex-sm-grow-1 { - flex-grow: 1 !important; - } - .flex-sm-shrink-0 { - flex-shrink: 0 !important; - } - .flex-sm-shrink-1 { - flex-shrink: 1 !important; - } - .flex-sm-wrap { - flex-wrap: wrap !important; - } - .flex-sm-nowrap { - flex-wrap: nowrap !important; - } - .flex-sm-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - .justify-content-sm-start { - justify-content: flex-start !important; - } - .justify-content-sm-end { - justify-content: flex-end !important; - } - .justify-content-sm-center { - justify-content: center !important; - } - .justify-content-sm-between { - justify-content: space-between !important; - } - .justify-content-sm-around { - justify-content: space-around !important; - } - .justify-content-sm-evenly { - justify-content: space-evenly !important; - } - .align-items-sm-start { - align-items: flex-start !important; - } - .align-items-sm-end { - align-items: flex-end !important; - } - .align-items-sm-center { - align-items: center !important; - } - .align-items-sm-baseline { - align-items: baseline !important; - } - .align-items-sm-stretch { - align-items: stretch !important; - } - .align-content-sm-start { - align-content: flex-start !important; - } - .align-content-sm-end { - align-content: flex-end !important; - } - .align-content-sm-center { - align-content: center !important; - } - .align-content-sm-between { - align-content: space-between !important; - } - .align-content-sm-around { - align-content: space-around !important; - } - .align-content-sm-stretch { - align-content: stretch !important; - } - .align-self-sm-auto { - align-self: auto !important; - } - .align-self-sm-start { - align-self: flex-start !important; - } - .align-self-sm-end { - align-self: flex-end !important; - } - .align-self-sm-center { - align-self: center !important; - } - .align-self-sm-baseline { - align-self: baseline !important; - } - .align-self-sm-stretch { - align-self: stretch !important; - } - .order-sm-first { - order: -1 !important; - } - .order-sm-0 { - order: 0 !important; - } - .order-sm-1 { - order: 1 !important; - } - .order-sm-2 { - order: 2 !important; - } - .order-sm-3 { - order: 3 !important; - } - .order-sm-4 { - order: 4 !important; - } - .order-sm-5 { - order: 5 !important; - } - .order-sm-last { - order: 6 !important; - } - .m-sm-0 { - margin: 0 !important; - } - .m-sm-1 { - margin: 0.25rem !important; - } - .m-sm-2 { - margin: 0.5rem !important; - } - .m-sm-3 { - margin: 1rem !important; - } - .m-sm-4 { - margin: 1.5rem !important; - } - .m-sm-5 { - margin: 3rem !important; - } - .m-sm-auto { - margin: auto !important; - } - .mx-sm-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - .mx-sm-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - .mx-sm-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - .mx-sm-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - .mx-sm-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - .mx-sm-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - .mx-sm-auto { - margin-right: auto !important; - margin-left: auto !important; - } - .my-sm-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - .my-sm-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - .my-sm-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - .my-sm-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - .my-sm-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - .my-sm-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - .my-sm-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - .mt-sm-0 { - margin-top: 0 !important; - } - .mt-sm-1 { - margin-top: 0.25rem !important; - } - .mt-sm-2 { - margin-top: 0.5rem !important; - } - .mt-sm-3 { - margin-top: 1rem !important; - } - .mt-sm-4 { - margin-top: 1.5rem !important; - } - .mt-sm-5 { - margin-top: 3rem !important; - } - .mt-sm-auto { - margin-top: auto !important; - } - .me-sm-0 { - margin-right: 0 !important; - } - .me-sm-1 { - margin-right: 0.25rem !important; - } - .me-sm-2 { - margin-right: 0.5rem !important; - } - .me-sm-3 { - margin-right: 1rem !important; - } - .me-sm-4 { - margin-right: 1.5rem !important; - } - .me-sm-5 { - margin-right: 3rem !important; - } - .me-sm-auto { - margin-right: auto !important; - } - .mb-sm-0 { - margin-bottom: 0 !important; - } - .mb-sm-1 { - margin-bottom: 0.25rem !important; - } - .mb-sm-2 { - margin-bottom: 0.5rem !important; - } - .mb-sm-3 { - margin-bottom: 1rem !important; - } - .mb-sm-4 { - margin-bottom: 1.5rem !important; - } - .mb-sm-5 { - margin-bottom: 3rem !important; - } - .mb-sm-auto { - margin-bottom: auto !important; - } - .ms-sm-0 { - margin-left: 0 !important; - } - .ms-sm-1 { - margin-left: 0.25rem !important; - } - .ms-sm-2 { - margin-left: 0.5rem !important; - } - .ms-sm-3 { - margin-left: 1rem !important; - } - .ms-sm-4 { - margin-left: 1.5rem !important; - } - .ms-sm-5 { - margin-left: 3rem !important; - } - .ms-sm-auto { - margin-left: auto !important; - } - .p-sm-0 { - padding: 0 !important; - } - .p-sm-1 { - padding: 0.25rem !important; - } - .p-sm-2 { - padding: 0.5rem !important; - } - .p-sm-3 { - padding: 1rem !important; - } - .p-sm-4 { - padding: 1.5rem !important; - } - .p-sm-5 { - padding: 3rem !important; - } - .px-sm-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - .px-sm-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - .px-sm-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - .px-sm-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - .px-sm-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - .px-sm-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - .py-sm-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - .py-sm-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - .py-sm-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - .py-sm-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - .py-sm-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - .py-sm-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - .pt-sm-0 { - padding-top: 0 !important; - } - .pt-sm-1 { - padding-top: 0.25rem !important; - } - .pt-sm-2 { - padding-top: 0.5rem !important; - } - .pt-sm-3 { - padding-top: 1rem !important; - } - .pt-sm-4 { - padding-top: 1.5rem !important; - } - .pt-sm-5 { - padding-top: 3rem !important; - } - .pe-sm-0 { - padding-right: 0 !important; - } - .pe-sm-1 { - padding-right: 0.25rem !important; - } - .pe-sm-2 { - padding-right: 0.5rem !important; - } - .pe-sm-3 { - padding-right: 1rem !important; - } - .pe-sm-4 { - padding-right: 1.5rem !important; - } - .pe-sm-5 { - padding-right: 3rem !important; - } - .pb-sm-0 { - padding-bottom: 0 !important; - } - .pb-sm-1 { - padding-bottom: 0.25rem !important; - } - .pb-sm-2 { - padding-bottom: 0.5rem !important; - } - .pb-sm-3 { - padding-bottom: 1rem !important; - } - .pb-sm-4 { - padding-bottom: 1.5rem !important; - } - .pb-sm-5 { - padding-bottom: 3rem !important; - } - .ps-sm-0 { - padding-left: 0 !important; - } - .ps-sm-1 { - padding-left: 0.25rem !important; - } - .ps-sm-2 { - padding-left: 0.5rem !important; - } - .ps-sm-3 { - padding-left: 1rem !important; - } - .ps-sm-4 { - padding-left: 1.5rem !important; - } - .ps-sm-5 { - padding-left: 3rem !important; - } - .gap-sm-0 { - gap: 0 !important; - } - .gap-sm-1 { - gap: 0.25rem !important; - } - .gap-sm-2 { - gap: 0.5rem !important; - } - .gap-sm-3 { - gap: 1rem !important; - } - .gap-sm-4 { - gap: 1.5rem !important; - } - .gap-sm-5 { - gap: 3rem !important; - } - .row-gap-sm-0 { - row-gap: 0 !important; - } - .row-gap-sm-1 { - row-gap: 0.25rem !important; - } - .row-gap-sm-2 { - row-gap: 0.5rem !important; - } - .row-gap-sm-3 { - row-gap: 1rem !important; - } - .row-gap-sm-4 { - row-gap: 1.5rem !important; - } - .row-gap-sm-5 { - row-gap: 3rem !important; - } - .column-gap-sm-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; - } - .column-gap-sm-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; - } - .column-gap-sm-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; - } - .column-gap-sm-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; - } - .column-gap-sm-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; - } - .column-gap-sm-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; - } - .text-sm-start { - text-align: left !important; - } - .text-sm-end { - text-align: right !important; - } - .text-sm-center { - text-align: center !important; - } -} -@media (min-width: 768px) { - .float-md-start { - float: left !important; - } - .float-md-end { - float: right !important; - } - .float-md-none { - float: none !important; - } - .object-fit-md-contain { - -o-object-fit: contain !important; - object-fit: contain !important; - } - .object-fit-md-cover { - -o-object-fit: cover !important; - object-fit: cover !important; - } - .object-fit-md-fill { - -o-object-fit: fill !important; - object-fit: fill !important; - } - .object-fit-md-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; - } - .object-fit-md-none { - -o-object-fit: none !important; - object-fit: none !important; - } - .d-md-inline { - display: inline !important; - } - .d-md-inline-block { - display: inline-block !important; - } - .d-md-block { - display: block !important; - } - .d-md-grid { - display: grid !important; - } - .d-md-inline-grid { - display: inline-grid !important; - } - .d-md-table { - display: table !important; - } - .d-md-table-row { - display: table-row !important; - } - .d-md-table-cell { - display: table-cell !important; - } - .d-md-flex { - display: flex !important; - } - .d-md-inline-flex { - display: inline-flex !important; - } - .d-md-none { - display: none !important; - } - .flex-md-fill { - flex: 1 1 auto !important; - } - .flex-md-row { - flex-direction: row !important; - } - .flex-md-column { - flex-direction: column !important; - } - .flex-md-row-reverse { - flex-direction: row-reverse !important; - } - .flex-md-column-reverse { - flex-direction: column-reverse !important; - } - .flex-md-grow-0 { - flex-grow: 0 !important; - } - .flex-md-grow-1 { - flex-grow: 1 !important; - } - .flex-md-shrink-0 { - flex-shrink: 0 !important; - } - .flex-md-shrink-1 { - flex-shrink: 1 !important; - } - .flex-md-wrap { - flex-wrap: wrap !important; - } - .flex-md-nowrap { - flex-wrap: nowrap !important; - } - .flex-md-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - .justify-content-md-start { - justify-content: flex-start !important; - } - .justify-content-md-end { - justify-content: flex-end !important; - } - .justify-content-md-center { - justify-content: center !important; - } - .justify-content-md-between { - justify-content: space-between !important; - } - .justify-content-md-around { - justify-content: space-around !important; - } - .justify-content-md-evenly { - justify-content: space-evenly !important; - } - .align-items-md-start { - align-items: flex-start !important; - } - .align-items-md-end { - align-items: flex-end !important; - } - .align-items-md-center { - align-items: center !important; - } - .align-items-md-baseline { - align-items: baseline !important; - } - .align-items-md-stretch { - align-items: stretch !important; - } - .align-content-md-start { - align-content: flex-start !important; - } - .align-content-md-end { - align-content: flex-end !important; - } - .align-content-md-center { - align-content: center !important; - } - .align-content-md-between { - align-content: space-between !important; - } - .align-content-md-around { - align-content: space-around !important; - } - .align-content-md-stretch { - align-content: stretch !important; - } - .align-self-md-auto { - align-self: auto !important; - } - .align-self-md-start { - align-self: flex-start !important; - } - .align-self-md-end { - align-self: flex-end !important; - } - .align-self-md-center { - align-self: center !important; - } - .align-self-md-baseline { - align-self: baseline !important; - } - .align-self-md-stretch { - align-self: stretch !important; - } - .order-md-first { - order: -1 !important; - } - .order-md-0 { - order: 0 !important; - } - .order-md-1 { - order: 1 !important; - } - .order-md-2 { - order: 2 !important; - } - .order-md-3 { - order: 3 !important; - } - .order-md-4 { - order: 4 !important; - } - .order-md-5 { - order: 5 !important; - } - .order-md-last { - order: 6 !important; - } - .m-md-0 { - margin: 0 !important; - } - .m-md-1 { - margin: 0.25rem !important; - } - .m-md-2 { - margin: 0.5rem !important; - } - .m-md-3 { - margin: 1rem !important; - } - .m-md-4 { - margin: 1.5rem !important; - } - .m-md-5 { - margin: 3rem !important; - } - .m-md-auto { - margin: auto !important; - } - .mx-md-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - .mx-md-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - .mx-md-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - .mx-md-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - .mx-md-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - .mx-md-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - .mx-md-auto { - margin-right: auto !important; - margin-left: auto !important; - } - .my-md-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - .my-md-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - .my-md-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - .my-md-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - .my-md-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - .my-md-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - .my-md-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - .mt-md-0 { - margin-top: 0 !important; - } - .mt-md-1 { - margin-top: 0.25rem !important; - } - .mt-md-2 { - margin-top: 0.5rem !important; - } - .mt-md-3 { - margin-top: 1rem !important; - } - .mt-md-4 { - margin-top: 1.5rem !important; - } - .mt-md-5 { - margin-top: 3rem !important; - } - .mt-md-auto { - margin-top: auto !important; - } - .me-md-0 { - margin-right: 0 !important; - } - .me-md-1 { - margin-right: 0.25rem !important; - } - .me-md-2 { - margin-right: 0.5rem !important; - } - .me-md-3 { - margin-right: 1rem !important; - } - .me-md-4 { - margin-right: 1.5rem !important; - } - .me-md-5 { - margin-right: 3rem !important; - } - .me-md-auto { - margin-right: auto !important; - } - .mb-md-0 { - margin-bottom: 0 !important; - } - .mb-md-1 { - margin-bottom: 0.25rem !important; - } - .mb-md-2 { - margin-bottom: 0.5rem !important; - } - .mb-md-3 { - margin-bottom: 1rem !important; - } - .mb-md-4 { - margin-bottom: 1.5rem !important; - } - .mb-md-5 { - margin-bottom: 3rem !important; - } - .mb-md-auto { - margin-bottom: auto !important; - } - .ms-md-0 { - margin-left: 0 !important; - } - .ms-md-1 { - margin-left: 0.25rem !important; - } - .ms-md-2 { - margin-left: 0.5rem !important; - } - .ms-md-3 { - margin-left: 1rem !important; - } - .ms-md-4 { - margin-left: 1.5rem !important; - } - .ms-md-5 { - margin-left: 3rem !important; - } - .ms-md-auto { - margin-left: auto !important; - } - .p-md-0 { - padding: 0 !important; - } - .p-md-1 { - padding: 0.25rem !important; - } - .p-md-2 { - padding: 0.5rem !important; - } - .p-md-3 { - padding: 1rem !important; - } - .p-md-4 { - padding: 1.5rem !important; - } - .p-md-5 { - padding: 3rem !important; - } - .px-md-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - .px-md-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - .px-md-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - .px-md-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - .px-md-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - .px-md-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - .py-md-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - .py-md-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - .py-md-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - .py-md-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - .py-md-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - .py-md-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - .pt-md-0 { - padding-top: 0 !important; - } - .pt-md-1 { - padding-top: 0.25rem !important; - } - .pt-md-2 { - padding-top: 0.5rem !important; - } - .pt-md-3 { - padding-top: 1rem !important; - } - .pt-md-4 { - padding-top: 1.5rem !important; - } - .pt-md-5 { - padding-top: 3rem !important; - } - .pe-md-0 { - padding-right: 0 !important; - } - .pe-md-1 { - padding-right: 0.25rem !important; - } - .pe-md-2 { - padding-right: 0.5rem !important; - } - .pe-md-3 { - padding-right: 1rem !important; - } - .pe-md-4 { - padding-right: 1.5rem !important; - } - .pe-md-5 { - padding-right: 3rem !important; - } - .pb-md-0 { - padding-bottom: 0 !important; - } - .pb-md-1 { - padding-bottom: 0.25rem !important; - } - .pb-md-2 { - padding-bottom: 0.5rem !important; - } - .pb-md-3 { - padding-bottom: 1rem !important; - } - .pb-md-4 { - padding-bottom: 1.5rem !important; - } - .pb-md-5 { - padding-bottom: 3rem !important; - } - .ps-md-0 { - padding-left: 0 !important; - } - .ps-md-1 { - padding-left: 0.25rem !important; - } - .ps-md-2 { - padding-left: 0.5rem !important; - } - .ps-md-3 { - padding-left: 1rem !important; - } - .ps-md-4 { - padding-left: 1.5rem !important; - } - .ps-md-5 { - padding-left: 3rem !important; - } - .gap-md-0 { - gap: 0 !important; - } - .gap-md-1 { - gap: 0.25rem !important; - } - .gap-md-2 { - gap: 0.5rem !important; - } - .gap-md-3 { - gap: 1rem !important; - } - .gap-md-4 { - gap: 1.5rem !important; - } - .gap-md-5 { - gap: 3rem !important; - } - .row-gap-md-0 { - row-gap: 0 !important; - } - .row-gap-md-1 { - row-gap: 0.25rem !important; - } - .row-gap-md-2 { - row-gap: 0.5rem !important; - } - .row-gap-md-3 { - row-gap: 1rem !important; - } - .row-gap-md-4 { - row-gap: 1.5rem !important; - } - .row-gap-md-5 { - row-gap: 3rem !important; - } - .column-gap-md-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; - } - .column-gap-md-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; - } - .column-gap-md-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; - } - .column-gap-md-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; - } - .column-gap-md-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; - } - .column-gap-md-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; - } - .text-md-start { - text-align: left !important; - } - .text-md-end { - text-align: right !important; - } - .text-md-center { - text-align: center !important; - } -} -@media (min-width: 992px) { - .float-lg-start { - float: left !important; - } - .float-lg-end { - float: right !important; - } - .float-lg-none { - float: none !important; - } - .object-fit-lg-contain { - -o-object-fit: contain !important; - object-fit: contain !important; - } - .object-fit-lg-cover { - -o-object-fit: cover !important; - object-fit: cover !important; - } - .object-fit-lg-fill { - -o-object-fit: fill !important; - object-fit: fill !important; - } - .object-fit-lg-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; - } - .object-fit-lg-none { - -o-object-fit: none !important; - object-fit: none !important; - } - .d-lg-inline { - display: inline !important; - } - .d-lg-inline-block { - display: inline-block !important; - } - .d-lg-block { - display: block !important; - } - .d-lg-grid { - display: grid !important; - } - .d-lg-inline-grid { - display: inline-grid !important; - } - .d-lg-table { - display: table !important; - } - .d-lg-table-row { - display: table-row !important; - } - .d-lg-table-cell { - display: table-cell !important; - } - .d-lg-flex { - display: flex !important; - } - .d-lg-inline-flex { - display: inline-flex !important; - } - .d-lg-none { - display: none !important; - } - .flex-lg-fill { - flex: 1 1 auto !important; - } - .flex-lg-row { - flex-direction: row !important; - } - .flex-lg-column { - flex-direction: column !important; - } - .flex-lg-row-reverse { - flex-direction: row-reverse !important; - } - .flex-lg-column-reverse { - flex-direction: column-reverse !important; - } - .flex-lg-grow-0 { - flex-grow: 0 !important; - } - .flex-lg-grow-1 { - flex-grow: 1 !important; - } - .flex-lg-shrink-0 { - flex-shrink: 0 !important; - } - .flex-lg-shrink-1 { - flex-shrink: 1 !important; - } - .flex-lg-wrap { - flex-wrap: wrap !important; - } - .flex-lg-nowrap { - flex-wrap: nowrap !important; - } - .flex-lg-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - .justify-content-lg-start { - justify-content: flex-start !important; - } - .justify-content-lg-end { - justify-content: flex-end !important; - } - .justify-content-lg-center { - justify-content: center !important; - } - .justify-content-lg-between { - justify-content: space-between !important; - } - .justify-content-lg-around { - justify-content: space-around !important; - } - .justify-content-lg-evenly { - justify-content: space-evenly !important; - } - .align-items-lg-start { - align-items: flex-start !important; - } - .align-items-lg-end { - align-items: flex-end !important; - } - .align-items-lg-center { - align-items: center !important; - } - .align-items-lg-baseline { - align-items: baseline !important; - } - .align-items-lg-stretch { - align-items: stretch !important; - } - .align-content-lg-start { - align-content: flex-start !important; - } - .align-content-lg-end { - align-content: flex-end !important; - } - .align-content-lg-center { - align-content: center !important; - } - .align-content-lg-between { - align-content: space-between !important; - } - .align-content-lg-around { - align-content: space-around !important; - } - .align-content-lg-stretch { - align-content: stretch !important; - } - .align-self-lg-auto { - align-self: auto !important; - } - .align-self-lg-start { - align-self: flex-start !important; - } - .align-self-lg-end { - align-self: flex-end !important; - } - .align-self-lg-center { - align-self: center !important; - } - .align-self-lg-baseline { - align-self: baseline !important; - } - .align-self-lg-stretch { - align-self: stretch !important; - } - .order-lg-first { - order: -1 !important; - } - .order-lg-0 { - order: 0 !important; - } - .order-lg-1 { - order: 1 !important; - } - .order-lg-2 { - order: 2 !important; - } - .order-lg-3 { - order: 3 !important; - } - .order-lg-4 { - order: 4 !important; - } - .order-lg-5 { - order: 5 !important; - } - .order-lg-last { - order: 6 !important; - } - .m-lg-0 { - margin: 0 !important; - } - .m-lg-1 { - margin: 0.25rem !important; - } - .m-lg-2 { - margin: 0.5rem !important; - } - .m-lg-3 { - margin: 1rem !important; - } - .m-lg-4 { - margin: 1.5rem !important; - } - .m-lg-5 { - margin: 3rem !important; - } - .m-lg-auto { - margin: auto !important; - } - .mx-lg-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - .mx-lg-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - .mx-lg-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - .mx-lg-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - .mx-lg-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - .mx-lg-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - .mx-lg-auto { - margin-right: auto !important; - margin-left: auto !important; - } - .my-lg-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - .my-lg-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - .my-lg-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - .my-lg-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - .my-lg-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - .my-lg-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - .my-lg-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - .mt-lg-0 { - margin-top: 0 !important; - } - .mt-lg-1 { - margin-top: 0.25rem !important; - } - .mt-lg-2 { - margin-top: 0.5rem !important; - } - .mt-lg-3 { - margin-top: 1rem !important; - } - .mt-lg-4 { - margin-top: 1.5rem !important; - } - .mt-lg-5 { - margin-top: 3rem !important; - } - .mt-lg-auto { - margin-top: auto !important; - } - .me-lg-0 { - margin-right: 0 !important; - } - .me-lg-1 { - margin-right: 0.25rem !important; - } - .me-lg-2 { - margin-right: 0.5rem !important; - } - .me-lg-3 { - margin-right: 1rem !important; - } - .me-lg-4 { - margin-right: 1.5rem !important; - } - .me-lg-5 { - margin-right: 3rem !important; - } - .me-lg-auto { - margin-right: auto !important; - } - .mb-lg-0 { - margin-bottom: 0 !important; - } - .mb-lg-1 { - margin-bottom: 0.25rem !important; - } - .mb-lg-2 { - margin-bottom: 0.5rem !important; - } - .mb-lg-3 { - margin-bottom: 1rem !important; - } - .mb-lg-4 { - margin-bottom: 1.5rem !important; - } - .mb-lg-5 { - margin-bottom: 3rem !important; - } - .mb-lg-auto { - margin-bottom: auto !important; - } - .ms-lg-0 { - margin-left: 0 !important; - } - .ms-lg-1 { - margin-left: 0.25rem !important; - } - .ms-lg-2 { - margin-left: 0.5rem !important; - } - .ms-lg-3 { - margin-left: 1rem !important; - } - .ms-lg-4 { - margin-left: 1.5rem !important; - } - .ms-lg-5 { - margin-left: 3rem !important; - } - .ms-lg-auto { - margin-left: auto !important; - } - .p-lg-0 { - padding: 0 !important; - } - .p-lg-1 { - padding: 0.25rem !important; - } - .p-lg-2 { - padding: 0.5rem !important; - } - .p-lg-3 { - padding: 1rem !important; - } - .p-lg-4 { - padding: 1.5rem !important; - } - .p-lg-5 { - padding: 3rem !important; - } - .px-lg-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - .px-lg-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - .px-lg-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - .px-lg-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - .px-lg-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - .px-lg-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - .py-lg-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - .py-lg-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - .py-lg-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - .py-lg-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - .py-lg-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - .py-lg-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - .pt-lg-0 { - padding-top: 0 !important; - } - .pt-lg-1 { - padding-top: 0.25rem !important; - } - .pt-lg-2 { - padding-top: 0.5rem !important; - } - .pt-lg-3 { - padding-top: 1rem !important; - } - .pt-lg-4 { - padding-top: 1.5rem !important; - } - .pt-lg-5 { - padding-top: 3rem !important; - } - .pe-lg-0 { - padding-right: 0 !important; - } - .pe-lg-1 { - padding-right: 0.25rem !important; - } - .pe-lg-2 { - padding-right: 0.5rem !important; - } - .pe-lg-3 { - padding-right: 1rem !important; - } - .pe-lg-4 { - padding-right: 1.5rem !important; - } - .pe-lg-5 { - padding-right: 3rem !important; - } - .pb-lg-0 { - padding-bottom: 0 !important; - } - .pb-lg-1 { - padding-bottom: 0.25rem !important; - } - .pb-lg-2 { - padding-bottom: 0.5rem !important; - } - .pb-lg-3 { - padding-bottom: 1rem !important; - } - .pb-lg-4 { - padding-bottom: 1.5rem !important; - } - .pb-lg-5 { - padding-bottom: 3rem !important; - } - .ps-lg-0 { - padding-left: 0 !important; - } - .ps-lg-1 { - padding-left: 0.25rem !important; - } - .ps-lg-2 { - padding-left: 0.5rem !important; - } - .ps-lg-3 { - padding-left: 1rem !important; - } - .ps-lg-4 { - padding-left: 1.5rem !important; - } - .ps-lg-5 { - padding-left: 3rem !important; - } - .gap-lg-0 { - gap: 0 !important; - } - .gap-lg-1 { - gap: 0.25rem !important; - } - .gap-lg-2 { - gap: 0.5rem !important; - } - .gap-lg-3 { - gap: 1rem !important; - } - .gap-lg-4 { - gap: 1.5rem !important; - } - .gap-lg-5 { - gap: 3rem !important; - } - .row-gap-lg-0 { - row-gap: 0 !important; - } - .row-gap-lg-1 { - row-gap: 0.25rem !important; - } - .row-gap-lg-2 { - row-gap: 0.5rem !important; - } - .row-gap-lg-3 { - row-gap: 1rem !important; - } - .row-gap-lg-4 { - row-gap: 1.5rem !important; - } - .row-gap-lg-5 { - row-gap: 3rem !important; - } - .column-gap-lg-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; - } - .column-gap-lg-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; - } - .column-gap-lg-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; - } - .column-gap-lg-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; - } - .column-gap-lg-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; - } - .column-gap-lg-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; - } - .text-lg-start { - text-align: left !important; - } - .text-lg-end { - text-align: right !important; - } - .text-lg-center { - text-align: center !important; - } -} -@media (min-width: 1200px) { - .float-xl-start { - float: left !important; - } - .float-xl-end { - float: right !important; - } - .float-xl-none { - float: none !important; - } - .object-fit-xl-contain { - -o-object-fit: contain !important; - object-fit: contain !important; - } - .object-fit-xl-cover { - -o-object-fit: cover !important; - object-fit: cover !important; - } - .object-fit-xl-fill { - -o-object-fit: fill !important; - object-fit: fill !important; - } - .object-fit-xl-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; - } - .object-fit-xl-none { - -o-object-fit: none !important; - object-fit: none !important; - } - .d-xl-inline { - display: inline !important; - } - .d-xl-inline-block { - display: inline-block !important; - } - .d-xl-block { - display: block !important; - } - .d-xl-grid { - display: grid !important; - } - .d-xl-inline-grid { - display: inline-grid !important; - } - .d-xl-table { - display: table !important; - } - .d-xl-table-row { - display: table-row !important; - } - .d-xl-table-cell { - display: table-cell !important; - } - .d-xl-flex { - display: flex !important; - } - .d-xl-inline-flex { - display: inline-flex !important; - } - .d-xl-none { - display: none !important; - } - .flex-xl-fill { - flex: 1 1 auto !important; - } - .flex-xl-row { - flex-direction: row !important; - } - .flex-xl-column { - flex-direction: column !important; - } - .flex-xl-row-reverse { - flex-direction: row-reverse !important; - } - .flex-xl-column-reverse { - flex-direction: column-reverse !important; - } - .flex-xl-grow-0 { - flex-grow: 0 !important; - } - .flex-xl-grow-1 { - flex-grow: 1 !important; - } - .flex-xl-shrink-0 { - flex-shrink: 0 !important; - } - .flex-xl-shrink-1 { - flex-shrink: 1 !important; - } - .flex-xl-wrap { - flex-wrap: wrap !important; - } - .flex-xl-nowrap { - flex-wrap: nowrap !important; - } - .flex-xl-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - .justify-content-xl-start { - justify-content: flex-start !important; - } - .justify-content-xl-end { - justify-content: flex-end !important; - } - .justify-content-xl-center { - justify-content: center !important; - } - .justify-content-xl-between { - justify-content: space-between !important; - } - .justify-content-xl-around { - justify-content: space-around !important; - } - .justify-content-xl-evenly { - justify-content: space-evenly !important; - } - .align-items-xl-start { - align-items: flex-start !important; - } - .align-items-xl-end { - align-items: flex-end !important; - } - .align-items-xl-center { - align-items: center !important; - } - .align-items-xl-baseline { - align-items: baseline !important; - } - .align-items-xl-stretch { - align-items: stretch !important; - } - .align-content-xl-start { - align-content: flex-start !important; - } - .align-content-xl-end { - align-content: flex-end !important; - } - .align-content-xl-center { - align-content: center !important; - } - .align-content-xl-between { - align-content: space-between !important; - } - .align-content-xl-around { - align-content: space-around !important; - } - .align-content-xl-stretch { - align-content: stretch !important; - } - .align-self-xl-auto { - align-self: auto !important; - } - .align-self-xl-start { - align-self: flex-start !important; - } - .align-self-xl-end { - align-self: flex-end !important; - } - .align-self-xl-center { - align-self: center !important; - } - .align-self-xl-baseline { - align-self: baseline !important; - } - .align-self-xl-stretch { - align-self: stretch !important; - } - .order-xl-first { - order: -1 !important; - } - .order-xl-0 { - order: 0 !important; - } - .order-xl-1 { - order: 1 !important; - } - .order-xl-2 { - order: 2 !important; - } - .order-xl-3 { - order: 3 !important; - } - .order-xl-4 { - order: 4 !important; - } - .order-xl-5 { - order: 5 !important; - } - .order-xl-last { - order: 6 !important; - } - .m-xl-0 { - margin: 0 !important; - } - .m-xl-1 { - margin: 0.25rem !important; - } - .m-xl-2 { - margin: 0.5rem !important; - } - .m-xl-3 { - margin: 1rem !important; - } - .m-xl-4 { - margin: 1.5rem !important; - } - .m-xl-5 { - margin: 3rem !important; - } - .m-xl-auto { - margin: auto !important; - } - .mx-xl-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - .mx-xl-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - .mx-xl-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - .mx-xl-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - .mx-xl-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - .mx-xl-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - .mx-xl-auto { - margin-right: auto !important; - margin-left: auto !important; - } - .my-xl-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - .my-xl-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - .my-xl-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - .my-xl-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - .my-xl-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - .my-xl-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - .my-xl-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - .mt-xl-0 { - margin-top: 0 !important; - } - .mt-xl-1 { - margin-top: 0.25rem !important; - } - .mt-xl-2 { - margin-top: 0.5rem !important; - } - .mt-xl-3 { - margin-top: 1rem !important; - } - .mt-xl-4 { - margin-top: 1.5rem !important; - } - .mt-xl-5 { - margin-top: 3rem !important; - } - .mt-xl-auto { - margin-top: auto !important; - } - .me-xl-0 { - margin-right: 0 !important; - } - .me-xl-1 { - margin-right: 0.25rem !important; - } - .me-xl-2 { - margin-right: 0.5rem !important; - } - .me-xl-3 { - margin-right: 1rem !important; - } - .me-xl-4 { - margin-right: 1.5rem !important; - } - .me-xl-5 { - margin-right: 3rem !important; - } - .me-xl-auto { - margin-right: auto !important; - } - .mb-xl-0 { - margin-bottom: 0 !important; - } - .mb-xl-1 { - margin-bottom: 0.25rem !important; - } - .mb-xl-2 { - margin-bottom: 0.5rem !important; - } - .mb-xl-3 { - margin-bottom: 1rem !important; - } - .mb-xl-4 { - margin-bottom: 1.5rem !important; - } - .mb-xl-5 { - margin-bottom: 3rem !important; - } - .mb-xl-auto { - margin-bottom: auto !important; - } - .ms-xl-0 { - margin-left: 0 !important; - } - .ms-xl-1 { - margin-left: 0.25rem !important; - } - .ms-xl-2 { - margin-left: 0.5rem !important; - } - .ms-xl-3 { - margin-left: 1rem !important; - } - .ms-xl-4 { - margin-left: 1.5rem !important; - } - .ms-xl-5 { - margin-left: 3rem !important; - } - .ms-xl-auto { - margin-left: auto !important; - } - .p-xl-0 { - padding: 0 !important; - } - .p-xl-1 { - padding: 0.25rem !important; - } - .p-xl-2 { - padding: 0.5rem !important; - } - .p-xl-3 { - padding: 1rem !important; - } - .p-xl-4 { - padding: 1.5rem !important; - } - .p-xl-5 { - padding: 3rem !important; - } - .px-xl-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - .px-xl-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - .px-xl-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - .px-xl-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - .px-xl-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - .px-xl-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - .py-xl-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - .py-xl-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - .py-xl-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - .py-xl-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - .py-xl-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - .py-xl-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - .pt-xl-0 { - padding-top: 0 !important; - } - .pt-xl-1 { - padding-top: 0.25rem !important; - } - .pt-xl-2 { - padding-top: 0.5rem !important; - } - .pt-xl-3 { - padding-top: 1rem !important; - } - .pt-xl-4 { - padding-top: 1.5rem !important; - } - .pt-xl-5 { - padding-top: 3rem !important; - } - .pe-xl-0 { - padding-right: 0 !important; - } - .pe-xl-1 { - padding-right: 0.25rem !important; - } - .pe-xl-2 { - padding-right: 0.5rem !important; - } - .pe-xl-3 { - padding-right: 1rem !important; - } - .pe-xl-4 { - padding-right: 1.5rem !important; - } - .pe-xl-5 { - padding-right: 3rem !important; - } - .pb-xl-0 { - padding-bottom: 0 !important; - } - .pb-xl-1 { - padding-bottom: 0.25rem !important; - } - .pb-xl-2 { - padding-bottom: 0.5rem !important; - } - .pb-xl-3 { - padding-bottom: 1rem !important; - } - .pb-xl-4 { - padding-bottom: 1.5rem !important; - } - .pb-xl-5 { - padding-bottom: 3rem !important; - } - .ps-xl-0 { - padding-left: 0 !important; - } - .ps-xl-1 { - padding-left: 0.25rem !important; - } - .ps-xl-2 { - padding-left: 0.5rem !important; - } - .ps-xl-3 { - padding-left: 1rem !important; - } - .ps-xl-4 { - padding-left: 1.5rem !important; - } - .ps-xl-5 { - padding-left: 3rem !important; - } - .gap-xl-0 { - gap: 0 !important; - } - .gap-xl-1 { - gap: 0.25rem !important; - } - .gap-xl-2 { - gap: 0.5rem !important; - } - .gap-xl-3 { - gap: 1rem !important; - } - .gap-xl-4 { - gap: 1.5rem !important; - } - .gap-xl-5 { - gap: 3rem !important; - } - .row-gap-xl-0 { - row-gap: 0 !important; - } - .row-gap-xl-1 { - row-gap: 0.25rem !important; - } - .row-gap-xl-2 { - row-gap: 0.5rem !important; - } - .row-gap-xl-3 { - row-gap: 1rem !important; - } - .row-gap-xl-4 { - row-gap: 1.5rem !important; - } - .row-gap-xl-5 { - row-gap: 3rem !important; - } - .column-gap-xl-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; - } - .column-gap-xl-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; - } - .column-gap-xl-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; - } - .column-gap-xl-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; - } - .column-gap-xl-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; - } - .column-gap-xl-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; - } - .text-xl-start { - text-align: left !important; - } - .text-xl-end { - text-align: right !important; - } - .text-xl-center { - text-align: center !important; - } -} -@media (min-width: 1400px) { - .float-xxl-start { - float: left !important; - } - .float-xxl-end { - float: right !important; - } - .float-xxl-none { - float: none !important; - } - .object-fit-xxl-contain { - -o-object-fit: contain !important; - object-fit: contain !important; - } - .object-fit-xxl-cover { - -o-object-fit: cover !important; - object-fit: cover !important; - } - .object-fit-xxl-fill { - -o-object-fit: fill !important; - object-fit: fill !important; - } - .object-fit-xxl-scale { - -o-object-fit: scale-down !important; - object-fit: scale-down !important; - } - .object-fit-xxl-none { - -o-object-fit: none !important; - object-fit: none !important; - } - .d-xxl-inline { - display: inline !important; - } - .d-xxl-inline-block { - display: inline-block !important; - } - .d-xxl-block { - display: block !important; - } - .d-xxl-grid { - display: grid !important; - } - .d-xxl-inline-grid { - display: inline-grid !important; - } - .d-xxl-table { - display: table !important; - } - .d-xxl-table-row { - display: table-row !important; - } - .d-xxl-table-cell { - display: table-cell !important; - } - .d-xxl-flex { - display: flex !important; - } - .d-xxl-inline-flex { - display: inline-flex !important; - } - .d-xxl-none { - display: none !important; - } - .flex-xxl-fill { - flex: 1 1 auto !important; - } - .flex-xxl-row { - flex-direction: row !important; - } - .flex-xxl-column { - flex-direction: column !important; - } - .flex-xxl-row-reverse { - flex-direction: row-reverse !important; - } - .flex-xxl-column-reverse { - flex-direction: column-reverse !important; - } - .flex-xxl-grow-0 { - flex-grow: 0 !important; - } - .flex-xxl-grow-1 { - flex-grow: 1 !important; - } - .flex-xxl-shrink-0 { - flex-shrink: 0 !important; - } - .flex-xxl-shrink-1 { - flex-shrink: 1 !important; - } - .flex-xxl-wrap { - flex-wrap: wrap !important; - } - .flex-xxl-nowrap { - flex-wrap: nowrap !important; - } - .flex-xxl-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - .justify-content-xxl-start { - justify-content: flex-start !important; - } - .justify-content-xxl-end { - justify-content: flex-end !important; - } - .justify-content-xxl-center { - justify-content: center !important; - } - .justify-content-xxl-between { - justify-content: space-between !important; - } - .justify-content-xxl-around { - justify-content: space-around !important; - } - .justify-content-xxl-evenly { - justify-content: space-evenly !important; - } - .align-items-xxl-start { - align-items: flex-start !important; - } - .align-items-xxl-end { - align-items: flex-end !important; - } - .align-items-xxl-center { - align-items: center !important; - } - .align-items-xxl-baseline { - align-items: baseline !important; - } - .align-items-xxl-stretch { - align-items: stretch !important; - } - .align-content-xxl-start { - align-content: flex-start !important; - } - .align-content-xxl-end { - align-content: flex-end !important; - } - .align-content-xxl-center { - align-content: center !important; - } - .align-content-xxl-between { - align-content: space-between !important; - } - .align-content-xxl-around { - align-content: space-around !important; - } - .align-content-xxl-stretch { - align-content: stretch !important; - } - .align-self-xxl-auto { - align-self: auto !important; - } - .align-self-xxl-start { - align-self: flex-start !important; - } - .align-self-xxl-end { - align-self: flex-end !important; - } - .align-self-xxl-center { - align-self: center !important; - } - .align-self-xxl-baseline { - align-self: baseline !important; - } - .align-self-xxl-stretch { - align-self: stretch !important; - } - .order-xxl-first { - order: -1 !important; - } - .order-xxl-0 { - order: 0 !important; - } - .order-xxl-1 { - order: 1 !important; - } - .order-xxl-2 { - order: 2 !important; - } - .order-xxl-3 { - order: 3 !important; - } - .order-xxl-4 { - order: 4 !important; - } - .order-xxl-5 { - order: 5 !important; - } - .order-xxl-last { - order: 6 !important; - } - .m-xxl-0 { - margin: 0 !important; - } - .m-xxl-1 { - margin: 0.25rem !important; - } - .m-xxl-2 { - margin: 0.5rem !important; - } - .m-xxl-3 { - margin: 1rem !important; - } - .m-xxl-4 { - margin: 1.5rem !important; - } - .m-xxl-5 { - margin: 3rem !important; - } - .m-xxl-auto { - margin: auto !important; - } - .mx-xxl-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - .mx-xxl-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - .mx-xxl-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - .mx-xxl-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - .mx-xxl-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - .mx-xxl-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - .mx-xxl-auto { - margin-right: auto !important; - margin-left: auto !important; - } - .my-xxl-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - .my-xxl-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - .my-xxl-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - .my-xxl-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - .my-xxl-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - .my-xxl-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - .my-xxl-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - .mt-xxl-0 { - margin-top: 0 !important; - } - .mt-xxl-1 { - margin-top: 0.25rem !important; - } - .mt-xxl-2 { - margin-top: 0.5rem !important; - } - .mt-xxl-3 { - margin-top: 1rem !important; - } - .mt-xxl-4 { - margin-top: 1.5rem !important; - } - .mt-xxl-5 { - margin-top: 3rem !important; - } - .mt-xxl-auto { - margin-top: auto !important; - } - .me-xxl-0 { - margin-right: 0 !important; - } - .me-xxl-1 { - margin-right: 0.25rem !important; - } - .me-xxl-2 { - margin-right: 0.5rem !important; - } - .me-xxl-3 { - margin-right: 1rem !important; - } - .me-xxl-4 { - margin-right: 1.5rem !important; - } - .me-xxl-5 { - margin-right: 3rem !important; - } - .me-xxl-auto { - margin-right: auto !important; - } - .mb-xxl-0 { - margin-bottom: 0 !important; - } - .mb-xxl-1 { - margin-bottom: 0.25rem !important; - } - .mb-xxl-2 { - margin-bottom: 0.5rem !important; - } - .mb-xxl-3 { - margin-bottom: 1rem !important; - } - .mb-xxl-4 { - margin-bottom: 1.5rem !important; - } - .mb-xxl-5 { - margin-bottom: 3rem !important; - } - .mb-xxl-auto { - margin-bottom: auto !important; - } - .ms-xxl-0 { - margin-left: 0 !important; - } - .ms-xxl-1 { - margin-left: 0.25rem !important; - } - .ms-xxl-2 { - margin-left: 0.5rem !important; - } - .ms-xxl-3 { - margin-left: 1rem !important; - } - .ms-xxl-4 { - margin-left: 1.5rem !important; - } - .ms-xxl-5 { - margin-left: 3rem !important; - } - .ms-xxl-auto { - margin-left: auto !important; - } - .p-xxl-0 { - padding: 0 !important; - } - .p-xxl-1 { - padding: 0.25rem !important; - } - .p-xxl-2 { - padding: 0.5rem !important; - } - .p-xxl-3 { - padding: 1rem !important; - } - .p-xxl-4 { - padding: 1.5rem !important; - } - .p-xxl-5 { - padding: 3rem !important; - } - .px-xxl-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - .px-xxl-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - .px-xxl-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - .px-xxl-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - .px-xxl-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - .px-xxl-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - .py-xxl-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - .py-xxl-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - .py-xxl-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - .py-xxl-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - .py-xxl-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - .py-xxl-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - .pt-xxl-0 { - padding-top: 0 !important; - } - .pt-xxl-1 { - padding-top: 0.25rem !important; - } - .pt-xxl-2 { - padding-top: 0.5rem !important; - } - .pt-xxl-3 { - padding-top: 1rem !important; - } - .pt-xxl-4 { - padding-top: 1.5rem !important; - } - .pt-xxl-5 { - padding-top: 3rem !important; - } - .pe-xxl-0 { - padding-right: 0 !important; - } - .pe-xxl-1 { - padding-right: 0.25rem !important; - } - .pe-xxl-2 { - padding-right: 0.5rem !important; - } - .pe-xxl-3 { - padding-right: 1rem !important; - } - .pe-xxl-4 { - padding-right: 1.5rem !important; - } - .pe-xxl-5 { - padding-right: 3rem !important; - } - .pb-xxl-0 { - padding-bottom: 0 !important; - } - .pb-xxl-1 { - padding-bottom: 0.25rem !important; - } - .pb-xxl-2 { - padding-bottom: 0.5rem !important; - } - .pb-xxl-3 { - padding-bottom: 1rem !important; - } - .pb-xxl-4 { - padding-bottom: 1.5rem !important; - } - .pb-xxl-5 { - padding-bottom: 3rem !important; - } - .ps-xxl-0 { - padding-left: 0 !important; - } - .ps-xxl-1 { - padding-left: 0.25rem !important; - } - .ps-xxl-2 { - padding-left: 0.5rem !important; - } - .ps-xxl-3 { - padding-left: 1rem !important; - } - .ps-xxl-4 { - padding-left: 1.5rem !important; - } - .ps-xxl-5 { - padding-left: 3rem !important; - } - .gap-xxl-0 { - gap: 0 !important; - } - .gap-xxl-1 { - gap: 0.25rem !important; - } - .gap-xxl-2 { - gap: 0.5rem !important; - } - .gap-xxl-3 { - gap: 1rem !important; - } - .gap-xxl-4 { - gap: 1.5rem !important; - } - .gap-xxl-5 { - gap: 3rem !important; - } - .row-gap-xxl-0 { - row-gap: 0 !important; - } - .row-gap-xxl-1 { - row-gap: 0.25rem !important; - } - .row-gap-xxl-2 { - row-gap: 0.5rem !important; - } - .row-gap-xxl-3 { - row-gap: 1rem !important; - } - .row-gap-xxl-4 { - row-gap: 1.5rem !important; - } - .row-gap-xxl-5 { - row-gap: 3rem !important; - } - .column-gap-xxl-0 { - -moz-column-gap: 0 !important; - column-gap: 0 !important; - } - .column-gap-xxl-1 { - -moz-column-gap: 0.25rem !important; - column-gap: 0.25rem !important; - } - .column-gap-xxl-2 { - -moz-column-gap: 0.5rem !important; - column-gap: 0.5rem !important; - } - .column-gap-xxl-3 { - -moz-column-gap: 1rem !important; - column-gap: 1rem !important; - } - .column-gap-xxl-4 { - -moz-column-gap: 1.5rem !important; - column-gap: 1.5rem !important; - } - .column-gap-xxl-5 { - -moz-column-gap: 3rem !important; - column-gap: 3rem !important; - } - .text-xxl-start { - text-align: left !important; - } - .text-xxl-end { - text-align: right !important; - } - .text-xxl-center { - text-align: center !important; - } -} -@media (min-width: 1200px) { - .fs-1 { - font-size: 2.5rem !important; - } - .fs-2 { - font-size: 2rem !important; - } - .fs-3 { - font-size: 1.75rem !important; - } - .fs-4 { - font-size: 1.5rem !important; - } -} -@media print { - .d-print-inline { - display: inline !important; - } - .d-print-inline-block { - display: inline-block !important; - } - .d-print-block { - display: block !important; - } - .d-print-grid { - display: grid !important; - } - .d-print-inline-grid { - display: inline-grid !important; - } - .d-print-table { - display: table !important; - } - .d-print-table-row { - display: table-row !important; - } - .d-print-table-cell { - display: table-cell !important; - } - .d-print-flex { - display: flex !important; - } - .d-print-inline-flex { - display: inline-flex !important; - } - .d-print-none { - display: none !important; - } -} - -/*# sourceMappingURL=bootstrap.css.map */ \ No newline at end of file diff --git a/backend/static/js/bootstrap.bundle.js b/backend/static/js/bootstrap.bundle.js deleted file mode 100644 index 37e6f95d..00000000 --- a/backend/static/js/bootstrap.bundle.js +++ /dev/null @@ -1,6314 +0,0 @@ -/*! - * Bootstrap v5.3.2 (https://getbootstrap.com/) - * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.bootstrap = factory()); -})(this, (function () { 'use strict'; - - /** - * -------------------------------------------------------------------------- - * Bootstrap dom/data.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - /** - * Constants - */ - - const elementMap = new Map(); - const Data = { - set(element, key, instance) { - if (!elementMap.has(element)) { - elementMap.set(element, new Map()); - } - const instanceMap = elementMap.get(element); - - // make it clear we only want one instance per element - // can be removed later when multiple key/instances are fine to be used - if (!instanceMap.has(key) && instanceMap.size !== 0) { - // eslint-disable-next-line no-console - console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`); - return; - } - instanceMap.set(key, instance); - }, - get(element, key) { - if (elementMap.has(element)) { - return elementMap.get(element).get(key) || null; - } - return null; - }, - remove(element, key) { - if (!elementMap.has(element)) { - return; - } - const instanceMap = elementMap.get(element); - instanceMap.delete(key); - - // free up element references if there are no instances left for an element - if (instanceMap.size === 0) { - elementMap.delete(element); - } - } - }; - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/index.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - const MAX_UID = 1000000; - const MILLISECONDS_MULTIPLIER = 1000; - const TRANSITION_END = 'transitionend'; - - /** - * Properly escape IDs selectors to handle weird IDs - * @param {string} selector - * @returns {string} - */ - const parseSelector = selector => { - if (selector && window.CSS && window.CSS.escape) { - // document.querySelector needs escaping to handle IDs (html5+) containing for instance / - selector = selector.replace(/#([^\s"#']+)/g, (match, id) => `#${CSS.escape(id)}`); - } - return selector; - }; - - // Shout-out Angus Croll (https://goo.gl/pxwQGp) - const toType = object => { - if (object === null || object === undefined) { - return `${object}`; - } - return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)[1].toLowerCase(); - }; - - /** - * Public Util API - */ - - const getUID = prefix => { - do { - prefix += Math.floor(Math.random() * MAX_UID); - } while (document.getElementById(prefix)); - return prefix; - }; - const getTransitionDurationFromElement = element => { - if (!element) { - return 0; - } - - // Get transition-duration of the element - let { - transitionDuration, - transitionDelay - } = window.getComputedStyle(element); - const floatTransitionDuration = Number.parseFloat(transitionDuration); - const floatTransitionDelay = Number.parseFloat(transitionDelay); - - // Return 0 if element or transition duration is not found - if (!floatTransitionDuration && !floatTransitionDelay) { - return 0; - } - - // If multiple durations are defined, take the first - transitionDuration = transitionDuration.split(',')[0]; - transitionDelay = transitionDelay.split(',')[0]; - return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER; - }; - const triggerTransitionEnd = element => { - element.dispatchEvent(new Event(TRANSITION_END)); - }; - const isElement$1 = object => { - if (!object || typeof object !== 'object') { - return false; - } - if (typeof object.jquery !== 'undefined') { - object = object[0]; - } - return typeof object.nodeType !== 'undefined'; - }; - const getElement = object => { - // it's a jQuery object or a node element - if (isElement$1(object)) { - return object.jquery ? object[0] : object; - } - if (typeof object === 'string' && object.length > 0) { - return document.querySelector(parseSelector(object)); - } - return null; - }; - const isVisible = element => { - if (!isElement$1(element) || element.getClientRects().length === 0) { - return false; - } - const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'; - // Handle `details` element as its content may falsie appear visible when it is closed - const closedDetails = element.closest('details:not([open])'); - if (!closedDetails) { - return elementIsVisible; - } - if (closedDetails !== element) { - const summary = element.closest('summary'); - if (summary && summary.parentNode !== closedDetails) { - return false; - } - if (summary === null) { - return false; - } - } - return elementIsVisible; - }; - const isDisabled = element => { - if (!element || element.nodeType !== Node.ELEMENT_NODE) { - return true; - } - if (element.classList.contains('disabled')) { - return true; - } - if (typeof element.disabled !== 'undefined') { - return element.disabled; - } - return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'; - }; - const findShadowRoot = element => { - if (!document.documentElement.attachShadow) { - return null; - } - - // Can find the shadow root otherwise it'll return the document - if (typeof element.getRootNode === 'function') { - const root = element.getRootNode(); - return root instanceof ShadowRoot ? root : null; - } - if (element instanceof ShadowRoot) { - return element; - } - - // when we don't find a shadow root - if (!element.parentNode) { - return null; - } - return findShadowRoot(element.parentNode); - }; - const noop = () => {}; - - /** - * Trick to restart an element's animation - * - * @param {HTMLElement} element - * @return void - * - * @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation - */ - const reflow = element => { - element.offsetHeight; // eslint-disable-line no-unused-expressions - }; - - const getjQuery = () => { - if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) { - return window.jQuery; - } - return null; - }; - const DOMContentLoadedCallbacks = []; - const onDOMContentLoaded = callback => { - if (document.readyState === 'loading') { - // add listener on the first call when the document is in loading state - if (!DOMContentLoadedCallbacks.length) { - document.addEventListener('DOMContentLoaded', () => { - for (const callback of DOMContentLoadedCallbacks) { - callback(); - } - }); - } - DOMContentLoadedCallbacks.push(callback); - } else { - callback(); - } - }; - const isRTL = () => document.documentElement.dir === 'rtl'; - const defineJQueryPlugin = plugin => { - onDOMContentLoaded(() => { - const $ = getjQuery(); - /* istanbul ignore if */ - if ($) { - const name = plugin.NAME; - const JQUERY_NO_CONFLICT = $.fn[name]; - $.fn[name] = plugin.jQueryInterface; - $.fn[name].Constructor = plugin; - $.fn[name].noConflict = () => { - $.fn[name] = JQUERY_NO_CONFLICT; - return plugin.jQueryInterface; - }; - } - }); - }; - const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => { - return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue; - }; - const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => { - if (!waitForTransition) { - execute(callback); - return; - } - const durationPadding = 5; - const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding; - let called = false; - const handler = ({ - target - }) => { - if (target !== transitionElement) { - return; - } - called = true; - transitionElement.removeEventListener(TRANSITION_END, handler); - execute(callback); - }; - transitionElement.addEventListener(TRANSITION_END, handler); - setTimeout(() => { - if (!called) { - triggerTransitionEnd(transitionElement); - } - }, emulatedDuration); - }; - - /** - * Return the previous/next element of a list. - * - * @param {array} list The list of elements - * @param activeElement The active element - * @param shouldGetNext Choose to get next or previous element - * @param isCycleAllowed - * @return {Element|elem} The proper element - */ - const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => { - const listLength = list.length; - let index = list.indexOf(activeElement); - - // if the element does not exist in the list return an element - // depending on the direction and if cycle is allowed - if (index === -1) { - return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]; - } - index += shouldGetNext ? 1 : -1; - if (isCycleAllowed) { - index = (index + listLength) % listLength; - } - return list[Math.max(0, Math.min(index, listLength - 1))]; - }; - - /** - * -------------------------------------------------------------------------- - * Bootstrap dom/event-handler.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const namespaceRegex = /[^.]*(?=\..*)\.|.*/; - const stripNameRegex = /\..*/; - const stripUidRegex = /::\d+$/; - const eventRegistry = {}; // Events storage - let uidEvent = 1; - const customEvents = { - mouseenter: 'mouseover', - mouseleave: 'mouseout' - }; - const nativeEvents = new Set(['click', 'dblclick', 'mouseup', 'mousedown', 'contextmenu', 'mousewheel', 'DOMMouseScroll', 'mouseover', 'mouseout', 'mousemove', 'selectstart', 'selectend', 'keydown', 'keypress', 'keyup', 'orientationchange', 'touchstart', 'touchmove', 'touchend', 'touchcancel', 'pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'pointercancel', 'gesturestart', 'gesturechange', 'gestureend', 'focus', 'blur', 'change', 'reset', 'select', 'submit', 'focusin', 'focusout', 'load', 'unload', 'beforeunload', 'resize', 'move', 'DOMContentLoaded', 'readystatechange', 'error', 'abort', 'scroll']); - - /** - * Private methods - */ - - function makeEventUid(element, uid) { - return uid && `${uid}::${uidEvent++}` || element.uidEvent || uidEvent++; - } - function getElementEvents(element) { - const uid = makeEventUid(element); - element.uidEvent = uid; - eventRegistry[uid] = eventRegistry[uid] || {}; - return eventRegistry[uid]; - } - function bootstrapHandler(element, fn) { - return function handler(event) { - hydrateObj(event, { - delegateTarget: element - }); - if (handler.oneOff) { - EventHandler.off(element, event.type, fn); - } - return fn.apply(element, [event]); - }; - } - function bootstrapDelegationHandler(element, selector, fn) { - return function handler(event) { - const domElements = element.querySelectorAll(selector); - for (let { - target - } = event; target && target !== this; target = target.parentNode) { - for (const domElement of domElements) { - if (domElement !== target) { - continue; - } - hydrateObj(event, { - delegateTarget: target - }); - if (handler.oneOff) { - EventHandler.off(element, event.type, selector, fn); - } - return fn.apply(target, [event]); - } - } - }; - } - function findHandler(events, callable, delegationSelector = null) { - return Object.values(events).find(event => event.callable === callable && event.delegationSelector === delegationSelector); - } - function normalizeParameters(originalTypeEvent, handler, delegationFunction) { - const isDelegated = typeof handler === 'string'; - // TODO: tooltip passes `false` instead of selector, so we need to check - const callable = isDelegated ? delegationFunction : handler || delegationFunction; - let typeEvent = getTypeEvent(originalTypeEvent); - if (!nativeEvents.has(typeEvent)) { - typeEvent = originalTypeEvent; - } - return [isDelegated, callable, typeEvent]; - } - function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) { - if (typeof originalTypeEvent !== 'string' || !element) { - return; - } - let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction); - - // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position - // this prevents the handler from being dispatched the same way as mouseover or mouseout does - if (originalTypeEvent in customEvents) { - const wrapFunction = fn => { - return function (event) { - if (!event.relatedTarget || event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget)) { - return fn.call(this, event); - } - }; - }; - callable = wrapFunction(callable); - } - const events = getElementEvents(element); - const handlers = events[typeEvent] || (events[typeEvent] = {}); - const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null); - if (previousFunction) { - previousFunction.oneOff = previousFunction.oneOff && oneOff; - return; - } - const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, '')); - const fn = isDelegated ? bootstrapDelegationHandler(element, handler, callable) : bootstrapHandler(element, callable); - fn.delegationSelector = isDelegated ? handler : null; - fn.callable = callable; - fn.oneOff = oneOff; - fn.uidEvent = uid; - handlers[uid] = fn; - element.addEventListener(typeEvent, fn, isDelegated); - } - function removeHandler(element, events, typeEvent, handler, delegationSelector) { - const fn = findHandler(events[typeEvent], handler, delegationSelector); - if (!fn) { - return; - } - element.removeEventListener(typeEvent, fn, Boolean(delegationSelector)); - delete events[typeEvent][fn.uidEvent]; - } - function removeNamespacedHandlers(element, events, typeEvent, namespace) { - const storeElementEvent = events[typeEvent] || {}; - for (const [handlerKey, event] of Object.entries(storeElementEvent)) { - if (handlerKey.includes(namespace)) { - removeHandler(element, events, typeEvent, event.callable, event.delegationSelector); - } - } - } - function getTypeEvent(event) { - // allow to get the native events from namespaced events ('click.bs.button' --> 'click') - event = event.replace(stripNameRegex, ''); - return customEvents[event] || event; - } - const EventHandler = { - on(element, event, handler, delegationFunction) { - addHandler(element, event, handler, delegationFunction, false); - }, - one(element, event, handler, delegationFunction) { - addHandler(element, event, handler, delegationFunction, true); - }, - off(element, originalTypeEvent, handler, delegationFunction) { - if (typeof originalTypeEvent !== 'string' || !element) { - return; - } - const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction); - const inNamespace = typeEvent !== originalTypeEvent; - const events = getElementEvents(element); - const storeElementEvent = events[typeEvent] || {}; - const isNamespace = originalTypeEvent.startsWith('.'); - if (typeof callable !== 'undefined') { - // Simplest case: handler is passed, remove that listener ONLY. - if (!Object.keys(storeElementEvent).length) { - return; - } - removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null); - return; - } - if (isNamespace) { - for (const elementEvent of Object.keys(events)) { - removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1)); - } - } - for (const [keyHandlers, event] of Object.entries(storeElementEvent)) { - const handlerKey = keyHandlers.replace(stripUidRegex, ''); - if (!inNamespace || originalTypeEvent.includes(handlerKey)) { - removeHandler(element, events, typeEvent, event.callable, event.delegationSelector); - } - } - }, - trigger(element, event, args) { - if (typeof event !== 'string' || !element) { - return null; - } - const $ = getjQuery(); - const typeEvent = getTypeEvent(event); - const inNamespace = event !== typeEvent; - let jQueryEvent = null; - let bubbles = true; - let nativeDispatch = true; - let defaultPrevented = false; - if (inNamespace && $) { - jQueryEvent = $.Event(event, args); - $(element).trigger(jQueryEvent); - bubbles = !jQueryEvent.isPropagationStopped(); - nativeDispatch = !jQueryEvent.isImmediatePropagationStopped(); - defaultPrevented = jQueryEvent.isDefaultPrevented(); - } - const evt = hydrateObj(new Event(event, { - bubbles, - cancelable: true - }), args); - if (defaultPrevented) { - evt.preventDefault(); - } - if (nativeDispatch) { - element.dispatchEvent(evt); - } - if (evt.defaultPrevented && jQueryEvent) { - jQueryEvent.preventDefault(); - } - return evt; - } - }; - function hydrateObj(obj, meta = {}) { - for (const [key, value] of Object.entries(meta)) { - try { - obj[key] = value; - } catch (_unused) { - Object.defineProperty(obj, key, { - configurable: true, - get() { - return value; - } - }); - } - } - return obj; - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap dom/manipulator.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - function normalizeData(value) { - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - if (value === Number(value).toString()) { - return Number(value); - } - if (value === '' || value === 'null') { - return null; - } - if (typeof value !== 'string') { - return value; - } - try { - return JSON.parse(decodeURIComponent(value)); - } catch (_unused) { - return value; - } - } - function normalizeDataKey(key) { - return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`); - } - const Manipulator = { - setDataAttribute(element, key, value) { - element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value); - }, - removeDataAttribute(element, key) { - element.removeAttribute(`data-bs-${normalizeDataKey(key)}`); - }, - getDataAttributes(element) { - if (!element) { - return {}; - } - const attributes = {}; - const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig')); - for (const key of bsKeys) { - let pureKey = key.replace(/^bs/, ''); - pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length); - attributes[pureKey] = normalizeData(element.dataset[key]); - } - return attributes; - }, - getDataAttribute(element, key) { - return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`)); - } - }; - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/config.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Class definition - */ - - class Config { - // Getters - static get Default() { - return {}; - } - static get DefaultType() { - return {}; - } - static get NAME() { - throw new Error('You have to implement the static method "NAME", for each component!'); - } - _getConfig(config) { - config = this._mergeConfigObj(config); - config = this._configAfterMerge(config); - this._typeCheckConfig(config); - return config; - } - _configAfterMerge(config) { - return config; - } - _mergeConfigObj(config, element) { - const jsonConfig = isElement$1(element) ? Manipulator.getDataAttribute(element, 'config') : {}; // try to parse - - return { - ...this.constructor.Default, - ...(typeof jsonConfig === 'object' ? jsonConfig : {}), - ...(isElement$1(element) ? Manipulator.getDataAttributes(element) : {}), - ...(typeof config === 'object' ? config : {}) - }; - } - _typeCheckConfig(config, configTypes = this.constructor.DefaultType) { - for (const [property, expectedTypes] of Object.entries(configTypes)) { - const value = config[property]; - const valueType = isElement$1(value) ? 'element' : toType(value); - if (!new RegExp(expectedTypes).test(valueType)) { - throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`); - } - } - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap base-component.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const VERSION = '5.3.2'; - - /** - * Class definition - */ - - class BaseComponent extends Config { - constructor(element, config) { - super(); - element = getElement(element); - if (!element) { - return; - } - this._element = element; - this._config = this._getConfig(config); - Data.set(this._element, this.constructor.DATA_KEY, this); - } - - // Public - dispose() { - Data.remove(this._element, this.constructor.DATA_KEY); - EventHandler.off(this._element, this.constructor.EVENT_KEY); - for (const propertyName of Object.getOwnPropertyNames(this)) { - this[propertyName] = null; - } - } - _queueCallback(callback, element, isAnimated = true) { - executeAfterTransition(callback, element, isAnimated); - } - _getConfig(config) { - config = this._mergeConfigObj(config, this._element); - config = this._configAfterMerge(config); - this._typeCheckConfig(config); - return config; - } - - // Static - static getInstance(element) { - return Data.get(getElement(element), this.DATA_KEY); - } - static getOrCreateInstance(element, config = {}) { - return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null); - } - static get VERSION() { - return VERSION; - } - static get DATA_KEY() { - return `bs.${this.NAME}`; - } - static get EVENT_KEY() { - return `.${this.DATA_KEY}`; - } - static eventName(name) { - return `${name}${this.EVENT_KEY}`; - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap dom/selector-engine.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - const getSelector = element => { - let selector = element.getAttribute('data-bs-target'); - if (!selector || selector === '#') { - let hrefAttribute = element.getAttribute('href'); - - // The only valid content that could double as a selector are IDs or classes, - // so everything starting with `#` or `.`. If a "real" URL is used as the selector, - // `document.querySelector` will rightfully complain it is invalid. - // See https://github.com/twbs/bootstrap/issues/32273 - if (!hrefAttribute || !hrefAttribute.includes('#') && !hrefAttribute.startsWith('.')) { - return null; - } - - // Just in case some CMS puts out a full URL with the anchor appended - if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) { - hrefAttribute = `#${hrefAttribute.split('#')[1]}`; - } - selector = hrefAttribute && hrefAttribute !== '#' ? parseSelector(hrefAttribute.trim()) : null; - } - return selector; - }; - const SelectorEngine = { - find(selector, element = document.documentElement) { - return [].concat(...Element.prototype.querySelectorAll.call(element, selector)); - }, - findOne(selector, element = document.documentElement) { - return Element.prototype.querySelector.call(element, selector); - }, - children(element, selector) { - return [].concat(...element.children).filter(child => child.matches(selector)); - }, - parents(element, selector) { - const parents = []; - let ancestor = element.parentNode.closest(selector); - while (ancestor) { - parents.push(ancestor); - ancestor = ancestor.parentNode.closest(selector); - } - return parents; - }, - prev(element, selector) { - let previous = element.previousElementSibling; - while (previous) { - if (previous.matches(selector)) { - return [previous]; - } - previous = previous.previousElementSibling; - } - return []; - }, - // TODO: this is now unused; remove later along with prev() - next(element, selector) { - let next = element.nextElementSibling; - while (next) { - if (next.matches(selector)) { - return [next]; - } - next = next.nextElementSibling; - } - return []; - }, - focusableChildren(element) { - const focusables = ['a', 'button', 'input', 'textarea', 'select', 'details', '[tabindex]', '[contenteditable="true"]'].map(selector => `${selector}:not([tabindex^="-"])`).join(','); - return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el)); - }, - getSelectorFromElement(element) { - const selector = getSelector(element); - if (selector) { - return SelectorEngine.findOne(selector) ? selector : null; - } - return null; - }, - getElementFromSelector(element) { - const selector = getSelector(element); - return selector ? SelectorEngine.findOne(selector) : null; - }, - getMultipleElementsFromSelector(element) { - const selector = getSelector(element); - return selector ? SelectorEngine.find(selector) : []; - } - }; - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/component-functions.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - const enableDismissTrigger = (component, method = 'hide') => { - const clickEvent = `click.dismiss${component.EVENT_KEY}`; - const name = component.NAME; - EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) { - if (['A', 'AREA'].includes(this.tagName)) { - event.preventDefault(); - } - if (isDisabled(this)) { - return; - } - const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`); - const instance = component.getOrCreateInstance(target); - - // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method - instance[method](); - }); - }; - - /** - * -------------------------------------------------------------------------- - * Bootstrap alert.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$f = 'alert'; - const DATA_KEY$a = 'bs.alert'; - const EVENT_KEY$b = `.${DATA_KEY$a}`; - const EVENT_CLOSE = `close${EVENT_KEY$b}`; - const EVENT_CLOSED = `closed${EVENT_KEY$b}`; - const CLASS_NAME_FADE$5 = 'fade'; - const CLASS_NAME_SHOW$8 = 'show'; - - /** - * Class definition - */ - - class Alert extends BaseComponent { - // Getters - static get NAME() { - return NAME$f; - } - - // Public - close() { - const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE); - if (closeEvent.defaultPrevented) { - return; - } - this._element.classList.remove(CLASS_NAME_SHOW$8); - const isAnimated = this._element.classList.contains(CLASS_NAME_FADE$5); - this._queueCallback(() => this._destroyElement(), this._element, isAnimated); - } - - // Private - _destroyElement() { - this._element.remove(); - EventHandler.trigger(this._element, EVENT_CLOSED); - this.dispose(); - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Alert.getOrCreateInstance(this); - if (typeof config !== 'string') { - return; - } - if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { - throw new TypeError(`No method named "${config}"`); - } - data[config](this); - }); - } - } - - /** - * Data API implementation - */ - - enableDismissTrigger(Alert, 'close'); - - /** - * jQuery - */ - - defineJQueryPlugin(Alert); - - /** - * -------------------------------------------------------------------------- - * Bootstrap button.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$e = 'button'; - const DATA_KEY$9 = 'bs.button'; - const EVENT_KEY$a = `.${DATA_KEY$9}`; - const DATA_API_KEY$6 = '.data-api'; - const CLASS_NAME_ACTIVE$3 = 'active'; - const SELECTOR_DATA_TOGGLE$5 = '[data-bs-toggle="button"]'; - const EVENT_CLICK_DATA_API$6 = `click${EVENT_KEY$a}${DATA_API_KEY$6}`; - - /** - * Class definition - */ - - class Button extends BaseComponent { - // Getters - static get NAME() { - return NAME$e; - } - - // Public - toggle() { - // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method - this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE$3)); - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Button.getOrCreateInstance(this); - if (config === 'toggle') { - data[config](); - } - }); - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API$6, SELECTOR_DATA_TOGGLE$5, event => { - event.preventDefault(); - const button = event.target.closest(SELECTOR_DATA_TOGGLE$5); - const data = Button.getOrCreateInstance(button); - data.toggle(); - }); - - /** - * jQuery - */ - - defineJQueryPlugin(Button); - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/swipe.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$d = 'swipe'; - const EVENT_KEY$9 = '.bs.swipe'; - const EVENT_TOUCHSTART = `touchstart${EVENT_KEY$9}`; - const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY$9}`; - const EVENT_TOUCHEND = `touchend${EVENT_KEY$9}`; - const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY$9}`; - const EVENT_POINTERUP = `pointerup${EVENT_KEY$9}`; - const POINTER_TYPE_TOUCH = 'touch'; - const POINTER_TYPE_PEN = 'pen'; - const CLASS_NAME_POINTER_EVENT = 'pointer-event'; - const SWIPE_THRESHOLD = 40; - const Default$c = { - endCallback: null, - leftCallback: null, - rightCallback: null - }; - const DefaultType$c = { - endCallback: '(function|null)', - leftCallback: '(function|null)', - rightCallback: '(function|null)' - }; - - /** - * Class definition - */ - - class Swipe extends Config { - constructor(element, config) { - super(); - this._element = element; - if (!element || !Swipe.isSupported()) { - return; - } - this._config = this._getConfig(config); - this._deltaX = 0; - this._supportPointerEvents = Boolean(window.PointerEvent); - this._initEvents(); - } - - // Getters - static get Default() { - return Default$c; - } - static get DefaultType() { - return DefaultType$c; - } - static get NAME() { - return NAME$d; - } - - // Public - dispose() { - EventHandler.off(this._element, EVENT_KEY$9); - } - - // Private - _start(event) { - if (!this._supportPointerEvents) { - this._deltaX = event.touches[0].clientX; - return; - } - if (this._eventIsPointerPenTouch(event)) { - this._deltaX = event.clientX; - } - } - _end(event) { - if (this._eventIsPointerPenTouch(event)) { - this._deltaX = event.clientX - this._deltaX; - } - this._handleSwipe(); - execute(this._config.endCallback); - } - _move(event) { - this._deltaX = event.touches && event.touches.length > 1 ? 0 : event.touches[0].clientX - this._deltaX; - } - _handleSwipe() { - const absDeltaX = Math.abs(this._deltaX); - if (absDeltaX <= SWIPE_THRESHOLD) { - return; - } - const direction = absDeltaX / this._deltaX; - this._deltaX = 0; - if (!direction) { - return; - } - execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback); - } - _initEvents() { - if (this._supportPointerEvents) { - EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event)); - EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event)); - this._element.classList.add(CLASS_NAME_POINTER_EVENT); - } else { - EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event)); - EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event)); - EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event)); - } - } - _eventIsPointerPenTouch(event) { - return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH); - } - - // Static - static isSupported() { - return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0; - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap carousel.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$c = 'carousel'; - const DATA_KEY$8 = 'bs.carousel'; - const EVENT_KEY$8 = `.${DATA_KEY$8}`; - const DATA_API_KEY$5 = '.data-api'; - const ARROW_LEFT_KEY$1 = 'ArrowLeft'; - const ARROW_RIGHT_KEY$1 = 'ArrowRight'; - const TOUCHEVENT_COMPAT_WAIT = 500; // Time for mouse compat events to fire after touch - - const ORDER_NEXT = 'next'; - const ORDER_PREV = 'prev'; - const DIRECTION_LEFT = 'left'; - const DIRECTION_RIGHT = 'right'; - const EVENT_SLIDE = `slide${EVENT_KEY$8}`; - const EVENT_SLID = `slid${EVENT_KEY$8}`; - const EVENT_KEYDOWN$1 = `keydown${EVENT_KEY$8}`; - const EVENT_MOUSEENTER$1 = `mouseenter${EVENT_KEY$8}`; - const EVENT_MOUSELEAVE$1 = `mouseleave${EVENT_KEY$8}`; - const EVENT_DRAG_START = `dragstart${EVENT_KEY$8}`; - const EVENT_LOAD_DATA_API$3 = `load${EVENT_KEY$8}${DATA_API_KEY$5}`; - const EVENT_CLICK_DATA_API$5 = `click${EVENT_KEY$8}${DATA_API_KEY$5}`; - const CLASS_NAME_CAROUSEL = 'carousel'; - const CLASS_NAME_ACTIVE$2 = 'active'; - const CLASS_NAME_SLIDE = 'slide'; - const CLASS_NAME_END = 'carousel-item-end'; - const CLASS_NAME_START = 'carousel-item-start'; - const CLASS_NAME_NEXT = 'carousel-item-next'; - const CLASS_NAME_PREV = 'carousel-item-prev'; - const SELECTOR_ACTIVE = '.active'; - const SELECTOR_ITEM = '.carousel-item'; - const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM; - const SELECTOR_ITEM_IMG = '.carousel-item img'; - const SELECTOR_INDICATORS = '.carousel-indicators'; - const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'; - const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'; - const KEY_TO_DIRECTION = { - [ARROW_LEFT_KEY$1]: DIRECTION_RIGHT, - [ARROW_RIGHT_KEY$1]: DIRECTION_LEFT - }; - const Default$b = { - interval: 5000, - keyboard: true, - pause: 'hover', - ride: false, - touch: true, - wrap: true - }; - const DefaultType$b = { - interval: '(number|boolean)', - // TODO:v6 remove boolean support - keyboard: 'boolean', - pause: '(string|boolean)', - ride: '(boolean|string)', - touch: 'boolean', - wrap: 'boolean' - }; - - /** - * Class definition - */ - - class Carousel extends BaseComponent { - constructor(element, config) { - super(element, config); - this._interval = null; - this._activeElement = null; - this._isSliding = false; - this.touchTimeout = null; - this._swipeHelper = null; - this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element); - this._addEventListeners(); - if (this._config.ride === CLASS_NAME_CAROUSEL) { - this.cycle(); - } - } - - // Getters - static get Default() { - return Default$b; - } - static get DefaultType() { - return DefaultType$b; - } - static get NAME() { - return NAME$c; - } - - // Public - next() { - this._slide(ORDER_NEXT); - } - nextWhenVisible() { - // FIXME TODO use `document.visibilityState` - // Don't call next when the page isn't visible - // or the carousel or its parent isn't visible - if (!document.hidden && isVisible(this._element)) { - this.next(); - } - } - prev() { - this._slide(ORDER_PREV); - } - pause() { - if (this._isSliding) { - triggerTransitionEnd(this._element); - } - this._clearInterval(); - } - cycle() { - this._clearInterval(); - this._updateInterval(); - this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval); - } - _maybeEnableCycle() { - if (!this._config.ride) { - return; - } - if (this._isSliding) { - EventHandler.one(this._element, EVENT_SLID, () => this.cycle()); - return; - } - this.cycle(); - } - to(index) { - const items = this._getItems(); - if (index > items.length - 1 || index < 0) { - return; - } - if (this._isSliding) { - EventHandler.one(this._element, EVENT_SLID, () => this.to(index)); - return; - } - const activeIndex = this._getItemIndex(this._getActive()); - if (activeIndex === index) { - return; - } - const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV; - this._slide(order, items[index]); - } - dispose() { - if (this._swipeHelper) { - this._swipeHelper.dispose(); - } - super.dispose(); - } - - // Private - _configAfterMerge(config) { - config.defaultInterval = config.interval; - return config; - } - _addEventListeners() { - if (this._config.keyboard) { - EventHandler.on(this._element, EVENT_KEYDOWN$1, event => this._keydown(event)); - } - if (this._config.pause === 'hover') { - EventHandler.on(this._element, EVENT_MOUSEENTER$1, () => this.pause()); - EventHandler.on(this._element, EVENT_MOUSELEAVE$1, () => this._maybeEnableCycle()); - } - if (this._config.touch && Swipe.isSupported()) { - this._addTouchEventListeners(); - } - } - _addTouchEventListeners() { - for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) { - EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault()); - } - const endCallBack = () => { - if (this._config.pause !== 'hover') { - return; - } - - // If it's a touch-enabled device, mouseenter/leave are fired as - // part of the mouse compatibility events on first tap - the carousel - // would stop cycling until user tapped out of it; - // here, we listen for touchend, explicitly pause the carousel - // (as if it's the second time we tap on it, mouseenter compat event - // is NOT fired) and after a timeout (to allow for mouse compatibility - // events to fire) we explicitly restart cycling - - this.pause(); - if (this.touchTimeout) { - clearTimeout(this.touchTimeout); - } - this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval); - }; - const swipeConfig = { - leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)), - rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)), - endCallback: endCallBack - }; - this._swipeHelper = new Swipe(this._element, swipeConfig); - } - _keydown(event) { - if (/input|textarea/i.test(event.target.tagName)) { - return; - } - const direction = KEY_TO_DIRECTION[event.key]; - if (direction) { - event.preventDefault(); - this._slide(this._directionToOrder(direction)); - } - } - _getItemIndex(element) { - return this._getItems().indexOf(element); - } - _setActiveIndicatorElement(index) { - if (!this._indicatorsElement) { - return; - } - const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement); - activeIndicator.classList.remove(CLASS_NAME_ACTIVE$2); - activeIndicator.removeAttribute('aria-current'); - const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement); - if (newActiveIndicator) { - newActiveIndicator.classList.add(CLASS_NAME_ACTIVE$2); - newActiveIndicator.setAttribute('aria-current', 'true'); - } - } - _updateInterval() { - const element = this._activeElement || this._getActive(); - if (!element) { - return; - } - const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10); - this._config.interval = elementInterval || this._config.defaultInterval; - } - _slide(order, element = null) { - if (this._isSliding) { - return; - } - const activeElement = this._getActive(); - const isNext = order === ORDER_NEXT; - const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap); - if (nextElement === activeElement) { - return; - } - const nextElementIndex = this._getItemIndex(nextElement); - const triggerEvent = eventName => { - return EventHandler.trigger(this._element, eventName, { - relatedTarget: nextElement, - direction: this._orderToDirection(order), - from: this._getItemIndex(activeElement), - to: nextElementIndex - }); - }; - const slideEvent = triggerEvent(EVENT_SLIDE); - if (slideEvent.defaultPrevented) { - return; - } - if (!activeElement || !nextElement) { - // Some weirdness is happening, so we bail - // TODO: change tests that use empty divs to avoid this check - return; - } - const isCycling = Boolean(this._interval); - this.pause(); - this._isSliding = true; - this._setActiveIndicatorElement(nextElementIndex); - this._activeElement = nextElement; - const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END; - const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV; - nextElement.classList.add(orderClassName); - reflow(nextElement); - activeElement.classList.add(directionalClassName); - nextElement.classList.add(directionalClassName); - const completeCallBack = () => { - nextElement.classList.remove(directionalClassName, orderClassName); - nextElement.classList.add(CLASS_NAME_ACTIVE$2); - activeElement.classList.remove(CLASS_NAME_ACTIVE$2, orderClassName, directionalClassName); - this._isSliding = false; - triggerEvent(EVENT_SLID); - }; - this._queueCallback(completeCallBack, activeElement, this._isAnimated()); - if (isCycling) { - this.cycle(); - } - } - _isAnimated() { - return this._element.classList.contains(CLASS_NAME_SLIDE); - } - _getActive() { - return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element); - } - _getItems() { - return SelectorEngine.find(SELECTOR_ITEM, this._element); - } - _clearInterval() { - if (this._interval) { - clearInterval(this._interval); - this._interval = null; - } - } - _directionToOrder(direction) { - if (isRTL()) { - return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT; - } - return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV; - } - _orderToDirection(order) { - if (isRTL()) { - return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT; - } - return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT; - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Carousel.getOrCreateInstance(this, config); - if (typeof config === 'number') { - data.to(config); - return; - } - if (typeof config === 'string') { - if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { - throw new TypeError(`No method named "${config}"`); - } - data[config](); - } - }); - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API$5, SELECTOR_DATA_SLIDE, function (event) { - const target = SelectorEngine.getElementFromSelector(this); - if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) { - return; - } - event.preventDefault(); - const carousel = Carousel.getOrCreateInstance(target); - const slideIndex = this.getAttribute('data-bs-slide-to'); - if (slideIndex) { - carousel.to(slideIndex); - carousel._maybeEnableCycle(); - return; - } - if (Manipulator.getDataAttribute(this, 'slide') === 'next') { - carousel.next(); - carousel._maybeEnableCycle(); - return; - } - carousel.prev(); - carousel._maybeEnableCycle(); - }); - EventHandler.on(window, EVENT_LOAD_DATA_API$3, () => { - const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE); - for (const carousel of carousels) { - Carousel.getOrCreateInstance(carousel); - } - }); - - /** - * jQuery - */ - - defineJQueryPlugin(Carousel); - - /** - * -------------------------------------------------------------------------- - * Bootstrap collapse.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$b = 'collapse'; - const DATA_KEY$7 = 'bs.collapse'; - const EVENT_KEY$7 = `.${DATA_KEY$7}`; - const DATA_API_KEY$4 = '.data-api'; - const EVENT_SHOW$6 = `show${EVENT_KEY$7}`; - const EVENT_SHOWN$6 = `shown${EVENT_KEY$7}`; - const EVENT_HIDE$6 = `hide${EVENT_KEY$7}`; - const EVENT_HIDDEN$6 = `hidden${EVENT_KEY$7}`; - const EVENT_CLICK_DATA_API$4 = `click${EVENT_KEY$7}${DATA_API_KEY$4}`; - const CLASS_NAME_SHOW$7 = 'show'; - const CLASS_NAME_COLLAPSE = 'collapse'; - const CLASS_NAME_COLLAPSING = 'collapsing'; - const CLASS_NAME_COLLAPSED = 'collapsed'; - const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`; - const CLASS_NAME_HORIZONTAL = 'collapse-horizontal'; - const WIDTH = 'width'; - const HEIGHT = 'height'; - const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'; - const SELECTOR_DATA_TOGGLE$4 = '[data-bs-toggle="collapse"]'; - const Default$a = { - parent: null, - toggle: true - }; - const DefaultType$a = { - parent: '(null|element)', - toggle: 'boolean' - }; - - /** - * Class definition - */ - - class Collapse extends BaseComponent { - constructor(element, config) { - super(element, config); - this._isTransitioning = false; - this._triggerArray = []; - const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE$4); - for (const elem of toggleList) { - const selector = SelectorEngine.getSelectorFromElement(elem); - const filterElement = SelectorEngine.find(selector).filter(foundElement => foundElement === this._element); - if (selector !== null && filterElement.length) { - this._triggerArray.push(elem); - } - } - this._initializeChildren(); - if (!this._config.parent) { - this._addAriaAndCollapsedClass(this._triggerArray, this._isShown()); - } - if (this._config.toggle) { - this.toggle(); - } - } - - // Getters - static get Default() { - return Default$a; - } - static get DefaultType() { - return DefaultType$a; - } - static get NAME() { - return NAME$b; - } - - // Public - toggle() { - if (this._isShown()) { - this.hide(); - } else { - this.show(); - } - } - show() { - if (this._isTransitioning || this._isShown()) { - return; - } - let activeChildren = []; - - // find active children - if (this._config.parent) { - activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES).filter(element => element !== this._element).map(element => Collapse.getOrCreateInstance(element, { - toggle: false - })); - } - if (activeChildren.length && activeChildren[0]._isTransitioning) { - return; - } - const startEvent = EventHandler.trigger(this._element, EVENT_SHOW$6); - if (startEvent.defaultPrevented) { - return; - } - for (const activeInstance of activeChildren) { - activeInstance.hide(); - } - const dimension = this._getDimension(); - this._element.classList.remove(CLASS_NAME_COLLAPSE); - this._element.classList.add(CLASS_NAME_COLLAPSING); - this._element.style[dimension] = 0; - this._addAriaAndCollapsedClass(this._triggerArray, true); - this._isTransitioning = true; - const complete = () => { - this._isTransitioning = false; - this._element.classList.remove(CLASS_NAME_COLLAPSING); - this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW$7); - this._element.style[dimension] = ''; - EventHandler.trigger(this._element, EVENT_SHOWN$6); - }; - const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1); - const scrollSize = `scroll${capitalizedDimension}`; - this._queueCallback(complete, this._element, true); - this._element.style[dimension] = `${this._element[scrollSize]}px`; - } - hide() { - if (this._isTransitioning || !this._isShown()) { - return; - } - const startEvent = EventHandler.trigger(this._element, EVENT_HIDE$6); - if (startEvent.defaultPrevented) { - return; - } - const dimension = this._getDimension(); - this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`; - reflow(this._element); - this._element.classList.add(CLASS_NAME_COLLAPSING); - this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW$7); - for (const trigger of this._triggerArray) { - const element = SelectorEngine.getElementFromSelector(trigger); - if (element && !this._isShown(element)) { - this._addAriaAndCollapsedClass([trigger], false); - } - } - this._isTransitioning = true; - const complete = () => { - this._isTransitioning = false; - this._element.classList.remove(CLASS_NAME_COLLAPSING); - this._element.classList.add(CLASS_NAME_COLLAPSE); - EventHandler.trigger(this._element, EVENT_HIDDEN$6); - }; - this._element.style[dimension] = ''; - this._queueCallback(complete, this._element, true); - } - _isShown(element = this._element) { - return element.classList.contains(CLASS_NAME_SHOW$7); - } - - // Private - _configAfterMerge(config) { - config.toggle = Boolean(config.toggle); // Coerce string values - config.parent = getElement(config.parent); - return config; - } - _getDimension() { - return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT; - } - _initializeChildren() { - if (!this._config.parent) { - return; - } - const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE$4); - for (const element of children) { - const selected = SelectorEngine.getElementFromSelector(element); - if (selected) { - this._addAriaAndCollapsedClass([element], this._isShown(selected)); - } - } - } - _getFirstLevelChildren(selector) { - const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent); - // remove children if greater depth - return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element)); - } - _addAriaAndCollapsedClass(triggerArray, isOpen) { - if (!triggerArray.length) { - return; - } - for (const element of triggerArray) { - element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen); - element.setAttribute('aria-expanded', isOpen); - } - } - - // Static - static jQueryInterface(config) { - const _config = {}; - if (typeof config === 'string' && /show|hide/.test(config)) { - _config.toggle = false; - } - return this.each(function () { - const data = Collapse.getOrCreateInstance(this, _config); - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`); - } - data[config](); - } - }); - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API$4, SELECTOR_DATA_TOGGLE$4, function (event) { - // preventDefault only for elements (which change the URL) not inside the collapsible element - if (event.target.tagName === 'A' || event.delegateTarget && event.delegateTarget.tagName === 'A') { - event.preventDefault(); - } - for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) { - Collapse.getOrCreateInstance(element, { - toggle: false - }).toggle(); - } - }); - - /** - * jQuery - */ - - defineJQueryPlugin(Collapse); - - var top = 'top'; - var bottom = 'bottom'; - var right = 'right'; - var left = 'left'; - var auto = 'auto'; - var basePlacements = [top, bottom, right, left]; - var start = 'start'; - var end = 'end'; - var clippingParents = 'clippingParents'; - var viewport = 'viewport'; - var popper = 'popper'; - var reference = 'reference'; - var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) { - return acc.concat([placement + "-" + start, placement + "-" + end]); - }, []); - var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) { - return acc.concat([placement, placement + "-" + start, placement + "-" + end]); - }, []); // modifiers that need to read the DOM - - var beforeRead = 'beforeRead'; - var read = 'read'; - var afterRead = 'afterRead'; // pure-logic modifiers - - var beforeMain = 'beforeMain'; - var main = 'main'; - var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state) - - var beforeWrite = 'beforeWrite'; - var write = 'write'; - var afterWrite = 'afterWrite'; - var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite]; - - function getNodeName(element) { - return element ? (element.nodeName || '').toLowerCase() : null; - } - - function getWindow(node) { - if (node == null) { - return window; - } - - if (node.toString() !== '[object Window]') { - var ownerDocument = node.ownerDocument; - return ownerDocument ? ownerDocument.defaultView || window : window; - } - - return node; - } - - function isElement(node) { - var OwnElement = getWindow(node).Element; - return node instanceof OwnElement || node instanceof Element; - } - - function isHTMLElement(node) { - var OwnElement = getWindow(node).HTMLElement; - return node instanceof OwnElement || node instanceof HTMLElement; - } - - function isShadowRoot(node) { - // IE 11 has no ShadowRoot - if (typeof ShadowRoot === 'undefined') { - return false; - } - - var OwnElement = getWindow(node).ShadowRoot; - return node instanceof OwnElement || node instanceof ShadowRoot; - } - - // and applies them to the HTMLElements such as popper and arrow - - function applyStyles(_ref) { - var state = _ref.state; - Object.keys(state.elements).forEach(function (name) { - var style = state.styles[name] || {}; - var attributes = state.attributes[name] || {}; - var element = state.elements[name]; // arrow is optional + virtual elements - - if (!isHTMLElement(element) || !getNodeName(element)) { - return; - } // Flow doesn't support to extend this property, but it's the most - // effective way to apply styles to an HTMLElement - // $FlowFixMe[cannot-write] - - - Object.assign(element.style, style); - Object.keys(attributes).forEach(function (name) { - var value = attributes[name]; - - if (value === false) { - element.removeAttribute(name); - } else { - element.setAttribute(name, value === true ? '' : value); - } - }); - }); - } - - function effect$2(_ref2) { - var state = _ref2.state; - var initialStyles = { - popper: { - position: state.options.strategy, - left: '0', - top: '0', - margin: '0' - }, - arrow: { - position: 'absolute' - }, - reference: {} - }; - Object.assign(state.elements.popper.style, initialStyles.popper); - state.styles = initialStyles; - - if (state.elements.arrow) { - Object.assign(state.elements.arrow.style, initialStyles.arrow); - } - - return function () { - Object.keys(state.elements).forEach(function (name) { - var element = state.elements[name]; - var attributes = state.attributes[name] || {}; - var styleProperties = Object.keys(state.styles.hasOwnProperty(name) ? state.styles[name] : initialStyles[name]); // Set all values to an empty string to unset them - - var style = styleProperties.reduce(function (style, property) { - style[property] = ''; - return style; - }, {}); // arrow is optional + virtual elements - - if (!isHTMLElement(element) || !getNodeName(element)) { - return; - } - - Object.assign(element.style, style); - Object.keys(attributes).forEach(function (attribute) { - element.removeAttribute(attribute); - }); - }); - }; - } // eslint-disable-next-line import/no-unused-modules - - - const applyStyles$1 = { - name: 'applyStyles', - enabled: true, - phase: 'write', - fn: applyStyles, - effect: effect$2, - requires: ['computeStyles'] - }; - - function getBasePlacement(placement) { - return placement.split('-')[0]; - } - - var max = Math.max; - var min = Math.min; - var round = Math.round; - - function getUAString() { - var uaData = navigator.userAgentData; - - if (uaData != null && uaData.brands && Array.isArray(uaData.brands)) { - return uaData.brands.map(function (item) { - return item.brand + "/" + item.version; - }).join(' '); - } - - return navigator.userAgent; - } - - function isLayoutViewport() { - return !/^((?!chrome|android).)*safari/i.test(getUAString()); - } - - function getBoundingClientRect(element, includeScale, isFixedStrategy) { - if (includeScale === void 0) { - includeScale = false; - } - - if (isFixedStrategy === void 0) { - isFixedStrategy = false; - } - - var clientRect = element.getBoundingClientRect(); - var scaleX = 1; - var scaleY = 1; - - if (includeScale && isHTMLElement(element)) { - scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1; - scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1; - } - - var _ref = isElement(element) ? getWindow(element) : window, - visualViewport = _ref.visualViewport; - - var addVisualOffsets = !isLayoutViewport() && isFixedStrategy; - var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX; - var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY; - var width = clientRect.width / scaleX; - var height = clientRect.height / scaleY; - return { - width: width, - height: height, - top: y, - right: x + width, - bottom: y + height, - left: x, - x: x, - y: y - }; - } - - // means it doesn't take into account transforms. - - function getLayoutRect(element) { - var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed. - // Fixes https://github.com/popperjs/popper-core/issues/1223 - - var width = element.offsetWidth; - var height = element.offsetHeight; - - if (Math.abs(clientRect.width - width) <= 1) { - width = clientRect.width; - } - - if (Math.abs(clientRect.height - height) <= 1) { - height = clientRect.height; - } - - return { - x: element.offsetLeft, - y: element.offsetTop, - width: width, - height: height - }; - } - - function contains(parent, child) { - var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method - - if (parent.contains(child)) { - return true; - } // then fallback to custom implementation with Shadow DOM support - else if (rootNode && isShadowRoot(rootNode)) { - var next = child; - - do { - if (next && parent.isSameNode(next)) { - return true; - } // $FlowFixMe[prop-missing]: need a better way to handle this... - - - next = next.parentNode || next.host; - } while (next); - } // Give up, the result is false - - - return false; - } - - function getComputedStyle$1(element) { - return getWindow(element).getComputedStyle(element); - } - - function isTableElement(element) { - return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0; - } - - function getDocumentElement(element) { - // $FlowFixMe[incompatible-return]: assume body is always available - return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing] - element.document) || window.document).documentElement; - } - - function getParentNode(element) { - if (getNodeName(element) === 'html') { - return element; - } - - return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle - // $FlowFixMe[incompatible-return] - // $FlowFixMe[prop-missing] - element.assignedSlot || // step into the shadow DOM of the parent of a slotted node - element.parentNode || ( // DOM Element detected - isShadowRoot(element) ? element.host : null) || // ShadowRoot detected - // $FlowFixMe[incompatible-call]: HTMLElement is a Node - getDocumentElement(element) // fallback - - ); - } - - function getTrueOffsetParent(element) { - if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837 - getComputedStyle$1(element).position === 'fixed') { - return null; - } - - return element.offsetParent; - } // `.offsetParent` reports `null` for fixed elements, while absolute elements - // return the containing block - - - function getContainingBlock(element) { - var isFirefox = /firefox/i.test(getUAString()); - var isIE = /Trident/i.test(getUAString()); - - if (isIE && isHTMLElement(element)) { - // In IE 9, 10 and 11 fixed elements containing block is always established by the viewport - var elementCss = getComputedStyle$1(element); - - if (elementCss.position === 'fixed') { - return null; - } - } - - var currentNode = getParentNode(element); - - if (isShadowRoot(currentNode)) { - currentNode = currentNode.host; - } - - while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) { - var css = getComputedStyle$1(currentNode); // This is non-exhaustive but covers the most common CSS properties that - // create a containing block. - // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block - - if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') { - return currentNode; - } else { - currentNode = currentNode.parentNode; - } - } - - return null; - } // Gets the closest ancestor positioned element. Handles some edge cases, - // such as table ancestors and cross browser bugs. - - - function getOffsetParent(element) { - var window = getWindow(element); - var offsetParent = getTrueOffsetParent(element); - - while (offsetParent && isTableElement(offsetParent) && getComputedStyle$1(offsetParent).position === 'static') { - offsetParent = getTrueOffsetParent(offsetParent); - } - - if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle$1(offsetParent).position === 'static')) { - return window; - } - - return offsetParent || getContainingBlock(element) || window; - } - - function getMainAxisFromPlacement(placement) { - return ['top', 'bottom'].indexOf(placement) >= 0 ? 'x' : 'y'; - } - - function within(min$1, value, max$1) { - return max(min$1, min(value, max$1)); - } - function withinMaxClamp(min, value, max) { - var v = within(min, value, max); - return v > max ? max : v; - } - - function getFreshSideObject() { - return { - top: 0, - right: 0, - bottom: 0, - left: 0 - }; - } - - function mergePaddingObject(paddingObject) { - return Object.assign({}, getFreshSideObject(), paddingObject); - } - - function expandToHashMap(value, keys) { - return keys.reduce(function (hashMap, key) { - hashMap[key] = value; - return hashMap; - }, {}); - } - - var toPaddingObject = function toPaddingObject(padding, state) { - padding = typeof padding === 'function' ? padding(Object.assign({}, state.rects, { - placement: state.placement - })) : padding; - return mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); - }; - - function arrow(_ref) { - var _state$modifiersData$; - - var state = _ref.state, - name = _ref.name, - options = _ref.options; - var arrowElement = state.elements.arrow; - var popperOffsets = state.modifiersData.popperOffsets; - var basePlacement = getBasePlacement(state.placement); - var axis = getMainAxisFromPlacement(basePlacement); - var isVertical = [left, right].indexOf(basePlacement) >= 0; - var len = isVertical ? 'height' : 'width'; - - if (!arrowElement || !popperOffsets) { - return; - } - - var paddingObject = toPaddingObject(options.padding, state); - var arrowRect = getLayoutRect(arrowElement); - var minProp = axis === 'y' ? top : left; - var maxProp = axis === 'y' ? bottom : right; - var endDiff = state.rects.reference[len] + state.rects.reference[axis] - popperOffsets[axis] - state.rects.popper[len]; - var startDiff = popperOffsets[axis] - state.rects.reference[axis]; - var arrowOffsetParent = getOffsetParent(arrowElement); - var clientSize = arrowOffsetParent ? axis === 'y' ? arrowOffsetParent.clientHeight || 0 : arrowOffsetParent.clientWidth || 0 : 0; - var centerToReference = endDiff / 2 - startDiff / 2; // Make sure the arrow doesn't overflow the popper if the center point is - // outside of the popper bounds - - var min = paddingObject[minProp]; - var max = clientSize - arrowRect[len] - paddingObject[maxProp]; - var center = clientSize / 2 - arrowRect[len] / 2 + centerToReference; - var offset = within(min, center, max); // Prevents breaking syntax highlighting... - - var axisProp = axis; - state.modifiersData[name] = (_state$modifiersData$ = {}, _state$modifiersData$[axisProp] = offset, _state$modifiersData$.centerOffset = offset - center, _state$modifiersData$); - } - - function effect$1(_ref2) { - var state = _ref2.state, - options = _ref2.options; - var _options$element = options.element, - arrowElement = _options$element === void 0 ? '[data-popper-arrow]' : _options$element; - - if (arrowElement == null) { - return; - } // CSS selector - - - if (typeof arrowElement === 'string') { - arrowElement = state.elements.popper.querySelector(arrowElement); - - if (!arrowElement) { - return; - } - } - - if (!contains(state.elements.popper, arrowElement)) { - return; - } - - state.elements.arrow = arrowElement; - } // eslint-disable-next-line import/no-unused-modules - - - const arrow$1 = { - name: 'arrow', - enabled: true, - phase: 'main', - fn: arrow, - effect: effect$1, - requires: ['popperOffsets'], - requiresIfExists: ['preventOverflow'] - }; - - function getVariation(placement) { - return placement.split('-')[1]; - } - - var unsetSides = { - top: 'auto', - right: 'auto', - bottom: 'auto', - left: 'auto' - }; // Round the offsets to the nearest suitable subpixel based on the DPR. - // Zooming can change the DPR, but it seems to report a value that will - // cleanly divide the values into the appropriate subpixels. - - function roundOffsetsByDPR(_ref, win) { - var x = _ref.x, - y = _ref.y; - var dpr = win.devicePixelRatio || 1; - return { - x: round(x * dpr) / dpr || 0, - y: round(y * dpr) / dpr || 0 - }; - } - - function mapToStyles(_ref2) { - var _Object$assign2; - - var popper = _ref2.popper, - popperRect = _ref2.popperRect, - placement = _ref2.placement, - variation = _ref2.variation, - offsets = _ref2.offsets, - position = _ref2.position, - gpuAcceleration = _ref2.gpuAcceleration, - adaptive = _ref2.adaptive, - roundOffsets = _ref2.roundOffsets, - isFixed = _ref2.isFixed; - var _offsets$x = offsets.x, - x = _offsets$x === void 0 ? 0 : _offsets$x, - _offsets$y = offsets.y, - y = _offsets$y === void 0 ? 0 : _offsets$y; - - var _ref3 = typeof roundOffsets === 'function' ? roundOffsets({ - x: x, - y: y - }) : { - x: x, - y: y - }; - - x = _ref3.x; - y = _ref3.y; - var hasX = offsets.hasOwnProperty('x'); - var hasY = offsets.hasOwnProperty('y'); - var sideX = left; - var sideY = top; - var win = window; - - if (adaptive) { - var offsetParent = getOffsetParent(popper); - var heightProp = 'clientHeight'; - var widthProp = 'clientWidth'; - - if (offsetParent === getWindow(popper)) { - offsetParent = getDocumentElement(popper); - - if (getComputedStyle$1(offsetParent).position !== 'static' && position === 'absolute') { - heightProp = 'scrollHeight'; - widthProp = 'scrollWidth'; - } - } // $FlowFixMe[incompatible-cast]: force type refinement, we compare offsetParent with window above, but Flow doesn't detect it - - - offsetParent = offsetParent; - - if (placement === top || (placement === left || placement === right) && variation === end) { - sideY = bottom; - var offsetY = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.height : // $FlowFixMe[prop-missing] - offsetParent[heightProp]; - y -= offsetY - popperRect.height; - y *= gpuAcceleration ? 1 : -1; - } - - if (placement === left || (placement === top || placement === bottom) && variation === end) { - sideX = right; - var offsetX = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.width : // $FlowFixMe[prop-missing] - offsetParent[widthProp]; - x -= offsetX - popperRect.width; - x *= gpuAcceleration ? 1 : -1; - } - } - - var commonStyles = Object.assign({ - position: position - }, adaptive && unsetSides); - - var _ref4 = roundOffsets === true ? roundOffsetsByDPR({ - x: x, - y: y - }, getWindow(popper)) : { - x: x, - y: y - }; - - x = _ref4.x; - y = _ref4.y; - - if (gpuAcceleration) { - var _Object$assign; - - return Object.assign({}, commonStyles, (_Object$assign = {}, _Object$assign[sideY] = hasY ? '0' : '', _Object$assign[sideX] = hasX ? '0' : '', _Object$assign.transform = (win.devicePixelRatio || 1) <= 1 ? "translate(" + x + "px, " + y + "px)" : "translate3d(" + x + "px, " + y + "px, 0)", _Object$assign)); - } - - return Object.assign({}, commonStyles, (_Object$assign2 = {}, _Object$assign2[sideY] = hasY ? y + "px" : '', _Object$assign2[sideX] = hasX ? x + "px" : '', _Object$assign2.transform = '', _Object$assign2)); - } - - function computeStyles(_ref5) { - var state = _ref5.state, - options = _ref5.options; - var _options$gpuAccelerat = options.gpuAcceleration, - gpuAcceleration = _options$gpuAccelerat === void 0 ? true : _options$gpuAccelerat, - _options$adaptive = options.adaptive, - adaptive = _options$adaptive === void 0 ? true : _options$adaptive, - _options$roundOffsets = options.roundOffsets, - roundOffsets = _options$roundOffsets === void 0 ? true : _options$roundOffsets; - var commonStyles = { - placement: getBasePlacement(state.placement), - variation: getVariation(state.placement), - popper: state.elements.popper, - popperRect: state.rects.popper, - gpuAcceleration: gpuAcceleration, - isFixed: state.options.strategy === 'fixed' - }; - - if (state.modifiersData.popperOffsets != null) { - state.styles.popper = Object.assign({}, state.styles.popper, mapToStyles(Object.assign({}, commonStyles, { - offsets: state.modifiersData.popperOffsets, - position: state.options.strategy, - adaptive: adaptive, - roundOffsets: roundOffsets - }))); - } - - if (state.modifiersData.arrow != null) { - state.styles.arrow = Object.assign({}, state.styles.arrow, mapToStyles(Object.assign({}, commonStyles, { - offsets: state.modifiersData.arrow, - position: 'absolute', - adaptive: false, - roundOffsets: roundOffsets - }))); - } - - state.attributes.popper = Object.assign({}, state.attributes.popper, { - 'data-popper-placement': state.placement - }); - } // eslint-disable-next-line import/no-unused-modules - - - const computeStyles$1 = { - name: 'computeStyles', - enabled: true, - phase: 'beforeWrite', - fn: computeStyles, - data: {} - }; - - var passive = { - passive: true - }; - - function effect(_ref) { - var state = _ref.state, - instance = _ref.instance, - options = _ref.options; - var _options$scroll = options.scroll, - scroll = _options$scroll === void 0 ? true : _options$scroll, - _options$resize = options.resize, - resize = _options$resize === void 0 ? true : _options$resize; - var window = getWindow(state.elements.popper); - var scrollParents = [].concat(state.scrollParents.reference, state.scrollParents.popper); - - if (scroll) { - scrollParents.forEach(function (scrollParent) { - scrollParent.addEventListener('scroll', instance.update, passive); - }); - } - - if (resize) { - window.addEventListener('resize', instance.update, passive); - } - - return function () { - if (scroll) { - scrollParents.forEach(function (scrollParent) { - scrollParent.removeEventListener('scroll', instance.update, passive); - }); - } - - if (resize) { - window.removeEventListener('resize', instance.update, passive); - } - }; - } // eslint-disable-next-line import/no-unused-modules - - - const eventListeners = { - name: 'eventListeners', - enabled: true, - phase: 'write', - fn: function fn() {}, - effect: effect, - data: {} - }; - - var hash$1 = { - left: 'right', - right: 'left', - bottom: 'top', - top: 'bottom' - }; - function getOppositePlacement(placement) { - return placement.replace(/left|right|bottom|top/g, function (matched) { - return hash$1[matched]; - }); - } - - var hash = { - start: 'end', - end: 'start' - }; - function getOppositeVariationPlacement(placement) { - return placement.replace(/start|end/g, function (matched) { - return hash[matched]; - }); - } - - function getWindowScroll(node) { - var win = getWindow(node); - var scrollLeft = win.pageXOffset; - var scrollTop = win.pageYOffset; - return { - scrollLeft: scrollLeft, - scrollTop: scrollTop - }; - } - - function getWindowScrollBarX(element) { - // If has a CSS width greater than the viewport, then this will be - // incorrect for RTL. - // Popper 1 is broken in this case and never had a bug report so let's assume - // it's not an issue. I don't think anyone ever specifies width on - // anyway. - // Browsers where the left scrollbar doesn't cause an issue report `0` for - // this (e.g. Edge 2019, IE11, Safari) - return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft; - } - - function getViewportRect(element, strategy) { - var win = getWindow(element); - var html = getDocumentElement(element); - var visualViewport = win.visualViewport; - var width = html.clientWidth; - var height = html.clientHeight; - var x = 0; - var y = 0; - - if (visualViewport) { - width = visualViewport.width; - height = visualViewport.height; - var layoutViewport = isLayoutViewport(); - - if (layoutViewport || !layoutViewport && strategy === 'fixed') { - x = visualViewport.offsetLeft; - y = visualViewport.offsetTop; - } - } - - return { - width: width, - height: height, - x: x + getWindowScrollBarX(element), - y: y - }; - } - - // of the `` and `` rect bounds if horizontally scrollable - - function getDocumentRect(element) { - var _element$ownerDocumen; - - var html = getDocumentElement(element); - var winScroll = getWindowScroll(element); - var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body; - var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0); - var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0); - var x = -winScroll.scrollLeft + getWindowScrollBarX(element); - var y = -winScroll.scrollTop; - - if (getComputedStyle$1(body || html).direction === 'rtl') { - x += max(html.clientWidth, body ? body.clientWidth : 0) - width; - } - - return { - width: width, - height: height, - x: x, - y: y - }; - } - - function isScrollParent(element) { - // Firefox wants us to check `-x` and `-y` variations as well - var _getComputedStyle = getComputedStyle$1(element), - overflow = _getComputedStyle.overflow, - overflowX = _getComputedStyle.overflowX, - overflowY = _getComputedStyle.overflowY; - - return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX); - } - - function getScrollParent(node) { - if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) { - // $FlowFixMe[incompatible-return]: assume body is always available - return node.ownerDocument.body; - } - - if (isHTMLElement(node) && isScrollParent(node)) { - return node; - } - - return getScrollParent(getParentNode(node)); - } - - /* - given a DOM element, return the list of all scroll parents, up the list of ancesors - until we get to the top window object. This list is what we attach scroll listeners - to, because if any of these parent elements scroll, we'll need to re-calculate the - reference element's position. - */ - - function listScrollParents(element, list) { - var _element$ownerDocumen; - - if (list === void 0) { - list = []; - } - - var scrollParent = getScrollParent(element); - var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body); - var win = getWindow(scrollParent); - var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent; - var updatedList = list.concat(target); - return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here - updatedList.concat(listScrollParents(getParentNode(target))); - } - - function rectToClientRect(rect) { - return Object.assign({}, rect, { - left: rect.x, - top: rect.y, - right: rect.x + rect.width, - bottom: rect.y + rect.height - }); - } - - function getInnerBoundingClientRect(element, strategy) { - var rect = getBoundingClientRect(element, false, strategy === 'fixed'); - rect.top = rect.top + element.clientTop; - rect.left = rect.left + element.clientLeft; - rect.bottom = rect.top + element.clientHeight; - rect.right = rect.left + element.clientWidth; - rect.width = element.clientWidth; - rect.height = element.clientHeight; - rect.x = rect.left; - rect.y = rect.top; - return rect; - } - - function getClientRectFromMixedType(element, clippingParent, strategy) { - return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element))); - } // A "clipping parent" is an overflowable container with the characteristic of - // clipping (or hiding) overflowing elements with a position different from - // `initial` - - - function getClippingParents(element) { - var clippingParents = listScrollParents(getParentNode(element)); - var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle$1(element).position) >= 0; - var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element; - - if (!isElement(clipperElement)) { - return []; - } // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414 - - - return clippingParents.filter(function (clippingParent) { - return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body'; - }); - } // Gets the maximum area that the element is visible in due to any number of - // clipping parents - - - function getClippingRect(element, boundary, rootBoundary, strategy) { - var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary); - var clippingParents = [].concat(mainClippingParents, [rootBoundary]); - var firstClippingParent = clippingParents[0]; - var clippingRect = clippingParents.reduce(function (accRect, clippingParent) { - var rect = getClientRectFromMixedType(element, clippingParent, strategy); - accRect.top = max(rect.top, accRect.top); - accRect.right = min(rect.right, accRect.right); - accRect.bottom = min(rect.bottom, accRect.bottom); - accRect.left = max(rect.left, accRect.left); - return accRect; - }, getClientRectFromMixedType(element, firstClippingParent, strategy)); - clippingRect.width = clippingRect.right - clippingRect.left; - clippingRect.height = clippingRect.bottom - clippingRect.top; - clippingRect.x = clippingRect.left; - clippingRect.y = clippingRect.top; - return clippingRect; - } - - function computeOffsets(_ref) { - var reference = _ref.reference, - element = _ref.element, - placement = _ref.placement; - var basePlacement = placement ? getBasePlacement(placement) : null; - var variation = placement ? getVariation(placement) : null; - var commonX = reference.x + reference.width / 2 - element.width / 2; - var commonY = reference.y + reference.height / 2 - element.height / 2; - var offsets; - - switch (basePlacement) { - case top: - offsets = { - x: commonX, - y: reference.y - element.height - }; - break; - - case bottom: - offsets = { - x: commonX, - y: reference.y + reference.height - }; - break; - - case right: - offsets = { - x: reference.x + reference.width, - y: commonY - }; - break; - - case left: - offsets = { - x: reference.x - element.width, - y: commonY - }; - break; - - default: - offsets = { - x: reference.x, - y: reference.y - }; - } - - var mainAxis = basePlacement ? getMainAxisFromPlacement(basePlacement) : null; - - if (mainAxis != null) { - var len = mainAxis === 'y' ? 'height' : 'width'; - - switch (variation) { - case start: - offsets[mainAxis] = offsets[mainAxis] - (reference[len] / 2 - element[len] / 2); - break; - - case end: - offsets[mainAxis] = offsets[mainAxis] + (reference[len] / 2 - element[len] / 2); - break; - } - } - - return offsets; - } - - function detectOverflow(state, options) { - if (options === void 0) { - options = {}; - } - - var _options = options, - _options$placement = _options.placement, - placement = _options$placement === void 0 ? state.placement : _options$placement, - _options$strategy = _options.strategy, - strategy = _options$strategy === void 0 ? state.strategy : _options$strategy, - _options$boundary = _options.boundary, - boundary = _options$boundary === void 0 ? clippingParents : _options$boundary, - _options$rootBoundary = _options.rootBoundary, - rootBoundary = _options$rootBoundary === void 0 ? viewport : _options$rootBoundary, - _options$elementConte = _options.elementContext, - elementContext = _options$elementConte === void 0 ? popper : _options$elementConte, - _options$altBoundary = _options.altBoundary, - altBoundary = _options$altBoundary === void 0 ? false : _options$altBoundary, - _options$padding = _options.padding, - padding = _options$padding === void 0 ? 0 : _options$padding; - var paddingObject = mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); - var altContext = elementContext === popper ? reference : popper; - var popperRect = state.rects.popper; - var element = state.elements[altBoundary ? altContext : elementContext]; - var clippingClientRect = getClippingRect(isElement(element) ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, rootBoundary, strategy); - var referenceClientRect = getBoundingClientRect(state.elements.reference); - var popperOffsets = computeOffsets({ - reference: referenceClientRect, - element: popperRect, - strategy: 'absolute', - placement: placement - }); - var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets)); - var elementClientRect = elementContext === popper ? popperClientRect : referenceClientRect; // positive = overflowing the clipping rect - // 0 or negative = within the clipping rect - - var overflowOffsets = { - top: clippingClientRect.top - elementClientRect.top + paddingObject.top, - bottom: elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom, - left: clippingClientRect.left - elementClientRect.left + paddingObject.left, - right: elementClientRect.right - clippingClientRect.right + paddingObject.right - }; - var offsetData = state.modifiersData.offset; // Offsets can be applied only to the popper element - - if (elementContext === popper && offsetData) { - var offset = offsetData[placement]; - Object.keys(overflowOffsets).forEach(function (key) { - var multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1; - var axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x'; - overflowOffsets[key] += offset[axis] * multiply; - }); - } - - return overflowOffsets; - } - - function computeAutoPlacement(state, options) { - if (options === void 0) { - options = {}; - } - - var _options = options, - placement = _options.placement, - boundary = _options.boundary, - rootBoundary = _options.rootBoundary, - padding = _options.padding, - flipVariations = _options.flipVariations, - _options$allowedAutoP = _options.allowedAutoPlacements, - allowedAutoPlacements = _options$allowedAutoP === void 0 ? placements : _options$allowedAutoP; - var variation = getVariation(placement); - var placements$1 = variation ? flipVariations ? variationPlacements : variationPlacements.filter(function (placement) { - return getVariation(placement) === variation; - }) : basePlacements; - var allowedPlacements = placements$1.filter(function (placement) { - return allowedAutoPlacements.indexOf(placement) >= 0; - }); - - if (allowedPlacements.length === 0) { - allowedPlacements = placements$1; - } // $FlowFixMe[incompatible-type]: Flow seems to have problems with two array unions... - - - var overflows = allowedPlacements.reduce(function (acc, placement) { - acc[placement] = detectOverflow(state, { - placement: placement, - boundary: boundary, - rootBoundary: rootBoundary, - padding: padding - })[getBasePlacement(placement)]; - return acc; - }, {}); - return Object.keys(overflows).sort(function (a, b) { - return overflows[a] - overflows[b]; - }); - } - - function getExpandedFallbackPlacements(placement) { - if (getBasePlacement(placement) === auto) { - return []; - } - - var oppositePlacement = getOppositePlacement(placement); - return [getOppositeVariationPlacement(placement), oppositePlacement, getOppositeVariationPlacement(oppositePlacement)]; - } - - function flip(_ref) { - var state = _ref.state, - options = _ref.options, - name = _ref.name; - - if (state.modifiersData[name]._skip) { - return; - } - - var _options$mainAxis = options.mainAxis, - checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, - _options$altAxis = options.altAxis, - checkAltAxis = _options$altAxis === void 0 ? true : _options$altAxis, - specifiedFallbackPlacements = options.fallbackPlacements, - padding = options.padding, - boundary = options.boundary, - rootBoundary = options.rootBoundary, - altBoundary = options.altBoundary, - _options$flipVariatio = options.flipVariations, - flipVariations = _options$flipVariatio === void 0 ? true : _options$flipVariatio, - allowedAutoPlacements = options.allowedAutoPlacements; - var preferredPlacement = state.options.placement; - var basePlacement = getBasePlacement(preferredPlacement); - var isBasePlacement = basePlacement === preferredPlacement; - var fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipVariations ? [getOppositePlacement(preferredPlacement)] : getExpandedFallbackPlacements(preferredPlacement)); - var placements = [preferredPlacement].concat(fallbackPlacements).reduce(function (acc, placement) { - return acc.concat(getBasePlacement(placement) === auto ? computeAutoPlacement(state, { - placement: placement, - boundary: boundary, - rootBoundary: rootBoundary, - padding: padding, - flipVariations: flipVariations, - allowedAutoPlacements: allowedAutoPlacements - }) : placement); - }, []); - var referenceRect = state.rects.reference; - var popperRect = state.rects.popper; - var checksMap = new Map(); - var makeFallbackChecks = true; - var firstFittingPlacement = placements[0]; - - for (var i = 0; i < placements.length; i++) { - var placement = placements[i]; - - var _basePlacement = getBasePlacement(placement); - - var isStartVariation = getVariation(placement) === start; - var isVertical = [top, bottom].indexOf(_basePlacement) >= 0; - var len = isVertical ? 'width' : 'height'; - var overflow = detectOverflow(state, { - placement: placement, - boundary: boundary, - rootBoundary: rootBoundary, - altBoundary: altBoundary, - padding: padding - }); - var mainVariationSide = isVertical ? isStartVariation ? right : left : isStartVariation ? bottom : top; - - if (referenceRect[len] > popperRect[len]) { - mainVariationSide = getOppositePlacement(mainVariationSide); - } - - var altVariationSide = getOppositePlacement(mainVariationSide); - var checks = []; - - if (checkMainAxis) { - checks.push(overflow[_basePlacement] <= 0); - } - - if (checkAltAxis) { - checks.push(overflow[mainVariationSide] <= 0, overflow[altVariationSide] <= 0); - } - - if (checks.every(function (check) { - return check; - })) { - firstFittingPlacement = placement; - makeFallbackChecks = false; - break; - } - - checksMap.set(placement, checks); - } - - if (makeFallbackChecks) { - // `2` may be desired in some cases – research later - var numberOfChecks = flipVariations ? 3 : 1; - - var _loop = function _loop(_i) { - var fittingPlacement = placements.find(function (placement) { - var checks = checksMap.get(placement); - - if (checks) { - return checks.slice(0, _i).every(function (check) { - return check; - }); - } - }); - - if (fittingPlacement) { - firstFittingPlacement = fittingPlacement; - return "break"; - } - }; - - for (var _i = numberOfChecks; _i > 0; _i--) { - var _ret = _loop(_i); - - if (_ret === "break") break; - } - } - - if (state.placement !== firstFittingPlacement) { - state.modifiersData[name]._skip = true; - state.placement = firstFittingPlacement; - state.reset = true; - } - } // eslint-disable-next-line import/no-unused-modules - - - const flip$1 = { - name: 'flip', - enabled: true, - phase: 'main', - fn: flip, - requiresIfExists: ['offset'], - data: { - _skip: false - } - }; - - function getSideOffsets(overflow, rect, preventedOffsets) { - if (preventedOffsets === void 0) { - preventedOffsets = { - x: 0, - y: 0 - }; - } - - return { - top: overflow.top - rect.height - preventedOffsets.y, - right: overflow.right - rect.width + preventedOffsets.x, - bottom: overflow.bottom - rect.height + preventedOffsets.y, - left: overflow.left - rect.width - preventedOffsets.x - }; - } - - function isAnySideFullyClipped(overflow) { - return [top, right, bottom, left].some(function (side) { - return overflow[side] >= 0; - }); - } - - function hide(_ref) { - var state = _ref.state, - name = _ref.name; - var referenceRect = state.rects.reference; - var popperRect = state.rects.popper; - var preventedOffsets = state.modifiersData.preventOverflow; - var referenceOverflow = detectOverflow(state, { - elementContext: 'reference' - }); - var popperAltOverflow = detectOverflow(state, { - altBoundary: true - }); - var referenceClippingOffsets = getSideOffsets(referenceOverflow, referenceRect); - var popperEscapeOffsets = getSideOffsets(popperAltOverflow, popperRect, preventedOffsets); - var isReferenceHidden = isAnySideFullyClipped(referenceClippingOffsets); - var hasPopperEscaped = isAnySideFullyClipped(popperEscapeOffsets); - state.modifiersData[name] = { - referenceClippingOffsets: referenceClippingOffsets, - popperEscapeOffsets: popperEscapeOffsets, - isReferenceHidden: isReferenceHidden, - hasPopperEscaped: hasPopperEscaped - }; - state.attributes.popper = Object.assign({}, state.attributes.popper, { - 'data-popper-reference-hidden': isReferenceHidden, - 'data-popper-escaped': hasPopperEscaped - }); - } // eslint-disable-next-line import/no-unused-modules - - - const hide$1 = { - name: 'hide', - enabled: true, - phase: 'main', - requiresIfExists: ['preventOverflow'], - fn: hide - }; - - function distanceAndSkiddingToXY(placement, rects, offset) { - var basePlacement = getBasePlacement(placement); - var invertDistance = [left, top].indexOf(basePlacement) >= 0 ? -1 : 1; - - var _ref = typeof offset === 'function' ? offset(Object.assign({}, rects, { - placement: placement - })) : offset, - skidding = _ref[0], - distance = _ref[1]; - - skidding = skidding || 0; - distance = (distance || 0) * invertDistance; - return [left, right].indexOf(basePlacement) >= 0 ? { - x: distance, - y: skidding - } : { - x: skidding, - y: distance - }; - } - - function offset(_ref2) { - var state = _ref2.state, - options = _ref2.options, - name = _ref2.name; - var _options$offset = options.offset, - offset = _options$offset === void 0 ? [0, 0] : _options$offset; - var data = placements.reduce(function (acc, placement) { - acc[placement] = distanceAndSkiddingToXY(placement, state.rects, offset); - return acc; - }, {}); - var _data$state$placement = data[state.placement], - x = _data$state$placement.x, - y = _data$state$placement.y; - - if (state.modifiersData.popperOffsets != null) { - state.modifiersData.popperOffsets.x += x; - state.modifiersData.popperOffsets.y += y; - } - - state.modifiersData[name] = data; - } // eslint-disable-next-line import/no-unused-modules - - - const offset$1 = { - name: 'offset', - enabled: true, - phase: 'main', - requires: ['popperOffsets'], - fn: offset - }; - - function popperOffsets(_ref) { - var state = _ref.state, - name = _ref.name; - // Offsets are the actual position the popper needs to have to be - // properly positioned near its reference element - // This is the most basic placement, and will be adjusted by - // the modifiers in the next step - state.modifiersData[name] = computeOffsets({ - reference: state.rects.reference, - element: state.rects.popper, - strategy: 'absolute', - placement: state.placement - }); - } // eslint-disable-next-line import/no-unused-modules - - - const popperOffsets$1 = { - name: 'popperOffsets', - enabled: true, - phase: 'read', - fn: popperOffsets, - data: {} - }; - - function getAltAxis(axis) { - return axis === 'x' ? 'y' : 'x'; - } - - function preventOverflow(_ref) { - var state = _ref.state, - options = _ref.options, - name = _ref.name; - var _options$mainAxis = options.mainAxis, - checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, - _options$altAxis = options.altAxis, - checkAltAxis = _options$altAxis === void 0 ? false : _options$altAxis, - boundary = options.boundary, - rootBoundary = options.rootBoundary, - altBoundary = options.altBoundary, - padding = options.padding, - _options$tether = options.tether, - tether = _options$tether === void 0 ? true : _options$tether, - _options$tetherOffset = options.tetherOffset, - tetherOffset = _options$tetherOffset === void 0 ? 0 : _options$tetherOffset; - var overflow = detectOverflow(state, { - boundary: boundary, - rootBoundary: rootBoundary, - padding: padding, - altBoundary: altBoundary - }); - var basePlacement = getBasePlacement(state.placement); - var variation = getVariation(state.placement); - var isBasePlacement = !variation; - var mainAxis = getMainAxisFromPlacement(basePlacement); - var altAxis = getAltAxis(mainAxis); - var popperOffsets = state.modifiersData.popperOffsets; - var referenceRect = state.rects.reference; - var popperRect = state.rects.popper; - var tetherOffsetValue = typeof tetherOffset === 'function' ? tetherOffset(Object.assign({}, state.rects, { - placement: state.placement - })) : tetherOffset; - var normalizedTetherOffsetValue = typeof tetherOffsetValue === 'number' ? { - mainAxis: tetherOffsetValue, - altAxis: tetherOffsetValue - } : Object.assign({ - mainAxis: 0, - altAxis: 0 - }, tetherOffsetValue); - var offsetModifierState = state.modifiersData.offset ? state.modifiersData.offset[state.placement] : null; - var data = { - x: 0, - y: 0 - }; - - if (!popperOffsets) { - return; - } - - if (checkMainAxis) { - var _offsetModifierState$; - - var mainSide = mainAxis === 'y' ? top : left; - var altSide = mainAxis === 'y' ? bottom : right; - var len = mainAxis === 'y' ? 'height' : 'width'; - var offset = popperOffsets[mainAxis]; - var min$1 = offset + overflow[mainSide]; - var max$1 = offset - overflow[altSide]; - var additive = tether ? -popperRect[len] / 2 : 0; - var minLen = variation === start ? referenceRect[len] : popperRect[len]; - var maxLen = variation === start ? -popperRect[len] : -referenceRect[len]; // We need to include the arrow in the calculation so the arrow doesn't go - // outside the reference bounds - - var arrowElement = state.elements.arrow; - var arrowRect = tether && arrowElement ? getLayoutRect(arrowElement) : { - width: 0, - height: 0 - }; - var arrowPaddingObject = state.modifiersData['arrow#persistent'] ? state.modifiersData['arrow#persistent'].padding : getFreshSideObject(); - var arrowPaddingMin = arrowPaddingObject[mainSide]; - var arrowPaddingMax = arrowPaddingObject[altSide]; // If the reference length is smaller than the arrow length, we don't want - // to include its full size in the calculation. If the reference is small - // and near the edge of a boundary, the popper can overflow even if the - // reference is not overflowing as well (e.g. virtual elements with no - // width or height) - - var arrowLen = within(0, referenceRect[len], arrowRect[len]); - var minOffset = isBasePlacement ? referenceRect[len] / 2 - additive - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis : minLen - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis; - var maxOffset = isBasePlacement ? -referenceRect[len] / 2 + additive + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis : maxLen + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis; - var arrowOffsetParent = state.elements.arrow && getOffsetParent(state.elements.arrow); - var clientOffset = arrowOffsetParent ? mainAxis === 'y' ? arrowOffsetParent.clientTop || 0 : arrowOffsetParent.clientLeft || 0 : 0; - var offsetModifierValue = (_offsetModifierState$ = offsetModifierState == null ? void 0 : offsetModifierState[mainAxis]) != null ? _offsetModifierState$ : 0; - var tetherMin = offset + minOffset - offsetModifierValue - clientOffset; - var tetherMax = offset + maxOffset - offsetModifierValue; - var preventedOffset = within(tether ? min(min$1, tetherMin) : min$1, offset, tether ? max(max$1, tetherMax) : max$1); - popperOffsets[mainAxis] = preventedOffset; - data[mainAxis] = preventedOffset - offset; - } - - if (checkAltAxis) { - var _offsetModifierState$2; - - var _mainSide = mainAxis === 'x' ? top : left; - - var _altSide = mainAxis === 'x' ? bottom : right; - - var _offset = popperOffsets[altAxis]; - - var _len = altAxis === 'y' ? 'height' : 'width'; - - var _min = _offset + overflow[_mainSide]; - - var _max = _offset - overflow[_altSide]; - - var isOriginSide = [top, left].indexOf(basePlacement) !== -1; - - var _offsetModifierValue = (_offsetModifierState$2 = offsetModifierState == null ? void 0 : offsetModifierState[altAxis]) != null ? _offsetModifierState$2 : 0; - - var _tetherMin = isOriginSide ? _min : _offset - referenceRect[_len] - popperRect[_len] - _offsetModifierValue + normalizedTetherOffsetValue.altAxis; - - var _tetherMax = isOriginSide ? _offset + referenceRect[_len] + popperRect[_len] - _offsetModifierValue - normalizedTetherOffsetValue.altAxis : _max; - - var _preventedOffset = tether && isOriginSide ? withinMaxClamp(_tetherMin, _offset, _tetherMax) : within(tether ? _tetherMin : _min, _offset, tether ? _tetherMax : _max); - - popperOffsets[altAxis] = _preventedOffset; - data[altAxis] = _preventedOffset - _offset; - } - - state.modifiersData[name] = data; - } // eslint-disable-next-line import/no-unused-modules - - - const preventOverflow$1 = { - name: 'preventOverflow', - enabled: true, - phase: 'main', - fn: preventOverflow, - requiresIfExists: ['offset'] - }; - - function getHTMLElementScroll(element) { - return { - scrollLeft: element.scrollLeft, - scrollTop: element.scrollTop - }; - } - - function getNodeScroll(node) { - if (node === getWindow(node) || !isHTMLElement(node)) { - return getWindowScroll(node); - } else { - return getHTMLElementScroll(node); - } - } - - function isElementScaled(element) { - var rect = element.getBoundingClientRect(); - var scaleX = round(rect.width) / element.offsetWidth || 1; - var scaleY = round(rect.height) / element.offsetHeight || 1; - return scaleX !== 1 || scaleY !== 1; - } // Returns the composite rect of an element relative to its offsetParent. - // Composite means it takes into account transforms as well as layout. - - - function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) { - if (isFixed === void 0) { - isFixed = false; - } - - var isOffsetParentAnElement = isHTMLElement(offsetParent); - var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent); - var documentElement = getDocumentElement(offsetParent); - var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed); - var scroll = { - scrollLeft: 0, - scrollTop: 0 - }; - var offsets = { - x: 0, - y: 0 - }; - - if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { - if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078 - isScrollParent(documentElement)) { - scroll = getNodeScroll(offsetParent); - } - - if (isHTMLElement(offsetParent)) { - offsets = getBoundingClientRect(offsetParent, true); - offsets.x += offsetParent.clientLeft; - offsets.y += offsetParent.clientTop; - } else if (documentElement) { - offsets.x = getWindowScrollBarX(documentElement); - } - } - - return { - x: rect.left + scroll.scrollLeft - offsets.x, - y: rect.top + scroll.scrollTop - offsets.y, - width: rect.width, - height: rect.height - }; - } - - function order(modifiers) { - var map = new Map(); - var visited = new Set(); - var result = []; - modifiers.forEach(function (modifier) { - map.set(modifier.name, modifier); - }); // On visiting object, check for its dependencies and visit them recursively - - function sort(modifier) { - visited.add(modifier.name); - var requires = [].concat(modifier.requires || [], modifier.requiresIfExists || []); - requires.forEach(function (dep) { - if (!visited.has(dep)) { - var depModifier = map.get(dep); - - if (depModifier) { - sort(depModifier); - } - } - }); - result.push(modifier); - } - - modifiers.forEach(function (modifier) { - if (!visited.has(modifier.name)) { - // check for visited object - sort(modifier); - } - }); - return result; - } - - function orderModifiers(modifiers) { - // order based on dependencies - var orderedModifiers = order(modifiers); // order based on phase - - return modifierPhases.reduce(function (acc, phase) { - return acc.concat(orderedModifiers.filter(function (modifier) { - return modifier.phase === phase; - })); - }, []); - } - - function debounce(fn) { - var pending; - return function () { - if (!pending) { - pending = new Promise(function (resolve) { - Promise.resolve().then(function () { - pending = undefined; - resolve(fn()); - }); - }); - } - - return pending; - }; - } - - function mergeByName(modifiers) { - var merged = modifiers.reduce(function (merged, current) { - var existing = merged[current.name]; - merged[current.name] = existing ? Object.assign({}, existing, current, { - options: Object.assign({}, existing.options, current.options), - data: Object.assign({}, existing.data, current.data) - }) : current; - return merged; - }, {}); // IE11 does not support Object.values - - return Object.keys(merged).map(function (key) { - return merged[key]; - }); - } - - var DEFAULT_OPTIONS = { - placement: 'bottom', - modifiers: [], - strategy: 'absolute' - }; - - function areValidElements() { - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - - return !args.some(function (element) { - return !(element && typeof element.getBoundingClientRect === 'function'); - }); - } - - function popperGenerator(generatorOptions) { - if (generatorOptions === void 0) { - generatorOptions = {}; - } - - var _generatorOptions = generatorOptions, - _generatorOptions$def = _generatorOptions.defaultModifiers, - defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def, - _generatorOptions$def2 = _generatorOptions.defaultOptions, - defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2; - return function createPopper(reference, popper, options) { - if (options === void 0) { - options = defaultOptions; - } - - var state = { - placement: 'bottom', - orderedModifiers: [], - options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions), - modifiersData: {}, - elements: { - reference: reference, - popper: popper - }, - attributes: {}, - styles: {} - }; - var effectCleanupFns = []; - var isDestroyed = false; - var instance = { - state: state, - setOptions: function setOptions(setOptionsAction) { - var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction; - cleanupModifierEffects(); - state.options = Object.assign({}, defaultOptions, state.options, options); - state.scrollParents = { - reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [], - popper: listScrollParents(popper) - }; // Orders the modifiers based on their dependencies and `phase` - // properties - - var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers - - state.orderedModifiers = orderedModifiers.filter(function (m) { - return m.enabled; - }); - runModifierEffects(); - return instance.update(); - }, - // Sync update – it will always be executed, even if not necessary. This - // is useful for low frequency updates where sync behavior simplifies the - // logic. - // For high frequency updates (e.g. `resize` and `scroll` events), always - // prefer the async Popper#update method - forceUpdate: function forceUpdate() { - if (isDestroyed) { - return; - } - - var _state$elements = state.elements, - reference = _state$elements.reference, - popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements - // anymore - - if (!areValidElements(reference, popper)) { - return; - } // Store the reference and popper rects to be read by modifiers - - - state.rects = { - reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'), - popper: getLayoutRect(popper) - }; // Modifiers have the ability to reset the current update cycle. The - // most common use case for this is the `flip` modifier changing the - // placement, which then needs to re-run all the modifiers, because the - // logic was previously ran for the previous placement and is therefore - // stale/incorrect - - state.reset = false; - state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier - // is filled with the initial data specified by the modifier. This means - // it doesn't persist and is fresh on each update. - // To ensure persistent data, use `${name}#persistent` - - state.orderedModifiers.forEach(function (modifier) { - return state.modifiersData[modifier.name] = Object.assign({}, modifier.data); - }); - - for (var index = 0; index < state.orderedModifiers.length; index++) { - if (state.reset === true) { - state.reset = false; - index = -1; - continue; - } - - var _state$orderedModifie = state.orderedModifiers[index], - fn = _state$orderedModifie.fn, - _state$orderedModifie2 = _state$orderedModifie.options, - _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2, - name = _state$orderedModifie.name; - - if (typeof fn === 'function') { - state = fn({ - state: state, - options: _options, - name: name, - instance: instance - }) || state; - } - } - }, - // Async and optimistically optimized update – it will not be executed if - // not necessary (debounced to run at most once-per-tick) - update: debounce(function () { - return new Promise(function (resolve) { - instance.forceUpdate(); - resolve(state); - }); - }), - destroy: function destroy() { - cleanupModifierEffects(); - isDestroyed = true; - } - }; - - if (!areValidElements(reference, popper)) { - return instance; - } - - instance.setOptions(options).then(function (state) { - if (!isDestroyed && options.onFirstUpdate) { - options.onFirstUpdate(state); - } - }); // Modifiers have the ability to execute arbitrary code before the first - // update cycle runs. They will be executed in the same order as the update - // cycle. This is useful when a modifier adds some persistent data that - // other modifiers need to use, but the modifier is run after the dependent - // one. - - function runModifierEffects() { - state.orderedModifiers.forEach(function (_ref) { - var name = _ref.name, - _ref$options = _ref.options, - options = _ref$options === void 0 ? {} : _ref$options, - effect = _ref.effect; - - if (typeof effect === 'function') { - var cleanupFn = effect({ - state: state, - name: name, - instance: instance, - options: options - }); - - var noopFn = function noopFn() {}; - - effectCleanupFns.push(cleanupFn || noopFn); - } - }); - } - - function cleanupModifierEffects() { - effectCleanupFns.forEach(function (fn) { - return fn(); - }); - effectCleanupFns = []; - } - - return instance; - }; - } - var createPopper$2 = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules - - var defaultModifiers$1 = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1]; - var createPopper$1 = /*#__PURE__*/popperGenerator({ - defaultModifiers: defaultModifiers$1 - }); // eslint-disable-next-line import/no-unused-modules - - var defaultModifiers = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1, offset$1, flip$1, preventOverflow$1, arrow$1, hide$1]; - var createPopper = /*#__PURE__*/popperGenerator({ - defaultModifiers: defaultModifiers - }); // eslint-disable-next-line import/no-unused-modules - - const Popper = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({ - __proto__: null, - afterMain, - afterRead, - afterWrite, - applyStyles: applyStyles$1, - arrow: arrow$1, - auto, - basePlacements, - beforeMain, - beforeRead, - beforeWrite, - bottom, - clippingParents, - computeStyles: computeStyles$1, - createPopper, - createPopperBase: createPopper$2, - createPopperLite: createPopper$1, - detectOverflow, - end, - eventListeners, - flip: flip$1, - hide: hide$1, - left, - main, - modifierPhases, - offset: offset$1, - placements, - popper, - popperGenerator, - popperOffsets: popperOffsets$1, - preventOverflow: preventOverflow$1, - read, - reference, - right, - start, - top, - variationPlacements, - viewport, - write - }, Symbol.toStringTag, { value: 'Module' })); - - /** - * -------------------------------------------------------------------------- - * Bootstrap dropdown.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$a = 'dropdown'; - const DATA_KEY$6 = 'bs.dropdown'; - const EVENT_KEY$6 = `.${DATA_KEY$6}`; - const DATA_API_KEY$3 = '.data-api'; - const ESCAPE_KEY$2 = 'Escape'; - const TAB_KEY$1 = 'Tab'; - const ARROW_UP_KEY$1 = 'ArrowUp'; - const ARROW_DOWN_KEY$1 = 'ArrowDown'; - const RIGHT_MOUSE_BUTTON = 2; // MouseEvent.button value for the secondary button, usually the right button - - const EVENT_HIDE$5 = `hide${EVENT_KEY$6}`; - const EVENT_HIDDEN$5 = `hidden${EVENT_KEY$6}`; - const EVENT_SHOW$5 = `show${EVENT_KEY$6}`; - const EVENT_SHOWN$5 = `shown${EVENT_KEY$6}`; - const EVENT_CLICK_DATA_API$3 = `click${EVENT_KEY$6}${DATA_API_KEY$3}`; - const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY$6}${DATA_API_KEY$3}`; - const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY$6}${DATA_API_KEY$3}`; - const CLASS_NAME_SHOW$6 = 'show'; - const CLASS_NAME_DROPUP = 'dropup'; - const CLASS_NAME_DROPEND = 'dropend'; - const CLASS_NAME_DROPSTART = 'dropstart'; - const CLASS_NAME_DROPUP_CENTER = 'dropup-center'; - const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'; - const SELECTOR_DATA_TOGGLE$3 = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)'; - const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE$3}.${CLASS_NAME_SHOW$6}`; - const SELECTOR_MENU = '.dropdown-menu'; - const SELECTOR_NAVBAR = '.navbar'; - const SELECTOR_NAVBAR_NAV = '.navbar-nav'; - const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'; - const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'; - const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'; - const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'; - const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'; - const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'; - const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'; - const PLACEMENT_TOPCENTER = 'top'; - const PLACEMENT_BOTTOMCENTER = 'bottom'; - const Default$9 = { - autoClose: true, - boundary: 'clippingParents', - display: 'dynamic', - offset: [0, 2], - popperConfig: null, - reference: 'toggle' - }; - const DefaultType$9 = { - autoClose: '(boolean|string)', - boundary: '(string|element)', - display: 'string', - offset: '(array|string|function)', - popperConfig: '(null|object|function)', - reference: '(string|element|object)' - }; - - /** - * Class definition - */ - - class Dropdown extends BaseComponent { - constructor(element, config) { - super(element, config); - this._popper = null; - this._parent = this._element.parentNode; // dropdown wrapper - // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ - this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] || SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || SelectorEngine.findOne(SELECTOR_MENU, this._parent); - this._inNavbar = this._detectNavbar(); - } - - // Getters - static get Default() { - return Default$9; - } - static get DefaultType() { - return DefaultType$9; - } - static get NAME() { - return NAME$a; - } - - // Public - toggle() { - return this._isShown() ? this.hide() : this.show(); - } - show() { - if (isDisabled(this._element) || this._isShown()) { - return; - } - const relatedTarget = { - relatedTarget: this._element - }; - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$5, relatedTarget); - if (showEvent.defaultPrevented) { - return; - } - this._createPopper(); - - // If this is a touch-enabled device we add extra - // empty mouseover listeners to the body's immediate children; - // only needed because of broken event delegation on iOS - // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html - if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) { - for (const element of [].concat(...document.body.children)) { - EventHandler.on(element, 'mouseover', noop); - } - } - this._element.focus(); - this._element.setAttribute('aria-expanded', true); - this._menu.classList.add(CLASS_NAME_SHOW$6); - this._element.classList.add(CLASS_NAME_SHOW$6); - EventHandler.trigger(this._element, EVENT_SHOWN$5, relatedTarget); - } - hide() { - if (isDisabled(this._element) || !this._isShown()) { - return; - } - const relatedTarget = { - relatedTarget: this._element - }; - this._completeHide(relatedTarget); - } - dispose() { - if (this._popper) { - this._popper.destroy(); - } - super.dispose(); - } - update() { - this._inNavbar = this._detectNavbar(); - if (this._popper) { - this._popper.update(); - } - } - - // Private - _completeHide(relatedTarget) { - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$5, relatedTarget); - if (hideEvent.defaultPrevented) { - return; - } - - // If this is a touch-enabled device we remove the extra - // empty mouseover listeners we added for iOS support - if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...document.body.children)) { - EventHandler.off(element, 'mouseover', noop); - } - } - if (this._popper) { - this._popper.destroy(); - } - this._menu.classList.remove(CLASS_NAME_SHOW$6); - this._element.classList.remove(CLASS_NAME_SHOW$6); - this._element.setAttribute('aria-expanded', 'false'); - Manipulator.removeDataAttribute(this._menu, 'popper'); - EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget); - } - _getConfig(config) { - config = super._getConfig(config); - if (typeof config.reference === 'object' && !isElement$1(config.reference) && typeof config.reference.getBoundingClientRect !== 'function') { - // Popper virtual elements require a getBoundingClientRect method - throw new TypeError(`${NAME$a.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`); - } - return config; - } - _createPopper() { - if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)'); - } - let referenceElement = this._element; - if (this._config.reference === 'parent') { - referenceElement = this._parent; - } else if (isElement$1(this._config.reference)) { - referenceElement = getElement(this._config.reference); - } else if (typeof this._config.reference === 'object') { - referenceElement = this._config.reference; - } - const popperConfig = this._getPopperConfig(); - this._popper = createPopper(referenceElement, this._menu, popperConfig); - } - _isShown() { - return this._menu.classList.contains(CLASS_NAME_SHOW$6); - } - _getPlacement() { - const parentDropdown = this._parent; - if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { - return PLACEMENT_RIGHT; - } - if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { - return PLACEMENT_LEFT; - } - if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) { - return PLACEMENT_TOPCENTER; - } - if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) { - return PLACEMENT_BOTTOMCENTER; - } - - // We need to trim the value because custom properties can also include spaces - const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'; - if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { - return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP; - } - return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM; - } - _detectNavbar() { - return this._element.closest(SELECTOR_NAVBAR) !== null; - } - _getOffset() { - const { - offset - } = this._config; - if (typeof offset === 'string') { - return offset.split(',').map(value => Number.parseInt(value, 10)); - } - if (typeof offset === 'function') { - return popperData => offset(popperData, this._element); - } - return offset; - } - _getPopperConfig() { - const defaultBsPopperConfig = { - placement: this._getPlacement(), - modifiers: [{ - name: 'preventOverflow', - options: { - boundary: this._config.boundary - } - }, { - name: 'offset', - options: { - offset: this._getOffset() - } - }] - }; - - // Disable Popper if we have a static display or Dropdown is in Navbar - if (this._inNavbar || this._config.display === 'static') { - Manipulator.setDataAttribute(this._menu, 'popper', 'static'); // TODO: v6 remove - defaultBsPopperConfig.modifiers = [{ - name: 'applyStyles', - enabled: false - }]; - } - return { - ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [defaultBsPopperConfig]) - }; - } - _selectMenuItem({ - key, - target - }) { - const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element)); - if (!items.length) { - return; - } - - // if target isn't included in items (e.g. when expanding the dropdown) - // allow cycling to get the last item in case key equals ARROW_UP_KEY - getNextActiveElement(items, target, key === ARROW_DOWN_KEY$1, !items.includes(target)).focus(); - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Dropdown.getOrCreateInstance(this, config); - if (typeof config !== 'string') { - return; - } - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`); - } - data[config](); - }); - } - static clearMenus(event) { - if (event.button === RIGHT_MOUSE_BUTTON || event.type === 'keyup' && event.key !== TAB_KEY$1) { - return; - } - const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN); - for (const toggle of openToggles) { - const context = Dropdown.getInstance(toggle); - if (!context || context._config.autoClose === false) { - continue; - } - const composedPath = event.composedPath(); - const isMenuTarget = composedPath.includes(context._menu); - if (composedPath.includes(context._element) || context._config.autoClose === 'inside' && !isMenuTarget || context._config.autoClose === 'outside' && isMenuTarget) { - continue; - } - - // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu - if (context._menu.contains(event.target) && (event.type === 'keyup' && event.key === TAB_KEY$1 || /input|select|option|textarea|form/i.test(event.target.tagName))) { - continue; - } - const relatedTarget = { - relatedTarget: context._element - }; - if (event.type === 'click') { - relatedTarget.clickEvent = event; - } - context._completeHide(relatedTarget); - } - } - static dataApiKeydownHandler(event) { - // If not an UP | DOWN | ESCAPE key => not a dropdown command - // If input/textarea && if key is other than ESCAPE => not a dropdown command - - const isInput = /input|textarea/i.test(event.target.tagName); - const isEscapeEvent = event.key === ESCAPE_KEY$2; - const isUpOrDownEvent = [ARROW_UP_KEY$1, ARROW_DOWN_KEY$1].includes(event.key); - if (!isUpOrDownEvent && !isEscapeEvent) { - return; - } - if (isInput && !isEscapeEvent) { - return; - } - event.preventDefault(); - - // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ - const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE$3) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE$3)[0] || SelectorEngine.next(this, SELECTOR_DATA_TOGGLE$3)[0] || SelectorEngine.findOne(SELECTOR_DATA_TOGGLE$3, event.delegateTarget.parentNode); - const instance = Dropdown.getOrCreateInstance(getToggleButton); - if (isUpOrDownEvent) { - event.stopPropagation(); - instance.show(); - instance._selectMenuItem(event); - return; - } - if (instance._isShown()) { - // else is escape and we check if it is shown - event.stopPropagation(); - instance.hide(); - getToggleButton.focus(); - } - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE$3, Dropdown.dataApiKeydownHandler); - EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler); - EventHandler.on(document, EVENT_CLICK_DATA_API$3, Dropdown.clearMenus); - EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus); - EventHandler.on(document, EVENT_CLICK_DATA_API$3, SELECTOR_DATA_TOGGLE$3, function (event) { - event.preventDefault(); - Dropdown.getOrCreateInstance(this).toggle(); - }); - - /** - * jQuery - */ - - defineJQueryPlugin(Dropdown); - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/backdrop.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$9 = 'backdrop'; - const CLASS_NAME_FADE$4 = 'fade'; - const CLASS_NAME_SHOW$5 = 'show'; - const EVENT_MOUSEDOWN = `mousedown.bs.${NAME$9}`; - const Default$8 = { - className: 'modal-backdrop', - clickCallback: null, - isAnimated: false, - isVisible: true, - // if false, we use the backdrop helper without adding any element to the dom - rootElement: 'body' // give the choice to place backdrop under different elements - }; - - const DefaultType$8 = { - className: 'string', - clickCallback: '(function|null)', - isAnimated: 'boolean', - isVisible: 'boolean', - rootElement: '(element|string)' - }; - - /** - * Class definition - */ - - class Backdrop extends Config { - constructor(config) { - super(); - this._config = this._getConfig(config); - this._isAppended = false; - this._element = null; - } - - // Getters - static get Default() { - return Default$8; - } - static get DefaultType() { - return DefaultType$8; - } - static get NAME() { - return NAME$9; - } - - // Public - show(callback) { - if (!this._config.isVisible) { - execute(callback); - return; - } - this._append(); - const element = this._getElement(); - if (this._config.isAnimated) { - reflow(element); - } - element.classList.add(CLASS_NAME_SHOW$5); - this._emulateAnimation(() => { - execute(callback); - }); - } - hide(callback) { - if (!this._config.isVisible) { - execute(callback); - return; - } - this._getElement().classList.remove(CLASS_NAME_SHOW$5); - this._emulateAnimation(() => { - this.dispose(); - execute(callback); - }); - } - dispose() { - if (!this._isAppended) { - return; - } - EventHandler.off(this._element, EVENT_MOUSEDOWN); - this._element.remove(); - this._isAppended = false; - } - - // Private - _getElement() { - if (!this._element) { - const backdrop = document.createElement('div'); - backdrop.className = this._config.className; - if (this._config.isAnimated) { - backdrop.classList.add(CLASS_NAME_FADE$4); - } - this._element = backdrop; - } - return this._element; - } - _configAfterMerge(config) { - // use getElement() with the default "body" to get a fresh Element on each instantiation - config.rootElement = getElement(config.rootElement); - return config; - } - _append() { - if (this._isAppended) { - return; - } - const element = this._getElement(); - this._config.rootElement.append(element); - EventHandler.on(element, EVENT_MOUSEDOWN, () => { - execute(this._config.clickCallback); - }); - this._isAppended = true; - } - _emulateAnimation(callback) { - executeAfterTransition(callback, this._getElement(), this._config.isAnimated); - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/focustrap.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$8 = 'focustrap'; - const DATA_KEY$5 = 'bs.focustrap'; - const EVENT_KEY$5 = `.${DATA_KEY$5}`; - const EVENT_FOCUSIN$2 = `focusin${EVENT_KEY$5}`; - const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY$5}`; - const TAB_KEY = 'Tab'; - const TAB_NAV_FORWARD = 'forward'; - const TAB_NAV_BACKWARD = 'backward'; - const Default$7 = { - autofocus: true, - trapElement: null // The element to trap focus inside of - }; - - const DefaultType$7 = { - autofocus: 'boolean', - trapElement: 'element' - }; - - /** - * Class definition - */ - - class FocusTrap extends Config { - constructor(config) { - super(); - this._config = this._getConfig(config); - this._isActive = false; - this._lastTabNavDirection = null; - } - - // Getters - static get Default() { - return Default$7; - } - static get DefaultType() { - return DefaultType$7; - } - static get NAME() { - return NAME$8; - } - - // Public - activate() { - if (this._isActive) { - return; - } - if (this._config.autofocus) { - this._config.trapElement.focus(); - } - EventHandler.off(document, EVENT_KEY$5); // guard against infinite focus loop - EventHandler.on(document, EVENT_FOCUSIN$2, event => this._handleFocusin(event)); - EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event)); - this._isActive = true; - } - deactivate() { - if (!this._isActive) { - return; - } - this._isActive = false; - EventHandler.off(document, EVENT_KEY$5); - } - - // Private - _handleFocusin(event) { - const { - trapElement - } = this._config; - if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) { - return; - } - const elements = SelectorEngine.focusableChildren(trapElement); - if (elements.length === 0) { - trapElement.focus(); - } else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) { - elements[elements.length - 1].focus(); - } else { - elements[0].focus(); - } - } - _handleKeydown(event) { - if (event.key !== TAB_KEY) { - return; - } - this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD; - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/scrollBar.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'; - const SELECTOR_STICKY_CONTENT = '.sticky-top'; - const PROPERTY_PADDING = 'padding-right'; - const PROPERTY_MARGIN = 'margin-right'; - - /** - * Class definition - */ - - class ScrollBarHelper { - constructor() { - this._element = document.body; - } - - // Public - getWidth() { - // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes - const documentWidth = document.documentElement.clientWidth; - return Math.abs(window.innerWidth - documentWidth); - } - hide() { - const width = this.getWidth(); - this._disableOverFlow(); - // give padding to element to balance the hidden scrollbar width - this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width); - // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth - this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width); - this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width); - } - reset() { - this._resetElementAttributes(this._element, 'overflow'); - this._resetElementAttributes(this._element, PROPERTY_PADDING); - this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING); - this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN); - } - isOverflowing() { - return this.getWidth() > 0; - } - - // Private - _disableOverFlow() { - this._saveInitialAttribute(this._element, 'overflow'); - this._element.style.overflow = 'hidden'; - } - _setElementAttributes(selector, styleProperty, callback) { - const scrollbarWidth = this.getWidth(); - const manipulationCallBack = element => { - if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) { - return; - } - this._saveInitialAttribute(element, styleProperty); - const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty); - element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`); - }; - this._applyManipulationCallback(selector, manipulationCallBack); - } - _saveInitialAttribute(element, styleProperty) { - const actualValue = element.style.getPropertyValue(styleProperty); - if (actualValue) { - Manipulator.setDataAttribute(element, styleProperty, actualValue); - } - } - _resetElementAttributes(selector, styleProperty) { - const manipulationCallBack = element => { - const value = Manipulator.getDataAttribute(element, styleProperty); - // We only want to remove the property if the value is `null`; the value can also be zero - if (value === null) { - element.style.removeProperty(styleProperty); - return; - } - Manipulator.removeDataAttribute(element, styleProperty); - element.style.setProperty(styleProperty, value); - }; - this._applyManipulationCallback(selector, manipulationCallBack); - } - _applyManipulationCallback(selector, callBack) { - if (isElement$1(selector)) { - callBack(selector); - return; - } - for (const sel of SelectorEngine.find(selector, this._element)) { - callBack(sel); - } - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap modal.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$7 = 'modal'; - const DATA_KEY$4 = 'bs.modal'; - const EVENT_KEY$4 = `.${DATA_KEY$4}`; - const DATA_API_KEY$2 = '.data-api'; - const ESCAPE_KEY$1 = 'Escape'; - const EVENT_HIDE$4 = `hide${EVENT_KEY$4}`; - const EVENT_HIDE_PREVENTED$1 = `hidePrevented${EVENT_KEY$4}`; - const EVENT_HIDDEN$4 = `hidden${EVENT_KEY$4}`; - const EVENT_SHOW$4 = `show${EVENT_KEY$4}`; - const EVENT_SHOWN$4 = `shown${EVENT_KEY$4}`; - const EVENT_RESIZE$1 = `resize${EVENT_KEY$4}`; - const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY$4}`; - const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY$4}`; - const EVENT_KEYDOWN_DISMISS$1 = `keydown.dismiss${EVENT_KEY$4}`; - const EVENT_CLICK_DATA_API$2 = `click${EVENT_KEY$4}${DATA_API_KEY$2}`; - const CLASS_NAME_OPEN = 'modal-open'; - const CLASS_NAME_FADE$3 = 'fade'; - const CLASS_NAME_SHOW$4 = 'show'; - const CLASS_NAME_STATIC = 'modal-static'; - const OPEN_SELECTOR$1 = '.modal.show'; - const SELECTOR_DIALOG = '.modal-dialog'; - const SELECTOR_MODAL_BODY = '.modal-body'; - const SELECTOR_DATA_TOGGLE$2 = '[data-bs-toggle="modal"]'; - const Default$6 = { - backdrop: true, - focus: true, - keyboard: true - }; - const DefaultType$6 = { - backdrop: '(boolean|string)', - focus: 'boolean', - keyboard: 'boolean' - }; - - /** - * Class definition - */ - - class Modal extends BaseComponent { - constructor(element, config) { - super(element, config); - this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element); - this._backdrop = this._initializeBackDrop(); - this._focustrap = this._initializeFocusTrap(); - this._isShown = false; - this._isTransitioning = false; - this._scrollBar = new ScrollBarHelper(); - this._addEventListeners(); - } - - // Getters - static get Default() { - return Default$6; - } - static get DefaultType() { - return DefaultType$6; - } - static get NAME() { - return NAME$7; - } - - // Public - toggle(relatedTarget) { - return this._isShown ? this.hide() : this.show(relatedTarget); - } - show(relatedTarget) { - if (this._isShown || this._isTransitioning) { - return; - } - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$4, { - relatedTarget - }); - if (showEvent.defaultPrevented) { - return; - } - this._isShown = true; - this._isTransitioning = true; - this._scrollBar.hide(); - document.body.classList.add(CLASS_NAME_OPEN); - this._adjustDialog(); - this._backdrop.show(() => this._showElement(relatedTarget)); - } - hide() { - if (!this._isShown || this._isTransitioning) { - return; - } - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$4); - if (hideEvent.defaultPrevented) { - return; - } - this._isShown = false; - this._isTransitioning = true; - this._focustrap.deactivate(); - this._element.classList.remove(CLASS_NAME_SHOW$4); - this._queueCallback(() => this._hideModal(), this._element, this._isAnimated()); - } - dispose() { - EventHandler.off(window, EVENT_KEY$4); - EventHandler.off(this._dialog, EVENT_KEY$4); - this._backdrop.dispose(); - this._focustrap.deactivate(); - super.dispose(); - } - handleUpdate() { - this._adjustDialog(); - } - - // Private - _initializeBackDrop() { - return new Backdrop({ - isVisible: Boolean(this._config.backdrop), - // 'static' option will be translated to true, and booleans will keep their value, - isAnimated: this._isAnimated() - }); - } - _initializeFocusTrap() { - return new FocusTrap({ - trapElement: this._element - }); - } - _showElement(relatedTarget) { - // try to append dynamic modal - if (!document.body.contains(this._element)) { - document.body.append(this._element); - } - this._element.style.display = 'block'; - this._element.removeAttribute('aria-hidden'); - this._element.setAttribute('aria-modal', true); - this._element.setAttribute('role', 'dialog'); - this._element.scrollTop = 0; - const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog); - if (modalBody) { - modalBody.scrollTop = 0; - } - reflow(this._element); - this._element.classList.add(CLASS_NAME_SHOW$4); - const transitionComplete = () => { - if (this._config.focus) { - this._focustrap.activate(); - } - this._isTransitioning = false; - EventHandler.trigger(this._element, EVENT_SHOWN$4, { - relatedTarget - }); - }; - this._queueCallback(transitionComplete, this._dialog, this._isAnimated()); - } - _addEventListeners() { - EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS$1, event => { - if (event.key !== ESCAPE_KEY$1) { - return; - } - if (this._config.keyboard) { - this.hide(); - return; - } - this._triggerBackdropTransition(); - }); - EventHandler.on(window, EVENT_RESIZE$1, () => { - if (this._isShown && !this._isTransitioning) { - this._adjustDialog(); - } - }); - EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => { - // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks - EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => { - if (this._element !== event.target || this._element !== event2.target) { - return; - } - if (this._config.backdrop === 'static') { - this._triggerBackdropTransition(); - return; - } - if (this._config.backdrop) { - this.hide(); - } - }); - }); - } - _hideModal() { - this._element.style.display = 'none'; - this._element.setAttribute('aria-hidden', true); - this._element.removeAttribute('aria-modal'); - this._element.removeAttribute('role'); - this._isTransitioning = false; - this._backdrop.hide(() => { - document.body.classList.remove(CLASS_NAME_OPEN); - this._resetAdjustments(); - this._scrollBar.reset(); - EventHandler.trigger(this._element, EVENT_HIDDEN$4); - }); - } - _isAnimated() { - return this._element.classList.contains(CLASS_NAME_FADE$3); - } - _triggerBackdropTransition() { - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED$1); - if (hideEvent.defaultPrevented) { - return; - } - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; - const initialOverflowY = this._element.style.overflowY; - // return if the following background transition hasn't yet completed - if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) { - return; - } - if (!isModalOverflowing) { - this._element.style.overflowY = 'hidden'; - } - this._element.classList.add(CLASS_NAME_STATIC); - this._queueCallback(() => { - this._element.classList.remove(CLASS_NAME_STATIC); - this._queueCallback(() => { - this._element.style.overflowY = initialOverflowY; - }, this._dialog); - }, this._dialog); - this._element.focus(); - } - - /** - * The following methods are used to handle overflowing modals - */ - - _adjustDialog() { - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; - const scrollbarWidth = this._scrollBar.getWidth(); - const isBodyOverflowing = scrollbarWidth > 0; - if (isBodyOverflowing && !isModalOverflowing) { - const property = isRTL() ? 'paddingLeft' : 'paddingRight'; - this._element.style[property] = `${scrollbarWidth}px`; - } - if (!isBodyOverflowing && isModalOverflowing) { - const property = isRTL() ? 'paddingRight' : 'paddingLeft'; - this._element.style[property] = `${scrollbarWidth}px`; - } - } - _resetAdjustments() { - this._element.style.paddingLeft = ''; - this._element.style.paddingRight = ''; - } - - // Static - static jQueryInterface(config, relatedTarget) { - return this.each(function () { - const data = Modal.getOrCreateInstance(this, config); - if (typeof config !== 'string') { - return; - } - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`); - } - data[config](relatedTarget); - }); - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API$2, SELECTOR_DATA_TOGGLE$2, function (event) { - const target = SelectorEngine.getElementFromSelector(this); - if (['A', 'AREA'].includes(this.tagName)) { - event.preventDefault(); - } - EventHandler.one(target, EVENT_SHOW$4, showEvent => { - if (showEvent.defaultPrevented) { - // only register focus restorer if modal will actually get shown - return; - } - EventHandler.one(target, EVENT_HIDDEN$4, () => { - if (isVisible(this)) { - this.focus(); - } - }); - }); - - // avoid conflict when clicking modal toggler while another one is open - const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR$1); - if (alreadyOpen) { - Modal.getInstance(alreadyOpen).hide(); - } - const data = Modal.getOrCreateInstance(target); - data.toggle(this); - }); - enableDismissTrigger(Modal); - - /** - * jQuery - */ - - defineJQueryPlugin(Modal); - - /** - * -------------------------------------------------------------------------- - * Bootstrap offcanvas.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$6 = 'offcanvas'; - const DATA_KEY$3 = 'bs.offcanvas'; - const EVENT_KEY$3 = `.${DATA_KEY$3}`; - const DATA_API_KEY$1 = '.data-api'; - const EVENT_LOAD_DATA_API$2 = `load${EVENT_KEY$3}${DATA_API_KEY$1}`; - const ESCAPE_KEY = 'Escape'; - const CLASS_NAME_SHOW$3 = 'show'; - const CLASS_NAME_SHOWING$1 = 'showing'; - const CLASS_NAME_HIDING = 'hiding'; - const CLASS_NAME_BACKDROP = 'offcanvas-backdrop'; - const OPEN_SELECTOR = '.offcanvas.show'; - const EVENT_SHOW$3 = `show${EVENT_KEY$3}`; - const EVENT_SHOWN$3 = `shown${EVENT_KEY$3}`; - const EVENT_HIDE$3 = `hide${EVENT_KEY$3}`; - const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY$3}`; - const EVENT_HIDDEN$3 = `hidden${EVENT_KEY$3}`; - const EVENT_RESIZE = `resize${EVENT_KEY$3}`; - const EVENT_CLICK_DATA_API$1 = `click${EVENT_KEY$3}${DATA_API_KEY$1}`; - const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY$3}`; - const SELECTOR_DATA_TOGGLE$1 = '[data-bs-toggle="offcanvas"]'; - const Default$5 = { - backdrop: true, - keyboard: true, - scroll: false - }; - const DefaultType$5 = { - backdrop: '(boolean|string)', - keyboard: 'boolean', - scroll: 'boolean' - }; - - /** - * Class definition - */ - - class Offcanvas extends BaseComponent { - constructor(element, config) { - super(element, config); - this._isShown = false; - this._backdrop = this._initializeBackDrop(); - this._focustrap = this._initializeFocusTrap(); - this._addEventListeners(); - } - - // Getters - static get Default() { - return Default$5; - } - static get DefaultType() { - return DefaultType$5; - } - static get NAME() { - return NAME$6; - } - - // Public - toggle(relatedTarget) { - return this._isShown ? this.hide() : this.show(relatedTarget); - } - show(relatedTarget) { - if (this._isShown) { - return; - } - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$3, { - relatedTarget - }); - if (showEvent.defaultPrevented) { - return; - } - this._isShown = true; - this._backdrop.show(); - if (!this._config.scroll) { - new ScrollBarHelper().hide(); - } - this._element.setAttribute('aria-modal', true); - this._element.setAttribute('role', 'dialog'); - this._element.classList.add(CLASS_NAME_SHOWING$1); - const completeCallBack = () => { - if (!this._config.scroll || this._config.backdrop) { - this._focustrap.activate(); - } - this._element.classList.add(CLASS_NAME_SHOW$3); - this._element.classList.remove(CLASS_NAME_SHOWING$1); - EventHandler.trigger(this._element, EVENT_SHOWN$3, { - relatedTarget - }); - }; - this._queueCallback(completeCallBack, this._element, true); - } - hide() { - if (!this._isShown) { - return; - } - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$3); - if (hideEvent.defaultPrevented) { - return; - } - this._focustrap.deactivate(); - this._element.blur(); - this._isShown = false; - this._element.classList.add(CLASS_NAME_HIDING); - this._backdrop.hide(); - const completeCallback = () => { - this._element.classList.remove(CLASS_NAME_SHOW$3, CLASS_NAME_HIDING); - this._element.removeAttribute('aria-modal'); - this._element.removeAttribute('role'); - if (!this._config.scroll) { - new ScrollBarHelper().reset(); - } - EventHandler.trigger(this._element, EVENT_HIDDEN$3); - }; - this._queueCallback(completeCallback, this._element, true); - } - dispose() { - this._backdrop.dispose(); - this._focustrap.deactivate(); - super.dispose(); - } - - // Private - _initializeBackDrop() { - const clickCallback = () => { - if (this._config.backdrop === 'static') { - EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED); - return; - } - this.hide(); - }; - - // 'static' option will be translated to true, and booleans will keep their value - const isVisible = Boolean(this._config.backdrop); - return new Backdrop({ - className: CLASS_NAME_BACKDROP, - isVisible, - isAnimated: true, - rootElement: this._element.parentNode, - clickCallback: isVisible ? clickCallback : null - }); - } - _initializeFocusTrap() { - return new FocusTrap({ - trapElement: this._element - }); - } - _addEventListeners() { - EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { - if (event.key !== ESCAPE_KEY) { - return; - } - if (this._config.keyboard) { - this.hide(); - return; - } - EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED); - }); - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Offcanvas.getOrCreateInstance(this, config); - if (typeof config !== 'string') { - return; - } - if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { - throw new TypeError(`No method named "${config}"`); - } - data[config](this); - }); - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API$1, SELECTOR_DATA_TOGGLE$1, function (event) { - const target = SelectorEngine.getElementFromSelector(this); - if (['A', 'AREA'].includes(this.tagName)) { - event.preventDefault(); - } - if (isDisabled(this)) { - return; - } - EventHandler.one(target, EVENT_HIDDEN$3, () => { - // focus on trigger when it is closed - if (isVisible(this)) { - this.focus(); - } - }); - - // avoid conflict when clicking a toggler of an offcanvas, while another is open - const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR); - if (alreadyOpen && alreadyOpen !== target) { - Offcanvas.getInstance(alreadyOpen).hide(); - } - const data = Offcanvas.getOrCreateInstance(target); - data.toggle(this); - }); - EventHandler.on(window, EVENT_LOAD_DATA_API$2, () => { - for (const selector of SelectorEngine.find(OPEN_SELECTOR)) { - Offcanvas.getOrCreateInstance(selector).show(); - } - }); - EventHandler.on(window, EVENT_RESIZE, () => { - for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) { - if (getComputedStyle(element).position !== 'fixed') { - Offcanvas.getOrCreateInstance(element).hide(); - } - } - }); - enableDismissTrigger(Offcanvas); - - /** - * jQuery - */ - - defineJQueryPlugin(Offcanvas); - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/sanitizer.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - // js-docs-start allow-list - const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i; - const DefaultAllowlist = { - // Global attributes allowed on any supplied element below. - '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN], - a: ['target', 'href', 'title', 'rel'], - area: [], - b: [], - br: [], - col: [], - code: [], - div: [], - em: [], - hr: [], - h1: [], - h2: [], - h3: [], - h4: [], - h5: [], - h6: [], - i: [], - img: ['src', 'srcset', 'alt', 'title', 'width', 'height'], - li: [], - ol: [], - p: [], - pre: [], - s: [], - small: [], - span: [], - sub: [], - sup: [], - strong: [], - u: [], - ul: [] - }; - // js-docs-end allow-list - - const uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longdesc', 'poster', 'src', 'xlink:href']); - - /** - * A pattern that recognizes URLs that are safe wrt. XSS in URL navigation - * contexts. - * - * Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38 - */ - // eslint-disable-next-line unicorn/better-regex - const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i; - const allowedAttribute = (attribute, allowedAttributeList) => { - const attributeName = attribute.nodeName.toLowerCase(); - if (allowedAttributeList.includes(attributeName)) { - if (uriAttributes.has(attributeName)) { - return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue)); - } - return true; - } - - // Check if a regular expression validates the attribute. - return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp).some(regex => regex.test(attributeName)); - }; - function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) { - if (!unsafeHtml.length) { - return unsafeHtml; - } - if (sanitizeFunction && typeof sanitizeFunction === 'function') { - return sanitizeFunction(unsafeHtml); - } - const domParser = new window.DOMParser(); - const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html'); - const elements = [].concat(...createdDocument.body.querySelectorAll('*')); - for (const element of elements) { - const elementName = element.nodeName.toLowerCase(); - if (!Object.keys(allowList).includes(elementName)) { - element.remove(); - continue; - } - const attributeList = [].concat(...element.attributes); - const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || []); - for (const attribute of attributeList) { - if (!allowedAttribute(attribute, allowedAttributes)) { - element.removeAttribute(attribute.nodeName); - } - } - } - return createdDocument.body.innerHTML; - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap util/template-factory.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$5 = 'TemplateFactory'; - const Default$4 = { - allowList: DefaultAllowlist, - content: {}, - // { selector : text , selector2 : text2 , } - extraClass: '', - html: false, - sanitize: true, - sanitizeFn: null, - template: '
' - }; - const DefaultType$4 = { - allowList: 'object', - content: 'object', - extraClass: '(string|function)', - html: 'boolean', - sanitize: 'boolean', - sanitizeFn: '(null|function)', - template: 'string' - }; - const DefaultContentType = { - entry: '(string|element|function|null)', - selector: '(string|element)' - }; - - /** - * Class definition - */ - - class TemplateFactory extends Config { - constructor(config) { - super(); - this._config = this._getConfig(config); - } - - // Getters - static get Default() { - return Default$4; - } - static get DefaultType() { - return DefaultType$4; - } - static get NAME() { - return NAME$5; - } - - // Public - getContent() { - return Object.values(this._config.content).map(config => this._resolvePossibleFunction(config)).filter(Boolean); - } - hasContent() { - return this.getContent().length > 0; - } - changeContent(content) { - this._checkContent(content); - this._config.content = { - ...this._config.content, - ...content - }; - return this; - } - toHtml() { - const templateWrapper = document.createElement('div'); - templateWrapper.innerHTML = this._maybeSanitize(this._config.template); - for (const [selector, text] of Object.entries(this._config.content)) { - this._setContent(templateWrapper, text, selector); - } - const template = templateWrapper.children[0]; - const extraClass = this._resolvePossibleFunction(this._config.extraClass); - if (extraClass) { - template.classList.add(...extraClass.split(' ')); - } - return template; - } - - // Private - _typeCheckConfig(config) { - super._typeCheckConfig(config); - this._checkContent(config.content); - } - _checkContent(arg) { - for (const [selector, content] of Object.entries(arg)) { - super._typeCheckConfig({ - selector, - entry: content - }, DefaultContentType); - } - } - _setContent(template, content, selector) { - const templateElement = SelectorEngine.findOne(selector, template); - if (!templateElement) { - return; - } - content = this._resolvePossibleFunction(content); - if (!content) { - templateElement.remove(); - return; - } - if (isElement$1(content)) { - this._putElementInTemplate(getElement(content), templateElement); - return; - } - if (this._config.html) { - templateElement.innerHTML = this._maybeSanitize(content); - return; - } - templateElement.textContent = content; - } - _maybeSanitize(arg) { - return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg; - } - _resolvePossibleFunction(arg) { - return execute(arg, [this]); - } - _putElementInTemplate(element, templateElement) { - if (this._config.html) { - templateElement.innerHTML = ''; - templateElement.append(element); - return; - } - templateElement.textContent = element.textContent; - } - } - - /** - * -------------------------------------------------------------------------- - * Bootstrap tooltip.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$4 = 'tooltip'; - const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']); - const CLASS_NAME_FADE$2 = 'fade'; - const CLASS_NAME_MODAL = 'modal'; - const CLASS_NAME_SHOW$2 = 'show'; - const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'; - const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`; - const EVENT_MODAL_HIDE = 'hide.bs.modal'; - const TRIGGER_HOVER = 'hover'; - const TRIGGER_FOCUS = 'focus'; - const TRIGGER_CLICK = 'click'; - const TRIGGER_MANUAL = 'manual'; - const EVENT_HIDE$2 = 'hide'; - const EVENT_HIDDEN$2 = 'hidden'; - const EVENT_SHOW$2 = 'show'; - const EVENT_SHOWN$2 = 'shown'; - const EVENT_INSERTED = 'inserted'; - const EVENT_CLICK$1 = 'click'; - const EVENT_FOCUSIN$1 = 'focusin'; - const EVENT_FOCUSOUT$1 = 'focusout'; - const EVENT_MOUSEENTER = 'mouseenter'; - const EVENT_MOUSELEAVE = 'mouseleave'; - const AttachmentMap = { - AUTO: 'auto', - TOP: 'top', - RIGHT: isRTL() ? 'left' : 'right', - BOTTOM: 'bottom', - LEFT: isRTL() ? 'right' : 'left' - }; - const Default$3 = { - allowList: DefaultAllowlist, - animation: true, - boundary: 'clippingParents', - container: false, - customClass: '', - delay: 0, - fallbackPlacements: ['top', 'right', 'bottom', 'left'], - html: false, - offset: [0, 6], - placement: 'top', - popperConfig: null, - sanitize: true, - sanitizeFn: null, - selector: false, - template: '', - title: '', - trigger: 'hover focus' - }; - const DefaultType$3 = { - allowList: 'object', - animation: 'boolean', - boundary: '(string|element)', - container: '(string|element|boolean)', - customClass: '(string|function)', - delay: '(number|object)', - fallbackPlacements: 'array', - html: 'boolean', - offset: '(array|string|function)', - placement: '(string|function)', - popperConfig: '(null|object|function)', - sanitize: 'boolean', - sanitizeFn: '(null|function)', - selector: '(string|boolean)', - template: 'string', - title: '(string|element|function)', - trigger: 'string' - }; - - /** - * Class definition - */ - - class Tooltip extends BaseComponent { - constructor(element, config) { - if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)'); - } - super(element, config); - - // Private - this._isEnabled = true; - this._timeout = 0; - this._isHovered = null; - this._activeTrigger = {}; - this._popper = null; - this._templateFactory = null; - this._newContent = null; - - // Protected - this.tip = null; - this._setListeners(); - if (!this._config.selector) { - this._fixTitle(); - } - } - - // Getters - static get Default() { - return Default$3; - } - static get DefaultType() { - return DefaultType$3; - } - static get NAME() { - return NAME$4; - } - - // Public - enable() { - this._isEnabled = true; - } - disable() { - this._isEnabled = false; - } - toggleEnabled() { - this._isEnabled = !this._isEnabled; - } - toggle() { - if (!this._isEnabled) { - return; - } - this._activeTrigger.click = !this._activeTrigger.click; - if (this._isShown()) { - this._leave(); - return; - } - this._enter(); - } - dispose() { - clearTimeout(this._timeout); - EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler); - if (this._element.getAttribute('data-bs-original-title')) { - this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')); - } - this._disposePopper(); - super.dispose(); - } - show() { - if (this._element.style.display === 'none') { - throw new Error('Please use show on visible elements'); - } - if (!(this._isWithContent() && this._isEnabled)) { - return; - } - const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW$2)); - const shadowRoot = findShadowRoot(this._element); - const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element); - if (showEvent.defaultPrevented || !isInTheDom) { - return; - } - - // TODO: v6 remove this or make it optional - this._disposePopper(); - const tip = this._getTipElement(); - this._element.setAttribute('aria-describedby', tip.getAttribute('id')); - const { - container - } = this._config; - if (!this._element.ownerDocument.documentElement.contains(this.tip)) { - container.append(tip); - EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED)); - } - this._popper = this._createPopper(tip); - tip.classList.add(CLASS_NAME_SHOW$2); - - // If this is a touch-enabled device we add extra - // empty mouseover listeners to the body's immediate children; - // only needed because of broken event delegation on iOS - // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html - if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...document.body.children)) { - EventHandler.on(element, 'mouseover', noop); - } - } - const complete = () => { - EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN$2)); - if (this._isHovered === false) { - this._leave(); - } - this._isHovered = false; - }; - this._queueCallback(complete, this.tip, this._isAnimated()); - } - hide() { - if (!this._isShown()) { - return; - } - const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE$2)); - if (hideEvent.defaultPrevented) { - return; - } - const tip = this._getTipElement(); - tip.classList.remove(CLASS_NAME_SHOW$2); - - // If this is a touch-enabled device we remove the extra - // empty mouseover listeners we added for iOS support - if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...document.body.children)) { - EventHandler.off(element, 'mouseover', noop); - } - } - this._activeTrigger[TRIGGER_CLICK] = false; - this._activeTrigger[TRIGGER_FOCUS] = false; - this._activeTrigger[TRIGGER_HOVER] = false; - this._isHovered = null; // it is a trick to support manual triggering - - const complete = () => { - if (this._isWithActiveTrigger()) { - return; - } - if (!this._isHovered) { - this._disposePopper(); - } - this._element.removeAttribute('aria-describedby'); - EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN$2)); - }; - this._queueCallback(complete, this.tip, this._isAnimated()); - } - update() { - if (this._popper) { - this._popper.update(); - } - } - - // Protected - _isWithContent() { - return Boolean(this._getTitle()); - } - _getTipElement() { - if (!this.tip) { - this.tip = this._createTipElement(this._newContent || this._getContentForTemplate()); - } - return this.tip; - } - _createTipElement(content) { - const tip = this._getTemplateFactory(content).toHtml(); - - // TODO: remove this check in v6 - if (!tip) { - return null; - } - tip.classList.remove(CLASS_NAME_FADE$2, CLASS_NAME_SHOW$2); - // TODO: v6 the following can be achieved with CSS only - tip.classList.add(`bs-${this.constructor.NAME}-auto`); - const tipId = getUID(this.constructor.NAME).toString(); - tip.setAttribute('id', tipId); - if (this._isAnimated()) { - tip.classList.add(CLASS_NAME_FADE$2); - } - return tip; - } - setContent(content) { - this._newContent = content; - if (this._isShown()) { - this._disposePopper(); - this.show(); - } - } - _getTemplateFactory(content) { - if (this._templateFactory) { - this._templateFactory.changeContent(content); - } else { - this._templateFactory = new TemplateFactory({ - ...this._config, - // the `content` var has to be after `this._config` - // to override config.content in case of popover - content, - extraClass: this._resolvePossibleFunction(this._config.customClass) - }); - } - return this._templateFactory; - } - _getContentForTemplate() { - return { - [SELECTOR_TOOLTIP_INNER]: this._getTitle() - }; - } - _getTitle() { - return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title'); - } - - // Private - _initializeOnDelegatedTarget(event) { - return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()); - } - _isAnimated() { - return this._config.animation || this.tip && this.tip.classList.contains(CLASS_NAME_FADE$2); - } - _isShown() { - return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW$2); - } - _createPopper(tip) { - const placement = execute(this._config.placement, [this, tip, this._element]); - const attachment = AttachmentMap[placement.toUpperCase()]; - return createPopper(this._element, tip, this._getPopperConfig(attachment)); - } - _getOffset() { - const { - offset - } = this._config; - if (typeof offset === 'string') { - return offset.split(',').map(value => Number.parseInt(value, 10)); - } - if (typeof offset === 'function') { - return popperData => offset(popperData, this._element); - } - return offset; - } - _resolvePossibleFunction(arg) { - return execute(arg, [this._element]); - } - _getPopperConfig(attachment) { - const defaultBsPopperConfig = { - placement: attachment, - modifiers: [{ - name: 'flip', - options: { - fallbackPlacements: this._config.fallbackPlacements - } - }, { - name: 'offset', - options: { - offset: this._getOffset() - } - }, { - name: 'preventOverflow', - options: { - boundary: this._config.boundary - } - }, { - name: 'arrow', - options: { - element: `.${this.constructor.NAME}-arrow` - } - }, { - name: 'preSetPlacement', - enabled: true, - phase: 'beforeMain', - fn: data => { - // Pre-set Popper's placement attribute in order to read the arrow sizes properly. - // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement - this._getTipElement().setAttribute('data-popper-placement', data.state.placement); - } - }] - }; - return { - ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [defaultBsPopperConfig]) - }; - } - _setListeners() { - const triggers = this._config.trigger.split(' '); - for (const trigger of triggers) { - if (trigger === 'click') { - EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => { - const context = this._initializeOnDelegatedTarget(event); - context.toggle(); - }); - } else if (trigger !== TRIGGER_MANUAL) { - const eventIn = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSEENTER) : this.constructor.eventName(EVENT_FOCUSIN$1); - const eventOut = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSELEAVE) : this.constructor.eventName(EVENT_FOCUSOUT$1); - EventHandler.on(this._element, eventIn, this._config.selector, event => { - const context = this._initializeOnDelegatedTarget(event); - context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true; - context._enter(); - }); - EventHandler.on(this._element, eventOut, this._config.selector, event => { - const context = this._initializeOnDelegatedTarget(event); - context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = context._element.contains(event.relatedTarget); - context._leave(); - }); - } - } - this._hideModalHandler = () => { - if (this._element) { - this.hide(); - } - }; - EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler); - } - _fixTitle() { - const title = this._element.getAttribute('title'); - if (!title) { - return; - } - if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) { - this._element.setAttribute('aria-label', title); - } - this._element.setAttribute('data-bs-original-title', title); // DO NOT USE IT. Is only for backwards compatibility - this._element.removeAttribute('title'); - } - _enter() { - if (this._isShown() || this._isHovered) { - this._isHovered = true; - return; - } - this._isHovered = true; - this._setTimeout(() => { - if (this._isHovered) { - this.show(); - } - }, this._config.delay.show); - } - _leave() { - if (this._isWithActiveTrigger()) { - return; - } - this._isHovered = false; - this._setTimeout(() => { - if (!this._isHovered) { - this.hide(); - } - }, this._config.delay.hide); - } - _setTimeout(handler, timeout) { - clearTimeout(this._timeout); - this._timeout = setTimeout(handler, timeout); - } - _isWithActiveTrigger() { - return Object.values(this._activeTrigger).includes(true); - } - _getConfig(config) { - const dataAttributes = Manipulator.getDataAttributes(this._element); - for (const dataAttribute of Object.keys(dataAttributes)) { - if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { - delete dataAttributes[dataAttribute]; - } - } - config = { - ...dataAttributes, - ...(typeof config === 'object' && config ? config : {}) - }; - config = this._mergeConfigObj(config); - config = this._configAfterMerge(config); - this._typeCheckConfig(config); - return config; - } - _configAfterMerge(config) { - config.container = config.container === false ? document.body : getElement(config.container); - if (typeof config.delay === 'number') { - config.delay = { - show: config.delay, - hide: config.delay - }; - } - if (typeof config.title === 'number') { - config.title = config.title.toString(); - } - if (typeof config.content === 'number') { - config.content = config.content.toString(); - } - return config; - } - _getDelegateConfig() { - const config = {}; - for (const [key, value] of Object.entries(this._config)) { - if (this.constructor.Default[key] !== value) { - config[key] = value; - } - } - config.selector = false; - config.trigger = 'manual'; - - // In the future can be replaced with: - // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]]) - // `Object.fromEntries(keysWithDifferentValues)` - return config; - } - _disposePopper() { - if (this._popper) { - this._popper.destroy(); - this._popper = null; - } - if (this.tip) { - this.tip.remove(); - this.tip = null; - } - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Tooltip.getOrCreateInstance(this, config); - if (typeof config !== 'string') { - return; - } - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`); - } - data[config](); - }); - } - } - - /** - * jQuery - */ - - defineJQueryPlugin(Tooltip); - - /** - * -------------------------------------------------------------------------- - * Bootstrap popover.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$3 = 'popover'; - const SELECTOR_TITLE = '.popover-header'; - const SELECTOR_CONTENT = '.popover-body'; - const Default$2 = { - ...Tooltip.Default, - content: '', - offset: [0, 8], - placement: 'right', - template: '', - trigger: 'click' - }; - const DefaultType$2 = { - ...Tooltip.DefaultType, - content: '(null|string|element|function)' - }; - - /** - * Class definition - */ - - class Popover extends Tooltip { - // Getters - static get Default() { - return Default$2; - } - static get DefaultType() { - return DefaultType$2; - } - static get NAME() { - return NAME$3; - } - - // Overrides - _isWithContent() { - return this._getTitle() || this._getContent(); - } - - // Private - _getContentForTemplate() { - return { - [SELECTOR_TITLE]: this._getTitle(), - [SELECTOR_CONTENT]: this._getContent() - }; - } - _getContent() { - return this._resolvePossibleFunction(this._config.content); - } - - // Static - static jQueryInterface(config) { - return this.each(function () { - const data = Popover.getOrCreateInstance(this, config); - if (typeof config !== 'string') { - return; - } - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`); - } - data[config](); - }); - } - } - - /** - * jQuery - */ - - defineJQueryPlugin(Popover); - - /** - * -------------------------------------------------------------------------- - * Bootstrap scrollspy.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME$2 = 'scrollspy'; - const DATA_KEY$2 = 'bs.scrollspy'; - const EVENT_KEY$2 = `.${DATA_KEY$2}`; - const DATA_API_KEY = '.data-api'; - const EVENT_ACTIVATE = `activate${EVENT_KEY$2}`; - const EVENT_CLICK = `click${EVENT_KEY$2}`; - const EVENT_LOAD_DATA_API$1 = `load${EVENT_KEY$2}${DATA_API_KEY}`; - const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'; - const CLASS_NAME_ACTIVE$1 = 'active'; - const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'; - const SELECTOR_TARGET_LINKS = '[href]'; - const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'; - const SELECTOR_NAV_LINKS = '.nav-link'; - const SELECTOR_NAV_ITEMS = '.nav-item'; - const SELECTOR_LIST_ITEMS = '.list-group-item'; - const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`; - const SELECTOR_DROPDOWN = '.dropdown'; - const SELECTOR_DROPDOWN_TOGGLE$1 = '.dropdown-toggle'; - const Default$1 = { - offset: null, - // TODO: v6 @deprecated, keep it for backwards compatibility reasons - rootMargin: '0px 0px -25%', - smoothScroll: false, - target: null, - threshold: [0.1, 0.5, 1] - }; - const DefaultType$1 = { - offset: '(number|null)', - // TODO v6 @deprecated, keep it for backwards compatibility reasons - rootMargin: 'string', - smoothScroll: 'boolean', - target: 'element', - threshold: 'array' - }; - - /** - * Class definition - */ - - class ScrollSpy extends BaseComponent { - constructor(element, config) { - super(element, config); - - // this._element is the observablesContainer and config.target the menu links wrapper - this._targetLinks = new Map(); - this._observableSections = new Map(); - this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element; - this._activeTarget = null; - this._observer = null; - this._previousScrollData = { - visibleEntryTop: 0, - parentScrollTop: 0 - }; - this.refresh(); // initialize - } - - // Getters - static get Default() { - return Default$1; - } - static get DefaultType() { - return DefaultType$1; - } - static get NAME() { - return NAME$2; - } - - // Public - refresh() { - this._initializeTargetsAndObservables(); - this._maybeEnableSmoothScroll(); - if (this._observer) { - this._observer.disconnect(); - } else { - this._observer = this._getNewObserver(); - } - for (const section of this._observableSections.values()) { - this._observer.observe(section); - } - } - dispose() { - this._observer.disconnect(); - super.dispose(); - } - - // Private - _configAfterMerge(config) { - // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case - config.target = getElement(config.target) || document.body; - - // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only - config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin; - if (typeof config.threshold === 'string') { - config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value)); - } - return config; - } - _maybeEnableSmoothScroll() { - if (!this._config.smoothScroll) { - return; - } - - // unregister any previous listeners - EventHandler.off(this._config.target, EVENT_CLICK); - EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => { - const observableSection = this._observableSections.get(event.target.hash); - if (observableSection) { - event.preventDefault(); - const root = this._rootElement || window; - const height = observableSection.offsetTop - this._element.offsetTop; - if (root.scrollTo) { - root.scrollTo({ - top: height, - behavior: 'smooth' - }); - return; - } - - // Chrome 60 doesn't support `scrollTo` - root.scrollTop = height; - } - }); - } - _getNewObserver() { - const options = { - root: this._rootElement, - threshold: this._config.threshold, - rootMargin: this._config.rootMargin - }; - return new IntersectionObserver(entries => this._observerCallback(entries), options); - } - - // The logic of selection - _observerCallback(entries) { - const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`); - const activate = entry => { - this._previousScrollData.visibleEntryTop = entry.target.offsetTop; - this._process(targetElement(entry)); - }; - const parentScrollTop = (this._rootElement || document.documentElement).scrollTop; - const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop; - this._previousScrollData.parentScrollTop = parentScrollTop; - for (const entry of entries) { - if (!entry.isIntersecting) { - this._activeTarget = null; - this._clearActiveClass(targetElement(entry)); - continue; - } - const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop; - // if we are scrolling down, pick the bigger offsetTop - if (userScrollsDown && entryIsLowerThanPrevious) { - activate(entry); - // if parent isn't scrolled, let's keep the first visible item, breaking the iteration - if (!parentScrollTop) { - return; - } - continue; - } - - // if we are scrolling up, pick the smallest offsetTop - if (!userScrollsDown && !entryIsLowerThanPrevious) { - activate(entry); - } - } - } - _initializeTargetsAndObservables() { - this._targetLinks = new Map(); - this._observableSections = new Map(); - const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target); - for (const anchor of targetLinks) { - // ensure that the anchor has an id and is not disabled - if (!anchor.hash || isDisabled(anchor)) { - continue; - } - const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element); - - // ensure that the observableSection exists & is visible - if (isVisible(observableSection)) { - this._targetLinks.set(decodeURI(anchor.hash), anchor); - this._observableSections.set(anchor.hash, observableSection); - } - } - } - _process(target) { - if (this._activeTarget === target) { - return; - } - this._clearActiveClass(this._config.target); - this._activeTarget = target; - target.classList.add(CLASS_NAME_ACTIVE$1); - this._activateParents(target); - EventHandler.trigger(this._element, EVENT_ACTIVATE, { - relatedTarget: target - }); - } - _activateParents(target) { - // Activate dropdown parents - if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { - SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE$1, target.closest(SELECTOR_DROPDOWN)).classList.add(CLASS_NAME_ACTIVE$1); - return; - } - for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) { - // Set triggered links parents as active - // With both
    and