diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..91ccd77 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,96 @@ +name: Bug Report +description: Report a bug or issue with Continuum +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the information below to help us investigate. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of what the bug is + placeholder: What went wrong? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened + validations: + required: true + + - type: input + id: version + attributes: + label: Continuum Version + description: What version are you running? + placeholder: e.g., 2.0.0 + validations: + required: true + + - type: dropdown + id: install_method + attributes: + label: Installation Method + description: How did you install Continuum? + options: + - APT repository + - Manual .deb package + - From source + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: Information about your system + placeholder: | + - OS: Ubuntu 22.04 + - Python version: 3.11 + - Network interface: eth0 + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: Paste any relevant log output here (check /var/log/continuum/ or `sudo journalctl -u continuum`) + render: shell + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context about the problem + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ddf1fb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/Jordonh18/continuum/wiki + about: Check the documentation for help and guides + - name: Discussions + url: https://github.com/Jordonh18/continuum/discussions + about: Ask questions and discuss ideas with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c29b5cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,63 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Continuum +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe what you'd like to see added to Continuum. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? Is your feature request related to a problem? + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see + placeholder: I'd like to be able to... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false + + - type: dropdown + id: component + attributes: + label: Related Component + description: Which part of Continuum does this relate to? + options: + - Printer Management + - Network/Redirects + - Health Monitoring + - Job Tracking + - Workflows/Automation + - Web UI + - API + - Authentication + - Notifications + - System/Installation + - Other + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, screenshots, or examples about the feature request + validations: + required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa9f228..6f7bd4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,22 +1,32 @@ -# Copilot Instructions for Printer Proxy +# Copilot Instructions for Continuum ## Project Overview -Printer Proxy is a Flask web application that redirects network print traffic via NAT/iptables. When a printer fails, clients continue printing to the same IP while traffic is forwarded to a working printer. +Continuum is a Flask + React web application that redirects network print traffic via NAT/iptables. When a printer fails, clients continue printing to the same IP while traffic is forwarded to a working printer. ## Architecture ### Core Components -- **Flask App Factory**: [app/__init__.py](app/__init__.py) - Uses `create_app()` pattern with blueprints (`main_bp`, `auth_bp`, `api_bp`) -- **Routes**: [app/routes.py](app/routes.py) - All web routes and API endpoints +- **Flask App Factory**: [app/__init__.py](app/__init__.py) - Uses `create_app()` pattern with `api_bp` blueprint +- **React Frontend**: [frontend/](frontend/) - Vite + React + TypeScript SPA with Tailwind CSS +- **API Routes**: [app/routes.py](app/routes.py) - JSON API endpoints only (no templates) - **Models**: [app/models.py](app/models.py) - SQLite database with raw SQL (no ORM) - **Network Manager**: [app/network.py](app/network.py) - Calls privileged bash helper via sudo - **Network Helper**: [scripts/network_helper.sh](scripts/network_helper.sh) - Bash script for iptables/NAT operations (runs as root) +### Frontend Stack + +- **React 18** with TypeScript +- **Vite** for build tooling +- **Tailwind CSS** for styling +- **TanStack Query** for data fetching +- **React Router DOM** for client-side routing +- **JWT Authentication** via flask-jwt-extended + ### Data Flow for Redirects -1. User creates redirect via web UI → [app/routes.py](app/routes.py#L126) +1. User creates redirect via React UI → API call to `/api/redirects` 2. `NetworkManager` calls `network_helper.sh` via sudo 3. Helper script adds secondary IP + NAT rules with iptables 4. Print traffic to broken IP is forwarded to target printer @@ -75,15 +85,15 @@ Uses mDNS (zeroconf) and SNMP scanning. See [app/discovery.py](app/discovery.py) ### Authentication -Flask-Login with bcrypt password hashing. Account lockout after failed attempts. See [app/auth.py](app/auth.py). +JWT tokens for API authentication via flask-jwt-extended. Bcrypt password hashing. Account lockout after failed attempts. See [app/auth.py](app/auth.py). ## File Locations (Production) | Path | Purpose | |------|---------| -| `/opt/printer-proxy/` | Application code | -| `/var/lib/printer-proxy/` | SQLite database, secrets | -| `/var/log/printer-proxy/` | Application logs | +| `/opt/continuum/` | Application code | +| `/var/lib/continuum/` | SQLite database, secrets | +| `/var/log/continuum/` | Application logs | Config auto-detects install vs development mode in [config/config.py](config/config.py#L82). diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..da65f3e --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,140 @@ +name: PR Checks + +on: + pull_request: + branches: [main] + paths-ignore: + - 'README.md' + - 'docs/**' + - '.github/copilot-instructions.md' + - 'LICENSE' + - '*.md' + - '.gitignore' + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-version: + name: Check Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + VERSION=$(python3 -c "exec(open('app/version.py').read()); print(__version__)") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + build: + name: Build Package + runs-on: ubuntu-latest + needs: check-version + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y dpkg-dev fakeroot gzip + + - name: Build .deb package + run: | + chmod +x scripts/build-deb.sh + ./scripts/build-deb.sh + + - name: Verify package + run: | + VERSION="${{ needs.check-version.outputs.version }}" + DEB_FILE="builds/continuum_${VERSION}_all.deb" + + if [ ! -f "$DEB_FILE" ]; then + echo "ERROR: Package not built" + exit 1 + fi + + dpkg-deb --info "$DEB_FILE" + cd builds && sha256sum -c "continuum_${VERSION}_all.deb.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: debian-package + path: | + builds/*.deb + builds/*.sha256 + retention-days: 7 + + test: + name: Test Installation + runs-on: ubuntu-latest + needs: [check-version, build] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: debian-package + path: builds/ + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y python3 python3-venv python3-pip \ + iptables iproute2 iputils-ping arping nginx openssl + + - name: Test installation + run: | + VERSION="${{ needs.check-version.outputs.version }}" + sudo dpkg -i "builds/continuum_${VERSION}_all.deb" || sudo apt-get install -f -y + + - name: Verify installation + run: | + [ -d "/opt/continuum" ] && echo "OK: App directory exists" + [ -d "/opt/continuum/venv" ] && echo "OK: Virtual environment created" + [ -f "/lib/systemd/system/continuum.service" ] && echo "OK: Service installed" + + cd /opt/continuum + sudo -u continuum /opt/continuum/venv/bin/python -c "from app import create_app; print('Imports OK')" + + - name: Comment on PR + uses: actions/github-script@v7 + if: always() + with: + script: | + const status = '${{ job.status }}'; + const version = '${{ needs.check-version.outputs.version }}'; + const icon = status === 'success' ? '✅' : '❌'; + + const body = `## ${icon} Build & Test ${status === 'success' ? 'Passed' : 'Failed'} + + **Version:** \`${version}\` + **Package:** \`continuum_${version}_all.deb\` + + ${status === 'success' ? 'All checks passed! Ready to merge.' : 'Some checks failed. Please review the logs.'}`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8837a40..9834f9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,7 +99,7 @@ jobs: echo '__version_info__ = tuple(int(x) for x in __version__.split("."))' echo '' echo '# For display' - echo 'VERSION_STRING = f"Printer Proxy v{__version__}"' + echo 'VERSION_STRING = f"Continuum v{__version__}"' } > app/version.py echo "Updated app/version.py to $VERSION" fi @@ -112,7 +112,7 @@ jobs: - name: Verify package run: | VERSION="${{ needs.check-changes.outputs.new_version }}" - DEB_FILE="builds/printer-proxy_${VERSION}_all.deb" + DEB_FILE="builds/continuum_${VERSION}_all.deb" if [ ! -f "$DEB_FILE" ]; then echo "ERROR: Package not built" @@ -120,7 +120,7 @@ jobs: fi dpkg-deb --info "$DEB_FILE" - cd builds && sha256sum -c "printer-proxy_${VERSION}_all.deb.sha256" + cd builds && sha256sum -c "continuum_${VERSION}_all.deb.sha256" - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -155,16 +155,16 @@ jobs: - name: Test installation run: | VERSION="${{ needs.check-changes.outputs.new_version }}" - sudo dpkg -i "builds/printer-proxy_${VERSION}_all.deb" || sudo apt-get install -f -y + sudo dpkg -i "builds/continuum_${VERSION}_all.deb" || sudo apt-get install -f -y - name: Verify installation run: | - [ -d "/opt/printer-proxy" ] && echo "OK: App directory exists" - [ -d "/opt/printer-proxy/venv" ] && echo "OK: Virtual environment created" - [ -f "/lib/systemd/system/printer-proxy.service" ] && echo "OK: Service installed" + [ -d "/opt/continuum" ] && echo "OK: App directory exists" + [ -d "/opt/continuum/venv" ] && echo "OK: Virtual environment created" + [ -f "/lib/systemd/system/continuum.service" ] && echo "OK: Service installed" - cd /opt/printer-proxy - sudo -u printer-proxy /opt/printer-proxy/venv/bin/python -c "from app import create_app; print('Imports OK')" + cd /opt/continuum + sudo -u continuum /opt/continuum/venv/bin/python -c "from app import create_app; print('Imports OK')" release: name: Create Release @@ -205,13 +205,13 @@ jobs: uses: softprops/action-gh-release@v1 with: tag_name: v${{ needs.check-changes.outputs.new_version }} - name: Printer Proxy v${{ needs.check-changes.outputs.new_version }} + name: Continuum v${{ needs.check-changes.outputs.new_version }} body_path: release_notes.md draft: false prerelease: ${{ contains(needs.check-changes.outputs.new_version, 'beta') || contains(needs.check-changes.outputs.new_version, 'alpha') }} files: | - builds/printer-proxy_${{ needs.check-changes.outputs.new_version }}_all.deb - builds/printer-proxy_${{ needs.check-changes.outputs.new_version }}_all.deb.sha256 + builds/continuum_${{ needs.check-changes.outputs.new_version }}_all.deb + builds/continuum_${{ needs.check-changes.outputs.new_version }}_all.deb.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -241,7 +241,7 @@ jobs: - name: Build APT repository run: | VERSION="${{ needs.check-changes.outputs.new_version }}" - DEB_FILE="builds/printer-proxy_${VERSION}_all.deb" + DEB_FILE="builds/continuum_${VERSION}_all.deb" # Create site directory mkdir -p site/pool/main diff --git a/README.md b/README.md index 516301d..2cb30e9 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,99 @@ -# Printer Proxy +# Continuum [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) [![Platform](https://img.shields.io/badge/platform-linux-lightgrey.svg)](https://ubuntu.com/) +[![GitHub release](https://img.shields.io/github/v/release/Jordonh18/continuum)](https://github.com/Jordonh18/continuum/releases) +[![GitHub issues](https://img.shields.io/github/issues/Jordonh18/continuum)](https://github.com/Jordonh18/continuum/issues) +[![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/Jordonh18/continuum?utm_source=oss&utm_medium=github&utm_campaign=Jordonh18%2Fcontinuum&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)](https://coderabbit.ai) -A network traffic redirection solution for network printers that uses NAT and iptables to transparently redirect print jobs from a failed printer to a working replacement without requiring client reconfiguration. +A modern network printer management platform with transparent traffic redirection. When a printer fails, Continuum automatically redirects print jobs to working printers using NAT/iptables—no client reconfiguration required. -> **Note:** This software is currently in beta. The core network redirection functionality is stable and production-ready. Job monitoring and printer event logs are experimental features. +## Features + +### Printer Management +- **Automatic Discovery**: Find printers via mDNS, SNMP scanning, or manual entry +- **Health Monitoring**: Real-time status tracking with ICMP and TCP checks +- **Job Tracking**: Monitor print jobs and page counts via SNMP +- **Statistics & Analytics**: Historical data and uptime reports + +### Network Redirection +- **Transparent Failover**: Automatically redirect traffic from failed printers to working replacements +- **Secondary IP Assignment**: Dynamically claims failed printer IPs as network aliases +- **NAT/DNAT Rules**: iptables-based traffic forwarding for seamless redirection +- **Protocol Support**: RAW (9100), IPP (631), LPR (515) +- **Group Redirects**: Schedule redirects for multiple printers simultaneously + +### Workflow Automation +- **Visual Workflow Builder**: Drag-and-drop interface for creating automation +- **Triggers**: Schedule-based, event-based, or manual workflow execution +- **Actions**: Printer operations, redirects, notifications, HTTP requests, email +- **Conditions**: Branch logic based on printer status, time, or custom variables + +### Notifications & Alerts +- **Multi-Channel**: Email, Slack, Discord, Microsoft Teams, webhooks +- **Event Types**: Printer offline/online, redirect changes, job completion, workflow events +- **Custom Rules**: Configure notification preferences per event type + +### Security & Access +- **JWT Authentication**: Secure API access with token-based auth +- **User Management**: Create users, set passwords, manage permissions +- **MFA Support**: TOTP-based two-factor authentication +- **Account Lockout**: Brute-force protection with configurable thresholds +- **API Tokens**: Generate tokens for programmatic access +- **Rate Limiting**: Protect against abuse with configurable rate limits + +### Modern Web Interface +- **React + TypeScript**: Fast, responsive single-page application +- **Dark Mode**: System-aware theme switching +- **Real-Time Updates**: Live status updates via polling +- **Mobile-Friendly**: Responsive design for any device + +### System & Deployment +- **APT Repository**: Easy installation and automatic updates +- **Systemd Integration**: Native service management +- **Nginx Reverse Proxy**: Production-ready HTTPS setup +- **Self-Signed Certificates**: Auto-generated SSL for development ## Installation -Install Printer Proxy via the official APT repository: +### Via APT Repository (Recommended) ```bash -# 1. Add the GPG signing key -curl -fsSL https://jordonh18.github.io/printer-proxy/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/printer-proxy.gpg +# Add the GPG signing key +curl -fsSL https://apt.jordonh.me/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/continuum.gpg -# 2. Add the repository -echo "deb [signed-by=/usr/share/keyrings/printer-proxy.gpg] https://jordonh18.github.io/printer-proxy stable main" | sudo tee /etc/apt/sources.list.d/printer-proxy.list +# Add the repository +echo "deb [signed-by=/usr/share/keyrings/continuum.gpg] https://apt.jordonh.me stable main" | sudo tee /etc/apt/sources.list.d/continuum.list -# 3. Install +# Install Continuum sudo apt update -sudo apt install printer-proxy +sudo apt install continuum ``` -## Upgrading +### Manual Installation ```bash -sudo apt update -sudo apt upgrade printer-proxy -``` - -## Initial Setup +# Download the latest .deb package +wget https://github.com/Jordonh18/continuum/releases/latest/download/continuum_VERSION_all.deb -1. Access the web interface: `https://` -2. Accept the self-signed certificate warning -3. Create an administrator account on the setup page -4. Log in with your new credentials +# Install +sudo dpkg -i continuum_VERSION_all.deb +sudo apt-get install -f # Install dependencies +``` -## Overview +## Quick Start -Printer Proxy operates at the network layer to intercept and redirect TCP print traffic destined for a failed printer to an alternative target. When a printer fails, the proxy server claims the failed printer's IP address as a secondary interface and applies DNAT rules to forward incoming connections to a working printer. +1. **Access the Web UI**: Navigate to `https://` (accept the self-signed certificate warning) +2. **Create Admin Account**: Complete the initial setup wizard +3. **Discover Printers**: Use the discovery tool to find printers on your network +4. **Create Redirect**: Select a failed printer and redirect it to a working one -### How It Works +## How It Works ``` ┌──────────────────────────────────────┐ - │ Printer Proxy Server │ + │ Continuum Server │ │ │ │ 1. Claims 192.168.1.10 as secondary │ │ 2. Applies DNAT rule in iptables │ @@ -64,60 +111,98 @@ Printer Proxy operates at the network layer to intercept and redirect TCP print └──────────────────┘ ``` -## Features - -### Network Management -- **Secondary IP Assignment**: Dynamically assigns IP addresses to network interfaces -- **NAT/DNAT Rules**: Creates iptables rules for transparent traffic forwarding -- **Port Support**: TCP 9100 (RAW printing), with configurable port support +Continuum operates at the network layer, intercepting TCP print traffic destined for a failed printer and transparently redirecting it to a working replacement. The proxy server claims the failed printer's IP address as a secondary interface and applies DNAT rules to forward connections—clients continue printing without any configuration changes. -### Printer Discovery -- **mDNS/Bonjour**: Discovers printers advertising via multicast DNS -- **SNMP Scanning**: Scans network ranges for SNMP-enabled devices -- **Manual Entry**: Supports manual printer registration +## Upgrading -### Health Monitoring -- **Background Polling**: Monitors printer availability via ICMP and TCP -- **Health History**: Tracks uptime/downtime over time -- **Status Dashboard**: Real-time status indicators +```bash +sudo apt update +sudo apt upgrade continuum +``` -### Automatic Updates -- Updates are delivered via APT repository -- Check for updates in the Settings page -- Updates are applied automatically with service restart +The service will automatically restart after upgrade. -## System Requirements +## Development -- Ubuntu 20.04+ or Debian 11+ +### Prerequisites - Python 3.9+ -- Root/sudo access for iptables manipulation -- Network interface capable of multiple IP addresses +- Node.js 18+ and npm +- Linux with iptables support -## Development +### Setup ```bash # Clone repository -git clone https://github.com/Jordonh18/printer-proxy.git -cd printer-proxy +git clone https://github.com/Jordonh18/continuum.git +cd continuum -# Create virtual environment +# Backend setup python3 -m venv venv source venv/bin/activate - -# Install dependencies pip install -r requirements.txt -# Run development server +# Frontend setup +cd frontend +npm install +npm run build + +# Run development server (backend on :8080) +cd .. python run.py ``` -## Building +### Building ```bash # Build .deb package ./scripts/build-deb.sh + +# Package will be in builds/ +``` + +## System Requirements + +- **OS**: Ubuntu 20.04+ or Debian 11+ +- **Python**: 3.9 or higher +- **Permissions**: Root/sudo access for iptables manipulation +- **Network**: Interface capable of multiple IP addresses + +## Project Structure + +``` +continuum/ +├── app/ # Backend application +│ ├── models/ # Database models +│ ├── routes/ # API endpoints +│ ├── services/ # Business logic (health check, discovery, etc.) +│ └── utils/ # Authentication, rate limiting, etc. +├── frontend/ # React + TypeScript SPA +│ └── src/ +│ ├── components/ # UI components +│ ├── pages/ # Page components +│ └── lib/ # API client, utilities +├── scripts/ # Build and utility scripts +├── debian/ # Debian packaging files +└── config/ # Application configuration + ``` +## Contributing + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + ## License MIT License - see [LICENSE](LICENSE) for details. + +## Links + +- **Documentation**: [GitHub Wiki](https://github.com/Jordonh18/continuum/wiki) +- **Issue Tracker**: [GitHub Issues](https://github.com/Jordonh18/continuum/issues) +- **APT Repository**: [https://apt.jordonh.me](https://apt.jordonh.me) + diff --git a/TODO.md b/TODO.md index 1a0ff52..63ef9a2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -# Printer Proxy - Feature Roadmap +# Continuum - Feature Roadmap ## 🔥 High Priority @@ -9,11 +9,20 @@ - [x] Rollback capability on failed update - [x] Update notification banner in UI -### Notification System -- [ ] Email alerts (SMTP configuration) +### ~~Notification System~~ ✅ DONE +- [x] Email alerts (SMTP configuration) +- [x] Notification preferences per user +- [x] Security event notifications (login alerts) +- [x] Health check notifications (offline/online alerts) +- [x] Job failure notifications +- [x] Weekly report generation (backend scheduler) - [ ] Webhook support (Slack, Teams, Discord) +- [ ] Custom Reports page with scheduling + - Weekly report preferences UI + - Custom report templates + - Schedule configuration (daily, weekly, monthly) + - Manual report generation - [ ] Configurable alert triggers: - - Printer offline - Toner low (configurable threshold) - Redirect activated/deactivated - Failed health checks (consecutive failures) @@ -47,15 +56,15 @@ ## 💡 Nice to Have ### Dark Mode -- [ ] CSS theme toggle -- [ ] Remember preference -- [ ] System preference detection +- [x] CSS theme toggle +- [x] Remember preference +- [x] System preference detection ### Multi-user Roles -- [ ] Admin role (full access) -- [ ] Operator role (manage redirects, view only printers) -- [ ] Viewer role (read-only dashboard) -- [ ] User management page +- [x ] Admin role (full access) +- [x ] Operator role (manage redirects, view only printers) +- [ x] Viewer role (read-only dashboard) +- [ x] User management page ### Printer Groups - [ ] Create groups (e.g., "Floor 1", "Marketing") @@ -85,3 +94,7 @@ - [x] Dashboard with status overview - [x] Debian package builds - [x] **Auto-Update System** - Check GitHub releases, one-click update from web UI + + + +auto snapshots and backups of data allowing rollback at any time incase of a configuraton issue or update etc. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 0b91d64..8b7cd91 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,9 +5,14 @@ import logging from logging.handlers import RotatingFileHandler from pathlib import Path +from datetime import timedelta -from flask import Flask -from flask_wtf.csrf import CSRFProtect +from flask import Flask, request +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_compress import Compress from config.config import ( SECRET_KEY, @@ -15,13 +20,24 @@ LOG_FORMAT, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, - SESSION_TIMEOUT_MINUTES + SESSION_TIMEOUT_MINUTES, + JWT_SECRET_KEY, + JWT_ACCESS_TOKEN_EXPIRES_HOURS, + JWT_REFRESH_TOKEN_EXPIRES_DAYS ) -from app.models import init_db -from app.auth import login_manager +from app.models.base import init_db +from app.utils.auth import login_manager +from app.utils.rate_limiting import get_ip_for_ratelimit, handle_rate_limit_exceeded -csrf = CSRFProtect() +jwt = JWTManager() +limiter = Limiter( + key_func=get_ip_for_ratelimit, + default_limits=["100 per minute", "1000 per hour"], + storage_uri="memory://", + strategy="fixed-window" +) +compress = Compress() def create_app() -> Flask: @@ -31,7 +47,7 @@ def create_app() -> Flask: # Get the base directory (parent of app/) base_dir = Path(__file__).resolve().parent.parent - app = Flask('printer-proxy', + app = Flask('continuum', template_folder=str(base_dir / 'templates'), static_folder=str(base_dir / 'static')) @@ -42,6 +58,12 @@ def create_app() -> Flask: app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + # JWT Configuration + app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY + app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=JWT_ACCESS_TOKEN_EXPIRES_HOURS) + app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=JWT_REFRESH_TOKEN_EXPIRES_DAYS) + app.config['JWT_TOKEN_LOCATION'] = ['headers'] + # Make version available in all templates app.config['VERSION'] = __version__ app.config['VERSION_STRING'] = VERSION_STRING @@ -51,51 +73,132 @@ def inject_version(): return {'app_version': __version__, 'version_string': VERSION_STRING} # Initialize extensions - csrf.init_app(app) login_manager.init_app(app) + jwt.init_app(app) + limiter.init_app(app) + + # Enable gzip compression for all responses (massive speed improvement) + app.config['COMPRESS_MIMETYPES'] = ['text/html', 'text/css', 'text/xml', 'application/json', 'application/javascript'] + app.config['COMPRESS_LEVEL'] = 6 # Balance between speed and compression + app.config['COMPRESS_MIN_SIZE'] = 500 # Only compress responses > 500 bytes + compress.init_app(app) + + # Enable CORS for API routes (React frontend) + CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) + + # Note: CSRF is NOT initialized since we're using JWT for all API routes + # and React handles the frontend (no form submissions need CSRF) # Initialize database init_db() # Initialize health check tables - from app.health_check import init_health_check_tables, start_health_checks + from app.services.health_check import init_health_check_tables, start_health_checks init_health_check_tables() # Start background health checks (only in production, not in reloader) if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': # Only start printer polling services if printers exist - from app.printers import get_registry + from app.services.printer_registry import get_registry registry = get_registry() if registry.has_printers(): start_health_checks() # Start the job monitor for print job detection - from app.job_monitor import init_job_monitor + from app.services.job_monitor import init_job_monitor init_job_monitor(app, start=True) else: app.logger.info("No printers registered; deferring polling services") # Start the auto-update checker - from app.updater import init_updater + from app.services.updater import init_updater init_updater(start_background=True) + + # Start the weekly report scheduler + from app.services.notification_sender import start_weekly_reports + start_weekly_reports() + + # Start group redirect scheduler + from app.services.schedulers.group_redirect import init_group_redirect_scheduler + init_group_redirect_scheduler(start_background=True) + + # Start printer redirect scheduler + from app.services.schedulers.printer_redirect import init_printer_redirect_scheduler + init_printer_redirect_scheduler(start_background=True) + + # Start workflow scheduler + from app.services.schedulers.workflow import reload_workflow_schedules + reload_workflow_schedules() # Setup logging setup_logging(app) - # Register blueprints - from app.routes import main_bp, auth_bp, api_bp - app.register_blueprint(main_bp) - app.register_blueprint(auth_bp, url_prefix='/auth') + # Register API blueprint only - React handles all UI + from app.routes import api_bp app.register_blueprint(api_bp, url_prefix='/api') + # Serve React frontend + setup_react_frontend(app, base_dir) + # Error handlers register_error_handlers(app) - app.logger.info("Printer Proxy application started") + app.logger.info("Continuum application started") return app +def setup_react_frontend(app: Flask, base_dir: Path): + """Configure serving of React frontend for production.""" + from flask import send_from_directory, send_file, request + + # Path to the React build directory + frontend_dist = base_dir / 'frontend' / 'dist' + + # Only serve React frontend if the build exists + if not frontend_dist.exists(): + app.logger.info("React frontend not built; using legacy templates") + app.config['USE_REACT_FRONTEND'] = False + return + + app.logger.info(f"Serving React frontend from {frontend_dist}") + app.config['USE_REACT_FRONTEND'] = True + app.config['REACT_DIST_PATH'] = frontend_dist + + # Serve React static assets + @app.route('/assets/') + def react_assets(filename): + return send_from_directory(frontend_dist / 'assets', filename) + + # Serve vite.svg favicon + @app.route('/vite.svg') + def react_vite_svg(): + return send_from_directory(frontend_dist, 'vite.svg') + + # Override the root route before blueprints process it + @app.before_request + def serve_react_for_spa_routes(): + """Intercept SPA routes and serve React app.""" + # Skip API routes + if request.path.startswith('/api/'): + return None + + # Skip static files + if request.path.startswith('/static/'): + return None + + # Skip assets (already handled) + if request.path.startswith('/assets/'): + return None + + # For all other routes, serve React index.html + index_file = frontend_dist / 'index.html' + if index_file.exists(): + return send_file(index_file) + + return None # Fall through to normal routing + + def setup_logging(app: Flask): """Configure application logging.""" # Ensure log directory exists @@ -141,18 +244,27 @@ def setup_logging(app: Flask): def register_error_handlers(app: Flask): - """Register error handlers.""" - from flask import render_template + """Register error handlers that return JSON for API errors.""" + from flask import jsonify, request + from werkzeug.exceptions import TooManyRequests + from app.utils.rate_limiting import handle_rate_limit_exceeded + + # Rate limit error handler + app.errorhandler(429)(handle_rate_limit_exceeded) + app.errorhandler(TooManyRequests)(handle_rate_limit_exceeded) @app.errorhandler(404) def not_found_error(error): - return render_template('errors/404.html'), 404 + if request.path.startswith('/api/'): + return jsonify({'error': 'Not found'}), 404 + # For non-API routes, React handles 404 + return app.send_static_file('index.html') if app.config.get('USE_REACT_FRONTEND') else (jsonify({'error': 'Not found'}), 404) @app.errorhandler(500) def internal_error(error): app.logger.error(f"Internal server error: {error}") - return render_template('errors/500.html'), 500 + return jsonify({'error': 'Internal server error'}), 500 @app.errorhandler(403) def forbidden_error(error): - return render_template('errors/403.html'), 403 + return jsonify({'error': 'Forbidden'}), 403 diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 774cd36..0000000 --- a/app/models.py +++ /dev/null @@ -1,966 +0,0 @@ -""" -Database models for Printer Proxy -""" -import sqlite3 -from datetime import datetime -from pathlib import Path -from typing import Optional, List, Dict, Any -import json - -from config.config import DATABASE_PATH, DATA_DIR - - -def get_db_connection() -> sqlite3.Connection: - """Get a database connection with row factory.""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DATABASE_PATH)) - conn.row_factory = sqlite3.Row - return conn - - -def init_db(): - """Initialize the database schema.""" - conn = get_db_connection() - cursor = conn.cursor() - - # Users table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role TEXT DEFAULT 'admin', - is_active BOOLEAN DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_login TIMESTAMP, - failed_attempts INTEGER DEFAULT 0, - locked_until TIMESTAMP - ) - """) - - # Ensure role column exists for older installs - cursor.execute("PRAGMA table_info(users)") - user_columns = {row['name'] for row in cursor.fetchall()} - if 'role' not in user_columns: - cursor.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'admin'") - cursor.execute("UPDATE users SET role = 'admin' WHERE role IS NULL OR role = ''") - - # Active redirects table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS active_redirects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_printer_id TEXT UNIQUE NOT NULL, - source_ip TEXT NOT NULL, - target_printer_id TEXT NOT NULL, - target_ip TEXT NOT NULL, - protocol TEXT DEFAULT 'raw', - port INTEGER DEFAULT 9100, - enabled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - enabled_by TEXT NOT NULL - ) - """) - - # Audit log table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - username TEXT NOT NULL, - action TEXT NOT NULL, - source_printer_id TEXT, - source_ip TEXT, - target_printer_id TEXT, - target_ip TEXT, - details TEXT, - success BOOLEAN, - error_message TEXT - ) - """) - - # Login attempts table for rate limiting - cursor.execute(""" - CREATE TABLE IF NOT EXISTS login_attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - ip_address TEXT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - success BOOLEAN - ) - """) - - # Printers table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS printers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - ip TEXT UNIQUE NOT NULL, - protocols TEXT DEFAULT 'raw', - location TEXT DEFAULT '', - model TEXT DEFAULT '', - department TEXT DEFAULT '', - notes TEXT DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - # Redirect history table for statistics - cursor.execute(""" - CREATE TABLE IF NOT EXISTS redirect_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_printer_id TEXT NOT NULL, - source_ip TEXT NOT NULL, - target_printer_id TEXT NOT NULL, - target_ip TEXT NOT NULL, - enabled_at TIMESTAMP NOT NULL, - enabled_by TEXT NOT NULL, - disabled_at TIMESTAMP, - disabled_by TEXT, - duration_seconds INTEGER, - reason TEXT - ) - """) - - # Create index for redirect history queries - cursor.execute(""" - CREATE INDEX IF NOT EXISTS idx_redirect_history_source - ON redirect_history(source_printer_id, enabled_at DESC) - """) - - # Print job history table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS print_job_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - printer_id TEXT NOT NULL, - job_id INTEGER NOT NULL, - name TEXT DEFAULT '', - owner TEXT DEFAULT '', - status TEXT DEFAULT 'Unknown', - pages INTEGER DEFAULT 0, - size_bytes INTEGER DEFAULT 0, - submitted_at TIMESTAMP, - started_at TIMESTAMP, - completed_at TIMESTAMP, - recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (printer_id) REFERENCES printers(id) - ) - """) - - # Create index for job history queries - cursor.execute(""" - CREATE INDEX IF NOT EXISTS idx_job_history_printer - ON print_job_history(printer_id, recorded_at DESC) - """) - - # Printer error log table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS printer_error_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - printer_id TEXT NOT NULL, - code INTEGER NOT NULL, - severity TEXT DEFAULT 'warning', - message TEXT NOT NULL, - description TEXT DEFAULT '', - occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - resolved_at TIMESTAMP, - FOREIGN KEY (printer_id) REFERENCES printers(id) - ) - """) - - # Create index for error log queries - cursor.execute(""" - CREATE INDEX IF NOT EXISTS idx_error_log_printer - ON printer_error_log(printer_id, occurred_at DESC) - """) - - # Settings table for application configuration - cursor.execute(""" - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - conn.commit() - conn.close() - - -class User: - """User model for authentication.""" - - def __init__(self, id: int, username: str, password_hash: str, role: str = 'admin', - is_active: bool = True, last_login: Optional[datetime] = None, - failed_attempts: int = 0, locked_until: Optional[datetime] = None): - self.id = id - self.username = username - self.password_hash = password_hash - self.role = role or 'admin' - self.is_active = is_active - self.last_login = last_login - self.failed_attempts = failed_attempts - self.locked_until = locked_until - - @property - def is_authenticated(self): - return True - - @property - def is_anonymous(self): - return False - - def get_id(self): - return str(self.id) - - @property - def is_admin(self) -> bool: - return self.role == 'admin' - - @property - def is_operator(self) -> bool: - return self.role == 'operator' - - @property - def is_viewer(self) -> bool: - return self.role == 'viewer' - - @staticmethod - def get_by_id(user_id: int) -> Optional['User']: - """Get user by ID.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) - row = cursor.fetchone() - conn.close() - - if row: - return User( - id=row['id'], - username=row['username'], - password_hash=row['password_hash'], - role=row['role'] if 'role' in row.keys() else 'admin', - is_active=bool(row['is_active']), - last_login=row['last_login'], - failed_attempts=row['failed_attempts'], - locked_until=row['locked_until'] - ) - return None - - @staticmethod - def get_by_username(username: str) -> Optional['User']: - """Get user by username.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT * FROM users WHERE username = ?", (username,)) - row = cursor.fetchone() - conn.close() - - if row: - return User( - id=row['id'], - username=row['username'], - password_hash=row['password_hash'], - role=row['role'] if 'role' in row.keys() else 'admin', - is_active=bool(row['is_active']), - last_login=row['last_login'], - failed_attempts=row['failed_attempts'], - locked_until=row['locked_until'] - ) - return None - - @staticmethod - def create(username: str, password_hash: str, role: str = 'admin', is_active: bool = True) -> 'User': - """Create a new user.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "INSERT INTO users (username, password_hash, role, is_active) VALUES (?, ?, ?, ?)", - (username, password_hash, role, int(is_active)) - ) - conn.commit() - user_id = cursor.lastrowid - conn.close() - - return User(id=user_id, username=username, password_hash=password_hash, role=role, is_active=is_active) - - @staticmethod - def get_all() -> List['User']: - """Get all users.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT * FROM users ORDER BY username ASC") - rows = cursor.fetchall() - conn.close() - - return [User( - id=row['id'], - username=row['username'], - password_hash=row['password_hash'], - role=row['role'] if 'role' in row.keys() else 'admin', - is_active=bool(row['is_active']), - last_login=row['last_login'], - failed_attempts=row['failed_attempts'], - locked_until=row['locked_until'] - ) for row in rows] - - def update_role(self, role: str): - """Update user's role.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET role = ? WHERE id = ?", - (role, self.id) - ) - conn.commit() - conn.close() - self.role = role - - def set_active(self, is_active: bool): - """Enable or disable user account.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET is_active = ? WHERE id = ?", - (int(is_active), self.id) - ) - conn.commit() - conn.close() - self.is_active = is_active - - def update_last_login(self): - """Update last login timestamp.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET last_login = ?, failed_attempts = 0 WHERE id = ?", - (datetime.now().isoformat(), self.id) - ) - conn.commit() - conn.close() - - @staticmethod - def delete_by_id(user_id: int) -> bool: - """Delete a user by ID.""" - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - deleted = cursor.rowcount - conn.commit() - conn.close() - return deleted > 0 - except Exception: - return False - - def increment_failed_attempts(self): - """Increment failed login attempts.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?", - (self.id,) - ) - conn.commit() - conn.close() - - def lock_account(self, until: datetime): - """Lock account until specified time.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET locked_until = ? WHERE id = ?", - (until.isoformat(), self.id) - ) - conn.commit() - conn.close() - - def is_locked(self) -> bool: - """Check if account is currently locked.""" - if self.locked_until is None: - return False - if isinstance(self.locked_until, str): - locked_until = datetime.fromisoformat(self.locked_until) - else: - locked_until = self.locked_until - return datetime.now() < locked_until - - def update_password(self, new_password_hash: str): - """Update user's password.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE users SET password_hash = ? WHERE id = ?", - (new_password_hash, self.id) - ) - conn.commit() - conn.close() - self.password_hash = new_password_hash - - -class ActiveRedirect: - """Model for active printer redirects.""" - - def __init__(self, id: int, source_printer_id: str, source_ip: str, - target_printer_id: str, target_ip: str, protocol: str, - port: int, enabled_at: datetime, enabled_by: str): - self.id = id - self.source_printer_id = source_printer_id - self.source_ip = source_ip - self.target_printer_id = target_printer_id - self.target_ip = target_ip - self.protocol = protocol - self.port = port - self.enabled_at = enabled_at - self.enabled_by = enabled_by - - @staticmethod - def get_all() -> List['ActiveRedirect']: - """Get all active redirects.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT * FROM active_redirects ORDER BY enabled_at DESC") - rows = cursor.fetchall() - conn.close() - - return [ActiveRedirect( - id=row['id'], - source_printer_id=row['source_printer_id'], - source_ip=row['source_ip'], - target_printer_id=row['target_printer_id'], - target_ip=row['target_ip'], - protocol=row['protocol'], - port=row['port'], - enabled_at=row['enabled_at'], - enabled_by=row['enabled_by'] - ) for row in rows] - - @staticmethod - def get_by_source_printer(printer_id: str) -> Optional['ActiveRedirect']: - """Get redirect by source printer ID.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "SELECT * FROM active_redirects WHERE source_printer_id = ?", - (printer_id,) - ) - row = cursor.fetchone() - conn.close() - - if row: - return ActiveRedirect( - id=row['id'], - source_printer_id=row['source_printer_id'], - source_ip=row['source_ip'], - target_printer_id=row['target_printer_id'], - target_ip=row['target_ip'], - protocol=row['protocol'], - port=row['port'], - enabled_at=row['enabled_at'], - enabled_by=row['enabled_by'] - ) - return None - - @staticmethod - def get_by_source_ip(ip: str) -> Optional['ActiveRedirect']: - """Get redirect by source IP.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT * FROM active_redirects WHERE source_ip = ?", (ip,)) - row = cursor.fetchone() - conn.close() - - if row: - return ActiveRedirect( - id=row['id'], - source_printer_id=row['source_printer_id'], - source_ip=row['source_ip'], - target_printer_id=row['target_printer_id'], - target_ip=row['target_ip'], - protocol=row['protocol'], - port=row['port'], - enabled_at=row['enabled_at'], - enabled_by=row['enabled_by'] - ) - return None - - @staticmethod - def is_target_in_use(printer_id: str) -> bool: - """Check if a printer is already being used as a target.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - "SELECT COUNT(*) FROM active_redirects WHERE target_printer_id = ?", - (printer_id,) - ) - count = cursor.fetchone()[0] - conn.close() - return count > 0 - - @staticmethod - def create(source_printer_id: str, source_ip: str, target_printer_id: str, - target_ip: str, protocol: str, port: int, enabled_by: str) -> 'ActiveRedirect': - """Create a new redirect.""" - conn = get_db_connection() - cursor = conn.cursor() - enabled_at = datetime.now().isoformat() - cursor.execute(""" - INSERT INTO active_redirects - (source_printer_id, source_ip, target_printer_id, target_ip, - protocol, port, enabled_by, enabled_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, (source_printer_id, source_ip, target_printer_id, target_ip, - protocol, port, enabled_by, enabled_at)) - conn.commit() - redirect_id = cursor.lastrowid - conn.close() - - return ActiveRedirect( - id=redirect_id, - source_printer_id=source_printer_id, - source_ip=source_ip, - target_printer_id=target_printer_id, - target_ip=target_ip, - protocol=protocol, - port=port, - enabled_at=enabled_at, - enabled_by=enabled_by - ) - - def delete(self, disabled_by: str = None, reason: str = None): - """Delete this redirect and record in history.""" - conn = get_db_connection() - cursor = conn.cursor() - - # Calculate duration - if isinstance(self.enabled_at, str): - enabled_dt = datetime.fromisoformat(self.enabled_at) - else: - enabled_dt = self.enabled_at - duration = int((datetime.now() - enabled_dt).total_seconds()) - - # Record in history - cursor.execute(""" - INSERT INTO redirect_history - (source_printer_id, source_ip, target_printer_id, target_ip, - enabled_at, enabled_by, disabled_at, disabled_by, duration_seconds, reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - self.source_printer_id, self.source_ip, - self.target_printer_id, self.target_ip, - self.enabled_at, self.enabled_by, - datetime.now().isoformat(), disabled_by or 'system', - duration, reason - )) - - # Delete active redirect - cursor.execute("DELETE FROM active_redirects WHERE id = ?", (self.id,)) - conn.commit() - conn.close() - - -class AuditLog: - """Audit logging for all actions.""" - - @staticmethod - def log(username: str, action: str, source_printer_id: str = None, - source_ip: str = None, target_printer_id: str = None, - target_ip: str = None, details: str = None, - success: bool = True, error_message: str = None): - """Log an action.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO audit_log - (username, action, source_printer_id, source_ip, - target_printer_id, target_ip, details, success, error_message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (username, action, source_printer_id, source_ip, - target_printer_id, target_ip, details, success, error_message)) - conn.commit() - conn.close() - - @staticmethod - def get_recent(limit: int = 100) -> List[Dict[str, Any]]: - """Get recent audit log entries.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM audit_log - ORDER BY timestamp DESC - LIMIT ? - """, (limit,)) - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] - - @staticmethod - def get_by_printer(printer_id: str, limit: int = 50) -> List[Dict[str, Any]]: - """Get audit log entries for a specific printer.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM audit_log - WHERE source_printer_id = ? OR target_printer_id = ? - ORDER BY timestamp DESC - LIMIT ? - """, (printer_id, printer_id, limit)) - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] - - -class RedirectHistory: - """Model for redirect history and statistics.""" - - @staticmethod - def get_by_printer(printer_id: str, limit: int = 50) -> List[Dict[str, Any]]: - """Get redirect history for a printer (as source or target).""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM redirect_history - WHERE source_printer_id = ? OR target_printer_id = ? - ORDER BY enabled_at DESC - LIMIT ? - """, (printer_id, printer_id, limit)) - rows = cursor.fetchall() - conn.close() - return [dict(row) for row in rows] - - @staticmethod - def get_all(limit: int = 100) -> List[Dict[str, Any]]: - """Get all redirect history.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM redirect_history - ORDER BY enabled_at DESC - LIMIT ? - """, (limit,)) - rows = cursor.fetchall() - conn.close() - return [dict(row) for row in rows] - - @staticmethod - def get_statistics() -> Dict[str, Any]: - """Get overall redirect statistics.""" - conn = get_db_connection() - cursor = conn.cursor() - - # Total redirects ever - cursor.execute("SELECT COUNT(*) FROM redirect_history") - total_redirects = cursor.fetchone()[0] - - # Currently active - cursor.execute("SELECT COUNT(*) FROM active_redirects") - active_redirects = cursor.fetchone()[0] - - # Total duration (in hours) - cursor.execute("SELECT SUM(duration_seconds) FROM redirect_history") - total_seconds = cursor.fetchone()[0] or 0 - total_hours = round(total_seconds / 3600, 1) - - # Average duration - cursor.execute("SELECT AVG(duration_seconds) FROM redirect_history") - avg_seconds = cursor.fetchone()[0] or 0 - avg_duration = round(avg_seconds / 60, 1) # in minutes - - # Most redirected printer - cursor.execute(""" - SELECT source_printer_id, COUNT(*) as cnt - FROM redirect_history - GROUP BY source_printer_id - ORDER BY cnt DESC - LIMIT 1 - """) - row = cursor.fetchone() - most_redirected = row['source_printer_id'] if row else None - - # Redirects this month - cursor.execute(""" - SELECT COUNT(*) FROM redirect_history - WHERE enabled_at >= date('now', 'start of month') - """) - this_month = cursor.fetchone()[0] - - conn.close() - - return { - 'total_redirects': total_redirects, - 'active_redirects': active_redirects, - 'total_hours': total_hours, - 'avg_duration_minutes': avg_duration, - 'most_redirected_printer': most_redirected, - 'redirects_this_month': this_month - } - - -class PrintJobHistory: - """Model for storing print job history.""" - - def __init__(self, id: int, printer_id: str, job_id: int, name: str = "", - owner: str = "", status: str = "Unknown", pages: int = 0, - size_bytes: int = 0, submitted_at: Optional[datetime] = None, - started_at: Optional[datetime] = None, - completed_at: Optional[datetime] = None, - recorded_at: Optional[datetime] = None): - self.id = id - self.printer_id = printer_id - self.job_id = job_id - self.name = name - self.owner = owner - self.status = status - self.pages = pages - self.size_bytes = size_bytes - self.submitted_at = submitted_at - self.started_at = started_at - self.completed_at = completed_at - self.recorded_at = recorded_at or datetime.now() - - def to_dict(self) -> Dict[str, Any]: - return { - 'id': self.id, - 'printer_id': self.printer_id, - 'job_id': self.job_id, - 'name': self.name, - 'owner': self.owner, - 'status': self.status, - 'pages': self.pages, - 'size_bytes': self.size_bytes, - 'submitted_at': self.submitted_at.isoformat() if self.submitted_at else None, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'recorded_at': self.recorded_at.isoformat() if self.recorded_at else None - } - - @staticmethod - def create(printer_id: str, job_id: int, name: str = "", owner: str = "", - status: str = "Unknown", pages: int = 0, size_bytes: int = 0, - submitted_at: Optional[datetime] = None, - started_at: Optional[datetime] = None, - completed_at: Optional[datetime] = None) -> 'PrintJobHistory': - """Create a new job history entry.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO print_job_history - (printer_id, job_id, name, owner, status, pages, size_bytes, - submitted_at, started_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (printer_id, job_id, name, owner, status, pages, size_bytes, - submitted_at, started_at, completed_at)) - job_history_id = cursor.lastrowid - conn.commit() - conn.close() - - return PrintJobHistory( - id=job_history_id, printer_id=printer_id, job_id=job_id, - name=name, owner=owner, status=status, pages=pages, - size_bytes=size_bytes, submitted_at=submitted_at, - started_at=started_at, completed_at=completed_at - ) - - @staticmethod - def get_for_printer(printer_id: str, limit: int = 50) -> List['PrintJobHistory']: - """Get job history for a specific printer.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM print_job_history - WHERE printer_id = ? - ORDER BY recorded_at DESC - LIMIT ? - """, (printer_id, limit)) - rows = cursor.fetchall() - conn.close() - - return [PrintJobHistory( - id=row['id'], - printer_id=row['printer_id'], - job_id=row['job_id'], - name=row['name'], - owner=row['owner'], - status=row['status'], - pages=row['pages'], - size_bytes=row['size_bytes'], - submitted_at=row['submitted_at'], - started_at=row['started_at'], - completed_at=row['completed_at'], - recorded_at=row['recorded_at'] - ) for row in rows] - - @staticmethod - def record_job(printer_id, job_id: int, document_name: str = "", - username: str = "", status: str = "completed", - pages: int = 0, copies: int = 1, size_bytes: int = 0) -> Optional['PrintJobHistory']: - """Record a detected print job. - - This is a convenience method for the job monitor to record jobs. - """ - now = datetime.now() - return PrintJobHistory.create( - printer_id=str(printer_id), - job_id=job_id, - name=document_name, - owner=username, - status=status.title(), # Capitalize first letter - pages=pages, - size_bytes=size_bytes, - submitted_at=now, - started_at=now, - completed_at=now if status == 'completed' else None - ) - - @staticmethod - def get_statistics(printer_id: str) -> Dict[str, Any]: - """Get job statistics for a printer.""" - conn = get_db_connection() - cursor = conn.cursor() - - # Total jobs - cursor.execute(""" - SELECT COUNT(*) FROM print_job_history WHERE printer_id = ? - """, (printer_id,)) - total_jobs = cursor.fetchone()[0] - - # Total pages - cursor.execute(""" - SELECT SUM(pages) FROM print_job_history WHERE printer_id = ? - """, (printer_id,)) - total_pages = cursor.fetchone()[0] or 0 - - # Jobs today - cursor.execute(""" - SELECT COUNT(*) FROM print_job_history - WHERE printer_id = ? AND date(recorded_at) = date('now') - """, (printer_id,)) - jobs_today = cursor.fetchone()[0] - - # Completed jobs - cursor.execute(""" - SELECT COUNT(*) FROM print_job_history - WHERE printer_id = ? AND status = 'Completed' - """, (printer_id,)) - completed_jobs = cursor.fetchone()[0] - - conn.close() - - return { - 'total_jobs': total_jobs, - 'total_pages': total_pages, - 'jobs_today': jobs_today, - 'completed_jobs': completed_jobs - } - - -class PrinterErrorLog: - """Model for storing printer error logs.""" - - def __init__(self, id: int, printer_id: str, code: int, severity: str = "warning", - message: str = "", description: str = "", - occurred_at: Optional[datetime] = None, - resolved_at: Optional[datetime] = None): - self.id = id - self.printer_id = printer_id - self.code = code - self.severity = severity - self.message = message - self.description = description - self.occurred_at = occurred_at or datetime.now() - self.resolved_at = resolved_at - - def to_dict(self) -> Dict[str, Any]: - return { - 'id': self.id, - 'printer_id': self.printer_id, - 'code': self.code, - 'severity': self.severity, - 'message': self.message, - 'description': self.description, - 'occurred_at': self.occurred_at.isoformat() if self.occurred_at else None, - 'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None - } - - @staticmethod - def create(printer_id: str, code: int, severity: str = "warning", - message: str = "", description: str = "") -> 'PrinterErrorLog': - """Create a new error log entry.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO printer_error_log - (printer_id, code, severity, message, description) - VALUES (?, ?, ?, ?, ?) - """, (printer_id, code, severity, message, description)) - error_id = cursor.lastrowid - conn.commit() - conn.close() - - return PrinterErrorLog( - id=error_id, printer_id=printer_id, code=code, - severity=severity, message=message, description=description - ) - - @staticmethod - def resolve(error_id: int): - """Mark an error as resolved.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE printer_error_log - SET resolved_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (error_id,)) - conn.commit() - conn.close() - - @staticmethod - def get_for_printer(printer_id: str, include_resolved: bool = False, - limit: int = 50) -> List['PrinterErrorLog']: - """Get error log for a specific printer.""" - conn = get_db_connection() - cursor = conn.cursor() - - if include_resolved: - cursor.execute(""" - SELECT * FROM printer_error_log - WHERE printer_id = ? - ORDER BY occurred_at DESC - LIMIT ? - """, (printer_id, limit)) - else: - cursor.execute(""" - SELECT * FROM printer_error_log - WHERE printer_id = ? AND resolved_at IS NULL - ORDER BY occurred_at DESC - LIMIT ? - """, (printer_id, limit)) - - rows = cursor.fetchall() - conn.close() - - return [PrinterErrorLog( - id=row['id'], - printer_id=row['printer_id'], - code=row['code'], - severity=row['severity'], - message=row['message'], - description=row['description'], - occurred_at=row['occurred_at'], - resolved_at=row['resolved_at'] - ) for row in rows] - - @staticmethod - def get_active_count(printer_id: str) -> int: - """Get count of active (unresolved) errors for a printer.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT COUNT(*) FROM printer_error_log - WHERE printer_id = ? AND resolved_at IS NULL - """, (printer_id,)) - count = cursor.fetchone()[0] - conn.close() - return count diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..c4f6fc1 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,49 @@ +""" +Database models for Continuum + +This module re-exports all models from the base models file. +In the future, models can be split into separate files here. +""" + +# Re-export everything from base models +from app.models.base import ( + get_db_connection, + init_db, + User, + UserSession, + PrinterGroup, + GroupRedirectSchedule, + PrinterRedirectSchedule, + WorkflowRegistryNode, + Workflow, + ActiveRedirect, + AuditLog, + RedirectHistory, + PrintJobHistory, + PrinterErrorLog, +) + + +__all__ = [ + # Database utilities + 'get_db_connection', + 'init_db', + # User models + 'User', + 'UserSession', + # Printer models + 'PrinterGroup', + # Redirect models + 'ActiveRedirect', + 'RedirectHistory', + 'GroupRedirectSchedule', + 'PrinterRedirectSchedule', + # Workflow models + 'Workflow', + 'WorkflowRegistryNode', + # Audit models + 'AuditLog', + # Print job models + 'PrintJobHistory', + 'PrinterErrorLog', +] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..9c80530 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,3339 @@ +""" +Database models for Continuum +""" +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple +import json +import uuid + +from config.config import DATABASE_PATH, DATA_DIR + + +def get_db_connection() -> sqlite3.Connection: + """Get a database connection with row factory and optimized settings.""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DATABASE_PATH), timeout=10.0, check_same_thread=False) + conn.row_factory = sqlite3.Row + + # Enable WAL mode for better concurrent access + conn.execute('PRAGMA journal_mode=WAL') + # Optimize for concurrent reads/writes + conn.execute('PRAGMA synchronous=NORMAL') + conn.execute('PRAGMA cache_size=10000') + conn.execute('PRAGMA temp_store=MEMORY') + + return conn + + +def _seed_example_workflows(cursor): + """Seed database with example workflows.""" + + # Example 1: Printer Offline Alert + wf1_id = uuid.uuid4().hex + wf1_webhook_id = uuid.uuid4().hex + wf1_webhook_secret = uuid.uuid4().hex + + workflow1 = { + 'id': wf1_id, + 'name': 'Printer Offline Alert', + 'description': 'Send email notification when a printer goes offline', + 'enabled': 1, + 'nodes': [ + { + 'id': 'node_trigger', + 'type': 'trigger.health_change', + 'label': 'Printer Goes Offline', + 'position': {'x': 100, 'y': 100}, + 'properties': { + 'state': 'offline', + 'printer_id': '', # Any printer + 'description': 'Triggers when any printer goes offline' + } + }, + { + 'id': 'node_email', + 'type': 'action.notify.email', + 'label': 'Send Email Alert', + 'position': {'x': 400, 'y': 100}, + 'properties': { + 'to': 'admin@example.com', + 'subject': 'Printer {{printer_name}} is Offline', + 'message': 'Printer {{printer_name}} ({{printer_ip}}) went offline at {{timestamp}}' + } + } + ], + 'edges': [ + { + 'id': 'edge_1', + 'source': 'node_trigger', + 'target': 'node_email', + 'sourceHandle': 'out', + 'targetHandle': 'in' + } + ], + 'ui_state': {}, + 'created_by': 'system', + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat() + } + + # Example 2: Auto-Redirect on Failure + wf2_id = uuid.uuid4().hex + + workflow2 = { + 'id': wf2_id, + 'name': 'Auto-Redirect on Printer Failure', + 'description': 'Automatically create redirect when printer goes offline', + 'enabled': 1, + 'nodes': [ + { + 'id': 'node_trigger', + 'type': 'trigger.health_change', + 'label': 'Printer Goes Offline', + 'position': {'x': 100, 'y': 100}, + 'properties': { + 'state': 'offline', + 'printer_id': '', + 'description': 'Triggers when printer goes offline' + } + }, + { + 'id': 'node_redirect', + 'type': 'action.redirect', + 'label': 'Create Redirect', + 'position': {'x': 400, 'y': 100}, + 'properties': { + 'printer_id': '{{printer_id}}', + 'target_printer_id': '', # User should configure target + 'description': 'Redirect traffic to backup printer' + } + }, + { + 'id': 'node_notify', + 'type': 'action.notify.inapp', + 'label': 'Send Notification', + 'position': {'x': 700, 'y': 100}, + 'properties': { + 'title': 'Redirect Created', + 'message': 'Traffic from {{printer_name}} redirected to backup printer', + 'type': 'info' + } + } + ], + 'edges': [ + { + 'id': 'edge_1', + 'source': 'node_trigger', + 'target': 'node_redirect', + 'sourceHandle': 'out', + 'targetHandle': 'in' + }, + { + 'id': 'edge_2', + 'source': 'node_redirect', + 'target': 'node_notify', + 'sourceHandle': 'out', + 'targetHandle': 'in' + } + ], + 'ui_state': {}, + 'created_by': 'system', + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat() + } + + # Example 3: Scheduled Health Report + wf3_id = uuid.uuid4().hex + + workflow3 = { + 'id': wf3_id, + 'name': 'Daily Printer Health Report', + 'description': 'Send daily email with printer status summary', + 'enabled': 0, # Disabled by default + 'nodes': [ + { + 'id': 'node_trigger', + 'type': 'trigger.schedule', + 'label': 'Daily at 9 AM', + 'position': {'x': 100, 'y': 100}, + 'properties': { + 'schedule_type': 'cron', + 'cron': '0 9 * * *', + 'description': 'Runs every day at 9:00 AM' + } + }, + { + 'id': 'node_email', + 'type': 'action.notify.email', + 'label': 'Send Health Report', + 'position': {'x': 400, 'y': 100}, + 'properties': { + 'to': 'admin@example.com', + 'subject': 'Daily Printer Health Report', + 'message': 'Daily printer status report generated at {{timestamp}}' + } + } + ], + 'edges': [ + { + 'id': 'edge_1', + 'source': 'node_trigger', + 'target': 'node_email', + 'sourceHandle': 'out', + 'targetHandle': 'in' + } + ], + 'ui_state': {}, + 'created_by': 'system', + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat() + } + + # Example 4: Webhook Integration + wf4_id = uuid.uuid4().hex + wf4_webhook_id = uuid.uuid4().hex + wf4_webhook_secret = uuid.uuid4().hex + + workflow4 = { + 'id': wf4_id, + 'name': 'External System Integration', + 'description': 'Receive webhook from external system and log audit event', + 'enabled': 1, + 'nodes': [ + { + 'id': 'node_trigger', + 'type': 'trigger.webhook', + 'label': 'Webhook Trigger', + 'position': {'x': 100, 'y': 100}, + 'properties': { + 'hook_id': wf4_webhook_id, + 'hook_secret': wf4_webhook_secret, + 'description': 'External webhook endpoint' + } + }, + { + 'id': 'node_audit', + 'type': 'action.audit', + 'label': 'Log Event', + 'position': {'x': 400, 'y': 100}, + 'properties': { + 'event_type': 'external_webhook', + 'message': 'Webhook received from external system: {{payload}}' + } + } + ], + 'edges': [ + { + 'id': 'edge_1', + 'source': 'node_trigger', + 'target': 'node_audit', + 'sourceHandle': 'out', + 'targetHandle': 'in' + } + ], + 'ui_state': {}, + 'created_by': 'system', + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat() + } + + # Insert workflows + for workflow in [workflow1, workflow2, workflow3, workflow4]: + cursor.execute(""" + INSERT INTO workflows (id, name, description, enabled, nodes, edges, ui_state, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + workflow['id'], + workflow['name'], + workflow['description'], + workflow['enabled'], + json.dumps(workflow['nodes']), + json.dumps(workflow['edges']), + json.dumps(workflow['ui_state']), + workflow['created_by'], + workflow['created_at'], + workflow['updated_at'] + )) + + +def init_db(): + """Initialize the database schema.""" + conn = get_db_connection() + cursor = conn.cursor() + + # Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + full_name TEXT, + email TEXT, + password_hash TEXT NOT NULL, + role TEXT DEFAULT 'admin', + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP, + failed_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP, + mfa_secret TEXT, + mfa_enabled BOOLEAN DEFAULT 0, + mfa_recovery_codes TEXT, + theme TEXT DEFAULT 'system', + language TEXT DEFAULT 'en', + timezone TEXT DEFAULT 'UTC' + ) + """) + + # Ensure role column exists for older installs + cursor.execute("PRAGMA table_info(users)") + user_columns = {row['name'] for row in cursor.fetchall()} + if 'role' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'admin'") + cursor.execute("UPDATE users SET role = 'admin' WHERE role IS NULL OR role = ''") + if 'email' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN email TEXT") + if 'full_name' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN full_name TEXT") + if 'mfa_secret' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT") + if 'mfa_enabled' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN mfa_enabled BOOLEAN DEFAULT 0") + if 'mfa_recovery_codes' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN mfa_recovery_codes TEXT") + if 'theme' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN theme TEXT DEFAULT 'system'") + if 'language' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN language TEXT DEFAULT 'en'") + if 'timezone' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN timezone TEXT DEFAULT 'UTC'") + if 'notification_preferences' not in user_columns: + cursor.execute("ALTER TABLE users ADD COLUMN notification_preferences TEXT") + # Set default preferences for existing users + default_prefs = json.dumps({ + 'health_alerts': True, + 'offline_alerts': True, + 'job_failures': True, + 'security_events': True, + 'weekly_reports': False + }) + cursor.execute("UPDATE users SET notification_preferences = ? WHERE notification_preferences IS NULL", (default_prefs,)) + + # Active redirects table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS active_redirects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_printer_id TEXT UNIQUE NOT NULL, + source_ip TEXT NOT NULL, + target_printer_id TEXT NOT NULL, + target_ip TEXT NOT NULL, + protocol TEXT DEFAULT 'raw', + port INTEGER DEFAULT 9100, + enabled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + enabled_by TEXT NOT NULL + ) + """) + + # Audit log table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + action TEXT NOT NULL, + source_printer_id TEXT, + source_ip TEXT, + target_printer_id TEXT, + target_ip TEXT, + details TEXT, + success BOOLEAN, + error_message TEXT + ) + """) + + # Login attempts table for rate limiting + cursor.execute(""" + CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip_address TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN + ) + """) + + # Printers table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ip TEXT UNIQUE NOT NULL, + protocols TEXT DEFAULT 'raw', + location TEXT DEFAULT '', + model TEXT DEFAULT '', + department TEXT DEFAULT '', + notes TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Printer groups table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printer_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + owner_user_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Ensure owner_user_id exists for older installs + cursor.execute("PRAGMA table_info(printer_groups)") + group_columns = {row[1] for row in cursor.fetchall()} + if 'owner_user_id' not in group_columns: + cursor.execute("ALTER TABLE printer_groups ADD COLUMN owner_user_id INTEGER") + cursor.execute("UPDATE printer_groups SET owner_user_id = 1 WHERE owner_user_id IS NULL") + + # Printer group members table (one group per printer enforced by UNIQUE) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printer_group_members ( + group_id INTEGER NOT NULL, + printer_id TEXT NOT NULL UNIQUE, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES printer_groups(id) ON DELETE CASCADE, + FOREIGN KEY (printer_id) REFERENCES printers(id) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_printer_group_members_group + ON printer_group_members(group_id) + """) + + # Group redirect schedules table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS group_redirect_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + target_printer_id TEXT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP, + enabled BOOLEAN DEFAULT 1, + is_active BOOLEAN DEFAULT 0, + last_activated_at TIMESTAMP, + last_deactivated_at TIMESTAMP, + created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES printer_groups(id) ON DELETE CASCADE, + FOREIGN KEY (target_printer_id) REFERENCES printers(id) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_group_redirect_schedules_group + ON group_redirect_schedules(group_id) + """) + + # Printer redirect schedules table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printer_redirect_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_printer_id TEXT NOT NULL, + target_printer_id TEXT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP, + enabled BOOLEAN DEFAULT 1, + is_active BOOLEAN DEFAULT 0, + last_activated_at TIMESTAMP, + last_deactivated_at TIMESTAMP, + created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_printer_id) REFERENCES printers(id) ON DELETE CASCADE, + FOREIGN KEY (target_printer_id) REFERENCES printers(id) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_printer_redirect_schedules_source + ON printer_redirect_schedules(source_printer_id) + """) + + # Group redirect instances (track redirects created by schedules) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS group_redirect_instances ( + schedule_id INTEGER NOT NULL, + redirect_id INTEGER NOT NULL, + source_printer_id TEXT NOT NULL, + PRIMARY KEY (schedule_id, redirect_id), + FOREIGN KEY (schedule_id) REFERENCES group_redirect_schedules(id) ON DELETE CASCADE, + FOREIGN KEY (redirect_id) REFERENCES active_redirects(id) ON DELETE CASCADE + ) + """) + + # Printer redirect instances (track redirects created by schedules) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printer_redirect_instances ( + schedule_id INTEGER NOT NULL, + redirect_id INTEGER NOT NULL, + source_printer_id TEXT NOT NULL, + PRIMARY KEY (schedule_id, redirect_id), + FOREIGN KEY (schedule_id) REFERENCES printer_redirect_schedules(id) ON DELETE CASCADE, + FOREIGN KEY (redirect_id) REFERENCES active_redirects(id) ON DELETE CASCADE + ) + """) + + # Group-based notification subscriptions + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_group_subscriptions ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + preference_key TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, group_id, preference_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES printer_groups(id) ON DELETE CASCADE + ) + """) + + # Redirect history table for statistics + cursor.execute(""" + CREATE TABLE IF NOT EXISTS redirect_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_printer_id TEXT NOT NULL, + source_ip TEXT NOT NULL, + target_printer_id TEXT NOT NULL, + target_ip TEXT NOT NULL, + enabled_at TIMESTAMP NOT NULL, + enabled_by TEXT NOT NULL, + disabled_at TIMESTAMP, + disabled_by TEXT, + duration_seconds INTEGER, + reason TEXT + ) + """) + + # Create index for redirect history queries + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_redirect_history_source + ON redirect_history(source_printer_id, enabled_at DESC) + """) + + # Print job history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS print_job_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + printer_id TEXT NOT NULL, + job_id INTEGER NOT NULL, + name TEXT DEFAULT '', + owner TEXT DEFAULT '', + status TEXT DEFAULT 'Unknown', + pages INTEGER DEFAULT 0, + size_bytes INTEGER DEFAULT 0, + submitted_at TIMESTAMP, + started_at TIMESTAMP, + completed_at TIMESTAMP, + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (printer_id) REFERENCES printers(id) + ) + """) + + # Create index for job history queries + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_job_history_printer + ON print_job_history(printer_id, recorded_at DESC) + """) + + # Printer error log table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS printer_error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + printer_id TEXT NOT NULL, + code INTEGER NOT NULL, + severity TEXT DEFAULT 'warning', + message TEXT NOT NULL, + description TEXT DEFAULT '', + occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP, + FOREIGN KEY (printer_id) REFERENCES printers(id) + ) + """) + + # Create index for error log queries + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_error_log_printer + ON printer_error_log(printer_id, occurred_at DESC) + """) + + # Settings table for application configuration + cursor.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # User sessions table for JWT tracking + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + jti TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP, + ip_address TEXT, + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + # API tokens table for programmatic access + cursor.execute(""" + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + permissions TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP, + expires_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + # Add index for token lookup + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash + ON api_tokens(token_hash) + """) + + # Notifications table for storing all user notifications + cursor.execute(""" + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + link TEXT, + is_read INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + # Add indexes for notification queries + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_notifications_user_created + ON notifications(user_id, created_at DESC) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON notifications(user_id, is_read, created_at DESC) + """) + + # Workflow registry nodes (server-driven node catalog) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS workflow_registry_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + category TEXT NOT NULL, + color TEXT DEFAULT '#10b981', + icon TEXT DEFAULT 'Workflow', + inputs TEXT, + outputs TEXT, + config_schema TEXT, + default_properties TEXT, + enabled BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_workflow_registry_category + ON workflow_registry_nodes(category) + """) + + # Add output_schema column if it doesn't exist (migration) + cursor.execute("PRAGMA table_info(workflow_registry_nodes)") + registry_columns = {row['name'] for row in cursor.fetchall()} + if 'output_schema' not in registry_columns: + cursor.execute("ALTER TABLE workflow_registry_nodes ADD COLUMN output_schema TEXT") + + # Workflows table (simplified JSON schema) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + nodes TEXT, + edges TEXT, + ui_state TEXT, + created_by TEXT, + created_at TEXT, + updated_at TEXT + ) + """) + + # Seed or update workflow registry defaults + cursor.execute("SELECT node_key FROM workflow_registry_nodes") + existing_keys = {row['node_key'] for row in cursor.fetchall()} + + default_nodes = [ + { + 'node_key': 'trigger.schedule', + 'name': 'Schedule Trigger', + 'description': 'Start a workflow on a cron schedule.', + 'category': 'trigger', + 'color': '#22c55e', + 'icon': 'CalendarClock', + 'inputs': [], + 'outputs': [{'id': 'out', 'label': 'Run', 'type': 'flow'}], + 'output_schema': [ + {'key': 'scheduled_time', 'type': 'string', 'description': 'Scheduled execution time'}, + {'key': 'timestamp', 'type': 'string', 'description': 'ISO timestamp of execution'} + ], + 'config_schema': { + 'fields': [ + {'key': 'cron', 'label': 'Cron', 'type': 'string', 'placeholder': '0 9 * * 1-5'}, + {'key': 'timezone', 'label': 'Timezone', 'type': 'string', 'placeholder': 'UTC'} + ] + }, + 'default_properties': {'cron': '0 9 * * 1-5', 'timezone': 'UTC'} + }, + { + 'node_key': 'trigger.event', + 'name': 'Event Trigger', + 'description': 'Start when a printer or system event occurs.', + 'category': 'trigger', + 'color': '#22c55e', + 'icon': 'Zap', + 'inputs': [], + 'outputs': [{'id': 'out', 'label': 'Run', 'type': 'flow'}], + 'output_schema': [ + {'key': 'event_type', 'type': 'string', 'description': 'Type of event triggered'}, + {'key': 'printer_id', 'type': 'printer_id', 'description': 'ID of affected printer'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of affected printer'}, + {'key': 'printer_ip', 'type': 'ip_address', 'description': 'IP address of printer'}, + {'key': 'timestamp', 'type': 'timestamp', 'description': 'When the event occurred (ISO format)'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'event_type', + 'label': 'Event Type', + 'type': 'select', + 'options': [ + {'label': 'Printer Offline', 'value': 'printer_offline'}, + {'label': 'Printer Online', 'value': 'printer_online'}, + {'label': 'Job Failed', 'value': 'job_failed'}, + {'label': 'Job Completed', 'value': 'job_completed'}, + {'label': 'Redirect Activated', 'value': 'redirect_activated'} + ] + } + ] + }, + 'default_properties': {'event_type': 'printer_offline'} + }, + { + 'node_key': 'trigger.webhook', + 'name': 'Webhook Trigger', + 'description': 'Start when an incoming webhook is received.', + 'category': 'trigger', + 'color': '#22c55e', + 'icon': 'Webhook', + 'inputs': [], + 'outputs': [{'id': 'out', 'label': 'Run', 'type': 'flow'}], + 'output_schema': [ + {'key': 'payload', 'type': 'object', 'description': 'JSON payload from webhook request'}, + {'key': 'headers', 'type': 'object', 'description': 'HTTP headers from request'}, + {'key': 'method', 'type': 'string', 'description': 'HTTP method (GET/POST)'}, + {'key': 'timestamp', 'type': 'string', 'description': 'ISO timestamp of when webhook was received'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'path', + 'label': 'Webhook Endpoint', + 'type': 'string', + 'placeholder': '/webhooks/workflows', + 'readOnly': True, + 'helperText': 'Generated automatically for this workflow trigger.' + }, + { + 'key': 'secret', + 'label': 'Shared Secret', + 'type': 'string', + 'readOnly': True, + 'helperText': 'Use this secret when calling the webhook.' + } + ] + }, + 'default_properties': {'path': '/webhooks/printer', 'secret': ''} + }, + { + 'node_key': 'trigger.queue_threshold', + 'name': 'Queue Threshold', + 'description': 'Start when a printer queue exceeds a threshold.', + 'category': 'trigger', + 'color': '#22c55e', + 'icon': 'ListFilter', + 'inputs': [], + 'outputs': [{'id': 'out', 'label': 'Run', 'type': 'flow'}], + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'ID of the printer'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of the printer'}, + {'key': 'queue_count', 'type': 'number', 'description': 'Current queue count'}, + {'key': 'threshold', 'type': 'number', 'description': 'Threshold value that was exceeded'}, + {'key': 'timestamp', 'type': 'timestamp', 'description': 'When threshold was exceeded'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Choose the printer to monitor.', + 'icon': 'Printer' + }, + {'key': 'min_jobs', 'label': 'Minimum Jobs', 'type': 'number'} + ] + }, + 'default_properties': {'printer_id': '', 'min_jobs': 5} + }, + { + 'node_key': 'trigger.health_change', + 'name': 'Health Change', + 'description': 'Trigger workflow when a printer goes online or offline.', + 'category': 'trigger', + 'color': '#22c55e', + 'icon': 'Activity', + 'inputs': [], + 'outputs': [{'id': 'out', 'label': 'Run', 'type': 'flow'}], + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'ID of the affected printer'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Display name of the printer'}, + {'key': 'printer_ip', 'type': 'ip_address', 'description': 'IP address of the printer'}, + {'key': 'old_state', 'type': 'string', 'description': 'Previous health state (online/offline)'}, + {'key': 'new_state', 'type': 'string', 'description': 'New health state (online/offline)'}, + {'key': 'timestamp', 'type': 'timestamp', 'description': 'When the state change occurred (ISO format)'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Monitor Printer', + 'type': 'printer_id', + 'helperText': 'Select which printer to monitor for health changes. Leave empty to monitor all printers.', + 'placeholder': 'All printers', + 'icon': 'Printer' + }, + { + 'key': 'state', + 'label': 'Trigger On State', + 'type': 'select', + 'options': [ + {'label': 'Goes Offline', 'value': 'offline'}, + {'label': 'Comes Online', 'value': 'online'}, + {'label': 'Any Change', 'value': 'any'} + ], + 'helperText': 'Which state transition should trigger this workflow.', + 'required': True, + 'icon': 'Activity' + } + ] + }, + 'default_properties': {'printer_id': '', 'state': 'offline'} + }, + { + 'node_key': 'action.print', + 'name': 'Print Job', + 'description': 'Send a document to a printer.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'Printer', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'job_id', 'type': 'string', 'description': 'ID of the print job created'}, + {'key': 'printer_id', 'type': 'printer_id', 'description': 'ID of the printer used'}, + {'key': 'document_path', 'type': 'string', 'description': 'Path to the printed document'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether job was submitted successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Select the destination printer.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'required': True, + 'icon': 'Printer' + }, + { + 'key': 'document_path', + 'label': 'Document Path', + 'type': 'string', + 'placeholder': '/path/to/document.pdf', + 'helperText': 'File path to the document to print.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'icon': 'File' + }, + {'key': 'copies', 'label': 'Copies', 'type': 'number'} + ] + }, + 'default_properties': {'printer_id': '', 'document_path': '', 'copies': 1} + }, + { + 'node_key': 'action.redirect', + 'name': 'Activate Redirect', + 'description': 'Route print traffic from one printer to another. Jobs sent to the source printer will be forwarded to the target.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'ArrowRightLeft', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'redirect_id', 'type': 'string', 'description': 'ID of the redirect created'}, + {'key': 'source_printer_id', 'type': 'printer_id', 'description': 'Source printer ID'}, + {'key': 'source_printer_name', 'type': 'string', 'description': 'Source printer name'}, + {'key': 'source_printer_ip', 'type': 'ip_address', 'description': 'Source printer IP address'}, + {'key': 'target_printer_id', 'type': 'printer_id', 'description': 'Target printer ID'}, + {'key': 'target_printer_name', 'type': 'string', 'description': 'Target printer name'}, + {'key': 'target_printer_ip', 'type': 'ip_address', 'description': 'Target printer IP address'}, + {'key': 'port', 'type': 'number', 'description': 'Port used for redirect'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether redirect was created successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'source_printer_id', + 'label': 'Source Printer', + 'type': 'printer_id', + 'helperText': 'The offline or failing printer whose traffic should be redirected.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'required': True, + 'group': 'redirect', + 'icon': 'Printer' + }, + { + 'key': 'target_printer_id', + 'label': 'Target Printer', + 'type': 'printer_id', + 'helperText': 'The working printer that will receive the redirected traffic.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'required': True, + 'group': 'redirect', + 'icon': 'Printer' + }, + { + 'key': 'port', + 'label': 'Port', + 'type': 'number', + 'placeholder': '9100', + 'helperText': 'Network port for print traffic (default: 9100 for RAW printing).', + 'group': 'advanced', + 'icon': 'Hash' + } + ] + }, + 'default_properties': {'source_printer_id': '', 'target_printer_id': '', 'port': 9100} + }, + { + 'node_key': 'action.redirect.disable', + 'name': 'Deactivate Redirect', + 'description': 'Disable an active redirect for a printer.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'ArrowRightLeft', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'source_printer_id', 'type': 'printer_id', 'description': 'Printer that was redirected'}, + {'key': 'source_printer_name', 'type': 'string', 'description': 'Name of source printer'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether redirect was disabled successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'source_printer_id', + 'label': 'Source Printer', + 'type': 'printer_id', + 'helperText': 'Select the printer with an active redirect.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'icon': 'Printer' + } + ] + }, + 'default_properties': {'source_printer_id': ''} + }, + { + 'node_key': 'action.queue.pause', + 'name': 'Pause Print Queue', + 'description': 'Pause all jobs for a printer queue.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'PauseCircle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'Printer whose queue was paused'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of the printer'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether queue was paused successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Choose the queue to pause.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'icon': 'Printer' + } + ] + }, + 'default_properties': {'printer_id': ''} + }, + { + 'node_key': 'action.queue.resume', + 'name': 'Resume Print Queue', + 'description': 'Resume processing for a printer queue.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'PlayCircle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'Printer whose queue was resumed'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of the printer'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether queue was resumed successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Choose the queue to resume.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'icon': 'Printer' + } + ] + }, + 'default_properties': {'printer_id': ''} + }, + { + 'node_key': 'action.queue.clear', + 'name': 'Clear Print Queue', + 'description': 'Delete all pending jobs for a printer.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'Trash2', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'Printer whose queue was cleared'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of the printer'}, + {'key': 'jobs_cleared', 'type': 'number', 'description': 'Number of jobs removed'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether queue was cleared successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Choose the queue to clear.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'icon': 'Printer' + } + ] + }, + 'default_properties': {'printer_id': ''} + }, + { + 'node_key': 'action.notify.email', + 'name': 'Send Email', + 'description': 'Send an email notification to one or more recipients.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'Mail', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'to', 'type': 'email', 'description': 'Email recipient address'}, + {'key': 'subject', 'type': 'string', 'description': 'Email subject line'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether email was sent successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'to', + 'label': 'Recipient Email', + 'type': 'email', + 'placeholder': 'user@example.com', + 'helperText': 'Enter email address(es). Separate multiple with commas.', + 'supportsDynamic': True, + 'acceptsTypes': ['email', 'string'], + 'required': True, + 'group': 'recipient', + 'icon': 'AtSign' + }, + { + 'key': 'subject', + 'label': 'Subject Line', + 'type': 'string', + 'placeholder': 'Printer Alert: {{printer_name}}', + 'helperText': 'Email subject. Use {{variable}} to include dynamic data.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'required': True, + 'group': 'content', + 'icon': 'Type' + }, + { + 'key': 'body', + 'label': 'Email Body', + 'type': 'textarea', + 'placeholder': 'Printer {{printer_name}} ({{printer_ip}}) has gone offline.', + 'helperText': 'Email content. Supports {{variable}} syntax for dynamic values.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'required': True, + 'group': 'content', + 'icon': 'FileText' + } + ] + }, + 'default_properties': {'to': '', 'subject': 'Workflow Alert', 'body': ''} + }, + { + 'node_key': 'action.notify.inapp', + 'name': 'In-App Notification', + 'description': 'Create an in-app notification visible to dashboard users.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'Bell', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'notification_id', 'type': 'string', 'description': 'ID of the notification created'}, + {'key': 'title', 'type': 'string', 'description': 'Notification title'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether notification was created successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'title', + 'label': 'Notification Title', + 'type': 'string', + 'placeholder': 'Printer Alert', + 'helperText': 'Short title shown in notification banner.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'required': True, + 'icon': 'Type' + }, + { + 'key': 'message', + 'label': 'Message', + 'type': 'textarea', + 'placeholder': 'Printer {{printer_name}} requires attention.', + 'helperText': 'Notification body content. Supports {{variable}} syntax.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'required': True, + 'icon': 'MessageSquare' + }, + { + 'key': 'link', + 'label': 'Action Link', + 'type': 'url', + 'placeholder': '/printers/{{printer_id}}', + 'helperText': 'Optional URL to navigate when notification is clicked.', + 'supportsDynamic': True, + 'acceptsTypes': ['string', 'url'], + 'icon': 'ExternalLink' + } + ] + }, + 'default_properties': {'title': '', 'message': '', 'link': ''} + }, + { + 'node_key': 'action.audit', + 'name': 'Audit Log Entry', + 'description': 'Record an action to the audit trail for compliance and tracking.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'ClipboardList', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'action', 'type': 'string', 'description': 'Action that was logged'}, + {'key': 'details', 'type': 'string', 'description': 'Details of the action'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether log entry was created'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'action', + 'label': 'Action Type', + 'type': 'select', + 'options': [ + {'label': 'Workflow Action', 'value': 'WORKFLOW_ACTION'}, + {'label': 'Redirect Created', 'value': 'REDIRECT_CREATED'}, + {'label': 'Redirect Removed', 'value': 'REDIRECT_REMOVED'}, + {'label': 'Alert Triggered', 'value': 'ALERT_TRIGGERED'}, + {'label': 'Custom', 'value': 'CUSTOM'} + ], + 'helperText': 'Type of action to record in audit log.', + 'required': True, + 'icon': 'Tag' + }, + { + 'key': 'details', + 'label': 'Details', + 'type': 'textarea', + 'placeholder': 'Printer {{printer_name}} redirect activated by workflow.', + 'helperText': 'Detailed description of the action. Supports {{variable}} syntax.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'icon': 'FileText' + } + ] + }, + 'default_properties': {'action': 'WORKFLOW_ACTION', 'details': ''} + }, + { + 'node_key': 'action.printer.note', + 'name': 'Update Printer Notes', + 'description': 'Append a note to a printer record.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'StickyNote', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'printer_id', 'type': 'printer_id', 'description': 'Printer that was updated'}, + {'key': 'printer_name', 'type': 'string', 'description': 'Name of the printer'}, + {'key': 'note', 'type': 'string', 'description': 'Note that was added'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether note was added successfully'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'printer_id', + 'label': 'Printer', + 'type': 'printer_id', + 'helperText': 'Choose the printer to update.', + 'supportsDynamic': True, + 'acceptsTypes': ['printer_id', 'string'], + 'icon': 'Printer' + }, + { + 'key': 'note', + 'label': 'Note', + 'type': 'textarea', + 'placeholder': 'Add a note about printer {{printer_name}}', + 'helperText': 'Note content to append. Supports {{variable}} syntax.', + 'supportsDynamic': True, + 'acceptsTypes': ['string'], + 'icon': 'StickyNote' + } + ] + }, + 'default_properties': {'printer_id': '', 'note': ''} + }, + { + 'node_key': 'action.end', + 'name': 'End Workflow', + 'description': 'Stop processing this workflow branch.', + 'category': 'action', + 'color': '#38bdf8', + 'icon': 'StopCircle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [], + 'allow_multiple_inputs': True, + 'output_schema': [], + 'config_schema': None, + 'default_properties': {} + }, + { + 'node_key': 'transform.filter', + 'name': 'Filter', + 'description': 'Filter items by condition.', + 'category': 'transform', + 'color': '#a855f7', + 'icon': 'Filter', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'data'}], + 'outputs': [{'id': 'out', 'label': 'Out', 'type': 'data'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'matched', 'type': 'boolean', 'description': 'Whether the filter condition matched'}, + {'key': 'data', 'type': 'object', 'description': 'Filtered data (passed through if matched)'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'expression', + 'label': 'Filter Condition', + 'type': 'select', + 'options': [ + {'label': 'Printer Offline', 'value': 'printer_offline'}, + {'label': 'Printer Online', 'value': 'printer_online'}, + {'label': 'Queue High', 'value': 'queue_high'}, + {'label': 'Queue Empty', 'value': 'queue_empty'}, + {'label': 'Redirect Active', 'value': 'redirect_active'}, + {'label': 'Redirect Inactive', 'value': 'redirect_inactive'}, + {'label': 'Job Failed', 'value': 'job_failed'} + ], + 'helperText': 'Select a built-in condition to filter data.' + } + ] + }, + 'default_properties': {'expression': 'printer_offline'} + }, + { + 'node_key': 'transform.map_fields', + 'name': 'Map Fields', + 'description': 'Map incoming data fields to new keys.', + 'category': 'transform', + 'color': '#a855f7', + 'icon': 'Shuffle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'data'}], + 'outputs': [{'id': 'out', 'label': 'Out', 'type': 'data'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'data', 'type': 'object', 'description': 'Data with remapped field names'} + ], + 'config_schema': { + 'fields': [ + {'key': 'mappings', 'label': 'Mappings (JSON)', 'type': 'string', 'supportsDynamic': True} + ] + }, + 'default_properties': {'mappings': '{"source":"target"}'} + }, + { + 'node_key': 'transform.template', + 'name': 'Template', + 'description': 'Render a text template from data.', + 'category': 'transform', + 'color': '#a855f7', + 'icon': 'Type', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'data'}], + 'outputs': [{'id': 'out', 'label': 'Out', 'type': 'data'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'result', 'type': 'string', 'description': 'Rendered template string'} + ], + 'config_schema': { + 'fields': [ + {'key': 'template', 'label': 'Template', 'type': 'string', 'supportsDynamic': True}, + {'key': 'output_key', 'label': 'Output Key', 'type': 'string', 'placeholder': 'result'} + ] + }, + 'default_properties': {'template': 'Printer {{printer_id}} is offline.', 'output_key': 'result'} + }, + { + 'node_key': 'logic.if', + 'name': 'If / Else', + 'description': 'Branch based on a condition.', + 'category': 'conditional', + 'color': '#f97316', + 'icon': 'Split', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [ + {'id': 'true', 'label': 'True', 'type': 'flow'}, + {'id': 'false', 'label': 'False', 'type': 'flow'} + ], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'condition_result', 'type': 'boolean', 'description': 'Result of the condition evaluation'}, + {'key': 'branch', 'type': 'string', 'description': 'Which branch was taken (true/false)'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'expression', + 'label': 'Condition', + 'type': 'select', + 'options': [ + {'label': 'Printer Offline', 'value': 'printer_offline'}, + {'label': 'Printer Online', 'value': 'printer_online'}, + {'label': 'Queue High', 'value': 'queue_high'}, + {'label': 'Queue Empty', 'value': 'queue_empty'}, + {'label': 'Redirect Active', 'value': 'redirect_active'}, + {'label': 'Redirect Inactive', 'value': 'redirect_inactive'}, + {'label': 'Job Failed', 'value': 'job_failed'} + ], + 'helperText': 'Select a built-in condition for this branch.' + } + ] + }, + 'default_properties': {'expression': 'printer_offline'} + }, + { + 'node_key': 'logic.switch', + 'name': 'Switch', + 'description': 'Route flow based on matching cases.', + 'category': 'conditional', + 'color': '#f97316', + 'icon': 'SwitchCamera', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [ + {'id': 'case1', 'label': 'Case 1', 'type': 'flow'}, + {'id': 'case2', 'label': 'Case 2', 'type': 'flow'}, + {'id': 'default', 'label': 'Default', 'type': 'flow'} + ], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'matched_case', 'type': 'string', 'description': 'Which case was matched (case1/case2/default)'}, + {'key': 'switch_value', 'type': 'string', 'description': 'The value that was evaluated'} + ], + 'config_schema': { + 'fields': [ + { + 'key': 'value', + 'label': 'Switch On', + 'type': 'select', + 'options': [ + {'label': 'Printer State', 'value': 'printer_state'}, + {'label': 'Queue State', 'value': 'queue_state'}, + {'label': 'Redirect State', 'value': 'redirect_state'} + ] + }, + { + 'key': 'case1', + 'label': 'Case 1', + 'type': 'select', + 'options': [ + {'label': 'Online', 'value': 'online'}, + {'label': 'Offline', 'value': 'offline'}, + {'label': 'High Queue', 'value': 'queue_high'}, + {'label': 'Queue Empty', 'value': 'queue_empty'}, + {'label': 'Redirect Active', 'value': 'redirect_active'}, + {'label': 'Redirect Inactive', 'value': 'redirect_inactive'} + ] + }, + { + 'key': 'case2', + 'label': 'Case 2', + 'type': 'select', + 'options': [ + {'label': 'Online', 'value': 'online'}, + {'label': 'Offline', 'value': 'offline'}, + {'label': 'High Queue', 'value': 'queue_high'}, + {'label': 'Queue Empty', 'value': 'queue_empty'}, + {'label': 'Redirect Active', 'value': 'redirect_active'}, + {'label': 'Redirect Inactive', 'value': 'redirect_inactive'} + ] + } + ] + }, + 'default_properties': {'value': 'printer_state', 'case1': 'offline', 'case2': 'online'} + }, + { + 'node_key': 'integration.api', + 'name': 'API Call', + 'description': 'Call an external API or webhook.', + 'category': 'integration', + 'color': '#0ea5e9', + 'icon': 'Globe', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'status_code', 'type': 'number', 'description': 'HTTP response status code'}, + {'key': 'response_body', 'type': 'object', 'description': 'Response body (JSON parsed if applicable)'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether request was successful (2xx status)'} + ], + 'config_schema': { + 'fields': [ + {'key': 'url', 'label': 'URL', 'type': 'string', 'supportsDynamic': True}, + { + 'key': 'method', + 'label': 'Method', + 'type': 'select', + 'options': [ + {'label': 'GET', 'value': 'GET'}, + {'label': 'POST', 'value': 'POST'}, + {'label': 'PUT', 'value': 'PUT'}, + {'label': 'DELETE', 'value': 'DELETE'} + ] + }, + {'key': 'timeout', 'label': 'Timeout (ms)', 'type': 'number'} + ] + }, + 'default_properties': {'url': '', 'method': 'POST', 'timeout': 5000} + }, + { + 'node_key': 'integration.slack', + 'name': 'Slack Message', + 'description': 'Send a Slack message via webhook.', + 'category': 'integration', + 'color': '#0ea5e9', + 'icon': 'MessageCircle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'status_code', 'type': 'number', 'description': 'HTTP response status code'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether message was sent successfully'} + ], + 'config_schema': { + 'fields': [ + {'key': 'webhook_url', 'label': 'Webhook URL', 'type': 'string'}, + {'key': 'message', 'label': 'Message', 'type': 'string', 'supportsDynamic': True} + ] + }, + 'default_properties': {'webhook_url': '', 'message': ''} + }, + { + 'node_key': 'integration.teams', + 'name': 'Teams Message', + 'description': 'Send a Microsoft Teams webhook message.', + 'category': 'integration', + 'color': '#0ea5e9', + 'icon': 'MessageSquare', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'output_schema': [ + {'key': 'status_code', 'type': 'number', 'description': 'HTTP response status code'}, + {'key': 'success', 'type': 'boolean', 'description': 'Whether message was sent successfully'} + ], + 'config_schema': { + 'fields': [ + {'key': 'webhook_url', 'label': 'Webhook URL', 'type': 'string'}, + {'key': 'message', 'label': 'Message', 'type': 'string', 'supportsDynamic': True} + ] + }, + 'default_properties': {'webhook_url': '', 'message': ''} + }, + { + 'node_key': 'integration.discord', + 'name': 'Discord Message', + 'description': 'Send a Discord webhook message.', + 'category': 'integration', + 'color': '#0ea5e9', + 'icon': 'MessageCircle', + 'inputs': [{'id': 'in', 'label': 'In', 'type': 'flow'}], + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'allow_multiple_inputs': True, + 'outputs': [{'id': 'out', 'label': 'Next', 'type': 'flow'}], + 'config_schema': { + 'fields': [ + {'key': 'webhook_url', 'label': 'Webhook URL', 'type': 'string'}, + {'key': 'message', 'label': 'Message', 'type': 'string'} + ] + }, + 'default_properties': {'webhook_url': '', 'message': ''} + } + ] + + insert_rows = [ + ( + node['node_key'], + node['name'], + node['description'], + node['category'], + node['color'], + node['icon'], + json.dumps(node['inputs']), + json.dumps(node['outputs']), + json.dumps(node.get('output_schema')) if node.get('output_schema') is not None else None, + json.dumps(node['config_schema']) if node.get('config_schema') is not None else None, + json.dumps(node['default_properties']) + ) + for node in default_nodes + if node['node_key'] not in existing_keys + ] + + if insert_rows: + cursor.executemany( + """ + INSERT INTO workflow_registry_nodes + (node_key, name, description, category, color, icon, inputs, outputs, output_schema, config_schema, default_properties) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + insert_rows + ) + + cursor.execute("UPDATE workflow_registry_nodes SET enabled = 0 WHERE node_key = 'transform.convert'") + + conn.commit() + conn.close() + + +class User: + """User model for authentication.""" + + def __init__(self, id: int, username: str, password_hash: str, role: str = 'admin', + email: Optional[str] = None, + full_name: Optional[str] = None, + is_active: bool = True, last_login: Optional[datetime] = None, + failed_attempts: int = 0, locked_until: Optional[datetime] = None, + created_at: Optional[datetime] = None, + mfa_secret: Optional[str] = None, + mfa_enabled: bool = False, + mfa_recovery_codes: Optional[str] = None, + theme: str = 'system', + language: str = 'en', + timezone: str = 'UTC'): + self.id = id + self.username = username + self.email = email + self.full_name = full_name + self.password_hash = password_hash + self.role = role or 'admin' + self.is_active = is_active + self.last_login = last_login + self.failed_attempts = failed_attempts + self.locked_until = locked_until + self.created_at = created_at + self.mfa_secret = mfa_secret + self.mfa_enabled = bool(mfa_enabled) + self.mfa_recovery_codes = mfa_recovery_codes + self.theme = theme or 'system' + self.language = language or 'en' + self.timezone = timezone or 'utc' + + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return str(self.id) + + @property + def is_admin(self) -> bool: + return self.role == 'admin' + + @property + def is_operator(self) -> bool: + return self.role == 'operator' + + @property + def is_viewer(self) -> bool: + return self.role == 'viewer' + + @staticmethod + def get_by_id(user_id: int) -> Optional['User']: + """Get user by ID.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + conn.close() + + if row: + return User( + id=row['id'], + username=row['username'], + full_name=row['full_name'] if 'full_name' in row.keys() else None, + email=row['email'] if 'email' in row.keys() else None, + password_hash=row['password_hash'], + role=row['role'] if 'role' in row.keys() else 'admin', + is_active=bool(row['is_active']), + last_login=row['last_login'], + failed_attempts=row['failed_attempts'], + locked_until=row['locked_until'], + created_at=row['created_at'] if 'created_at' in row.keys() else None, + mfa_secret=row['mfa_secret'] if 'mfa_secret' in row.keys() else None, + mfa_enabled=bool(row['mfa_enabled']) if 'mfa_enabled' in row.keys() else False, + mfa_recovery_codes=row['mfa_recovery_codes'] if 'mfa_recovery_codes' in row.keys() else None, + theme=row['theme'] if 'theme' in row.keys() else 'system', + language=row['language'] if 'language' in row.keys() else 'en', + timezone=row['timezone'] if 'timezone' in row.keys() else 'utc' + ) + return None + + @staticmethod + def get_by_username(username: str) -> Optional['User']: + """Get user by username.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE username = ?", (username,)) + row = cursor.fetchone() + conn.close() + + if row: + return User( + id=row['id'], + username=row['username'], + full_name=row['full_name'] if 'full_name' in row.keys() else None, + email=row['email'] if 'email' in row.keys() else None, + password_hash=row['password_hash'], + role=row['role'] if 'role' in row.keys() else 'admin', + is_active=bool(row['is_active']), + last_login=row['last_login'], + failed_attempts=row['failed_attempts'], + locked_until=row['locked_until'], + created_at=row['created_at'] if 'created_at' in row.keys() else None, + mfa_secret=row['mfa_secret'] if 'mfa_secret' in row.keys() else None, + mfa_enabled=bool(row['mfa_enabled']) if 'mfa_enabled' in row.keys() else False, + mfa_recovery_codes=row['mfa_recovery_codes'] if 'mfa_recovery_codes' in row.keys() else None, + theme=row['theme'] if 'theme' in row.keys() else 'system', + language=row['language'] if 'language' in row.keys() else 'en', + timezone=row['timezone'] if 'timezone' in row.keys() else 'utc' + ) + return None + + @staticmethod + def create(username: str, password_hash: str, role: str = 'admin', is_active: bool = True, + email: Optional[str] = None, full_name: Optional[str] = None, + theme: str = 'system', language: str = 'en', + timezone: str = 'UTC') -> 'User': + """Create a new user.""" + default_prefs = json.dumps({ + 'health_alerts': True, + 'offline_alerts': True, + 'job_failures': True, + 'security_events': True, + 'weekly_reports': False + }) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO users (username, full_name, email, password_hash, role, is_active, theme, language, timezone, notification_preferences) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (username, full_name, email, password_hash, role, int(is_active), theme, language, timezone, default_prefs) + ) + conn.commit() + user_id = cursor.lastrowid + conn.close() + + return User( + id=user_id, + username=username, + full_name=full_name, + email=email, + password_hash=password_hash, + role=role, + is_active=is_active, + theme=theme, + language=language, + timezone=timezone + ) + + @staticmethod + def get_all() -> List['User']: + """Get all users.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM users ORDER BY username ASC") + rows = cursor.fetchall() + conn.close() + + return [User( + id=row['id'], + username=row['username'], + full_name=row['full_name'] if 'full_name' in row.keys() else None, + email=row['email'] if 'email' in row.keys() else None, + password_hash=row['password_hash'], + role=row['role'] if 'role' in row.keys() else 'admin', + is_active=bool(row['is_active']), + last_login=row['last_login'], + failed_attempts=row['failed_attempts'], + locked_until=row['locked_until'], + created_at=row['created_at'] if 'created_at' in row.keys() else None, + mfa_secret=row['mfa_secret'] if 'mfa_secret' in row.keys() else None, + mfa_enabled=bool(row['mfa_enabled']) if 'mfa_enabled' in row.keys() else False, + mfa_recovery_codes=row['mfa_recovery_codes'] if 'mfa_recovery_codes' in row.keys() else None, + theme=row['theme'] if 'theme' in row.keys() else 'system', + language=row['language'] if 'language' in row.keys() else 'en', + timezone=row['timezone'] if 'timezone' in row.keys() else 'utc' + ) for row in rows] + + @staticmethod + def get_by_email(email: str) -> Optional['User']: + """Get user by email.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE email = ?", (email,)) + row = cursor.fetchone() + conn.close() + + if row: + return User( + id=row['id'], + username=row['username'], + full_name=row['full_name'] if 'full_name' in row.keys() else None, + email=row['email'] if 'email' in row.keys() else None, + password_hash=row['password_hash'], + role=row['role'] if 'role' in row.keys() else 'admin', + is_active=bool(row['is_active']), + last_login=row['last_login'], + failed_attempts=row['failed_attempts'], + locked_until=row['locked_until'], + created_at=row['created_at'] if 'created_at' in row.keys() else None, + mfa_secret=row['mfa_secret'] if 'mfa_secret' in row.keys() else None, + mfa_enabled=bool(row['mfa_enabled']) if 'mfa_enabled' in row.keys() else False, + mfa_recovery_codes=row['mfa_recovery_codes'] if 'mfa_recovery_codes' in row.keys() else None, + theme=row['theme'] if 'theme' in row.keys() else 'system', + language=row['language'] if 'language' in row.keys() else 'en', + timezone=row['timezone'] if 'timezone' in row.keys() else 'utc' + ) + return None + + def update_role(self, role: str): + """Update user's role.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET role = ? WHERE id = ?", + (role, self.id) + ) + conn.commit() + conn.close() + self.role = role + + def set_active(self, is_active: bool): + """Enable or disable user account.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET is_active = ? WHERE id = ?", + (int(is_active), self.id) + ) + conn.commit() + conn.close() + self.is_active = is_active + + def update_last_login(self): + """Update last login timestamp.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET last_login = ?, failed_attempts = 0 WHERE id = ?", + (datetime.now().isoformat(), self.id) + ) + conn.commit() + conn.close() + + @staticmethod + def delete_by_id(user_id: int) -> bool: + """Delete a user by ID.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted > 0 + except Exception: + return False + + def increment_failed_attempts(self): + """Increment failed login attempts.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?", + (self.id,) + ) + conn.commit() + conn.close() + + def lock_account(self, until: datetime): + """Lock account until specified time.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET locked_until = ? WHERE id = ?", + (until.isoformat(), self.id) + ) + conn.commit() + conn.close() + + def is_locked(self) -> bool: + """Check if account is currently locked.""" + if self.locked_until is None: + return False + if isinstance(self.locked_until, str): + locked_until = datetime.fromisoformat(self.locked_until) + else: + locked_until = self.locked_until + return datetime.now() < locked_until + + def update_password(self, new_password_hash: str): + """Update user's password.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (new_password_hash, self.id) + ) + conn.commit() + conn.close() + self.password_hash = new_password_hash + + def update_profile(self, username: str, email: Optional[str], full_name: Optional[str] = None): + """Update user's profile info.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET username = ?, email = ?, full_name = ? WHERE id = ?", + (username, email, full_name, self.id) + ) + conn.commit() + conn.close() + self.username = username + self.email = email + self.full_name = full_name + + def update_preferences(self, theme: str, language: str, timezone: str): + """Update user's preference settings.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET theme = ?, language = ?, timezone = ? WHERE id = ?", + (theme, language, timezone, self.id) + ) + conn.commit() + conn.close() + self.theme = theme + self.language = language + self.timezone = timezone + + def set_mfa_secret(self, secret: Optional[str]): + """Set MFA secret.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET mfa_secret = ? WHERE id = ?", + (secret, self.id) + ) + conn.commit() + conn.close() + self.mfa_secret = secret + + def set_mfa_enabled(self, enabled: bool): + """Enable/disable MFA.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET mfa_enabled = ? WHERE id = ?", + (int(enabled), self.id) + ) + conn.commit() + conn.close() + self.mfa_enabled = enabled + + def set_recovery_codes(self, codes_json: Optional[str]): + """Store hashed recovery codes JSON.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE users SET mfa_recovery_codes = ? WHERE id = ?", + (codes_json, self.id) + ) + conn.commit() + conn.close() + self.mfa_recovery_codes = codes_json + + +class PrinterGroup: + """Model for printer groups.""" + + @staticmethod + def get_all() -> List[Dict[str, Any]]: + """Get all printer groups with printer counts.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT pg.id, pg.name, pg.description, pg.owner_user_id, pg.created_at, pg.updated_at, + u.username AS owner_username, + COUNT(pgm.printer_id) AS printer_count + FROM printer_groups pg + LEFT JOIN printer_group_members pgm ON pg.id = pgm.group_id + LEFT JOIN users u ON u.id = pg.owner_user_id + GROUP BY pg.id + ORDER BY pg.name + """) + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + @staticmethod + def get_by_id(group_id: int) -> Optional[Dict[str, Any]]: + """Get a printer group by ID with member printer IDs.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT pg.*, u.username AS owner_username + FROM printer_groups pg + LEFT JOIN users u ON u.id = pg.owner_user_id + WHERE pg.id = ? + """, (group_id,)) + group_row = cursor.fetchone() + if not group_row: + conn.close() + return None + + cursor.execute( + "SELECT printer_id FROM printer_group_members WHERE group_id = ?", + (group_id,) + ) + printer_rows = cursor.fetchall() + conn.close() + + group = dict(group_row) + group['printer_ids'] = [row['printer_id'] for row in printer_rows] + group['printer_count'] = len(group['printer_ids']) + return group + + @staticmethod + def create(name: str, description: str = '', owner_user_id: Optional[int] = None) -> Dict[str, Any]: + """Create a new printer group.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO printer_groups (name, description, owner_user_id) VALUES (?, ?, ?)", + (name, description or '', owner_user_id) + ) + conn.commit() + group_id = cursor.lastrowid + conn.close() + return PrinterGroup.get_by_id(group_id) + + @staticmethod + def update(group_id: int, name: str, description: str = '') -> Optional[Dict[str, Any]]: + """Update an existing printer group.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE printer_groups SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (name, description or '', group_id) + ) + conn.commit() + conn.close() + return PrinterGroup.get_by_id(group_id) + + @staticmethod + def delete(group_id: int) -> bool: + """Delete a printer group and its members.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM printer_group_members WHERE group_id = ?", (group_id,)) + cursor.execute("DELETE FROM printer_groups WHERE id = ?", (group_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted > 0 + + @staticmethod + def set_printers(group_id: int, printer_ids: List[str]) -> None: + """Set printer memberships for a group (one group per printer).""" + conn = get_db_connection() + cursor = conn.cursor() + + # Clear existing members for this group + cursor.execute("DELETE FROM printer_group_members WHERE group_id = ?", (group_id,)) + + if printer_ids: + # Remove printers from other groups to enforce 1 group per printer + placeholders = ",".join(["?"] * len(printer_ids)) + cursor.execute( + f"DELETE FROM printer_group_members WHERE printer_id IN ({placeholders}) AND group_id != ?", + (*printer_ids, group_id) + ) + + cursor.executemany( + "INSERT INTO printer_group_members (group_id, printer_id) VALUES (?, ?)", + [(group_id, printer_id) for printer_id in printer_ids] + ) + + conn.commit() + conn.close() + + +class GroupRedirectSchedule: + """Model for group redirect schedules.""" + + @staticmethod + def get_all(group_id: Optional[int] = None) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + if group_id is None: + cursor.execute(""" + SELECT grs.*, pg.name AS group_name, p.name AS target_printer_name + FROM group_redirect_schedules grs + JOIN printer_groups pg ON pg.id = grs.group_id + JOIN printers p ON p.id = grs.target_printer_id + ORDER BY grs.start_at DESC + """) + else: + cursor.execute(""" + SELECT grs.*, pg.name AS group_name, p.name AS target_printer_name + FROM group_redirect_schedules grs + JOIN printer_groups pg ON pg.id = grs.group_id + JOIN printers p ON p.id = grs.target_printer_id + WHERE grs.group_id = ? + ORDER BY grs.start_at DESC + """, (group_id,)) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + @staticmethod + def create(group_id: int, target_printer_id: str, start_at: str, end_at: Optional[str], created_by: str) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO group_redirect_schedules (group_id, target_printer_id, start_at, end_at, created_by) + VALUES (?, ?, ?, ?, ?) + """, + (group_id, target_printer_id, start_at, end_at, created_by) + ) + conn.commit() + schedule_id = cursor.lastrowid + conn.close() + schedules = GroupRedirectSchedule.get_all() + return next((s for s in schedules if s['id'] == schedule_id), None) + + @staticmethod + def update(schedule_id: int, target_printer_id: str, start_at: str, end_at: Optional[str], enabled: bool) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + UPDATE group_redirect_schedules + SET target_printer_id = ?, start_at = ?, end_at = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (target_printer_id, start_at, end_at, int(enabled), schedule_id) + ) + conn.commit() + conn.close() + schedules = GroupRedirectSchedule.get_all() + return next((s for s in schedules if s['id'] == schedule_id), None) + + @staticmethod + def delete(schedule_id: int) -> bool: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM group_redirect_schedules WHERE id = ?", (schedule_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted > 0 + + +class PrinterRedirectSchedule: + @staticmethod + def get_all(source_printer_id: Optional[str] = None) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + if source_printer_id: + cursor.execute(""" + SELECT prs.*, sp.name AS source_printer_name, tp.name AS target_printer_name + FROM printer_redirect_schedules prs + JOIN printers sp ON sp.id = prs.source_printer_id + JOIN printers tp ON tp.id = prs.target_printer_id + WHERE prs.source_printer_id = ? + ORDER BY prs.start_at DESC + """, (source_printer_id,)) + else: + cursor.execute(""" + SELECT prs.*, sp.name AS source_printer_name, tp.name AS target_printer_name + FROM printer_redirect_schedules prs + JOIN printers sp ON sp.id = prs.source_printer_id + JOIN printers tp ON tp.id = prs.target_printer_id + ORDER BY prs.start_at DESC + """) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + @staticmethod + def create(source_printer_id: str, target_printer_id: str, start_at: str, end_at: Optional[str], created_by: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO printer_redirect_schedules (source_printer_id, target_printer_id, start_at, end_at, created_by) + VALUES (?, ?, ?, ?, ?) + """, + (source_printer_id, target_printer_id, start_at, end_at, created_by) + ) + conn.commit() + schedule_id = cursor.lastrowid + conn.close() + schedules = PrinterRedirectSchedule.get_all() + return next((s for s in schedules if s['id'] == schedule_id), None) + + @staticmethod + def update(schedule_id: int, target_printer_id: str, start_at: str, end_at: Optional[str], enabled: bool) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + UPDATE printer_redirect_schedules + SET target_printer_id = ?, start_at = ?, end_at = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (target_printer_id, start_at, end_at, int(enabled), schedule_id) + ) + conn.commit() + conn.close() + schedules = PrinterRedirectSchedule.get_all() + return next((s for s in schedules if s['id'] == schedule_id), None) + + @staticmethod + def delete(schedule_id: int) -> bool: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM printer_redirect_schedules WHERE id = ?", (schedule_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted > 0 + + +class WorkflowRegistryNode: + """Model for workflow registry nodes.""" + + _cache: Optional[Dict[str, Any]] = None + _cache_time: Optional[float] = None + _cache_ttl: int = 300 # 5 minutes + + @staticmethod + def _get_cache() -> Optional[List[Dict[str, Any]]]: + """Get cached registry nodes if still valid.""" + import time + if WorkflowRegistryNode._cache is None or WorkflowRegistryNode._cache_time is None: + return None + if time.time() - WorkflowRegistryNode._cache_time > WorkflowRegistryNode._cache_ttl: + return None + return WorkflowRegistryNode._cache + + @staticmethod + def _set_cache(nodes: List[Dict[str, Any]]): + """Cache registry nodes.""" + import time + WorkflowRegistryNode._cache = nodes + WorkflowRegistryNode._cache_time = time.time() + + @staticmethod + def _clear_cache(): + """Clear the cache.""" + WorkflowRegistryNode._cache = None + WorkflowRegistryNode._cache_time = None + + @staticmethod + def get_all(include_disabled: bool = False) -> List[Dict[str, Any]]: + # Use cache for enabled nodes only + if not include_disabled: + cached = WorkflowRegistryNode._get_cache() + if cached is not None: + return cached + + conn = get_db_connection() + cursor = conn.cursor() + if include_disabled: + cursor.execute("SELECT * FROM workflow_registry_nodes ORDER BY category, name") + else: + cursor.execute("SELECT * FROM workflow_registry_nodes WHERE enabled = 1 ORDER BY category, name") + rows = cursor.fetchall() + conn.close() + nodes = [] + for row in rows: + nodes.append({ + 'id': row['id'], + 'key': row['node_key'], + 'name': row['name'], + 'description': row['description'], + 'category': row['category'], + 'color': row['color'], + 'icon': row['icon'], + 'inputs': json.loads(row['inputs']) if row['inputs'] else [], + 'outputs': json.loads(row['outputs']) if row['outputs'] else [], + 'output_schema': json.loads(row['output_schema']) if ('output_schema' in row.keys() and row['output_schema']) else None, + 'config_schema': json.loads(row['config_schema']) if row['config_schema'] else None, + 'default_properties': json.loads(row['default_properties']) if row['default_properties'] else {}, + 'enabled': bool(row['enabled']), + 'created_at': row['created_at'], + 'updated_at': row['updated_at'] + }) + + # Cache enabled nodes only + if not include_disabled: + WorkflowRegistryNode._set_cache(nodes) + + return nodes + + @staticmethod + def get_by_key(node_key: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM workflow_registry_nodes WHERE node_key = ?", (node_key,)) + row = cursor.fetchone() + conn.close() + if not row: + return None + return { + 'id': row['id'], + 'key': row['node_key'], + 'name': row['name'], + 'description': row['description'], + 'category': row['category'], + 'color': row['color'], + 'icon': row['icon'], + 'inputs': json.loads(row['inputs']) if row['inputs'] else [], + 'outputs': json.loads(row['outputs']) if row['outputs'] else [], + 'output_schema': json.loads(row['output_schema']) if ('output_schema' in row.keys() and row['output_schema']) else None, + 'config_schema': json.loads(row['config_schema']) if row['config_schema'] else None, + 'default_properties': json.loads(row['default_properties']) if row['default_properties'] else {}, + 'enabled': bool(row['enabled']), + 'created_at': row['created_at'], + 'updated_at': row['updated_at'] + } + + @staticmethod + def create(payload: Dict[str, Any]) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO workflow_registry_nodes + (node_key, name, description, category, color, icon, inputs, outputs, output_schema, config_schema, default_properties, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload['key'], + payload['name'], + payload.get('description', ''), + payload['category'], + payload.get('color', '#10b981'), + payload.get('icon', 'Workflow'), + json.dumps(payload.get('inputs', [])), + json.dumps(payload.get('outputs', [])), + json.dumps(payload.get('output_schema')) if payload.get('output_schema') is not None else None, + json.dumps(payload.get('config_schema')) if payload.get('config_schema') is not None else None, + json.dumps(payload.get('default_properties', {})), + int(payload.get('enabled', True)) + ) + ) + conn.commit() + conn.close() + WorkflowRegistryNode._clear_cache() + return WorkflowRegistryNode.get_by_key(payload['key']) + + @staticmethod + def update(node_key: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + UPDATE workflow_registry_nodes + SET name = ?, description = ?, category = ?, color = ?, icon = ?, + inputs = ?, outputs = ?, output_schema = ?, config_schema = ?, default_properties = ?, enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE node_key = ? + """, + ( + payload['name'], + payload.get('description', ''), + payload['category'], + payload.get('color', '#10b981'), + payload.get('icon', 'Workflow'), + json.dumps(payload.get('inputs', [])), + json.dumps(payload.get('outputs', [])), + json.dumps(payload.get('output_schema')) if payload.get('output_schema') is not None else None, + json.dumps(payload.get('config_schema')) if payload.get('config_schema') is not None else None, + json.dumps(payload.get('default_properties', {})), + int(payload.get('enabled', True)), + node_key + ) + ) + conn.commit() + conn.close() + WorkflowRegistryNode._clear_cache() + return WorkflowRegistryNode.get_by_key(node_key) + + @staticmethod + def delete(node_key: str) -> bool: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM workflow_registry_nodes WHERE node_key = ?", (node_key,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + WorkflowRegistryNode._clear_cache() + return deleted > 0 + + +class Workflow: + """Model for workflow graphs with simplified JSON storage.""" + + @staticmethod + def get_all() -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, name, description, enabled, created_by, created_at, updated_at + FROM workflows + ORDER BY updated_at DESC + """) + rows = cursor.fetchall() + conn.close() + return [{ + 'id': row['id'], + 'name': row['name'], + 'description': row['description'], + 'is_active': bool(row['enabled']), + 'created_by': row['created_by'], + 'created_at': row['created_at'], + 'updated_at': row['updated_at'] + } for row in rows] + + @staticmethod + def get_by_id(workflow_id: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + workflow = cursor.fetchone() + conn.close() + + if not workflow: + return None + + return { + 'id': workflow['id'], + 'name': workflow['name'], + 'description': workflow['description'], + 'is_active': bool(workflow['enabled']), + 'created_by': workflow['created_by'] if 'created_by' in workflow.keys() else None, + 'created_at': workflow['created_at'], + 'updated_at': workflow['updated_at'], + 'ui_state': json.loads(workflow['ui_state']) if workflow['ui_state'] else None, + 'nodes': json.loads(workflow['nodes']) if workflow['nodes'] else [], + 'edges': json.loads(workflow['edges']) if workflow['edges'] else [] + } + + @staticmethod + def create(name: str, description: str, created_by: str, + nodes: Optional[List[Dict[str, Any]]] = None, + edges: Optional[List[Dict[str, Any]]] = None, + ui_state: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + workflow_id = uuid.uuid4().hex + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO workflows (id, name, description, enabled, nodes, edges, ui_state, created_by, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now'), datetime('now')) + """, + ( + workflow_id, + name, + description or '', + json.dumps(nodes or []), + json.dumps(edges or []), + json.dumps(ui_state) if ui_state else None, + created_by + ) + ) + conn.commit() + conn.close() + + return Workflow.get_by_id(workflow_id) + + @staticmethod + def update(workflow_id: str, name: Optional[str] = None, description: Optional[str] = None, + is_active: Optional[bool] = None, + nodes: Optional[List[Dict[str, Any]]] = None, + edges: Optional[List[Dict[str, Any]]] = None, + ui_state: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + fields = [] + values = [] + + if name is not None: + fields.append("name = ?") + values.append(name) + if description is not None: + fields.append("description = ?") + values.append(description) + if is_active is not None: + fields.append("enabled = ?") + values.append(int(is_active)) + if nodes is not None: + fields.append("nodes = ?") + values.append(json.dumps(nodes)) + if edges is not None: + fields.append("edges = ?") + values.append(json.dumps(edges)) + if ui_state is not None: + fields.append("ui_state = ?") + values.append(json.dumps(ui_state)) + + if fields: + fields.append("updated_at = datetime('now')") + values.append(workflow_id) + cursor.execute( + f"UPDATE workflows SET {', '.join(fields)} WHERE id = ?", + values + ) + conn.commit() + conn.close() + + return Workflow.get_by_id(workflow_id) + + @staticmethod + def delete(workflow_id: str) -> bool: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted > 0 + + @staticmethod + def save_graph(workflow_id: str, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]) -> None: + """Save workflow graph (nodes and edges) - simplified version.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE workflows SET nodes = ?, edges = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(nodes), json.dumps(edges), workflow_id) + ) + conn.commit() + conn.close() + + @staticmethod + def validate_connection(workflow_id: str, source_node_id: str, target_node_id: str, + source_handle: Optional[str], target_handle: Optional[str], + source_node_type: Optional[str] = None, + target_node_type: Optional[str] = None) -> Tuple[bool, str]: + def normalize_handle(handle: Optional[str]) -> Optional[str]: + if not handle: + return handle + return handle.split(':')[0] + + if source_node_id == target_node_id: + return False, 'Cannot connect a node to itself.' + + # Get workflow to check node types + workflow = Workflow.get_by_id(workflow_id) + if not workflow: + return False, 'Workflow not found.' + + nodes = workflow.get('nodes', []) + edges = workflow.get('edges', []) + node_map = {node['id']: node['type'] for node in nodes} + + source_type = node_map.get(source_node_id) or source_node_type + target_type = node_map.get(target_node_id) or target_node_type + if not source_type or not target_type: + return False, 'Unknown node reference.' + + source_registry = WorkflowRegistryNode.get_by_key(source_type) + target_registry = WorkflowRegistryNode.get_by_key(target_type) + if not source_registry or not target_registry: + return False, 'Unknown node type.' + + source_outputs = source_registry.get('outputs', []) + target_inputs = target_registry.get('inputs', []) + + normalized_source = normalize_handle(source_handle) + normalized_target = normalize_handle(target_handle) + + if source_outputs: + output = next((item for item in source_outputs if item.get('id') == normalized_source), None) + else: + output = None + if target_inputs: + target = next((item for item in target_inputs if item.get('id') == normalized_target), None) + else: + target = None + + if output is None: + return False, 'Invalid source handle.' + if target is None: + return False, 'Invalid target handle.' + + output_type = output.get('type', 'any') + input_type = target.get('type', 'any') + if output_type != 'any' and input_type != 'any' and output_type != input_type: + return False, 'Incompatible connection types.' + + # Check if target handle already has an incoming connection + allow_multiple_inputs = target_registry.get('allow_multiple_inputs', False) + if not allow_multiple_inputs: + for edge in edges: + edge_target_handle = normalize_handle(edge.get('targetHandle') or edge.get('target_handle')) + if (edge.get('target') == target_node_id and + edge_target_handle == normalized_target): + return False, 'This input already has a connection. Node does not support multiple inputs.' + + return True, 'Connection valid.' + + +class ActiveRedirect: + """Model for active printer redirects.""" + + def __init__(self, id: int, source_printer_id: str, source_ip: str, + target_printer_id: str, target_ip: str, protocol: str, + port: int, enabled_at: datetime, enabled_by: str): + self.id = id + self.source_printer_id = source_printer_id + self.source_ip = source_ip + self.target_printer_id = target_printer_id + self.target_ip = target_ip + self.protocol = protocol + self.port = port + self.enabled_at = enabled_at + self.enabled_by = enabled_by + + @staticmethod + def get_all() -> List['ActiveRedirect']: + """Get all active redirects.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM active_redirects ORDER BY enabled_at DESC") + rows = cursor.fetchall() + conn.close() + + return [ActiveRedirect( + id=row['id'], + source_printer_id=row['source_printer_id'], + source_ip=row['source_ip'], + target_printer_id=row['target_printer_id'], + target_ip=row['target_ip'], + protocol=row['protocol'], + port=row['port'], + enabled_at=row['enabled_at'], + enabled_by=row['enabled_by'] + ) for row in rows] + + @staticmethod + def get_by_source_printer(printer_id: str) -> Optional['ActiveRedirect']: + """Get redirect by source printer ID.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM active_redirects WHERE source_printer_id = ?", + (printer_id,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return ActiveRedirect( + id=row['id'], + source_printer_id=row['source_printer_id'], + source_ip=row['source_ip'], + target_printer_id=row['target_printer_id'], + target_ip=row['target_ip'], + protocol=row['protocol'], + port=row['port'], + enabled_at=row['enabled_at'], + enabled_by=row['enabled_by'] + ) + return None + + @staticmethod + def get_by_source_ip(ip: str) -> Optional['ActiveRedirect']: + """Get redirect by source IP.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM active_redirects WHERE source_ip = ?", (ip,)) + row = cursor.fetchone() + conn.close() + + if row: + return ActiveRedirect( + id=row['id'], + source_printer_id=row['source_printer_id'], + source_ip=row['source_ip'], + target_printer_id=row['target_printer_id'], + target_ip=row['target_ip'], + protocol=row['protocol'], + port=row['port'], + enabled_at=row['enabled_at'], + enabled_by=row['enabled_by'] + ) + return None + + @staticmethod + def get_by_id(redirect_id: int) -> Optional['ActiveRedirect']: + """Get redirect by ID.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM active_redirects WHERE id = ?", (redirect_id,)) + row = cursor.fetchone() + conn.close() + + if row: + return ActiveRedirect( + id=row['id'], + source_printer_id=row['source_printer_id'], + source_ip=row['source_ip'], + target_printer_id=row['target_printer_id'], + target_ip=row['target_ip'], + protocol=row['protocol'], + port=row['port'], + enabled_at=row['enabled_at'], + enabled_by=row['enabled_by'] + ) + return None + + @staticmethod + def is_target_in_use(printer_id: str) -> bool: + """Check if a printer is already being used as a target.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT COUNT(*) FROM active_redirects WHERE target_printer_id = ?", + (printer_id,) + ) + count = cursor.fetchone()[0] + conn.close() + return count > 0 + + @staticmethod + def create(source_printer_id: str, source_ip: str, target_printer_id: str, + target_ip: str, protocol: str, port: int, enabled_by: str) -> 'ActiveRedirect': + """Create a new redirect.""" + conn = get_db_connection() + cursor = conn.cursor() + enabled_at = datetime.now().isoformat() + cursor.execute(""" + INSERT INTO active_redirects + (source_printer_id, source_ip, target_printer_id, target_ip, + protocol, port, enabled_by, enabled_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (source_printer_id, source_ip, target_printer_id, target_ip, + protocol, port, enabled_by, enabled_at)) + conn.commit() + redirect_id = cursor.lastrowid + conn.close() + + return ActiveRedirect( + id=redirect_id, + source_printer_id=source_printer_id, + source_ip=source_ip, + target_printer_id=target_printer_id, + target_ip=target_ip, + protocol=protocol, + port=port, + enabled_at=enabled_at, + enabled_by=enabled_by + ) + + def delete(self, disabled_by: str = None, reason: str = None): + """Delete this redirect and record in history.""" + conn = get_db_connection() + cursor = conn.cursor() + + # Calculate duration + if isinstance(self.enabled_at, str): + enabled_dt = datetime.fromisoformat(self.enabled_at) + else: + enabled_dt = self.enabled_at + duration = int((datetime.now() - enabled_dt).total_seconds()) + + # Record in history + cursor.execute(""" + INSERT INTO redirect_history + (source_printer_id, source_ip, target_printer_id, target_ip, + enabled_at, enabled_by, disabled_at, disabled_by, duration_seconds, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + self.source_printer_id, self.source_ip, + self.target_printer_id, self.target_ip, + self.enabled_at, self.enabled_by, + datetime.now().isoformat(), disabled_by or 'system', + duration, reason + )) + + # Delete active redirect + cursor.execute("DELETE FROM active_redirects WHERE id = ?", (self.id,)) + conn.commit() + conn.close() + + +class UserSession: + """Model for JWT sessions.""" + + def __init__(self, id: int, user_id: int, jti: str, created_at: str, + last_used: str, revoked_at: Optional[str], ip_address: Optional[str], + user_agent: Optional[str]): + self.id = id + self.user_id = user_id + self.jti = jti + self.created_at = created_at + self.last_used = last_used + self.revoked_at = revoked_at + self.ip_address = ip_address + self.user_agent = user_agent + + @staticmethod + def create(user_id: int, jti: str, ip_address: Optional[str], user_agent: Optional[str]) -> 'UserSession': + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO user_sessions (user_id, jti, ip_address, user_agent) + VALUES (?, ?, ?, ?) + """, + (user_id, jti, ip_address, user_agent) + ) + conn.commit() + session_id = cursor.lastrowid + cursor.execute("SELECT * FROM user_sessions WHERE id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + return UserSession( + id=row['id'], + user_id=row['user_id'], + jti=row['jti'], + created_at=row['created_at'], + last_used=row['last_used'], + revoked_at=row['revoked_at'], + ip_address=row['ip_address'], + user_agent=row['user_agent'] + ) + + @staticmethod + def get_by_jti(jti: str) -> Optional['UserSession']: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM user_sessions WHERE jti = ?", (jti,)) + row = cursor.fetchone() + conn.close() + if row: + return UserSession( + id=row['id'], + user_id=row['user_id'], + jti=row['jti'], + created_at=row['created_at'], + last_used=row['last_used'], + revoked_at=row['revoked_at'], + ip_address=row['ip_address'], + user_agent=row['user_agent'] + ) + return None + + @staticmethod + def get_by_user(user_id: int) -> List['UserSession']: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM user_sessions WHERE user_id = ? ORDER BY last_used DESC", + (user_id,) + ) + rows = cursor.fetchall() + conn.close() + return [UserSession( + id=row['id'], + user_id=row['user_id'], + jti=row['jti'], + created_at=row['created_at'], + last_used=row['last_used'], + revoked_at=row['revoked_at'], + ip_address=row['ip_address'], + user_agent=row['user_agent'] + ) for row in rows] + + @staticmethod + def revoke(session_id: int): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE user_sessions SET revoked_at = ? WHERE id = ?", + (datetime.now().isoformat(), session_id) + ) + conn.commit() + conn.close() + + @staticmethod + def revoke_by_jti(jti: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE user_sessions SET revoked_at = ? WHERE jti = ?", + (datetime.now().isoformat(), jti) + ) + conn.commit() + conn.close() + + @staticmethod + def touch(jti: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE user_sessions SET last_used = ? WHERE jti = ?", + (datetime.now().isoformat(), jti) + ) + conn.commit() + conn.close() + + +class AuditLog: + """Audit logging for all actions.""" + + @staticmethod + def log(username: str, action: str, source_printer_id: str = None, + source_ip: str = None, target_printer_id: str = None, + target_ip: str = None, details: str = None, + success: bool = True, error_message: str = None): + """Log an action.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO audit_log + (username, action, source_printer_id, source_ip, + target_printer_id, target_ip, details, success, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (username, action, source_printer_id, source_ip, + target_printer_id, target_ip, details, success, error_message)) + conn.commit() + conn.close() + + @staticmethod + def get_recent(limit: int = 100, offset: int = 0, + action: Optional[str] = None, + username: Optional[str] = None) -> List[Dict[str, Any]]: + """Get recent audit log entries with optional filtering.""" + conn = get_db_connection() + cursor = conn.cursor() + + query = "SELECT * FROM audit_log" + params: List[Any] = [] + filters = [] + + if action: + filters.append("action = ?") + params.append(action) + if username: + filters.append("username = ?") + params.append(username) + + if filters: + query += " WHERE " + " AND ".join(filters) + + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + @staticmethod + def get_by_printer(printer_id: str, limit: int = 50) -> List[Dict[str, Any]]: + """Get audit log entries for a specific printer.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM audit_log + WHERE source_printer_id = ? OR target_printer_id = ? + ORDER BY timestamp DESC + LIMIT ? + """, (printer_id, printer_id, limit)) + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + +class RedirectHistory: + """Model for redirect history and statistics.""" + + @staticmethod + def get_by_printer(printer_id: str, limit: int = 50) -> List[Dict[str, Any]]: + """Get redirect history for a printer (as source or target).""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM redirect_history + WHERE source_printer_id = ? OR target_printer_id = ? + ORDER BY enabled_at DESC + LIMIT ? + """, (printer_id, printer_id, limit)) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + @staticmethod + def get_all(limit: int = 100) -> List[Dict[str, Any]]: + """Get all redirect history.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM redirect_history + ORDER BY enabled_at DESC + LIMIT ? + """, (limit,)) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + @staticmethod + def get_statistics() -> Dict[str, Any]: + """Get overall redirect statistics.""" + conn = get_db_connection() + cursor = conn.cursor() + + # Total redirects ever + cursor.execute("SELECT COUNT(*) FROM redirect_history") + total_redirects = cursor.fetchone()[0] + + # Currently active + cursor.execute("SELECT COUNT(*) FROM active_redirects") + active_redirects = cursor.fetchone()[0] + + # Total duration (in hours) + cursor.execute("SELECT SUM(duration_seconds) FROM redirect_history") + total_seconds = cursor.fetchone()[0] or 0 + total_hours = round(total_seconds / 3600, 1) + + # Average duration + cursor.execute("SELECT AVG(duration_seconds) FROM redirect_history") + avg_seconds = cursor.fetchone()[0] or 0 + avg_duration = round(avg_seconds / 60, 1) # in minutes + + # Most redirected printer + cursor.execute(""" + SELECT source_printer_id, COUNT(*) as cnt + FROM redirect_history + GROUP BY source_printer_id + ORDER BY cnt DESC + LIMIT 1 + """) + row = cursor.fetchone() + most_redirected = row['source_printer_id'] if row else None + + # Redirects this month + cursor.execute(""" + SELECT COUNT(*) FROM redirect_history + WHERE enabled_at >= date('now', 'start of month') + """) + this_month = cursor.fetchone()[0] + + conn.close() + + return { + 'total_redirects': total_redirects, + 'active_redirects': active_redirects, + 'total_hours': total_hours, + 'avg_duration_minutes': avg_duration, + 'most_redirected_printer': most_redirected, + 'redirects_this_month': this_month + } + + +class PrintJobHistory: + """Model for storing print job history.""" + + def __init__(self, id: int, printer_id: str, job_id: int, name: str = "", + owner: str = "", status: str = "Unknown", pages: int = 0, + size_bytes: int = 0, submitted_at: Optional[datetime] = None, + started_at: Optional[datetime] = None, + completed_at: Optional[datetime] = None, + recorded_at: Optional[datetime] = None): + self.id = id + self.printer_id = printer_id + self.job_id = job_id + self.name = name + self.owner = owner + self.status = status + self.pages = pages + self.size_bytes = size_bytes + self.submitted_at = submitted_at + self.started_at = started_at + self.completed_at = completed_at + self.recorded_at = recorded_at or datetime.now() + + def to_dict(self) -> Dict[str, Any]: + return { + 'id': self.id, + 'printer_id': self.printer_id, + 'job_id': self.job_id, + 'name': self.name, + 'owner': self.owner, + 'status': self.status, + 'pages': self.pages, + 'size_bytes': self.size_bytes, + 'submitted_at': self.submitted_at.isoformat() if self.submitted_at else None, + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'recorded_at': self.recorded_at.isoformat() if self.recorded_at else None + } + + @staticmethod + def create(printer_id: str, job_id: int, name: str = "", owner: str = "", + status: str = "Unknown", pages: int = 0, size_bytes: int = 0, + submitted_at: Optional[datetime] = None, + started_at: Optional[datetime] = None, + completed_at: Optional[datetime] = None) -> 'PrintJobHistory': + """Create a new job history entry.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO print_job_history + (printer_id, job_id, name, owner, status, pages, size_bytes, + submitted_at, started_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (printer_id, job_id, name, owner, status, pages, size_bytes, + submitted_at, started_at, completed_at)) + job_history_id = cursor.lastrowid + conn.commit() + conn.close() + + return PrintJobHistory( + id=job_history_id, printer_id=printer_id, job_id=job_id, + name=name, owner=owner, status=status, pages=pages, + size_bytes=size_bytes, submitted_at=submitted_at, + started_at=started_at, completed_at=completed_at + ) + + @staticmethod + def get_for_printer(printer_id: str, limit: int = 50) -> List['PrintJobHistory']: + """Get job history for a specific printer.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM print_job_history + WHERE printer_id = ? + ORDER BY recorded_at DESC + LIMIT ? + """, (printer_id, limit)) + rows = cursor.fetchall() + conn.close() + + return [PrintJobHistory( + id=row['id'], + printer_id=row['printer_id'], + job_id=row['job_id'], + name=row['name'], + owner=row['owner'], + status=row['status'], + pages=row['pages'], + size_bytes=row['size_bytes'], + submitted_at=row['submitted_at'], + started_at=row['started_at'], + completed_at=row['completed_at'], + recorded_at=row['recorded_at'] + ) for row in rows] + + @staticmethod + def record_job(printer_id, job_id: int, document_name: str = "", + username: str = "", status: str = "completed", + pages: int = 0, copies: int = 1, size_bytes: int = 0) -> Optional['PrintJobHistory']: + """Record a detected print job. + + This is a convenience method for the job monitor to record jobs. + """ + now = datetime.now() + return PrintJobHistory.create( + printer_id=str(printer_id), + job_id=job_id, + name=document_name, + owner=username, + status=status.title(), # Capitalize first letter + pages=pages, + size_bytes=size_bytes, + submitted_at=now, + started_at=now, + completed_at=now if status == 'completed' else None + ) + + @staticmethod + def get_statistics(printer_id: str) -> Dict[str, Any]: + """Get job statistics for a printer.""" + conn = get_db_connection() + cursor = conn.cursor() + + # Total jobs + cursor.execute(""" + SELECT COUNT(*) FROM print_job_history WHERE printer_id = ? + """, (printer_id,)) + total_jobs = cursor.fetchone()[0] + + # Total pages + cursor.execute(""" + SELECT SUM(pages) FROM print_job_history WHERE printer_id = ? + """, (printer_id,)) + total_pages = cursor.fetchone()[0] or 0 + + # Jobs today + cursor.execute(""" + SELECT COUNT(*) FROM print_job_history + WHERE printer_id = ? AND date(recorded_at) = date('now') + """, (printer_id,)) + jobs_today = cursor.fetchone()[0] + + # Completed jobs + cursor.execute(""" + SELECT COUNT(*) FROM print_job_history + WHERE printer_id = ? AND status = 'Completed' + """, (printer_id,)) + completed_jobs = cursor.fetchone()[0] + + conn.close() + + return { + 'total_jobs': total_jobs, + 'total_pages': total_pages, + 'jobs_today': jobs_today, + 'completed_jobs': completed_jobs + } + + +class PrinterErrorLog: + """Model for storing printer error logs.""" + + def __init__(self, id: int, printer_id: str, code: int, severity: str = "warning", + message: str = "", description: str = "", + occurred_at: Optional[datetime] = None, + resolved_at: Optional[datetime] = None): + self.id = id + self.printer_id = printer_id + self.code = code + self.severity = severity + self.message = message + self.description = description + self.occurred_at = occurred_at or datetime.now() + self.resolved_at = resolved_at + + def to_dict(self) -> Dict[str, Any]: + return { + 'id': self.id, + 'printer_id': self.printer_id, + 'code': self.code, + 'severity': self.severity, + 'message': self.message, + 'description': self.description, + 'occurred_at': self.occurred_at.isoformat() if self.occurred_at else None, + 'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None + } + + @staticmethod + def create(printer_id: str, code: int, severity: str = "warning", + message: str = "", description: str = "") -> 'PrinterErrorLog': + """Create a new error log entry.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO printer_error_log + (printer_id, code, severity, message, description) + VALUES (?, ?, ?, ?, ?) + """, (printer_id, code, severity, message, description)) + error_id = cursor.lastrowid + conn.commit() + conn.close() + + return PrinterErrorLog( + id=error_id, printer_id=printer_id, code=code, + severity=severity, message=message, description=description + ) + + @staticmethod + def resolve(error_id: int): + """Mark an error as resolved.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE printer_error_log + SET resolved_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (error_id,)) + conn.commit() + conn.close() + + @staticmethod + def get_for_printer(printer_id: str, include_resolved: bool = False, + limit: int = 50) -> List['PrinterErrorLog']: + """Get error log for a specific printer.""" + conn = get_db_connection() + cursor = conn.cursor() + + if include_resolved: + cursor.execute(""" + SELECT * FROM printer_error_log + WHERE printer_id = ? + ORDER BY occurred_at DESC + LIMIT ? + """, (printer_id, limit)) + else: + cursor.execute(""" + SELECT * FROM printer_error_log + WHERE printer_id = ? AND resolved_at IS NULL + ORDER BY occurred_at DESC + LIMIT ? + """, (printer_id, limit)) + + rows = cursor.fetchall() + conn.close() + + return [PrinterErrorLog( + id=row['id'], + printer_id=row['printer_id'], + code=row['code'], + severity=row['severity'], + message=row['message'], + description=row['description'], + occurred_at=row['occurred_at'], + resolved_at=row['resolved_at'] + ) for row in rows] + + @staticmethod + def get_active_count(printer_id: str) -> int: + """Get count of active (unresolved) errors for a printer.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM printer_error_log + WHERE printer_id = ? AND resolved_at IS NULL + """, (printer_id,)) + count = cursor.fetchone()[0] + conn.close() + return count diff --git a/app/notifications.py b/app/notifications.py deleted file mode 100644 index 9f71c3d..0000000 --- a/app/notifications.py +++ /dev/null @@ -1,392 +0,0 @@ -""" -Notification system for Printer Proxy - -This module provides a unified interface for sending notifications -through various channels (SMTP, Teams, etc.). New channels can be -added by implementing a new notifier class and registering it. -""" -import smtplib -import ssl -import logging -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from abc import ABC, abstractmethod -from typing import Dict, Any, Optional, List -from datetime import datetime - -from app.settings import get_settings_manager - -logger = logging.getLogger(__name__) - - -class NotificationChannel(ABC): - """Base class for notification channels.""" - - @property - @abstractmethod - def channel_name(self) -> str: - """Return the channel name (e.g., 'smtp', 'teams').""" - pass - - @abstractmethod - def is_configured(self, settings: Dict[str, Any]) -> bool: - """Check if the channel is properly configured.""" - pass - - @abstractmethod - def is_enabled(self, settings: Dict[str, Any]) -> bool: - """Check if the channel is enabled.""" - pass - - @abstractmethod - def send(self, subject: str, message: str, settings: Dict[str, Any], - html_message: Optional[str] = None) -> bool: - """Send a notification. Returns True on success.""" - pass - - -class SMTPNotificationChannel(NotificationChannel): - """SMTP email notification channel.""" - - @property - def channel_name(self) -> str: - return 'smtp' - - def is_configured(self, settings: Dict[str, Any]) -> bool: - """Check if SMTP is properly configured.""" - smtp = settings.get('notifications', {}).get('smtp', {}) - required = ['host', 'port', 'from_address', 'to_addresses'] - return all(smtp.get(field) for field in required) - - def is_enabled(self, settings: Dict[str, Any]) -> bool: - """Check if SMTP notifications are enabled.""" - return settings.get('notifications', {}).get('smtp', {}).get('enabled', False) - - def send(self, subject: str, message: str, settings: Dict[str, Any], - html_message: Optional[str] = None) -> bool: - """Send email notification via SMTP.""" - smtp_settings = settings.get('notifications', {}).get('smtp', {}) - - if not self.is_configured(settings): - logger.warning("SMTP not properly configured") - return False - - try: - host = smtp_settings['host'] - port = int(smtp_settings['port']) - from_address = smtp_settings['from_address'] - to_addresses = smtp_settings['to_addresses'] - username = smtp_settings.get('username', '') - password = smtp_settings.get('password', '') - use_tls = smtp_settings.get('use_tls', True) - use_ssl = smtp_settings.get('use_ssl', False) - - # Parse to_addresses if it's a string - if isinstance(to_addresses, str): - to_addresses = [addr.strip() for addr in to_addresses.split(',') if addr.strip()] - - # Create message - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = from_address - msg['To'] = ', '.join(to_addresses) - msg['X-Mailer'] = 'Printer-Proxy-Notifier' - - # Add plain text part - msg.attach(MIMEText(message, 'plain')) - - # Add HTML part if provided - if html_message: - msg.attach(MIMEText(html_message, 'html')) - - # Connect and send - if use_ssl: - # Direct SSL connection (port 465) - context = ssl.create_default_context() - with smtplib.SMTP_SSL(host, port, context=context, timeout=30) as server: - if username and password: - server.login(username, password) - server.sendmail(from_address, to_addresses, msg.as_string()) - else: - # Standard connection, optionally with STARTTLS (port 587 or 25) - with smtplib.SMTP(host, port, timeout=30) as server: - server.ehlo() - if use_tls: - context = ssl.create_default_context() - server.starttls(context=context) - server.ehlo() - if username and password: - server.login(username, password) - server.sendmail(from_address, to_addresses, msg.as_string()) - - logger.info(f"Email notification sent to {to_addresses}") - return True - - except smtplib.SMTPAuthenticationError as e: - logger.error(f"SMTP authentication failed: {e}") - return False - except smtplib.SMTPRecipientsRefused as e: - logger.error(f"SMTP recipients refused: {e}") - return False - except smtplib.SMTPException as e: - logger.error(f"SMTP error: {e}") - return False - except Exception as e: - logger.error(f"Failed to send email notification: {e}") - return False - - -class NotificationManager: - """ - Unified notification manager that handles all notification channels. - - Usage: - from app.notifications import notify - - # Send to all enabled channels - notify("Printer Alert", "Printer HP-LaserJet is offline!") - - # Or with HTML - notify("Printer Alert", "Plain text", html_message="HTML version") - """ - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - self._initialized = True - self._channels: List[NotificationChannel] = [] - self._register_default_channels() - - def _register_default_channels(self): - """Register built-in notification channels.""" - self._channels.append(SMTPNotificationChannel()) - # Future: self._channels.append(TeamsNotificationChannel()) - # Future: self._channels.append(SlackNotificationChannel()) - - def register_channel(self, channel: NotificationChannel): - """Register a new notification channel.""" - self._channels.append(channel) - - def get_channels(self) -> List[NotificationChannel]: - """Get all registered channels.""" - return self._channels - - def get_enabled_channels(self) -> List[NotificationChannel]: - """Get all enabled and configured channels.""" - settings = get_settings_manager().get_all() - return [ - ch for ch in self._channels - if ch.is_enabled(settings) and ch.is_configured(settings) - ] - - def send(self, subject: str, message: str, html_message: Optional[str] = None) -> Dict[str, bool]: - """ - Send notification to all enabled channels. - - Args: - subject: Notification subject/title - message: Plain text message body - html_message: Optional HTML message body - - Returns: - Dict mapping channel names to success status - """ - settings = get_settings_manager().get_all() - results = {} - - for channel in self._channels: - if channel.is_enabled(settings) and channel.is_configured(settings): - try: - success = channel.send(subject, message, settings, html_message) - results[channel.channel_name] = success - except Exception as e: - logger.error(f"Error sending via {channel.channel_name}: {e}") - results[channel.channel_name] = False - else: - # Skip disabled/unconfigured channels silently - pass - - if not results: - logger.debug("No notification channels are enabled and configured") - - return results - - def test_channel(self, channel_name: str) -> tuple[bool, str]: - """ - Test a specific notification channel. - - Returns: - Tuple of (success, message) - """ - settings = get_settings_manager().get_all() - - for channel in self._channels: - if channel.channel_name == channel_name: - if not channel.is_configured(settings): - return False, f"{channel_name.upper()} is not properly configured" - - try: - success = channel.send( - subject="Printer Proxy - Test Notification", - message="This is a test notification from Printer Proxy. If you received this, your notification settings are working correctly.", - settings=settings, - html_message=""" - - -

Printer Proxy - Test Notification

-

This is a test notification from Printer Proxy.

-

If you received this, your notification settings are working correctly.

-
-

- Sent at: """ + datetime.now().strftime('%Y-%m-%d %H:%M:%S') + """ -

- - - """ - ) - if success: - return True, f"Test notification sent successfully via {channel_name.upper()}" - else: - return False, f"Failed to send test notification via {channel_name.upper()}" - except Exception as e: - return False, f"Error testing {channel_name.upper()}: {str(e)}" - - return False, f"Unknown channel: {channel_name}" - - -# Singleton instance -_notification_manager: Optional[NotificationManager] = None - - -def get_notification_manager() -> NotificationManager: - """Get the singleton notification manager instance.""" - global _notification_manager - if _notification_manager is None: - _notification_manager = NotificationManager() - return _notification_manager - - -def notify(subject: str, message: str, html_message: Optional[str] = None) -> Dict[str, bool]: - """ - Send notification to all enabled channels. - - This is the primary interface for sending notifications throughout the app. - - Usage: - from app.notifications import notify - - # Simple notification - notify("Alert", "Something happened!") - - # With HTML - notify("Alert", "Plain text", html_message="Rich HTML") - - Args: - subject: Notification subject/title - message: Plain text message body - html_message: Optional HTML message body - - Returns: - Dict mapping channel names to success status (empty if no channels enabled) - """ - return get_notification_manager().send(subject, message, html_message) - - -def notify_printer_offline(printer_name: str, printer_ip: str): - """Send notification when a printer goes offline.""" - subject = f"Printer Offline: {printer_name}" - message = f"Printer '{printer_name}' ({printer_ip}) is now offline." - html_message = f""" - - -

Printer Offline Alert

-

The following printer is no longer responding:

- - - - - - - - - - - - - -
Printer{printer_name}
IP Address{printer_ip}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
-

Please check the printer and consider setting up a redirect if needed.

- - - """ - return notify(subject, message, html_message) - - -def notify_printer_online(printer_name: str, printer_ip: str): - """Send notification when a printer comes back online.""" - subject = f"Printer Online: {printer_name}" - message = f"Printer '{printer_name}' ({printer_ip}) is now online." - html_message = f""" - - -

Printer Online Alert

-

The following printer is now responding:

- - - - - - - - - - - - - -
Printer{printer_name}
IP Address{printer_ip}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
- - - """ - return notify(subject, message, html_message) - - -def notify_redirect_created(source_printer: str, target_printer: str, created_by: str): - """Send notification when a redirect is created.""" - subject = f"Redirect Created: {source_printer} -> {target_printer}" - message = f"A redirect has been created from '{source_printer}' to '{target_printer}' by {created_by}." - html_message = f""" - - -

Redirect Created

-

A new print redirect has been configured:

- - - - - - - - - - - - - - - - - -
From{source_printer}
To{target_printer}
Created By{created_by}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
- - - """ - return notify(subject, message, html_message) diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 7ab9a71..0000000 --- a/app/routes.py +++ /dev/null @@ -1,1317 +0,0 @@ -""" -Flask routes and views -""" -from flask import ( - Blueprint, render_template, redirect, url_for, flash, - request, jsonify, current_app -) -from flask_login import login_user, logout_user, login_required, current_user - -from app.models import AuditLog, ActiveRedirect, User -from app.auth import authenticate_user, create_initial_admin, validate_password_strength, role_required, hash_password -from app.printers import get_registry, Printer -from app.network import get_network_manager -from app.discovery import get_discovery -from config.config import ( - DEFAULT_PORT, - MIN_PASSWORD_LENGTH, - PASSWORD_REQUIRE_UPPERCASE, - PASSWORD_REQUIRE_LOWERCASE, - PASSWORD_REQUIRE_DIGIT, - PASSWORD_REQUIRE_SPECIAL -) - - -# Blueprints -main_bp = Blueprint('main', __name__) -auth_bp = Blueprint('auth', __name__) -api_bp = Blueprint('api', __name__) - - -# ============================================================================ -# Main Routes -# ============================================================================ - -@main_bp.route('/') -@login_required -def dashboard(): - """Main dashboard showing all printers and their status.""" - registry = get_registry() - printers = registry.get_all_statuses() - active_redirects = ActiveRedirect.get_all() - - return render_template('dashboard.html', - printers=printers, - active_redirects=active_redirects) - - -@main_bp.route('/printer/') -@login_required -def printer_detail(printer_id): - """Detailed view of a specific printer.""" - from app.health_check import get_printer_health, get_printer_health_history - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.dashboard')) - - status = registry.get_printer_status(printer, use_cache=True) - available_targets = registry.get_available_targets(exclude_printer_id=printer_id) - audit_history = AuditLog.get_by_printer(printer_id) - - # Get health check status (fast - from cache) - health_status = get_printer_health(printer_id) - health_history = get_printer_health_history(printer_id, limit=24) - - # NOTE: SNMP stats are loaded asynchronously via JavaScript - # to avoid blocking page render - - return render_template('printer_detail.html', - printer=printer, - status=status, - available_targets=available_targets, - audit_history=audit_history, - health_status=health_status, - health_history=health_history) - - -@main_bp.route('/printer//queue') -@login_required -def printer_queue(printer_id): - """Print queue for a specific printer.""" - from app.print_queue import get_print_queue - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.dashboard')) - - queue = get_print_queue(printer.ip) - - return render_template('printer_queue.html', - printer=printer, - queue=queue) - - -@main_bp.route('/printer//jobs') -@login_required -def printer_jobs(printer_id): - """Job history for a specific printer.""" - from app.models import PrintJobHistory - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.dashboard')) - - jobs = PrintJobHistory.get_for_printer(printer_id, limit=100) - stats = PrintJobHistory.get_statistics(printer_id) - - return render_template('printer_jobs.html', - printer=printer, - jobs=jobs, - stats=stats) - - -@main_bp.route('/printer//logs') -@login_required -def printer_logs(printer_id): - """Logs for a specific printer.""" - from app.print_queue import get_printer_logs - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.dashboard')) - - # Get current logs from SNMP - logs = get_printer_logs(printer.ip) - - return render_template('printer_logs.html', - printer=printer, - logs=logs) - - -@main_bp.route('/redirect/', methods=['POST']) -@login_required -@role_required('admin', 'operator') -def create_redirect(printer_id): - """Create a new redirect for a printer.""" - registry = get_registry() - network = get_network_manager() - - source_printer = registry.get_by_id(printer_id) - if not source_printer: - flash('Source printer not found', 'error') - return redirect(url_for('main.dashboard')) - - target_printer_id = request.form.get('target_printer_id') - target_printer = registry.get_by_id(target_printer_id) - - if not target_printer: - flash('Target printer not found', 'error') - return redirect(url_for('main.printer_detail', printer_id=printer_id)) - - # Safety checks - if source_printer.ip == target_printer.ip: - flash('Source and target printer cannot have the same IP', 'error') - return redirect(url_for('main.printer_detail', printer_id=printer_id)) - - # Check if source is already redirected - existing = ActiveRedirect.get_by_source_printer(printer_id) - if existing: - flash('This printer already has an active redirect', 'error') - return redirect(url_for('main.printer_detail', printer_id=printer_id)) - - # Check if target is already in use - if ActiveRedirect.is_target_in_use(target_printer_id): - flash('Target printer is already being used as a redirect target', 'error') - return redirect(url_for('main.printer_detail', printer_id=printer_id)) - - # Check if source printer is still reachable (should be offline) - if registry.check_tcp_reachability(source_printer.ip): - flash('Warning: Source printer appears to be online. Redirect may cause conflicts.', 'warning') - - # Check if target printer is reachable - if not registry.check_tcp_reachability(target_printer.ip): - flash('Warning: Target printer appears to be offline', 'warning') - - # Enable the redirect - success, message = network.enable_redirect( - source_ip=source_printer.ip, - target_ip=target_printer.ip, - port=DEFAULT_PORT - ) - - if success: - # Record in database - ActiveRedirect.create( - source_printer_id=printer_id, - source_ip=source_printer.ip, - target_printer_id=target_printer_id, - target_ip=target_printer.ip, - protocol='raw', - port=DEFAULT_PORT, - enabled_by=current_user.username - ) - - # Audit log - AuditLog.log( - username=current_user.username, - action="REDIRECT_ENABLED", - source_printer_id=printer_id, - source_ip=source_printer.ip, - target_printer_id=target_printer_id, - target_ip=target_printer.ip, - details=f"Redirecting {source_printer.name} to {target_printer.name}", - success=True - ) - - flash(f'Redirect enabled: {source_printer.name} → {target_printer.name}', 'success') - else: - AuditLog.log( - username=current_user.username, - action="REDIRECT_ENABLE_FAILED", - source_printer_id=printer_id, - source_ip=source_printer.ip, - target_printer_id=target_printer_id, - target_ip=target_printer.ip, - success=False, - error_message=message - ) - flash(f'Failed to enable redirect: {message}', 'error') - - return redirect(url_for('main.printer_detail', printer_id=printer_id)) - - -@main_bp.route('/redirect//remove', methods=['POST']) -@login_required -@role_required('admin', 'operator') -def remove_redirect(printer_id): - """Remove an active redirect.""" - registry = get_registry() - network = get_network_manager() - - redirect_obj = ActiveRedirect.get_by_source_printer(printer_id) - if not redirect_obj: - flash('No active redirect found for this printer', 'error') - return redirect(url_for('main.dashboard')) - - # Disable the redirect - success, message = network.disable_redirect( - source_ip=redirect_obj.source_ip, - target_ip=redirect_obj.target_ip, - port=redirect_obj.port - ) - - source_printer = registry.get_by_id(printer_id) - target_printer = registry.get_by_id(redirect_obj.target_printer_id) - - if success: - # Remove from database and record history - redirect_obj.delete( - disabled_by=current_user.username, - reason="Manual removal via web UI" - ) - - # Audit log - AuditLog.log( - username=current_user.username, - action="REDIRECT_DISABLED", - source_printer_id=printer_id, - source_ip=redirect_obj.source_ip, - target_printer_id=redirect_obj.target_printer_id, - target_ip=redirect_obj.target_ip, - details=f"Removed redirect from {source_printer.name if source_printer else printer_id}", - success=True - ) - - flash('Redirect removed successfully', 'success') - else: - AuditLog.log( - username=current_user.username, - action="REDIRECT_DISABLE_FAILED", - source_printer_id=printer_id, - source_ip=redirect_obj.source_ip, - target_printer_id=redirect_obj.target_printer_id, - target_ip=redirect_obj.target_ip, - success=False, - error_message=message - ) - flash(f'Failed to remove redirect: {message}', 'error') - - return redirect(url_for('main.dashboard')) - - -@main_bp.route('/audit') -@login_required -@role_required('admin', 'operator') -def audit_log(): - """View audit log.""" - logs = AuditLog.get_recent(limit=200) - return render_template('audit_log.html', logs=logs) - - -@main_bp.route('/statistics') -@login_required -@role_required('admin', 'operator') -def statistics(): - """View redirect statistics.""" - from app.models import RedirectHistory - - stats = RedirectHistory.get_statistics() - history = RedirectHistory.get_all(limit=50) - - return render_template('statistics.html', stats=stats, history=history) - - -# ============================================================================ -# Printer Management Routes -# ============================================================================ - -@main_bp.route('/printers/manage') -@login_required -@role_required('admin', 'operator') -def manage_printers(): - """Printer management page.""" - registry = get_registry() - printers = registry.get_all_statuses(use_cache=True) - return render_template('manage_printers.html', printers=printers) - - -@main_bp.route('/printers/add', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def add_printer(): - """Add a new printer.""" - registry = get_registry() - - if request.method == 'POST': - printer_id = request.form.get('printer_id', '').strip().lower().replace(' ', '-') - name = request.form.get('name', '').strip() - ip = request.form.get('ip', '').strip() - location = request.form.get('location', '').strip() - model = request.form.get('model', '').strip() - department = request.form.get('department', '').strip() - notes = request.form.get('notes', '').strip() - - # Validation - errors = [] - if not printer_id: - errors.append('Printer ID is required') - elif registry.id_exists(printer_id): - errors.append('Printer ID already exists') - - if not name: - errors.append('Printer name is required') - - if not ip: - errors.append('IP address is required') - else: - # Validate IP format - import ipaddress - try: - ipaddress.ip_address(ip) - except ValueError: - errors.append('Invalid IP address format') - - if registry.ip_exists(ip): - errors.append('IP address already in use by another printer') - - if errors: - for error in errors: - flash(error, 'error') - return render_template('printer_form.html', - mode='add', - printer={'id': printer_id, 'name': name, 'ip': ip, - 'location': location, 'model': model, - 'department': department, 'notes': notes}) - - # Create printer - printer = Printer( - id=printer_id, - name=name, - ip=ip, - protocols=['raw'], - location=location, - model=model, - department=department, - notes=notes - ) - - if registry.add_printer(printer): - # Start background services if they were deferred - try: - from app.health_check import get_scheduler, start_health_checks - scheduler = get_scheduler() - if not scheduler.is_running(): - start_health_checks() - - from app.job_monitor import get_job_monitor - monitor = get_job_monitor() - if not monitor.is_running(): - monitor.init_app(current_app._get_current_object()) - monitor.start() - except Exception as e: - current_app.logger.error(f"Failed to start background services after add: {e}") - - AuditLog.log( - username=current_user.username, - action="PRINTER_ADDED", - source_printer_id=printer_id, - source_ip=ip, - details=f"Added printer: {name}", - success=True - ) - flash(f'Printer "{name}" added successfully', 'success') - return redirect(url_for('main.manage_printers')) - else: - flash('Failed to add printer', 'error') - - return render_template('printer_form.html', mode='add', printer=None) - - -@main_bp.route('/printers//edit', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def edit_printer(printer_id): - """Edit an existing printer.""" - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.manage_printers')) - - # Check for active redirects - has_redirect = ActiveRedirect.get_by_source_printer(printer_id) is not None - is_target = ActiveRedirect.is_target_in_use(printer_id) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - ip = request.form.get('ip', '').strip() - location = request.form.get('location', '').strip() - model = request.form.get('model', '').strip() - department = request.form.get('department', '').strip() - notes = request.form.get('notes', '').strip() - - # Validation - errors = [] - if not name: - errors.append('Printer name is required') - - if not ip: - errors.append('IP address is required') - else: - import ipaddress - try: - ipaddress.ip_address(ip) - except ValueError: - errors.append('Invalid IP address format') - - if registry.ip_exists(ip, exclude_id=printer_id): - errors.append('IP address already in use by another printer') - - # Prevent IP change if redirect is active - if (has_redirect or is_target) and ip != printer.ip: - errors.append('Cannot change IP while redirect is active') - - if errors: - for error in errors: - flash(error, 'error') - return render_template('printer_form.html', - mode='edit', - printer={'id': printer_id, 'name': name, 'ip': ip, - 'location': location, 'model': model, - 'department': department, 'notes': notes}, - has_redirect=has_redirect, - is_target=is_target) - - old_ip = printer.ip - - # Update printer - updated_printer = Printer( - id=printer_id, - name=name, - ip=ip, - protocols=printer.protocols, - location=location, - model=model, - department=department, - notes=notes - ) - - if registry.update_printer(updated_printer): - AuditLog.log( - username=current_user.username, - action="PRINTER_UPDATED", - source_printer_id=printer_id, - source_ip=ip, - details=f"Updated printer: {name}" + (f" (IP changed: {old_ip} -> {ip})" if old_ip != ip else ""), - success=True - ) - flash(f'Printer "{name}" updated successfully', 'success') - return redirect(url_for('main.manage_printers')) - else: - flash('Failed to update printer', 'error') - - return render_template('printer_form.html', - mode='edit', - printer=printer.to_dict(), - has_redirect=has_redirect, - is_target=is_target) - - -@main_bp.route('/printers//delete', methods=['POST']) -@login_required -@role_required('admin') -def delete_printer(printer_id): - """Delete a printer.""" - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - flash('Printer not found', 'error') - return redirect(url_for('main.manage_printers')) - - # Check for active redirects - if ActiveRedirect.get_by_source_printer(printer_id): - flash('Cannot delete printer with active redirect', 'error') - return redirect(url_for('main.manage_printers')) - - if ActiveRedirect.is_target_in_use(printer_id): - flash('Cannot delete printer that is a redirect target', 'error') - return redirect(url_for('main.manage_printers')) - - printer_name = printer.name - printer_ip = printer.ip - - if registry.delete_printer(printer_id): - # Stop background services if no printers remain - if not registry.has_printers(): - try: - from app.health_check import stop_health_checks - stop_health_checks() - - from app.job_monitor import get_job_monitor - monitor = get_job_monitor() - if monitor.is_running(): - monitor.stop() - except Exception as e: - current_app.logger.error(f"Failed to stop background services after delete: {e}") - - AuditLog.log( - username=current_user.username, - action="PRINTER_DELETED", - source_printer_id=printer_id, - source_ip=printer_ip, - details=f"Deleted printer: {printer_name}", - success=True - ) - flash(f'Printer "{printer_name}" deleted successfully', 'success') - else: - flash('Failed to delete printer', 'error') - - return redirect(url_for('main.manage_printers')) - - -@main_bp.route('/printers/discover', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def discover_printers(): - """Discover printers on the network.""" - if request.method == 'POST': - network = request.form.get('network', '').strip() or None - single_ip = request.form.get('single_ip', '').strip() or None - - discovery = get_discovery() - - if single_ip: - # Scan single IP - discovered = discovery.scan_single_ip(single_ip) - else: - # Full network scan - discovered = discovery.discover_all(network_cidr=network, timeout=15) - - # Filter out already registered printers - registry = get_registry() - existing_ips = {p.ip for p in registry.get_all()} - new_printers = [p for p in discovered if p.ip not in existing_ips] - - return render_template('discover_printers.html', - discovered=new_printers, - existing_count=len(discovered) - len(new_printers), - network=network) - - return render_template('discover_printers.html', discovered=None) - - -@main_bp.route('/printers/import', methods=['POST']) -@login_required -@role_required('admin') -def import_discovered_printer(): - """Import a discovered printer.""" - registry = get_registry() - - ip = request.form.get('ip', '').strip() - name = request.form.get('name', '').strip() or f"Printer at {ip}" - model = request.form.get('model', '').strip() - location = request.form.get('location', '').strip() - - if not ip: - flash('IP address is required', 'error') - return redirect(url_for('main.discover_printers')) - - # Generate printer ID from name - import re - printer_id = re.sub(r'[^a-z0-9-]', '-', name.lower()) - printer_id = re.sub(r'-+', '-', printer_id).strip('-') - - # Ensure unique ID - base_id = printer_id - counter = 1 - while registry.id_exists(printer_id): - printer_id = f"{base_id}-{counter}" - counter += 1 - - if registry.ip_exists(ip): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': False, 'message': 'Printer with this IP already exists'}, 400 - flash('Printer with this IP already exists', 'error') - return redirect(url_for('main.discover_printers')) - - printer = Printer( - id=printer_id, - name=name, - ip=ip, - protocols=['raw'], - location=location, - model=model, - department='', - notes='Imported via auto-discovery' - ) - - if registry.add_printer(printer): - AuditLog.log( - username=current_user.username, - action="PRINTER_IMPORTED", - source_printer_id=printer_id, - source_ip=ip, - details=f"Imported discovered printer: {name}", - success=True - ) - # Return JSON for AJAX requests - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'message': f'Printer "{name}" imported successfully', 'ip': ip} - flash(f'Printer "{name}" imported successfully', 'success') - else: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': False, 'message': 'Failed to import printer'}, 400 - flash('Failed to import printer', 'error') - - return redirect(url_for('main.discover_printers')) - - -# ============================================================================ -# Authentication Routes -# ============================================================================ - -@auth_bp.route('/login', methods=['GET', 'POST']) -def login(): - """Login page.""" - # Redirect to setup if no users exist yet - from app.models import get_db_connection - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM users") - user_count = cursor.fetchone()[0] - conn.close() - - if user_count == 0: - return redirect(url_for('auth.initial_setup')) - - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - client_ip = request.remote_addr - - user, error = authenticate_user(username, password, client_ip) - - if user: - login_user(user, remember=False) - next_page = request.args.get('next') - if next_page and next_page.startswith('/'): - return redirect(next_page) - return redirect(url_for('main.dashboard')) - else: - flash(error, 'error') - - return render_template('login.html') - - -@auth_bp.route('/logout') -@login_required -def logout(): - """Logout.""" - AuditLog.log( - username=current_user.username, - action="LOGOUT", - success=True - ) - logout_user() - flash('You have been logged out', 'info') - return redirect(url_for('auth.login')) - - -@auth_bp.route('/setup', methods=['GET', 'POST']) -def initial_setup(): - """Initial setup to create admin user.""" - from app.models import get_db_connection - - # Check if any users exist - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM users") - user_count = cursor.fetchone()[0] - conn.close() - - if user_count > 0: - return redirect(url_for('auth.login')) - - if request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - confirm_password = request.form.get('confirm_password', '') - - if not username: - flash('Username is required', 'error') - elif password != confirm_password: - flash('Passwords do not match', 'error') - else: - is_valid, error = validate_password_strength(password) - if not is_valid: - flash(error, 'error') - else: - success, message = create_initial_admin(username, password) - if success: - flash('Admin user created. Please log in.', 'success') - return redirect(url_for('auth.login')) - else: - flash(message, 'error') - - return render_template('setup.html') - - -@auth_bp.route('/change-password', methods=['GET', 'POST']) -@login_required -def change_password(): - """Change current user's password.""" - import bcrypt - - if request.method == 'POST': - current_password = request.form.get('current_password', '') - new_password = request.form.get('new_password', '') - confirm_password = request.form.get('confirm_password', '') - - # Verify current password - if not bcrypt.checkpw(current_password.encode('utf-8'), - current_user.password_hash.encode('utf-8')): - flash('Current password is incorrect', 'error') - return render_template('change_password.html') - - # Check new passwords match - if new_password != confirm_password: - flash('New passwords do not match', 'error') - return render_template('change_password.html') - - # Validate password strength - is_valid, error = validate_password_strength(new_password) - if not is_valid: - flash(error, 'error') - return render_template('change_password.html') - - # Update password - new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - current_user.update_password(new_hash) - - AuditLog.log( - username=current_user.username, - action="PASSWORD_CHANGED", - details="User changed their password", - success=True - ) - - flash('Password changed successfully', 'success') - return redirect(url_for('main.dashboard')) - - return render_template('change_password.html') - - -# ============================================================================ -# API Routes -# ============================================================================ - -@api_bp.route('/printers') -@login_required -def api_printers(): - """Get all printers with status.""" - registry = get_registry() - return jsonify(registry.get_all_statuses()) - - -@api_bp.route('/printers/') -@login_required -def api_printer(printer_id): - """Get a specific printer with status.""" - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - return jsonify({'error': 'Printer not found'}), 404 - - return jsonify(registry.get_printer_status(printer)) - - -@api_bp.route('/printers//check') -@login_required -def api_check_printer(printer_id): - """Quick reachability check for a printer.""" - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - return jsonify({'error': 'Printer not found'}), 404 - - icmp = registry.check_icmp_reachability(printer.ip) - tcp = registry.check_tcp_reachability(printer.ip) - - return jsonify({ - 'printer_id': printer_id, - 'ip': printer.ip, - 'icmp_reachable': icmp, - 'tcp_reachable': tcp, - 'is_online': icmp or tcp - }) - - -@api_bp.route('/redirects') -@login_required -@role_required('admin', 'operator') -def api_redirects(): - """Get all active redirects.""" - redirects = ActiveRedirect.get_all() - return jsonify([{ - 'id': r.id, - 'source_printer_id': r.source_printer_id, - 'source_ip': r.source_ip, - 'target_printer_id': r.target_printer_id, - 'target_ip': r.target_ip, - 'protocol': r.protocol, - 'port': r.port, - 'enabled_at': str(r.enabled_at), - 'enabled_by': r.enabled_by - } for r in redirects]) - - -@api_bp.route('/network/status') -@login_required -@role_required('admin') -def api_network_status(): - """Get current network status (secondary IPs and NAT rules).""" - network = get_network_manager() - - success, ips = network.get_secondary_ips() - success2, nat_rules = network.get_nat_rules() - - return jsonify({ - 'secondary_ips': ips if success else [], - 'nat_rules': nat_rules if success2 else 'Unable to retrieve' - }) - - -# ============================================================================ -# Server-Sent Events (SSE) for Live Updates -# ============================================================================ - -@api_bp.route('/sse/printer//queue') -@login_required -def sse_printer_queue(printer_id): - """SSE endpoint for live print queue updates.""" - from flask import Response - from app.print_queue import get_print_queue - import json - import time - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - return jsonify({'error': 'Printer not found'}), 404 - - def generate(): - while True: - try: - queue = get_print_queue(printer.ip) - data = { - 'queue': [job.to_dict() for job in queue], - 'count': len(queue), - 'timestamp': time.time() - } - yield f"data: {json.dumps(data)}\n\n" - time.sleep(5) # Update every 5 seconds - except GeneratorExit: - break - except Exception as e: - yield f"data: {json.dumps({'error': str(e)})}\n\n" - time.sleep(10) - - return Response(generate(), mimetype='text/event-stream', - headers={'Cache-Control': 'no-cache', - 'Connection': 'keep-alive'}) - - -# ============================================================================ -# Async Data Loading Endpoints (for fast UI) -# ============================================================================ - -@api_bp.route('/printers//stats') -@login_required -def api_printer_stats(printer_id): - """Get SNMP stats for a printer (async loading).""" - from app.printer_stats import get_printer_stats, get_toner_levels - - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - return jsonify({'error': 'Printer not found'}), 404 - - stats = get_printer_stats(printer.ip) - toner = get_toner_levels(printer.ip) - - return jsonify({ - 'stats': stats.to_dict() if stats else None, - 'toner': toner - }) - - -@api_bp.route('/printers//health') -@login_required -def api_printer_health(printer_id): - """Get health status for a printer (from cache).""" - from app.health_check import get_printer_health, get_printer_health_history - - health = get_printer_health(printer_id) - history = get_printer_health_history(printer_id, limit=24) - - return jsonify({ - 'current': health, - 'history': history - }) - - -@api_bp.route('/printers//refresh') -@login_required -@role_required('admin', 'operator') -def api_printer_refresh(printer_id): - """Force a live status check for a printer (bypasses cache).""" - registry = get_registry() - printer = registry.get_by_id(printer_id) - - if not printer: - return jsonify({'error': 'Printer not found'}), 404 - - # Do a live check and update cache - from app.health_check import HealthChecker - checker = HealthChecker() - result = checker.check_printer(printer_id, printer.ip) - checker.save_result(result) - - return jsonify({ - 'printer_id': printer_id, - 'ip': printer.ip, - 'icmp_reachable': result.icmp_ok, - 'tcp_reachable': result.tcp_9100_ok, - 'is_online': result.is_online, - 'response_time_ms': result.response_time_ms - }) - - -@api_bp.route('/dashboard/status') -@login_required -def api_dashboard_status(): - """Get all printer statuses for dashboard (fast, from cache).""" - registry = get_registry() - return jsonify(registry.get_all_statuses(use_cache=True)) - - -# ============================================================================ -# Update API Routes -# ============================================================================ - -@api_bp.route('/update/status') -def api_update_status(): - """Get current update status. No login required so updating page can poll.""" - from app.updater import get_update_manager - manager = get_update_manager() - return jsonify(manager.get_state()) - - -@api_bp.route('/update/check', methods=['POST']) -@login_required -@role_required('admin') -def api_update_check(): - """Force an update check.""" - from app.updater import get_update_manager - manager = get_update_manager() - update_available, error = manager.check_for_updates(force=True) - - if error: - return jsonify({ - 'success': False, - 'error': error, - 'update_available': False - }) - - return jsonify({ - 'success': True, - 'update_available': update_available, - **manager.get_state() - }) - - -@api_bp.route('/update/start', methods=['POST']) -@login_required -@role_required('admin') -def api_update_start(): - """Start the update process.""" - from app.updater import get_update_manager - from app.models import AuditLog - - manager = get_update_manager() - success, message = manager.start_update() - - if success: - # Log the update action - AuditLog.log( - username=current_user.username if current_user.is_authenticated else 'system', - action='UPDATE_STARTED', - details=f"Update to version {manager._state.available_version} initiated" - ) - - return jsonify({ - 'success': success, - 'message': message - }) - - -@main_bp.route('/settings') -@login_required -@role_required('admin') -def settings_page(): - """Settings page for application configuration.""" - from app.settings import get_settings_manager - settings = get_settings_manager().get_all() - return render_template('settings.html', settings=settings) - - -# ========================================================================= -# User Management (RBAC) -# ========================================================================= - -@main_bp.route('/users') -@login_required -@role_required('admin') -def user_management(): - """User management list for admins.""" - users = User.get_all() - return render_template('user_management.html', users=users) - - -@main_bp.route('/users/add', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def user_add(): - """Create a new user.""" - password_requirements = _get_password_requirements() - if request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - confirm_password = request.form.get('confirm_password', '') - role = request.form.get('role', 'viewer').strip() - is_active = request.form.get('is_active') == 'on' - - if not username: - flash('Username is required', 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - if User.get_by_username(username): - flash('Username already exists', 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - if role not in ['admin', 'operator', 'viewer']: - flash('Invalid role selected', 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - if password != confirm_password: - flash('Passwords do not match', 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - is_valid, error = validate_password_strength(password) - if not is_valid: - flash(error, 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - try: - User.create(username, hash_password(password), role=role, is_active=is_active) - except Exception as e: - current_app.logger.error(f"Failed to create user '{username}': {e}") - flash('Failed to create user. Check logs for details.', 'error') - return render_template('user_form.html', mode='add', form_data=request.form.to_dict(), password_requirements=password_requirements) - - AuditLog.log( - username=current_user.username, - action='USER_CREATED', - details=f"Created user '{username}' with role '{role}'", - success=True - ) - flash(f"User '{username}' created", 'success') - return redirect(url_for('main.user_management')) - - return render_template('user_form.html', mode='add', form_data={}, password_requirements=password_requirements) - - -@main_bp.route('/users//edit', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def user_edit(user_id: int): - """Edit an existing user.""" - user = User.get_by_id(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('main.user_management')) - - password_requirements = _get_password_requirements() - - if request.method == 'POST': - role = request.form.get('role', '').strip() - is_active = request.form.get('is_active') == 'on' - new_password = request.form.get('password', '') - confirm_password = request.form.get('confirm_password', '') - - if role not in ['admin', 'operator', 'viewer']: - flash('Invalid role selected', 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - - admins = [u for u in User.get_all() if u.role == 'admin'] - if user.role == 'admin' and role != 'admin' and len(admins) <= 1: - flash('At least one admin is required', 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - if user.id == current_user.id and role != 'admin': - flash('You cannot remove your own admin access', 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - if user.id == current_user.id and not is_active: - flash('You cannot disable your own account', 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - - if new_password or confirm_password: - if new_password != confirm_password: - flash('Passwords do not match', 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - is_valid, error = validate_password_strength(new_password) - if not is_valid: - flash(error, 'error') - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - user.update_password(hash_password(new_password)) - AuditLog.log( - username=current_user.username, - action='USER_PASSWORD_RESET', - details=f"Reset password for '{user.username}'", - success=True - ) - - user.update_role(role) - user.set_active(is_active) - AuditLog.log( - username=current_user.username, - action='USER_UPDATED', - details=f"Updated user '{user.username}' (role={role}, active={is_active})", - success=True - ) - flash(f"User '{user.username}' updated", 'success') - return redirect(url_for('main.user_management')) - - return render_template('user_form.html', mode='edit', user=user, password_requirements=password_requirements) - - -@main_bp.route('/users//delete', methods=['POST']) -@login_required -@role_required('admin') -def user_delete(user_id: int): - """Delete a user.""" - user = User.get_by_id(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('main.user_management')) - - if user.id == current_user.id: - flash('You cannot delete your own account', 'error') - return redirect(url_for('main.user_management')) - - admins = [u for u in User.get_all() if u.role == 'admin'] - if user.role == 'admin' and len(admins) <= 1: - flash('At least one admin is required', 'error') - return redirect(url_for('main.user_management')) - - if User.delete_by_id(user.id): - AuditLog.log( - username=current_user.username, - action='USER_DELETED', - details=f"Deleted user '{user.username}'", - success=True - ) - flash(f"User '{user.username}' deleted", 'success') - else: - flash('Failed to delete user', 'error') - - return redirect(url_for('main.user_management')) - - -def _get_password_requirements(): - """Build a list of password requirement strings for UI hints.""" - requirements = [f"At least {MIN_PASSWORD_LENGTH} characters"] - if PASSWORD_REQUIRE_UPPERCASE: - requirements.append("At least one uppercase letter") - if PASSWORD_REQUIRE_LOWERCASE: - requirements.append("At least one lowercase letter") - if PASSWORD_REQUIRE_DIGIT: - requirements.append("At least one digit") - if PASSWORD_REQUIRE_SPECIAL: - requirements.append("At least one special character") - return requirements - - -# ============================================================================ -# Settings API Routes -# ============================================================================ - -@api_bp.route('/settings/notifications/smtp', methods=['GET', 'POST']) -@login_required -@role_required('admin') -def api_settings_smtp(): - """Get or update SMTP notification settings.""" - from app.settings import get_settings_manager - manager = get_settings_manager() - - if request.method == 'GET': - smtp_settings = manager.get('notifications.smtp', {}) - # Don't expose the password - smtp_settings = dict(smtp_settings) - smtp_settings['password'] = '********' if smtp_settings.get('password') else '' - return jsonify({'success': True, 'settings': smtp_settings}) - - # POST - update settings - data = request.get_json() or {} - - try: - current_smtp = manager.get('notifications.smtp', {}) - - # Update fields that were provided - for field in ['enabled', 'host', 'port', 'username', 'from_address', 'to_addresses', 'use_tls', 'use_ssl']: - if field in data: - current_smtp[field] = data[field] - - # Only update password if a new one was provided - if data.get('password'): - current_smtp['password'] = data['password'] - - # Save the updated settings - manager.set('notifications.smtp', current_smtp) - - # Log the change - AuditLog.log( - username=current_user.username, - action='SETTINGS_UPDATED', - details='SMTP notification settings updated' - ) - - return jsonify({'success': True}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@api_bp.route('/settings/notifications/smtp/test', methods=['POST']) -@login_required -@role_required('admin') -def api_settings_smtp_test(): - """Send a test email using current SMTP settings.""" - from app.notifications import get_notification_manager - - manager = get_notification_manager() - success, message = manager.test_channel('smtp') - - if success: - AuditLog.log( - username=current_user.username, - action='SMTP_TEST', - details='Test email sent successfully' - ) - - return jsonify({ - 'success': success, - 'message': message if success else None, - 'error': message if not success else None - }) - diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..7595147 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,3038 @@ +""" +Flask API routes for React frontend +""" +from functools import wraps +from uuid import uuid4 +from flask import Blueprint, request, jsonify, g, current_app, Response, stream_with_context, session +from datetime import datetime +from flask_jwt_extended import ( + verify_jwt_in_request, get_jwt_identity, get_jwt, + create_access_token, create_refresh_token, jwt_required, decode_token +) +from flask_jwt_extended.exceptions import JWTExtendedException + +import sqlite3 +from app.models import AuditLog, ActiveRedirect, GroupRedirectSchedule, PrinterRedirectSchedule, PrinterGroup, User, UserSession, WorkflowRegistryNode, Workflow, get_db_connection +from app.utils.auth import authenticate_user, validate_password_strength, hash_password, verify_password +from app.utils.api_tokens import get_available_permissions +from app.services.notification_manager import get_notification_manager +from app.services.workflow_engine import get_workflow_engine +import queue +from app.services.printer_registry import get_registry, Printer +from app.services.printer_stats import get_stats +import time +import secrets +import json +import pyotp +import bcrypt +from app.services.network_manager import get_network_manager +from app.services.discovery import get_discovery +from config.config import ( + DEFAULT_PORT, + MIN_PASSWORD_LENGTH, + PASSWORD_REQUIRE_UPPERCASE, + PASSWORD_REQUIRE_LOWERCASE, + PASSWORD_REQUIRE_DIGIT, + PASSWORD_REQUIRE_SPECIAL, + SUPPORTED_PROTOCOLS +) + + +# API Blueprint only - React handles all UI +api_bp = Blueprint('api', __name__) + + +def _serialize_timestamp(value): + if not value: + return None + if hasattr(value, 'isoformat'): + return value.isoformat() + return str(value) + + +def _load_recovery_codes(user: User) -> list: + if not user.mfa_recovery_codes: + return [] + try: + return json.loads(user.mfa_recovery_codes) + except Exception: + return [] + + +def _consume_recovery_code(user: User, code: str) -> bool: + codes = _load_recovery_codes(user) + if not codes: + return False + remaining = [] + matched = False + for hashed in codes: + if not matched and bcrypt.checkpw(code.encode('utf-8'), hashed.encode('utf-8')): + matched = True + continue + remaining.append(hashed) + if matched: + user.set_recovery_codes(json.dumps(remaining)) + return matched + + +# ============================================================================ +# API Authentication Helpers +# ============================================================================ + +def api_auth_required(fn): + """Decorator for API routes that require JWT authentication.""" + @wraps(fn) + def wrapper(*args, **kwargs): + try: + verify_jwt_in_request() + user_id = get_jwt_identity() + claims = get_jwt() + # Ensure claims is a dict (convert from Row if needed) + if not isinstance(claims, dict): + current_app.logger.warning(f'JWT claims is {type(claims)}, converting to dict') + claims = dict(claims) + user = User.get_by_id(user_id) if user_id is not None else None + if user and user.is_active: + jti = claims.get('jti') + if not jti: + return jsonify({'error': 'Invalid token'}), 401 + session = UserSession.get_by_jti(jti) + if not session or session.revoked_at: + return jsonify({'error': 'Session revoked'}), 401 + UserSession.touch(jti) + g.api_user = user + g.api_claims = claims + g.api_session = session + return fn(*args, **kwargs) + except JWTExtendedException as exc: + auth_present = bool(request.headers.get('Authorization')) + current_app.logger.warning( + 'JWT auth failed: %s (auth header present: %s)', + str(exc), + auth_present + ) + status_code = getattr(exc, 'status_code', 401) + return jsonify({'error': str(exc)}), status_code + except Exception as exc: + import traceback + current_app.logger.warning('JWT auth error: %s\n%s', str(exc), traceback.format_exc()) + return jsonify({'error': 'Authentication required'}), 401 + return wrapper + + +def api_role_required(*roles): + """Decorator to require specific roles for API routes.""" + def decorator(fn): + @wraps(fn) + @api_auth_required + def wrapper(*args, **kwargs): + user_role = g.api_claims.get('role', '') + if user_role not in roles: + return jsonify({'error': 'Insufficient permissions'}), 403 + return fn(*args, **kwargs) + return wrapper + return decorator + + +def check_api_permission(required_permission: str): + """Check if current user role has required permission.""" + role = g.api_claims.get('role', 'viewer') if hasattr(g, 'api_claims') else 'viewer' + if required_permission not in get_available_permissions(role): + return jsonify({'error': 'Insufficient permissions'}), 403 + return None + + +# ============================================================================ +# JWT Authentication API Routes +# ============================================================================ + +@api_bp.route('/auth/login', methods=['POST']) +def api_auth_login(): + """Authenticate user and return JWT tokens.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + username = data.get('username', '').strip() + password = data.get('password', '') + email = data.get('email') + full_name = data.get('full_name') + totp_code = data.get('totp') + recovery_code = data.get('recovery_code') + + if not username or not password: + return jsonify({'error': 'Username and password are required'}), 400 + + client_ip = request.remote_addr + user, error = authenticate_user(username, password, client_ip) + + if user is None: + return jsonify({'error': error}), 401 + + if user.mfa_enabled: + if not totp_code and not recovery_code: + return jsonify({'error': 'MFA required', 'code': 'MFA_REQUIRED'}), 401 + if recovery_code: + if not _consume_recovery_code(user, str(recovery_code).strip()): + return jsonify({'error': 'Invalid recovery code'}), 401 + else: + if not user.mfa_secret: + return jsonify({'error': 'MFA not configured'}), 400 + totp = pyotp.TOTP(user.mfa_secret) + if not totp.verify(str(totp_code).strip(), valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 401 + + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + 'username': user.username, + 'role': user.role + } + ) + refresh_token = create_refresh_token(identity=str(user.id)) + + decoded = decode_token(access_token) + jti = decoded.get('jti') + ip_address = request.remote_addr + user_agent = request.headers.get('User-Agent') + if jti: + UserSession.create(user.id, jti, ip_address, user_agent) + + return jsonify({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'user': { + 'id': user.id, + 'username': user.username, + 'full_name': getattr(user, 'full_name', None), + 'email': getattr(user, 'email', None), + 'role': user.role, + 'mfa_enabled': bool(user.mfa_enabled), + 'theme': user.theme, + 'language': user.language, + 'timezone': user.timezone + } + }) + + +@api_bp.route('/auth/refresh', methods=['POST']) +@jwt_required(refresh=True) +def api_auth_refresh(): + """Refresh access token using refresh token.""" + user_id = get_jwt_identity() + user = User.get_by_id(int(user_id)) if user_id is not None else None + + if not user or not user.is_active: + return jsonify({'error': 'User not found or inactive'}), 401 + + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + 'username': user.username, + 'role': user.role + } + ) + decoded = decode_token(access_token) + jti = decoded.get('jti') + ip_address = request.remote_addr + user_agent = request.headers.get('User-Agent') + if jti: + UserSession.create(user.id, jti, ip_address, user_agent) + + return jsonify({'access_token': access_token}) + + +# ============================================================================ +# Workflow Registry & Workflow API Routes +# ============================================================================ + +@api_bp.route('/workflow-registry', methods=['GET']) +@api_auth_required +def api_workflow_registry_list(): + include_disabled = request.args.get('include_disabled', 'false').lower() == 'true' + if g.api_claims.get('role') != 'admin': + include_disabled = False + nodes = WorkflowRegistryNode.get_all(include_disabled=include_disabled) + + from app.services.settings import get_settings_manager + from app.services.notification_sender import SMTPNotificationChannel + + settings = get_settings_manager().get_all() + smtp_channel = SMTPNotificationChannel() + smtp_available = smtp_channel.is_enabled(settings) and smtp_channel.is_configured(settings) + + slack_settings = settings.get('notifications', {}).get('slack', {}) + teams_settings = settings.get('notifications', {}).get('teams', {}) + discord_settings = settings.get('notifications', {}).get('discord', {}) + + def is_integration_enabled(config: dict) -> bool: + return bool(config.get('enabled')) and bool(config.get('webhook_url')) + + filtered = [] + for node in nodes: + key = node.get('key') + if key == 'action.notify.email' and not smtp_available: + continue + if key == 'integration.slack' and not is_integration_enabled(slack_settings): + continue + if key == 'integration.teams' and not is_integration_enabled(teams_settings): + continue + if key == 'integration.discord' and not is_integration_enabled(discord_settings): + continue + filtered.append(node) + + return jsonify(filtered) + + +@api_bp.route('/workflow-registry', methods=['POST']) +@api_role_required('admin') +def api_workflow_registry_create(): + data = request.get_json() or {} + required_fields = ['key', 'name', 'category'] + missing = [field for field in required_fields if not data.get(field)] + if missing: + return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + node = WorkflowRegistryNode.create(data) + return jsonify(node), 201 + + +@api_bp.route('/workflow-registry/', methods=['PUT']) +@api_role_required('admin') +def api_workflow_registry_update(node_key: str): + data = request.get_json() or {} + required_fields = ['name', 'category'] + missing = [field for field in required_fields if not data.get(field)] + if missing: + return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + node = WorkflowRegistryNode.update(node_key, data) + if not node: + return jsonify({'error': 'Node not found'}), 404 + return jsonify(node) + + +@api_bp.route('/workflow-registry/', methods=['DELETE']) +@api_role_required('admin') +def api_workflow_registry_delete(node_key: str): + deleted = WorkflowRegistryNode.delete(node_key) + if not deleted: + return jsonify({'error': 'Node not found'}), 404 + return jsonify({'status': 'deleted'}) + + +@api_bp.route('/workflows', methods=['GET']) +@api_auth_required +def api_workflows_list(): + return jsonify(Workflow.get_all()) + + +@api_bp.route('/workflows', methods=['POST']) +@api_role_required('admin', 'operator') +def api_workflows_create(): + data = request.get_json() or {} + name = data.get('name', '').strip() + if not name: + return jsonify({'error': 'Workflow name is required'}), 400 + workflow = Workflow.create( + name=name, + description=data.get('description', ''), + created_by=g.api_claims.get('username', 'unknown'), + nodes=data.get('nodes'), + edges=data.get('edges'), + ui_state=data.get('ui_state') + ) + return jsonify(workflow), 201 + + +@api_bp.route('/workflows/', methods=['GET']) +@api_auth_required +def api_workflows_get(workflow_id: str): + workflow = Workflow.get_by_id(workflow_id) + if not workflow: + return jsonify({'error': 'Invalid workflow ID.'}), 404 + return jsonify(workflow) + + +@api_bp.route('/workflows/', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_workflows_update(workflow_id: str): + data = request.get_json() or {} + + # Log if is_active is being changed + if 'is_active' in data: + current_app.logger.info(f"Workflow {workflow_id} is_active changing to {data['is_active']}") + + workflow = Workflow.update( + workflow_id, + name=data.get('name'), + description=data.get('description'), + is_active=data.get('is_active'), + nodes=data.get('nodes'), + edges=data.get('edges'), + ui_state=data.get('ui_state') + ) + if not workflow: + return jsonify({'error': 'Workflow not found'}), 404 + + # Log audit trail for enable/disable + if 'is_active' in data: + AuditLog.log( + username=g.api_user.username, + action='WORKFLOW_UPDATED', + details=f"Workflow '{workflow['name']}' {'activated' if data['is_active'] else 'deactivated'}" + ) + + # Update scheduler if workflow has schedule trigger + try: + from app.services.schedulers.workflow import get_workflow_scheduler + import json + + # workflow['nodes'] is already parsed by get_by_id() + nodes = workflow.get('nodes', []) + schedule_node = next((n for n in nodes if n['type'] == 'trigger.schedule'), None) + + scheduler = get_workflow_scheduler() + + if schedule_node and workflow.get('is_active'): + # Schedule or reschedule the workflow + schedule_config = schedule_node.get('properties', {}) + scheduler.schedule_workflow(workflow_id, schedule_config) + else: + # Unschedule if no schedule trigger or disabled + scheduler.unschedule_workflow(workflow_id) + except Exception as e: + current_app.logger.error(f"Error updating workflow schedule: {e}") + + return jsonify(workflow) + + +@api_bp.route('/workflows/', methods=['DELETE']) +@api_role_required('admin', 'operator') +def api_workflows_delete(workflow_id: str): + # Unschedule before deleting + try: + from app.services.schedulers.workflow import get_workflow_scheduler + scheduler = get_workflow_scheduler() + scheduler.unschedule_workflow(workflow_id) + except Exception as e: + current_app.logger.error(f"Error unscheduling workflow: {e}") + + deleted = Workflow.delete(workflow_id) + if not deleted: + return jsonify({'error': 'Workflow not found'}), 404 + return jsonify({'status': 'deleted'}) + + +@api_bp.route('/workflows//validate-connection', methods=['POST']) +@api_role_required('admin', 'operator') +def api_workflows_validate_connection(workflow_id: str): + data = request.get_json() or {} + source_node_id = data.get('source_node_id') + target_node_id = data.get('target_node_id') + source_handle = data.get('source_handle') + target_handle = data.get('target_handle') + source_node_type = data.get('source_node_type') + target_node_type = data.get('target_node_type') + + if not source_node_id or not target_node_id: + return jsonify({'error': 'Missing source or target node'}), 400 + + valid, message = Workflow.validate_connection( + workflow_id, + source_node_id, + target_node_id, + source_handle, + target_handle, + source_node_type, + target_node_type + ) + if not valid: + return jsonify({'valid': False, 'message': message}), 400 + return jsonify({'valid': True, 'message': message}) + + +@api_bp.route('/auth/me') +@api_auth_required +def api_auth_me(): + """Get current user info from JWT token.""" + user = g.api_user + return jsonify({ + 'id': user.id, + 'username': user.username, + 'full_name': getattr(user, 'full_name', None), + 'email': getattr(user, 'email', None), + 'role': user.role, + 'is_active': user.is_active, + 'last_login': _serialize_timestamp(user.last_login), + 'mfa_enabled': bool(user.mfa_enabled), + 'theme': user.theme, + 'language': user.language, + 'timezone': user.timezone, + 'current_session_id': g.api_session.id if getattr(g, 'api_session', None) else None + }) + + +@api_bp.route('/auth/me', methods=['PUT']) +@api_auth_required +def api_auth_me_update(): + """Update current user's profile.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + user = g.api_user + new_username = data.get('username', '').strip() + new_email = data.get('email', None) + full_name = data.get('full_name', None) + theme = data.get('theme', user.theme) + language = data.get('language', user.language) + timezone = data.get('timezone', user.timezone) + + if theme not in ['system', 'light', 'dark']: + return jsonify({'error': 'Invalid theme'}), 400 + + if language not in ['en', 'es', 'fr']: + return jsonify({'error': 'Invalid language'}), 400 + + if isinstance(new_email, str): + new_email = new_email.strip() + if new_email == '': + new_email = None + if isinstance(full_name, str): + full_name = full_name.strip() + if full_name == '': + full_name = None + + if not new_username: + return jsonify({'error': 'Username is required'}), 400 + + existing = User.get_by_username(new_username) + if existing and existing.id != user.id: + return jsonify({'error': 'Username already exists'}), 400 + + if new_email: + existing_email = User.get_by_email(new_email) + if existing_email and existing_email.id != user.id: + return jsonify({'error': 'Email already in use'}), 400 + + user.update_profile(new_username, new_email, full_name) + user.update_preferences(theme, language, timezone) + + AuditLog.log( + username=user.username, + action='USER_PROFILE_UPDATED', + details='Updated account settings', + success=True + ) + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'full_name': getattr(user, 'full_name', None), + 'email': getattr(user, 'email', None), + 'role': user.role, + 'is_active': user.is_active, + 'last_login': _serialize_timestamp(user.last_login), + 'mfa_enabled': bool(user.mfa_enabled), + 'theme': user.theme, + 'language': user.language, + 'timezone': user.timezone + }) + + +@api_bp.route('/auth/mfa/setup', methods=['POST']) +@api_auth_required +def api_auth_mfa_setup(): + """Initialize MFA setup and return otpauth URI.""" + user = g.api_user + secret = pyotp.random_base32() + user.set_mfa_secret(secret) + user.set_mfa_enabled(False) + user.set_recovery_codes(None) + issuer = 'Continuum' + otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=user.username, issuer_name=issuer) + return jsonify({ + 'otpauth_uri': otpauth_uri, + 'issuer': issuer, + 'account': user.username + }) + + +@api_bp.route('/auth/mfa/verify', methods=['POST']) +@api_auth_required +def api_auth_mfa_verify(): + """Verify MFA setup and generate recovery codes.""" + user = g.api_user + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + code = str(data.get('code', '')).strip() + if not user.mfa_secret: + return jsonify({'error': 'MFA not initialized'}), 400 + totp = pyotp.TOTP(user.mfa_secret) + if not totp.verify(code, valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + recovery_codes = [secrets.token_hex(4) for _ in range(10)] + hashed_codes = [bcrypt.hashpw(code.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') for code in recovery_codes] + user.set_recovery_codes(json.dumps(hashed_codes)) + user.set_mfa_enabled(True) + + AuditLog.log( + username=user.username, + action='MFA_ENABLED', + details='MFA enabled with recovery codes', + success=True + ) + + return jsonify({ + 'recovery_codes': recovery_codes + }) + + +@api_bp.route('/auth/mfa/disable', methods=['POST']) +@api_auth_required +def api_auth_mfa_disable(): + """Disable MFA for current user (requires password or code).""" + user = g.api_user + data = request.get_json() or {} + password = data.get('password') + code = data.get('code') + + if not password and not code: + return jsonify({'error': 'Password or code required'}), 400 + + if password and not verify_password(password, user.password_hash): + return jsonify({'error': 'Invalid password'}), 400 + + if code and user.mfa_secret: + totp = pyotp.TOTP(user.mfa_secret) + if not totp.verify(str(code).strip(), valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + user.set_mfa_enabled(False) + user.set_mfa_secret(None) + user.set_recovery_codes(None) + + AuditLog.log( + username=user.username, + action='MFA_DISABLED', + details='MFA disabled', + success=True + ) + + return jsonify({'message': 'MFA disabled'}) + + +@api_bp.route('/auth/sessions') +@api_auth_required +def api_auth_sessions(): + """List active sessions for current user.""" + user = g.api_user + sessions = UserSession.get_by_user(user.id) + current_jti = g.api_claims.get('jti') + return jsonify([ + { + 'id': s.id, + 'created_at': _serialize_timestamp(s.created_at), + 'last_used': _serialize_timestamp(s.last_used), + 'revoked_at': _serialize_timestamp(s.revoked_at), + 'ip_address': s.ip_address, + 'user_agent': s.user_agent, + 'is_current': s.jti == current_jti + } + for s in sessions + ]) + + +@api_bp.route('/auth/sessions//revoke', methods=['POST']) +@api_auth_required +def api_auth_sessions_revoke(session_id: int): + """Revoke a session by ID.""" + user = g.api_user + sessions = UserSession.get_by_user(user.id) + session_ids = {s.id for s in sessions} + if session_id not in session_ids: + return jsonify({'error': 'Session not found'}), 404 + UserSession.revoke(session_id) + return jsonify({'message': 'Session revoked'}) + + +@api_bp.route('/auth/logout', methods=['POST']) +@api_auth_required +def api_auth_logout(): + """Logout endpoint (revokes current session).""" + jti = g.api_claims.get('jti') + if jti: + UserSession.revoke_by_jti(jti) + return jsonify({'message': 'Successfully logged out'}) + + +@api_bp.route('/auth/setup', methods=['GET', 'POST']) +def api_auth_setup(): + """Check if setup is needed or create initial admin.""" + from app.models import get_db_connection + from app.utils.auth import create_initial_admin + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM users") + user_count = cursor.fetchone()[0] + conn.close() + + if request.method == 'GET': + return jsonify({'setup_required': user_count == 0}) + + # POST - create initial admin + if user_count > 0: + return jsonify({'error': 'Setup already completed'}), 400 + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + username = data.get('username', '').strip() + password = data.get('password', '') + email = data.get('email') + full_name = data.get('full_name') + + if not username: + return jsonify({'error': 'Username is required'}), 400 + + if isinstance(email, str): + email = email.strip() + if email == '': + email = None + if isinstance(full_name, str): + full_name = full_name.strip() + if full_name == '': + full_name = None + + is_valid, error = validate_password_strength(password) + if not is_valid: + return jsonify({'error': error}), 400 + + success, message = create_initial_admin(username, password, email=email, full_name=full_name) + if success: + return jsonify({'message': 'Admin user created successfully'}) + else: + return jsonify({'error': message}), 400 + + +# ============================================================================ +# App Info API Routes +# ============================================================================ + +@api_bp.route('/info') +def api_info(): + """Get application info (version, etc.).""" + from app.version import __version__, VERSION_STRING + return jsonify({ + 'version': __version__, + 'version_string': VERSION_STRING, + 'app_name': 'Continuum' + }) + + +# ============================================================================ +# Printers API Routes +# ============================================================================ + +@api_bp.route('/printers') +@api_auth_required +def api_printers(): + """Get all printers with status.""" + registry = get_registry() + return jsonify(registry.get_statuses()) + + +@api_bp.route('/printers/') +@api_auth_required +def api_printer(printer_id): + """Get a specific printer with status.""" + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + return jsonify(registry.get_status(printer)) + + +@api_bp.route('/printers', methods=['POST']) +@api_role_required('admin', 'operator') +def api_printer_create(): + """Create a new printer.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + name = data.get('name', '').strip() + ip = data.get('ip', '').strip() + location = data.get('location', '').strip() + model = data.get('model', '').strip() + department = data.get('department', '').strip() + notes = data.get('notes', '').strip() + + protocols_raw = data.get('protocols') + if isinstance(protocols_raw, str): + protocols = [p.strip() for p in protocols_raw.split(',') if p.strip()] + elif isinstance(protocols_raw, list): + protocols = [str(p).strip() for p in protocols_raw if str(p).strip()] + else: + protocols = ['raw'] + + allowed_protocols = set(SUPPORTED_PROTOCOLS.keys()) + protocols = [p for p in protocols if p in allowed_protocols] + if not protocols: + protocols = ['raw'] + + if not name or not ip: + return jsonify({'error': 'Name and IP are required'}), 400 + + registry = get_registry() + + for p in registry.get_all(): + if p.ip == ip: + return jsonify({'error': 'A printer with this IP already exists'}), 400 + + try: + printer = Printer( + id=data.get('id') or uuid4().hex, + name=name, + ip=ip, + protocols=protocols, + location=location, + model=model, + department=department, + notes=notes + ) + success = registry.add_printer(printer) + if not success: + return jsonify({'error': 'Failed to create printer'}), 500 + + AuditLog.log( + username=g.api_user.username, + action='PRINTER_CREATED', + source_printer_id=printer.id, + source_ip=printer.ip, + details=f"Created printer '{name}' ({ip})", + success=True + ) + + return jsonify({ + 'id': printer.id, + 'name': printer.name, + 'ip': printer.ip, + 'location': printer.location, + 'model': printer.model, + 'protocols': printer.protocols, + 'department': printer.department, + 'notes': printer.notes + }), 201 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/printers/', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_printer_update(printer_id): + """Update a printer.""" + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + name = data.get('name', printer.name).strip() + ip = data.get('ip', printer.ip).strip() + location = data.get('location', printer.location or '').strip() + model = data.get('model', printer.model or '').strip() + department = data.get('department', printer.department or '').strip() + notes = data.get('notes', printer.notes or '').strip() + + protocols_raw = data.get('protocols', printer.protocols) + if isinstance(protocols_raw, str): + protocols = [p.strip() for p in protocols_raw.split(',') if p.strip()] + elif isinstance(protocols_raw, list): + protocols = [str(p).strip() for p in protocols_raw if str(p).strip()] + else: + protocols = printer.protocols + + allowed_protocols = set(SUPPORTED_PROTOCOLS.keys()) + protocols = [p for p in protocols if p in allowed_protocols] + if not protocols: + protocols = ['raw'] + + if not name or not ip: + return jsonify({'error': 'Name and IP are required'}), 400 + + for p in registry.get_all(): + if p.ip == ip and p.id != printer_id: + return jsonify({'error': 'A printer with this IP already exists'}), 400 + + try: + printer.name = name + printer.ip = ip + printer.location = location + printer.model = model + printer.department = department + printer.notes = notes + printer.protocols = protocols + success = registry.update_printer(printer) + if not success: + return jsonify({'error': 'Failed to update printer'}), 500 + + AuditLog.log( + username=g.api_user.username, + action='PRINTER_UPDATED', + source_printer_id=printer.id, + source_ip=printer.ip, + details=f"Updated printer '{name}' ({ip})", + success=True + ) + + return jsonify({ + 'id': printer.id, + 'name': printer.name, + 'ip': printer.ip, + 'location': printer.location, + 'model': printer.model, + 'protocols': printer.protocols, + 'department': printer.department, + 'notes': printer.notes + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/printers/', methods=['DELETE']) +@api_role_required('admin') +def api_printer_delete(printer_id): + """Delete a printer.""" + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + redirect_obj = ActiveRedirect.get_by_source_printer(printer_id) + if redirect_obj: + return jsonify({'error': 'Cannot delete printer with active redirect'}), 400 + + if ActiveRedirect.is_target_in_use(printer_id): + return jsonify({'error': 'Cannot delete printer that is a redirect target'}), 400 + + try: + success = registry.delete_printer(printer_id) + if not success: + return jsonify({'error': 'Failed to delete printer'}), 500 + + AuditLog.log( + username=g.api_user.username, + action='PRINTER_DELETED', + source_printer_id=printer.id, + source_ip=printer.ip, + details=f"Deleted printer '{printer.name}' ({printer.ip})", + success=True + ) + + return jsonify({'message': f"Printer '{printer.name}' deleted"}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/printers//check') +@api_auth_required +def api_check_printer(printer_id): + """Quick reachability check for a printer.""" + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + icmp = registry.check_icmp_reachability(printer.ip) + tcp = registry.check_tcp_reachability(printer.ip) + + return jsonify({ + 'printer_id': printer_id, + 'ip': printer.ip, + 'icmp_reachable': icmp, + 'tcp_reachable': tcp, + 'is_online': icmp or tcp + }) + + +# ============================================================================ +# Printer Groups API Routes +# ============================================================================ + +@api_bp.route('/printer-groups', methods=['GET']) +@api_auth_required +def api_printer_groups(): + """Get all printer groups with counts.""" + perm_check = check_api_permission('printers:read') + if perm_check: + return perm_check + + groups = PrinterGroup.get_all() + return jsonify({'groups': groups}) + + +@api_bp.route('/printer-groups/', methods=['GET']) +@api_auth_required +def api_printer_group(group_id: int): + """Get a printer group with members.""" + perm_check = check_api_permission('printers:read') + if perm_check: + return perm_check + + group = PrinterGroup.get_by_id(group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + return jsonify(group) + + +@api_bp.route('/printer-groups', methods=['POST']) +@api_role_required('admin', 'operator') +def api_printer_group_create(): + """Create a printer group.""" + perm_check = check_api_permission('printers:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + name = data.get('name', '').strip() + description = data.get('description', '').strip() + if not name: + return jsonify({'error': 'Group name is required'}), 400 + + try: + group = PrinterGroup.create(name=name, description=description, owner_user_id=g.api_user.id) + AuditLog.log( + username=g.api_user.username, + action='PRINTER_GROUP_CREATED', + details=f"Created printer group '{name}'", + success=True + ) + return jsonify(group), 201 + except sqlite3.IntegrityError: + return jsonify({'error': 'Group name already exists'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/printer-groups/', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_printer_group_update(group_id: int): + """Update a printer group.""" + perm_check = check_api_permission('printers:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + name = data.get('name', '').strip() + description = data.get('description', '').strip() + if not name: + return jsonify({'error': 'Group name is required'}), 400 + + existing = PrinterGroup.get_by_id(group_id) + if not existing: + return jsonify({'error': 'Group not found'}), 404 + if existing.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + try: + group = PrinterGroup.update(group_id, name=name, description=description) + if not group: + return jsonify({'error': 'Group not found'}), 404 + AuditLog.log( + username=g.api_user.username, + action='PRINTER_GROUP_UPDATED', + details=f"Updated printer group '{name}'", + success=True + ) + return jsonify(group) + except sqlite3.IntegrityError: + return jsonify({'error': 'Group name already exists'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/printer-groups/', methods=['DELETE']) +@api_role_required('admin', 'operator') +def api_printer_group_delete(group_id: int): + """Delete a printer group.""" + perm_check = check_api_permission('printers:write') + if perm_check: + return perm_check + + existing = PrinterGroup.get_by_id(group_id) + if not existing: + return jsonify({'error': 'Group not found'}), 404 + if existing.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + deleted = PrinterGroup.delete(group_id) + if not deleted: + return jsonify({'error': 'Group not found'}), 404 + + AuditLog.log( + username=g.api_user.username, + action='PRINTER_GROUP_DELETED', + details=f"Deleted printer group ID {group_id}", + success=True + ) + return jsonify({'message': 'Group deleted'}) + + +@api_bp.route('/printer-groups//printers', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_printer_group_set_printers(group_id: int): + """Set printer memberships for a group.""" + perm_check = check_api_permission('printers:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + printer_ids = data.get('printer_ids', []) + if not isinstance(printer_ids, list): + return jsonify({'error': 'printer_ids must be a list'}), 400 + + group = PrinterGroup.get_by_id(group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + if group.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + PrinterGroup.set_printers(group_id, printer_ids) + AuditLog.log( + username=g.api_user.username, + action='PRINTER_GROUP_MEMBERS_UPDATED', + details=f"Updated printers for group '{group.get('name')}'", + success=True + ) + return jsonify(PrinterGroup.get_by_id(group_id)) + + +# ============================================================================ +# Group Redirect Schedules API Routes +# ============================================================================ + +@api_bp.route('/group-redirect-schedules', methods=['GET']) +@api_auth_required +def api_group_redirect_schedules(): + """Get redirect schedules (optionally filtered by group).""" + perm_check = check_api_permission('redirects:read') + if perm_check: + return perm_check + + group_id = request.args.get('group_id', type=int) + schedules = GroupRedirectSchedule.get_all(group_id=group_id) + return jsonify({'schedules': schedules}) + + +@api_bp.route('/group-redirect-schedules', methods=['POST']) +@api_role_required('admin', 'operator') +def api_group_redirect_schedule_create(): + """Create a redirect schedule for a group.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + group_id = data.get('group_id') + target_printer_id = data.get('target_printer_id') + start_at = data.get('start_at') + end_at = data.get('end_at') + + if not group_id or not target_printer_id or not start_at: + return jsonify({'error': 'group_id, target_printer_id, and start_at are required'}), 400 + + group = PrinterGroup.get_by_id(int(group_id)) + if not group: + return jsonify({'error': 'Group not found'}), 404 + if group.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + try: + # Validate timestamps + datetime.fromisoformat(start_at) + if end_at: + datetime.fromisoformat(end_at) + except Exception: + return jsonify({'error': 'Invalid date format'}), 400 + + schedule = GroupRedirectSchedule.create( + group_id=int(group_id), + target_printer_id=target_printer_id, + start_at=start_at, + end_at=end_at, + created_by=g.api_user.username + ) + return jsonify(schedule), 201 + + +@api_bp.route('/group-redirect-schedules/', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_group_redirect_schedule_update(schedule_id: int): + """Update a redirect schedule for a group.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + target_printer_id = data.get('target_printer_id') + start_at = data.get('start_at') + end_at = data.get('end_at') + enabled = bool(data.get('enabled', True)) + + if not target_printer_id or not start_at: + return jsonify({'error': 'target_printer_id and start_at are required'}), 400 + + # Verify ownership via schedule -> group + schedules = GroupRedirectSchedule.get_all() + schedule = next((s for s in schedules if s['id'] == schedule_id), None) + if not schedule: + return jsonify({'error': 'Schedule not found'}), 404 + + group = PrinterGroup.get_by_id(schedule['group_id']) + if not group or group.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + try: + datetime.fromisoformat(start_at) + if end_at: + datetime.fromisoformat(end_at) + except Exception: + return jsonify({'error': 'Invalid date format'}), 400 + + updated = GroupRedirectSchedule.update(schedule_id, target_printer_id, start_at, end_at, enabled) + return jsonify(updated) + + +@api_bp.route('/group-redirect-schedules/', methods=['DELETE']) +@api_role_required('admin', 'operator') +def api_group_redirect_schedule_delete(schedule_id: int): + """Delete a redirect schedule.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + schedules = GroupRedirectSchedule.get_all() + schedule = next((s for s in schedules if s['id'] == schedule_id), None) + if not schedule: + return jsonify({'error': 'Schedule not found'}), 404 + + group = PrinterGroup.get_by_id(schedule['group_id']) + if not group or group.get('owner_user_id') != g.api_user.id: + return jsonify({'error': 'You can only manage groups you own'}), 403 + + deleted = GroupRedirectSchedule.delete(schedule_id) + if not deleted: + return jsonify({'error': 'Schedule not found'}), 404 + return jsonify({'message': 'Schedule deleted'}) + + +# ============================================================================ +# Printer Redirect Schedules API Routes +# ============================================================================ + +@api_bp.route('/printer-redirect-schedules', methods=['GET']) +@api_auth_required +def api_printer_redirect_schedules(): + """Get redirect schedules (optionally filtered by source printer).""" + perm_check = check_api_permission('redirects:read') + if perm_check: + return perm_check + + source_printer_id = request.args.get('source_printer_id') + schedules = PrinterRedirectSchedule.get_all(source_printer_id=source_printer_id) + return jsonify({'schedules': schedules}) + + +@api_bp.route('/printer-redirect-schedules', methods=['POST']) +@api_role_required('admin', 'operator') +def api_printer_redirect_schedule_create(): + """Create a redirect schedule for a printer.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + source_printer_id = data.get('source_printer_id') + target_printer_id = data.get('target_printer_id') + start_at = data.get('start_at') + end_at = data.get('end_at') + + if not source_printer_id or not target_printer_id or not start_at: + return jsonify({'error': 'source_printer_id, target_printer_id, and start_at are required'}), 400 + + if source_printer_id == target_printer_id: + return jsonify({'error': 'source_printer_id and target_printer_id must be different'}), 400 + + registry = get_registry() + if not registry.get_by_id(source_printer_id): + return jsonify({'error': 'Source printer not found'}), 404 + if not registry.get_by_id(target_printer_id): + return jsonify({'error': 'Target printer not found'}), 404 + + try: + datetime.fromisoformat(start_at) + if end_at: + datetime.fromisoformat(end_at) + except Exception: + return jsonify({'error': 'Invalid date format'}), 400 + + schedule = PrinterRedirectSchedule.create( + source_printer_id=source_printer_id, + target_printer_id=target_printer_id, + start_at=start_at, + end_at=end_at, + created_by=g.api_user.username + ) + return jsonify(schedule), 201 + + +@api_bp.route('/printer-redirect-schedules/', methods=['PUT']) +@api_role_required('admin', 'operator') +def api_printer_redirect_schedule_update(schedule_id: int): + """Update a redirect schedule for a printer.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + target_printer_id = data.get('target_printer_id') + start_at = data.get('start_at') + end_at = data.get('end_at') + enabled = bool(data.get('enabled', True)) + + if not target_printer_id or not start_at: + return jsonify({'error': 'target_printer_id and start_at are required'}), 400 + + schedules = PrinterRedirectSchedule.get_all() + schedule = next((s for s in schedules if s['id'] == schedule_id), None) + if not schedule: + return jsonify({'error': 'Schedule not found'}), 404 + + registry = get_registry() + if not registry.get_by_id(schedule['source_printer_id']): + return jsonify({'error': 'Source printer not found'}), 404 + if not registry.get_by_id(target_printer_id): + return jsonify({'error': 'Target printer not found'}), 404 + + try: + datetime.fromisoformat(start_at) + if end_at: + datetime.fromisoformat(end_at) + except Exception: + return jsonify({'error': 'Invalid date format'}), 400 + + updated = PrinterRedirectSchedule.update(schedule_id, target_printer_id, start_at, end_at, enabled) + return jsonify(updated) + + +@api_bp.route('/printer-redirect-schedules/', methods=['DELETE']) +@api_role_required('admin', 'operator') +def api_printer_redirect_schedule_delete(schedule_id: int): + """Delete a printer redirect schedule.""" + perm_check = check_api_permission('redirects:write') + if perm_check: + return perm_check + + schedules = PrinterRedirectSchedule.get_all() + schedule = next((s for s in schedules if s['id'] == schedule_id), None) + if not schedule: + return jsonify({'error': 'Schedule not found'}), 404 + + deleted = PrinterRedirectSchedule.delete(schedule_id) + if not deleted: + return jsonify({'error': 'Schedule not found'}), 404 + return jsonify({'message': 'Schedule deleted'}) + + +@api_bp.route('/printers//stats') +@api_auth_required +def api_printer_stats(printer_id): + """Get SNMP stats for a printer.""" + from app.services.printer_stats import get_stats, get_toner_levels + + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + stats = get_stats(printer.ip) + toner = get_toner_levels(printer.ip) + + return jsonify({ + 'stats': stats.to_dict() if stats else None, + 'toner': toner + }) + + +@api_bp.route('/printers//health') +@api_auth_required +def api_printer_health(printer_id): + """Get health status for a printer.""" + from app.services.health_check import get_status, get_history + + health = get_status(printer_id) + history = get_history(printer_id, limit=24) + + return jsonify({ + 'current': health, + 'history': history + }) + + +@api_bp.route('/printers//refresh') +@api_role_required('admin', 'operator') +def api_printer_refresh(printer_id): + """Force a live status check for a printer.""" + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + from app.services.health_check import HealthChecker + checker = HealthChecker() + result = checker.check_printer(printer_id, printer.ip) + checker.save_result(result) + + return jsonify({ + 'printer_id': printer_id, + 'ip': printer.ip, + 'icmp_reachable': result.icmp_ok, + 'tcp_reachable': result.tcp_9100_ok, + 'is_online': result.is_online, + 'response_time_ms': result.response_time_ms + }) + + +@api_bp.route('/printers//queue') +@api_auth_required +def api_printer_queue(printer_id): + """Get current print queue for a printer.""" + from app.services.print_queue import get_queue + + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + jobs = get_queue(printer.ip) + return jsonify({'jobs': [job.to_dict() for job in jobs]}) + + +@api_bp.route('/printers//jobs') +@api_auth_required +def api_printer_job_history(printer_id): + """Get print job history for a printer.""" + from app.models import PrintJobHistory + + limit = int(request.args.get('limit', 50)) + jobs = PrintJobHistory.get_for_printer(printer_id, limit=limit) + return jsonify({'jobs': [job.to_dict() for job in jobs]}) + + +@api_bp.route('/printers//logs') +@api_auth_required +def api_printer_logs(printer_id): + """Get device event logs for a printer.""" + from app.services.event_logs import get_logs + + registry = get_registry() + printer = registry.get_by_id(printer_id) + + if not printer: + return jsonify({'error': 'Printer not found'}), 404 + + events = get_logs(printer.ip) + return jsonify({'events': [event.to_dict() for event in events]}) + + +@api_bp.route('/printers//audit') +@api_auth_required +def api_printer_audit(printer_id): + """Get audit log entries related to a printer.""" + from app.models import AuditLog + + limit = int(request.args.get('limit', 20)) + logs = AuditLog.get_by_printer(printer_id, limit=limit) + return jsonify({'logs': logs}) + + +# ============================================================================ +# Redirects API Routes +# ============================================================================ + +@api_bp.route('/redirects') +@api_role_required('admin', 'operator') +def api_redirects(): + """Get all active redirects.""" + redirects = ActiveRedirect.get_all() + return jsonify([{ + 'id': r.id, + 'source_printer_id': r.source_printer_id, + 'source_ip': r.source_ip, + 'target_printer_id': r.target_printer_id, + 'target_ip': r.target_ip, + 'protocol': r.protocol, + 'port': r.port, + 'enabled_at': str(r.enabled_at), + 'enabled_by': r.enabled_by + } for r in redirects]) + + +@api_bp.route('/redirects', methods=['POST']) +@api_role_required('admin', 'operator') +def api_redirect_create(): + """Create a new redirect.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + source_printer_id = data.get('source_printer_id') + target_printer_id = data.get('target_printer_id') + + if not source_printer_id or not target_printer_id: + return jsonify({'error': 'Source and target printer IDs are required'}), 400 + + registry = get_registry() + network = get_network_manager() + + source_printer = registry.get_by_id(source_printer_id) + if not source_printer: + return jsonify({'error': 'Source printer not found'}), 404 + + target_printer = registry.get_by_id(target_printer_id) + if not target_printer: + return jsonify({'error': 'Target printer not found'}), 404 + + if source_printer.ip == target_printer.ip: + return jsonify({'error': 'Source and target cannot be the same'}), 400 + + existing = ActiveRedirect.get_by_source_printer(source_printer_id) + if existing: + return jsonify({'error': 'This printer already has an active redirect'}), 400 + + if ActiveRedirect.is_target_in_use(target_printer_id): + return jsonify({'error': 'Target printer is already in use'}), 400 + + success, message = network.enable_redirect( + source_ip=source_printer.ip, + target_ip=target_printer.ip, + port=DEFAULT_PORT + ) + + if success: + redirect_obj = ActiveRedirect.create( + source_printer_id=source_printer_id, + source_ip=source_printer.ip, + target_printer_id=target_printer_id, + target_ip=target_printer.ip, + protocol='raw', + port=DEFAULT_PORT, + enabled_by=g.api_user.username + ) + + AuditLog.log( + username=g.api_user.username, + action="REDIRECT_ENABLED", + source_printer_id=source_printer_id, + source_ip=source_printer.ip, + target_printer_id=target_printer_id, + target_ip=target_printer.ip, + details=f"Redirecting {source_printer.name} to {target_printer.name}", + success=True + ) + + return jsonify({ + 'id': redirect_obj.id, + 'source_printer_id': redirect_obj.source_printer_id, + 'target_printer_id': redirect_obj.target_printer_id, + 'message': f'Redirect enabled: {source_printer.name} → {target_printer.name}' + }), 201 + else: + return jsonify({'error': message}), 500 + + +@api_bp.route('/redirects/', methods=['DELETE']) +@api_role_required('admin', 'operator') +def api_redirect_delete(redirect_id): + """Remove a redirect.""" + redirect_obj = ActiveRedirect.get_by_id(redirect_id) + if not redirect_obj: + return jsonify({'error': 'Redirect not found'}), 404 + + network = get_network_manager() + + success, message = network.disable_redirect( + source_ip=redirect_obj.source_ip, + target_ip=redirect_obj.target_ip, + port=redirect_obj.port + ) + + if success: + ActiveRedirect.delete(redirect_obj.id) + + AuditLog.log( + username=g.api_user.username, + action="REDIRECT_DISABLED", + source_printer_id=redirect_obj.source_printer_id, + source_ip=redirect_obj.source_ip, + target_printer_id=redirect_obj.target_printer_id, + target_ip=redirect_obj.target_ip, + details="Redirect removed", + success=True + ) + + return jsonify({'message': 'Redirect removed'}) + else: + return jsonify({'error': message}), 500 + + +# ============================================================================ +# Users API Routes +# ============================================================================ + +@api_bp.route('/users') +@api_role_required('admin') +def api_users(): + """Get all users.""" + users = User.get_all() + return jsonify([{ + 'id': u.id, + 'username': u.username, + 'full_name': getattr(u, 'full_name', None), + 'email': getattr(u, 'email', None), + 'role': u.role, + 'is_active': u.is_active, + 'last_login': _serialize_timestamp(u.last_login), + 'created_at': _serialize_timestamp(getattr(u, 'created_at', None)) + } for u in users]) + + +@api_bp.route('/users', methods=['POST']) +@api_role_required('admin') +def api_user_create(): + """Create a new user.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + username = data.get('username', '').strip() + password = data.get('password', '') + email = data.get('email', None) + full_name = data.get('full_name', None) + role = data.get('role', 'viewer').strip() + is_active = data.get('is_active', True) + + if isinstance(email, str): + email = email.strip() + if email == '': + email = None + if isinstance(full_name, str): + full_name = full_name.strip() + if full_name == '': + full_name = None + + if not username: + return jsonify({'error': 'Username is required'}), 400 + + if User.get_by_username(username): + return jsonify({'error': 'Username already exists'}), 400 + + if role not in ['admin', 'operator', 'viewer']: + return jsonify({'error': 'Invalid role'}), 400 + + is_valid, error = validate_password_strength(password) + if not is_valid: + return jsonify({'error': error}), 400 + + try: + user = User.create(username, hash_password(password), role=role, is_active=is_active, email=email, full_name=full_name) + AuditLog.log( + username=g.api_user.username, + action='USER_CREATED', + details=f"Created user '{username}' with role '{role}'", + success=True + ) + return jsonify({ + 'id': user.id, + 'username': user.username, + 'full_name': getattr(user, 'full_name', None), + 'email': getattr(user, 'email', None), + 'role': user.role, + 'is_active': user.is_active + }), 201 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@api_bp.route('/users/') +@api_role_required('admin') +def api_user_get(user_id: int): + """Get a specific user.""" + user = User.get_by_id(user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'full_name': getattr(user, 'full_name', None), + 'email': getattr(user, 'email', None), + 'role': user.role, + 'is_active': user.is_active, + 'last_login': _serialize_timestamp(user.last_login), + 'created_at': _serialize_timestamp(getattr(user, 'created_at', None)) + }) + + +@api_bp.route('/users/', methods=['PUT']) +@api_role_required('admin') +def api_user_update(user_id: int): + """Update a user.""" + user = User.get_by_id(user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + role = data.get('role', user.role) + is_active = data.get('is_active', user.is_active) + new_password = data.get('password') + + if role not in ['admin', 'operator', 'viewer']: + return jsonify({'error': 'Invalid role'}), 400 + + admins = [u for u in User.get_all() if u.role == 'admin'] + if user.role == 'admin' and role != 'admin' and len(admins) <= 1: + return jsonify({'error': 'At least one admin is required'}), 400 + + current_api_user_id = g.api_user.id + if user.id == current_api_user_id and role != 'admin': + return jsonify({'error': 'You cannot remove your own admin access'}), 400 + if user.id == current_api_user_id and not is_active: + return jsonify({'error': 'You cannot disable your own account'}), 400 + + if new_password: + is_valid, error = validate_password_strength(new_password) + if not is_valid: + return jsonify({'error': error}), 400 + user.update_password(hash_password(new_password)) + + user.update_role(role) + user.set_active(is_active) + + AuditLog.log( + username=g.api_user.username, + action='USER_UPDATED', + details=f"Updated user '{user.username}' (role={role}, active={is_active})", + success=True + ) + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'email': getattr(user, 'email', None), + 'role': user.role, + 'is_active': user.is_active + }) + + +@api_bp.route('/users/', methods=['DELETE']) +@api_role_required('admin') +def api_user_delete(user_id: int): + """Delete a user.""" + user = User.get_by_id(user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + current_api_user_id = g.api_user.id + if user.id == current_api_user_id: + return jsonify({'error': 'You cannot delete your own account'}), 400 + + admins = [u for u in User.get_all() if u.role == 'admin'] + if user.role == 'admin' and len(admins) <= 1: + return jsonify({'error': 'At least one admin is required'}), 400 + + if User.delete_by_id(user.id): + AuditLog.log( + username=g.api_user.username, + action='USER_DELETED', + details=f"Deleted user '{user.username}'", + success=True + ) + return jsonify({'message': f"User '{user.username}' deleted"}) + else: + return jsonify({'error': 'Failed to delete user'}), 500 + + +# ============================================================================ +# Audit Log API Routes +# ============================================================================ + +@api_bp.route('/audit-logs') +@api_role_required('admin') +def api_audit_logs(): + """Get audit logs with optional filtering.""" + limit = request.args.get('limit', 100, type=int) + offset = request.args.get('offset', 0, type=int) + action = request.args.get('action') + username = request.args.get('username') + + logs = AuditLog.get_recent(limit=limit, offset=offset, action=action, username=username) + + return jsonify([{ + 'id': log.get('id'), + 'timestamp': _serialize_timestamp(log.get('timestamp')), + 'username': log.get('username'), + 'action': log.get('action'), + 'details': log.get('details'), + 'source_printer_id': log.get('source_printer_id'), + 'source_ip': log.get('source_ip'), + 'target_printer_id': log.get('target_printer_id'), + 'target_ip': log.get('target_ip'), + 'success': log.get('success'), + 'error_message': log.get('error_message') + } for log in logs]) + + +# ============================================================================ +# Discovery API Routes +# ============================================================================ + +@api_bp.route('/discovery/scan', methods=['POST']) +@api_role_required('admin', 'operator') +def api_discovery_scan(): + """Start a network scan for printers.""" + discovery = get_discovery() + + data = request.get_json() or {} + subnet = data.get('subnet') + + try: + if subnet: + # If a CIDR is provided, scan the network; otherwise treat as single IP. + if '/' in subnet: + printers = discovery.discover_all(network_cidr=subnet) + else: + printers = discovery.scan_single_ip(subnet) + else: + printers = discovery.discover_all() + return jsonify({ + 'success': True, + 'printers': [{ + 'ip': p.ip, + 'name': p.name, + 'model': p.model, + 'location': p.location, + 'discovery_method': p.discovery_method, + 'hostname': p.hostname, + 'tcp_9100_open': p.tcp_9100_open, + 'snmp_available': p.snmp_available + } for p in printers] + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ========================================================================= +# Group Notification Subscriptions API Routes +# ========================================================================= + +@api_bp.route('/notifications', methods=['GET']) +@api_auth_required +def api_notifications_list(): + """Get notifications for current user.""" + limit = request.args.get('limit', type=int) or 50 + offset = request.args.get('offset', type=int) or 0 + unread_only = request.args.get('unread_only', 'false').lower() == 'true' + manager = get_notification_manager() + notifications = manager.get_user_notifications( + user_id=g.api_user.id, + limit=limit, + offset=offset, + unread_only=unread_only + ) + return jsonify({'notifications': [n.to_dict() for n in notifications]}) + + +@api_bp.route('/notifications/unread-count', methods=['GET']) +@api_auth_required +def api_notifications_unread_count(): + """Get unread notification count for current user.""" + manager = get_notification_manager() + return jsonify({'count': manager.get_unread_count(g.api_user.id)}) + + +@api_bp.route('/notifications//read', methods=['POST']) +@api_auth_required +def api_notifications_mark_read(notification_id: int): + """Mark a notification as read.""" + manager = get_notification_manager() + success = manager.mark_as_read(notification_id, g.api_user.id) + if not success: + return jsonify({'error': 'Notification not found'}), 404 + return jsonify({'success': True}) + + +@api_bp.route('/notifications/read-all', methods=['POST']) +@api_auth_required +def api_notifications_mark_all_read(): + """Mark all notifications as read for current user.""" + manager = get_notification_manager() + manager.mark_all_as_read(g.api_user.id) + return jsonify({'success': True}) + + +@api_bp.route('/notifications/', methods=['DELETE']) +@api_auth_required +def api_notifications_delete(notification_id: int): + """Delete a notification.""" + manager = get_notification_manager() + success = manager.delete_notification(notification_id, g.api_user.id) + if not success: + return jsonify({'error': 'Notification not found'}), 404 + return jsonify({'success': True}) + + +@api_bp.route('/notifications/stream') +def api_notifications_stream(): + """Server-Sent Events stream for notifications.""" + token = request.args.get('access_token') + if not token: + return jsonify({'error': 'Access token required'}), 401 + + try: + decoded = decode_token(token) + user_id = decoded.get('sub') + jti = decoded.get('jti') + user = User.get_by_id(int(user_id)) if user_id is not None else None + session = UserSession.get_by_jti(jti) if jti else None + if not user or not user.is_active or not session or session.revoked_at: + return jsonify({'error': 'Authentication required'}), 401 + except Exception: + return jsonify({'error': 'Authentication required'}), 401 + + manager = get_notification_manager() + q = manager.register_connection(user.id) + + def stream(): + try: + yield f"data: {json.dumps({'type': 'connected'})}\n\n" + while True: + try: + notification = q.get(timeout=20) + payload = {'type': 'notification', 'notification': notification.to_dict()} + yield f"data: {json.dumps(payload)}\n\n" + except queue.Empty: + payload = {'type': 'unread_count', 'count': manager.get_unread_count(user.id)} + yield f"data: {json.dumps(payload)}\n\n" + finally: + manager.unregister_connection(user.id, q) + + return Response(stream_with_context(stream()), mimetype='text/event-stream') + +@api_bp.route('/notifications/subscriptions', methods=['GET']) +@api_auth_required +def get_notification_subscriptions(): + """Get group-based notification subscriptions for current user.""" + preference_key = request.args.get('preference') + conn = get_db_connection() + cursor = conn.cursor() + + if preference_key: + cursor.execute( + "SELECT group_id FROM user_group_subscriptions WHERE user_id = ? AND preference_key = ?", + (g.api_user.id, preference_key) + ) + rows = cursor.fetchall() + conn.close() + return jsonify({'preference': preference_key, 'group_ids': [row['group_id'] for row in rows]}) + + cursor.execute( + "SELECT preference_key, group_id FROM user_group_subscriptions WHERE user_id = ?", + (g.api_user.id,) + ) + rows = cursor.fetchall() + conn.close() + + grouped = {} + for row in rows: + grouped.setdefault(row['preference_key'], []).append(row['group_id']) + + return jsonify({'subscriptions': grouped}) + + +@api_bp.route('/notifications/subscriptions', methods=['PUT']) +@api_auth_required +def update_notification_subscriptions(): + """Update group-based notification subscriptions for current user.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Missing JSON body'}), 400 + + preference_key = data.get('preference') + group_ids = data.get('group_ids', []) + + if not preference_key: + return jsonify({'error': 'preference is required'}), 400 + + if not isinstance(group_ids, list): + return jsonify({'error': 'group_ids must be a list'}), 400 + + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute( + "DELETE FROM user_group_subscriptions WHERE user_id = ? AND preference_key = ?", + (g.api_user.id, preference_key) + ) + + if group_ids: + cursor.executemany( + "INSERT INTO user_group_subscriptions (user_id, group_id, preference_key) VALUES (?, ?, ?)", + [(g.api_user.id, int(group_id), preference_key) for group_id in group_ids] + ) + + conn.commit() + conn.close() + + return jsonify({'preference': preference_key, 'group_ids': group_ids}) + + +# ============================================================================ +# Network Status API Routes +# ============================================================================ + +@api_bp.route('/network/status') +@api_role_required('admin') +def api_network_status(): + """Get current network status (secondary IPs and NAT rules).""" + network = get_network_manager() + + success, ips = network.get_secondary_ips() + success2, nat_rules = network.get_nat_rules() + + return jsonify({ + 'secondary_ips': ips if success else [], + 'nat_rules': nat_rules if success2 else 'Unable to retrieve' + }) + + +# ============================================================================ +# Dashboard API Routes +# ============================================================================ + +_dashboard_analytics_cache = { + 'timestamp': 0.0, + 'data': None +} + +@api_bp.route('/dashboard/status') +@api_auth_required +def api_dashboard_status(): + """Get all printer statuses for dashboard.""" + registry = get_registry() + return jsonify(registry.get_statuses(use_cache=True)) + + +@api_bp.route('/dashboard/stats') +@api_auth_required +def api_dashboard_stats(): + """Get dashboard statistics.""" + registry = get_registry() + printers = registry.get_statuses() + redirects = ActiveRedirect.get_all() + + online_count = sum(1 for p in printers if p.get('is_online')) + offline_count = len(printers) - online_count + + return jsonify({ + 'total_printers': len(printers), + 'online_printers': online_count, + 'offline_printers': offline_count, + 'active_redirects': len(redirects) + }) + + +@api_bp.route('/dashboard/analytics') +@api_auth_required +def api_dashboard_analytics(): + """Get dashboard analytics for charts.""" + # Cache SNMP-heavy analytics for 5 minutes + now = time.time() + cached = _dashboard_analytics_cache.get('data') + if cached and (now - _dashboard_analytics_cache.get('timestamp', 0)) < 300: + return jsonify(cached) + + conn = get_db_connection() + cursor = conn.cursor() + + registry = get_registry() + printers = registry.get_all() + + def _parse_uptime_hours(uptime: str) -> float: + if not uptime: + return 0.0 + days = hours = minutes = 0 + try: + parts = uptime.split() + for part in parts: + if part.endswith('d'): + days = int(part[:-1]) + elif part.endswith('h'): + hours = int(part[:-1]) + elif part.endswith('m'): + minutes = int(part[:-1]) + except Exception: + return 0.0 + return (days * 24) + hours + (minutes / 60.0) + + # Top printers by SNMP total pages + uptime + snmp_pages = [] + for printer in printers: + stats = get_stats(printer.ip) + total_pages = stats.total_pages if stats and stats.total_pages is not None else 0 + uptime_hours = _parse_uptime_hours(stats.uptime) if stats and stats.uptime else 0.0 + snmp_pages.append({ + 'printer_id': printer.id, + 'name': printer.name, + 'total_pages': total_pages, + 'uptime_hours': round(uptime_hours, 1) + }) + top_pages = sorted(snmp_pages, key=lambda x: x['total_pages'], reverse=True)[:15] + + # Daily job volume (last 7 days) + cursor.execute( + """ + SELECT substr(recorded_at, 1, 10) AS day, + COALESCE(SUM(pages), 0) AS total_pages, + COUNT(*) AS total_jobs + FROM print_job_history + WHERE recorded_at >= datetime('now','-6 days') + GROUP BY substr(recorded_at, 1, 10) + ORDER BY substr(recorded_at, 1, 10) + """ + ) + daily_rows = cursor.fetchall() + conn.close() + + # Fill missing days + from datetime import datetime, timedelta + daily_map = {row['day']: row for row in daily_rows} + daily = [] + for i in range(6, -1, -1): + day = (datetime.utcnow() - timedelta(days=i)).date().isoformat() + row = daily_map.get(day) + daily.append({ + 'day': day, + 'total_pages': row['total_pages'] if row else 0, + 'total_jobs': row['total_jobs'] if row else 0 + }) + + payload = { + 'top_pages': top_pages, + 'daily_volume': daily + } + + _dashboard_analytics_cache['timestamp'] = now + _dashboard_analytics_cache['data'] = payload + + return jsonify(payload) + + +# ============================================================================ +# Update API Routes +# ============================================================================ + +@api_bp.route('/update/status') +def api_update_status(): + """Get current update status.""" + from app.updater import get_update_manager + manager = get_update_manager() + return jsonify(manager.get_state()) + + +@api_bp.route('/update/check', methods=['POST']) +@api_role_required('admin') +def api_update_check(): + """Force an update check.""" + from app.updater import get_update_manager + manager = get_update_manager() + update_available, error = manager.check_for_updates(force=True) + + if error: + return jsonify({ + 'success': False, + 'error': error, + 'update_available': False + }) + + return jsonify({ + 'success': True, + 'update_available': update_available, + **manager.get_state() + }) + + +@api_bp.route('/update/start', methods=['POST']) +@api_role_required('admin') +def api_update_start(): + """Start the update process.""" + from app.updater import get_update_manager + + manager = get_update_manager() + success, message = manager.start_update() + + if success: + AuditLog.log( + username=g.api_user.username, + action='UPDATE_STARTED', + details=f"Update to version {manager._state.available_version} initiated" + ) + + return jsonify({ + 'success': success, + 'message': message + }) + + +# ============================================================================ +# Settings API Routes +# ============================================================================ + +@api_bp.route('/settings') +@api_role_required('admin') +def api_settings(): + """Get all settings.""" + from app.services.settings import get_settings_manager + settings = get_settings_manager().get_all() + return jsonify({'success': True, 'settings': settings}) + + +@api_bp.route('/settings/notifications/smtp', methods=['GET', 'POST']) +@api_role_required('admin') +def api_settings_smtp(): + """Get or update SMTP notification settings.""" + from app.services.settings import get_settings_manager + manager = get_settings_manager() + + if request.method == 'GET': + smtp_settings = manager.get('notifications.smtp', {}) + smtp_settings = dict(smtp_settings) + smtp_settings['password'] = '********' if smtp_settings.get('password') else '' + return jsonify({'success': True, 'settings': smtp_settings}) + + data = request.get_json() or {} + + try: + current_smtp = manager.get('notifications.smtp', {}) + + for field in ['enabled', 'host', 'port', 'username', 'from_address', 'to_addresses', 'use_tls', 'use_ssl']: + if field in data: + current_smtp[field] = data[field] + + if data.get('password'): + current_smtp['password'] = data['password'] + + manager.set('notifications.smtp', current_smtp) + + AuditLog.log( + username=g.api_user.username, + action='SETTINGS_UPDATED', + details='SMTP notification settings updated' + ) + + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/settings/notifications/smtp/test', methods=['POST']) +@api_role_required('admin') +def api_settings_smtp_test(): + """Send a test email using current SMTP settings.""" + from app.services.notification_sender import SMTPNotificationChannel + from app.services.settings import get_settings_manager + + data = request.get_json() or {} + if data: + smtp_settings = { + 'enabled': data.get('enabled', True), + 'host': data.get('host', ''), + 'port': data.get('port', 587), + 'username': data.get('username', ''), + 'password': data.get('password', ''), + 'from_address': data.get('from_address', ''), + 'to_addresses': data.get('to_addresses', ''), + 'use_tls': data.get('use_tls', True), + 'use_ssl': data.get('use_ssl', False), + } + settings = {'notifications': {'smtp': smtp_settings}} + else: + settings = get_settings_manager().get_all() + + channel = SMTPNotificationChannel() + if not channel.is_configured(settings): + return jsonify({ + 'success': False, + 'error': 'SMTP is not properly configured' + }), 400 + + success = channel.send( + subject="Continuum - Test Notification", + message="This is a test notification from Continuum. If you received this, your notification settings are working correctly.", + settings=settings, + html_message=""" + + +

Continuum - Test Notification

+

This is a test notification from Continuum.

+

If you received this, your notification settings are working correctly.

+ + + """ + ) + + if success: + AuditLog.log( + username=g.api_user.username, + action='SMTP_TEST', + details='Test email sent successfully' + ) + return jsonify({ + 'success': True, + 'message': 'Test email sent successfully' + }) + + return jsonify({ + 'success': False, + 'error': 'Failed to send test email' + }), 500 + + +# ============================================================================ +# Network Status API Routes +# ============================================================================ + +@api_bp.route('/network/overview') +@api_role_required('admin', 'operator') +def api_network_overview(): + """ + Get complete network overview for the Networking page. + Combines interface info, claimed IPs, routing status, and redirect stats. + """ + network = get_network_manager() + registry = get_registry() + + # Get interface information + success, interfaces = network.get_interface_info() + if not success: + interfaces = [] + + # Get secondary IPs (claimed by Continuum) + success, secondary_ips = network.get_secondary_ips() + claimed_ips = [] + if success: + # Map claimed IPs to their owning redirects + redirects = ActiveRedirect.get_all() + redirect_map = {r.source_ip: r for r in redirects} + + for ip in secondary_ips: + redirect = redirect_map.get(ip) + source_printer = None + target_printer = None + if redirect: + source_printer = registry.get_by_id(redirect.source_printer_id) + target_printer = registry.get_by_id(redirect.target_printer_id) + + claimed_ips.append({ + 'ip': ip, + 'interface': network.interface, + 'owner_type': 'redirect' if redirect else 'unknown', + 'owner_id': redirect.id if redirect else None, + 'owner_name': f"{source_printer.name if source_printer else 'Unknown'} → {target_printer.name if target_printer else 'Unknown'}" if redirect else None, + 'status': 'active' if redirect else 'orphaned', + 'redirect_info': { + 'source_printer_id': redirect.source_printer_id if redirect else None, + 'source_printer_name': source_printer.name if source_printer else None, + 'target_printer_id': redirect.target_printer_id if redirect else None, + 'target_printer_name': target_printer.name if target_printer else None, + 'port': redirect.port if redirect else None, + 'enabled_at': str(redirect.enabled_at) if redirect else None, + 'enabled_by': redirect.enabled_by if redirect else None, + } if redirect else None + }) + + # Get routing information + success, routing_info = network.get_routing_info() + if not success: + routing_info = { + 'ip_forwarding': False, + 'nat_enabled': False, + 'policy_routing': False, + 'default_gateway': None, + 'default_interface': None + } + + # Build warnings for misconfiguration + warnings = [] + if not routing_info.get('ip_forwarding'): + warnings.append({ + 'type': 'error', + 'message': 'IP forwarding is disabled', + 'remediation': 'Run: sudo sysctl -w net.ipv4.ip_forward=1' + }) + + # Get active redirects with traffic stats + redirects = ActiveRedirect.get_all() + traffic_flows = [] + for redirect in redirects: + source_printer = registry.get_by_id(redirect.source_printer_id) + target_printer = registry.get_by_id(redirect.target_printer_id) + + # Get connection stats + success, stats = network.get_connection_stats( + redirect.source_ip, redirect.target_ip, redirect.port + ) + + traffic_flows.append({ + 'redirect_id': redirect.id, + 'source_ip': redirect.source_ip, + 'source_port': redirect.port, + 'target_ip': redirect.target_ip, + 'target_port': redirect.port, + 'protocol': redirect.protocol.upper(), + 'nat_type': 'DNAT', + 'interface': network.interface, + 'source_printer_name': source_printer.name if source_printer else 'Unknown', + 'target_printer_name': target_printer.name if target_printer else 'Unknown', + 'active_connections': stats.get('active_connections', 0) if success else 0, + 'bytes_forwarded': stats.get('bytes_forwarded', '0') if success else '0', + 'enabled_at': str(redirect.enabled_at), + 'enabled_by': redirect.enabled_by + }) + + return jsonify({ + 'interfaces': interfaces, + 'claimed_ips': claimed_ips, + 'routing': routing_info, + 'traffic_flows': traffic_flows, + 'warnings': warnings, + 'ports_intercepted': list(SUPPORTED_PROTOCOLS.values()), + 'default_interface': network.interface + }) + + +@api_bp.route('/network/interfaces') +@api_role_required('admin', 'operator') +def api_network_interfaces(): + """Get detailed network interface information.""" + network = get_network_manager() + success, interfaces = network.get_interface_info() + + if success: + return jsonify({'success': True, 'interfaces': interfaces}) + return jsonify({'success': False, 'error': 'Failed to get interface info', 'interfaces': []}), 500 + + +@api_bp.route('/network/arp-table') +@api_role_required('admin', 'operator') +def api_network_arp_table(): + """ + Get ARP/neighbour table. + Optionally filter to show only Continuum owned IPs. + """ + network = get_network_manager() + only_owned = request.args.get('only_owned', 'false').lower() == 'true' + + success, arp_entries = network.get_arp_table() + if not success: + return jsonify({'success': False, 'error': 'Failed to get ARP table', 'entries': []}), 500 + + if only_owned: + # Get list of claimed IPs + success, claimed_ips = network.get_secondary_ips() + claimed_set = set(claimed_ips) if success else set() + + # Also include IPs from redirects (targets) + redirects = ActiveRedirect.get_all() + for redirect in redirects: + claimed_set.add(redirect.source_ip) + claimed_set.add(redirect.target_ip) + + arp_entries = [entry for entry in arp_entries if entry['ip'] in claimed_set] + + return jsonify({'success': True, 'entries': arp_entries}) + + +@api_bp.route('/network/routing') +@api_role_required('admin', 'operator') +def api_network_routing(): + """Get routing and NAT status information.""" + network = get_network_manager() + success, routing_info = network.get_routing_info() + + if success: + return jsonify({'success': True, 'routing': routing_info}) + return jsonify({'success': False, 'error': 'Failed to get routing info'}), 500 + + +@api_bp.route('/network/nat-rules') +@api_role_required('admin', 'operator') +def api_network_nat_rules(): + """Get current NAT rules (formatted).""" + network = get_network_manager() + success, output = network.get_nat_rules() + + return jsonify({ + 'success': success, + 'rules': output if success else 'Failed to retrieve NAT rules' + }) + + +@api_bp.route('/network/ports') +@api_role_required('admin', 'operator') +def api_network_ports(): + """Get information about intercepted ports.""" + # Get active redirects to show which ports are actively intercepting traffic + redirects = ActiveRedirect.get_all() + + # Group by port + port_usage = {} + for redirect in redirects: + port = redirect.port + if port not in port_usage: + port_usage[port] = { + 'port': port, + 'protocol': redirect.protocol.upper(), + 'redirect_count': 0, + 'redirects': [] + } + port_usage[port]['redirect_count'] += 1 + port_usage[port]['redirects'].append({ + 'id': redirect.id, + 'source_ip': redirect.source_ip, + 'target_ip': redirect.target_ip + }) + + # Add all supported ports with status + ports = [] + for protocol, port in SUPPORTED_PROTOCOLS.items(): + usage = port_usage.get(port, {}) + ports.append({ + 'port': port, + 'protocol': protocol.upper(), + 'name': protocol, + 'redirect_count': usage.get('redirect_count', 0), + 'redirects': usage.get('redirects', []), + 'status': 'active' if usage.get('redirect_count', 0) > 0 else 'available' + }) + + return jsonify({'success': True, 'ports': ports}) + + +@api_bp.route('/network/safety') +@api_role_required('admin', 'operator') +def api_network_safety(): + """ + Get safety guardrail settings and status. + Fast endpoint - reads from database only, no sudo commands. + """ + from app.models import get_db_connection, ActiveRedirect + + # Get claimed IPs count from database instead of running sudo command + claimed_count = len(ActiveRedirect.get_all()) + + # Get safety settings from database + conn = get_db_connection() + cursor = conn.cursor() + + safety_settings = { + 'ip_conflict_detection': True, + 'refuse_active_ips': True, + 'arp_rate_limiting': True, + } + + for key in safety_settings.keys(): + cursor.execute("SELECT value FROM settings WHERE key = ?", (f"network_safety_{key}",)) + row = cursor.fetchone() + if row: + safety_settings[key] = row['value'].lower() == 'true' + + conn.close() + + return jsonify({ + 'success': True, + 'safety': { + **safety_settings, + 'max_claimed_ips_per_interface': 50, + 'current_claimed_count': claimed_count, + 'warnings': [] + } + }) + + +@api_bp.route('/network/safety', methods=['POST']) +@api_role_required('admin') +def api_network_safety_update(): + """ + Update a safety guardrail setting. + """ + from app.models import get_db_connection + + data = request.get_json() or {} + key = data.get('key') + enabled = data.get('enabled') + + allowed_keys = ['ip_conflict_detection', 'refuse_active_ips', 'arp_rate_limiting'] + + if key not in allowed_keys: + return jsonify({'success': False, 'error': 'Invalid safety setting key'}), 400 + + if enabled is None: + return jsonify({'success': False, 'error': 'Enabled value required'}), 400 + + conn = get_db_connection() + cursor = conn.cursor() + + db_key = f"network_safety_{key}" + value = 'true' if enabled else 'false' + + # Upsert the setting + cursor.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (db_key, value, value) + ) + conn.commit() + conn.close() + + # Log the change + AuditLog.log( + username=g.api_user.username, + action='NETWORK_SAFETY_UPDATE', + details=f"Safety setting '{key}' set to {enabled}", + success=True + ) + + return jsonify({'success': True}) + + +@api_bp.route('/network/sudo-status') +@api_role_required('admin', 'operator') +def api_network_sudo_status(): + """ + Check if sudo is available without password prompt. + Used by frontend to determine if sudo password modal is needed. + """ + import subprocess + + try: + # Try a simple sudo command with a very short timeout + result = subprocess.run( + ['sudo', '-n', 'true'], + capture_output=True, + timeout=2 + ) + sudo_available = result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + sudo_available = False + + return jsonify({'sudo_available': sudo_available}) + + +@api_bp.route('/network/sudo-auth', methods=['POST']) +@api_role_required('admin', 'operator') +def api_network_sudo_auth(): + """ + Authenticate with sudo password for development mode. + Stores password in session for subsequent operations. + """ + import subprocess + + data = request.get_json() or {} + password = data.get('password') + + if not password: + return jsonify({'success': False, 'error': 'Password required'}), 400 + + try: + # Validate the password by running a simple sudo command + # Use echo to provide password via stdin + cmd = f"echo '{password}' | sudo -S true 2>&1" + process = subprocess.run( + cmd, + shell=True, + capture_output=True, + timeout=5, + text=True + ) + + # Check if sudo succeeded (returncode 0 and no "Sorry" in output) + if process.returncode == 0 and 'Sorry' not in process.stderr: + # Store in Flask session for this user's subsequent requests + session['sudo_password'] = password + return jsonify({'success': True}) + else: + error_msg = 'Invalid password' + if 'Sorry' in process.stderr: + error_msg = 'Incorrect sudo password' + return jsonify({'success': False, 'error': error_msg}), 200 # Return 200 with error in JSON + except subprocess.TimeoutExpired: + return jsonify({'success': False, 'error': 'Authentication timed out'}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 200 + + +# ============================================================================ +# Network Diagnostics API Routes +# ============================================================================ + +@api_bp.route('/network/diagnostics/ping', methods=['POST']) +@api_role_required('admin', 'operator') +def api_network_diag_ping(): + """ + Ping test diagnostic. + Logs action to audit log. + """ + data = request.get_json() or {} + ip = data.get('ip') + + if not ip: + return jsonify({'success': False, 'error': 'IP address required'}), 400 + + network = get_network_manager() + success, result = network.ping_test(ip) + + # Log diagnostic action + AuditLog.log( + username=g.api_user.username, + action='NETWORK_DIAGNOSTIC', + details=f"Ping test to {ip}: {result.get('result', 'unknown')}", + success=result.get('result') == 'success' + ) + + return jsonify({ + 'success': success, + 'result': result + }) + + +@api_bp.route('/network/diagnostics/arp-probe', methods=['POST']) +@api_role_required('admin', 'operator') +def api_network_diag_arp_probe(): + """ + ARP probe diagnostic. + Logs action to audit log. + """ + data = request.get_json() or {} + ip = data.get('ip') + + if not ip: + return jsonify({'success': False, 'error': 'IP address required'}), 400 + + network = get_network_manager() + success, result = network.arp_probe(ip) + + # Log diagnostic action + AuditLog.log( + username=g.api_user.username, + action='NETWORK_DIAGNOSTIC', + details=f"ARP probe for {ip}: {result.get('result', 'unknown')}", + success=True + ) + + return jsonify({ + 'success': success, + 'result': result + }) + + +@api_bp.route('/network/diagnostics/tcp-test', methods=['POST']) +@api_role_required('admin', 'operator') +def api_network_diag_tcp_test(): + """ + TCP connection test diagnostic. + Logs action to audit log. + """ + data = request.get_json() or {} + ip = data.get('ip') + port = data.get('port', DEFAULT_PORT) + + if not ip: + return jsonify({'success': False, 'error': 'IP address required'}), 400 + + try: + port = int(port) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid port number'}), 400 + + network = get_network_manager() + success, result = network.tcp_test(ip, port) + + # Log diagnostic action + AuditLog.log( + username=g.api_user.username, + action='NETWORK_DIAGNOSTIC', + details=f"TCP test to {ip}:{port}: {result.get('result', 'unknown')}", + success=result.get('result') == 'success' + ) + + return jsonify({ + 'success': success, + 'result': result + }) + + +@api_bp.route('/network/diagnostics/re-announce-arp', methods=['POST']) +@api_role_required('admin') +def api_network_diag_reannounce_arp(): + """ + Re-announce ARP for a claimed IP. + Admin-only action that requires confirmation. + Logs action to audit log. + """ + data = request.get_json() or {} + ip = data.get('ip') + confirm = data.get('confirm', False) + + if not ip: + return jsonify({'success': False, 'error': 'IP address required'}), 400 + + if not confirm: + return jsonify({ + 'success': False, + 'error': 'Confirmation required', + 'requires_confirmation': True, + 'message': f'This will send gratuitous ARP packets for {ip}. Confirm to proceed.' + }), 400 + + # Verify IP is actually claimed by us + network = get_network_manager() + success, claimed_ips = network.get_secondary_ips() + if not success or ip not in claimed_ips: + return jsonify({ + 'success': False, + 'error': f'IP {ip} is not claimed by Continuum' + }), 400 + + success, message = network.re_announce_arp(ip) + + # Log action + AuditLog.log( + username=g.api_user.username, + action='NETWORK_ARP_REANNOUNCE', + source_ip=ip, + details=f"ARP re-announcement for {ip}: {message}", + success=success + ) + + return jsonify({ + 'success': success, + 'message': message if success else 'Failed to send ARP announcement' + }) + + +# ============================================================================ +# Network Advanced Diagnostics API Routes +# ============================================================================ + +@api_bp.route('/network/advanced/ip-addr') +@api_role_required('admin') +def api_network_advanced_ip_addr(): + """Get raw ip addr show output. Admin only.""" + network = get_network_manager() + success, output = network.get_ip_addr_raw() + + AuditLog.log( + username=g.api_user.username, + action='NETWORK_ADVANCED_VIEW', + details='Viewed raw ip addr output', + success=True + ) + + return jsonify({'success': success, 'output': output}) + + +@api_bp.route('/network/advanced/ip-route') +@api_role_required('admin') +def api_network_advanced_ip_route(): + """Get raw ip route output. Admin only.""" + network = get_network_manager() + success, output = network.get_ip_route_raw() + + AuditLog.log( + username=g.api_user.username, + action='NETWORK_ADVANCED_VIEW', + details='Viewed raw ip route output', + success=True + ) + + return jsonify({'success': success, 'output': output}) + + +@api_bp.route('/network/advanced/ip-rule') +@api_role_required('admin') +def api_network_advanced_ip_rule(): + """Get raw ip rule output. Admin only.""" + network = get_network_manager() + success, output = network.get_ip_rule_raw() + + AuditLog.log( + username=g.api_user.username, + action='NETWORK_ADVANCED_VIEW', + details='Viewed raw ip rule output', + success=True + ) + + return jsonify({'success': success, 'output': output}) + + +@api_bp.route('/network/advanced/nat-rules') +@api_role_required('admin') +def api_network_advanced_nat_rules(): + """Get raw iptables NAT rules output. Admin only.""" + network = get_network_manager() + success, output = network.get_nat_rules_raw() + + AuditLog.log( + username=g.api_user.username, + action='NETWORK_ADVANCED_VIEW', + details='Viewed raw iptables NAT rules', + success=True + ) + + return jsonify({'success': success, 'output': output}) + + +# ============================================================================ +# Password Requirements Helper +# ============================================================================ + +@api_bp.route('/auth/password-requirements') +def api_password_requirements(): + """Get password requirements for the frontend.""" + requirements = [f"At least {MIN_PASSWORD_LENGTH} characters"] + if PASSWORD_REQUIRE_UPPERCASE: + requirements.append("At least one uppercase letter") + if PASSWORD_REQUIRE_LOWERCASE: + requirements.append("At least one lowercase letter") + if PASSWORD_REQUIRE_DIGIT: + requirements.append("At least one digit") + if PASSWORD_REQUIRE_SPECIAL: + requirements.append("At least one special character") + + return jsonify({ + 'requirements': requirements, + 'min_length': MIN_PASSWORD_LENGTH, + 'require_uppercase': PASSWORD_REQUIRE_UPPERCASE, + 'require_lowercase': PASSWORD_REQUIRE_LOWERCASE, + 'require_digit': PASSWORD_REQUIRE_DIGIT, + 'require_special': PASSWORD_REQUIRE_SPECIAL + }) + + +# ========== Workflow Webhook Endpoints ========== + +@api_bp.route('/webhooks/workflows//', methods=['POST']) +def workflow_webhook_trigger(workflow_id, hook_id): + """ + Webhook endpoint to trigger workflow execution. + Verifies signature and executes the workflow. + """ + try: + # Get workflow and verify it has webhook trigger + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, name, nodes, enabled FROM workflows WHERE id = ?", + (workflow_id,) + ) + row = cursor.fetchone() + conn.close() + + if not row: + return jsonify({'error': 'Workflow not found'}), 404 + + if not row[3]: # enabled check + return jsonify({'error': 'Workflow is disabled'}), 403 + + nodes = json.loads(row[2]) if row[2] else [] + + # Find webhook trigger node and verify hook_id matches + webhook_node = None + for node in nodes: + if node['type'] == 'trigger.webhook': + node_path = node.get('properties', {}).get('path', '') + if hook_id in node_path: + webhook_node = node + break + + if not webhook_node: + return jsonify({'error': 'Invalid webhook path'}), 404 + + # Verify signature (secret is pre-hashed SHA256) + secret = webhook_node.get('properties', {}).get('secret', '') + signature = request.headers.get('X-Signature') + + if secret: + if not signature: + return jsonify({'error': 'Missing X-Signature header'}), 401 + if signature != secret: + return jsonify({'error': 'Invalid signature'}), 401 + + # Execute workflow + context = { + 'trigger': 'webhook', + 'workflow_id': workflow_id, + 'payload': request.get_json(silent=True) or {}, + 'timestamp': datetime.now().isoformat() + } + + engine = get_workflow_engine() + success = engine.execute_workflow(workflow_id, context) + + if success: + return jsonify({'message': 'Workflow executed successfully'}), 200 + else: + return jsonify({'error': 'Workflow execution failed'}), 500 + + except Exception as e: + current_app.logger.error(f"Webhook error: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..398e050 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,207 @@ +""" +Services module for Continuum + +This module provides all business logic and background services. +Services are organized by domain. +""" + +# Network management +from app.services.network_manager import NetworkManager, get_network_manager + +# Discovery service +from app.services.discovery import ( + PrinterDiscovery, + get_discovery, + DiscoveredPrinter, +) + +# Health check service +from app.services.health_check import ( + HealthCheckResult, + HealthChecker, + HealthCheckScheduler, + get_scheduler, + start_health_checks, + stop_health_checks, + init_health_check_tables, + get_status as get_health_status, + get_history as get_health_history, +) + +# Job monitoring +from app.services.job_monitor import ( + PrinterState, + JobMonitor, + get_job_monitor, + init_job_monitor, +) + +# Printer registry +from app.services.printer_registry import ( + PrinterRegistry, + get_registry, + Printer, +) + +# Notification services +from app.services.notification_sender import ( + NotificationChannel, + SMTPNotificationChannel, + NotificationManager as NotificationSender, + get_notification_manager as get_notification_sender, + notify, + notify_printer_offline, + notify_printer_online, + notify_user_login, + notify_printer_health_alert, + notify_job_failure, + notify_redirect_created, + send_weekly_report, + WeeklyReportScheduler, + start_weekly_reports, + stop_weekly_reports, +) +from app.services.notification_manager import ( + Notification, + NotificationManager, + get_notification_manager, +) + +# Updater service +from app.services.updater import ( + UpdateState, + UpdateManager, + get_update_manager, + init_updater, +) + +# Workflow engine +from app.services.workflow_engine import ( + WorkflowEngine, + get_workflow_engine, + trigger_workflows_for_event, +) + +# Event logs +from app.services.event_logs import ( + PrinterEvent, + categorize_hp_event_code, + get_logs, + get_errors, +) + +# Job history +from app.services.job_history import ( + JobHistoryEntry, + get_history as get_job_history, + add as add_job_to_history, + get_stats as get_job_history_stats, +) + +# Print queue +from app.services.print_queue import ( + PrintJob, + PrintQueueCollector, + get_queue_collector, + get_queue, +) + +# Printer stats +from app.services.printer_stats import ( + PrinterStats, + PrinterStatsCollector, + get_collector as get_stats_collector, + get_stats, + get_toner_levels, +) + +# Settings +from app.services.settings import ( + SettingsManager, + get_settings_manager, + init_settings_table, +) + + +__all__ = [ + # Network + 'NetworkManager', + 'get_network_manager', + # Discovery + 'PrinterDiscovery', + 'get_discovery', + 'DiscoveredPrinter', + # Health check + 'HealthCheckResult', + 'HealthChecker', + 'HealthCheckScheduler', + 'get_scheduler', + 'start_health_checks', + 'stop_health_checks', + 'init_health_check_tables', + 'get_health_status', + 'get_health_history', + # Job monitor + 'PrinterState', + 'JobMonitor', + 'get_job_monitor', + 'init_job_monitor', + # Printer registry + 'PrinterRegistry', + 'get_registry', + 'Printer', + # Notifications (notification_sender) + 'NotificationChannel', + 'SMTPNotificationChannel', + 'NotificationSender', + 'get_notification_sender', + 'notify', + 'notify_printer_offline', + 'notify_printer_online', + 'notify_user_login', + 'notify_printer_health_alert', + 'notify_job_failure', + 'notify_redirect_created', + 'send_weekly_report', + 'WeeklyReportScheduler', + 'start_weekly_reports', + 'stop_weekly_reports', + # Notifications (notification_manager) + 'Notification', + 'NotificationManager', + 'get_notification_manager', + # Updater + 'UpdateState', + 'UpdateManager', + 'get_update_manager', + 'init_updater', + # Workflow + 'WorkflowEngine', + 'get_workflow_engine', + 'trigger_workflows_for_event', + # Event logs + 'PrinterEvent', + 'categorize_hp_event_code', + 'get_logs', + 'get_errors', + # Job history + 'JobHistoryEntry', + 'get_job_history', + 'add_job_to_history', + 'get_job_history_stats', + # Print queue + 'PrintJob', + 'PrintQueueCollector', + 'get_queue_collector', + 'get_queue', + # Printer stats + 'PrinterStats', + 'PrinterStatsCollector', + 'get_stats_collector', + 'get_stats', + 'get_toner_levels', + # Settings + 'SettingsManager', + 'get_settings_manager', + 'init_settings_table', +] diff --git a/app/discovery.py b/app/services/discovery.py similarity index 100% rename from app/discovery.py rename to app/services/discovery.py diff --git a/app/event_logs.py b/app/services/event_logs.py similarity index 99% rename from app/event_logs.py rename to app/services/event_logs.py index e1d5145..5118d90 100644 --- a/app/event_logs.py +++ b/app/services/event_logs.py @@ -498,7 +498,7 @@ def categorize_hp_event_code(code: int) -> tuple: return ('info', f'Code {code}', f'HP event code {code}') -def get_printer_logs(ip: str, community: str = 'public') -> List[PrinterEvent]: +def get_logs(ip: str, community: str = 'public') -> List[PrinterEvent]: """Get logs/events for a printer by IP. Queries multiple SNMP OIDs to collect printer alerts/logs: @@ -761,6 +761,6 @@ async def collect_logs(): # Backwards compatibility alias -def get_printer_errors(ip: str) -> List[PrinterEvent]: - """Alias for get_printer_logs.""" - return get_printer_logs(ip) +def get_errors(ip: str) -> List[PrinterEvent]: + """Alias for get_logs.""" + return get_logs(ip) diff --git a/app/health_check.py b/app/services/health_check.py similarity index 77% rename from app/health_check.py rename to app/services/health_check.py index 43f8f29..386af98 100644 --- a/app/health_check.py +++ b/app/services/health_check.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from app.models import get_db_connection -from app.printers import get_registry +from app.services.printer_registry import get_registry logger = logging.getLogger(__name__) @@ -164,7 +164,7 @@ def save_result(self, result: HealthCheckResult): conn.commit() conn.close() - def get_cached_status(self, printer_id: str) -> Optional[Dict]: + def get_status(self, printer_id: str) -> Optional[Dict]: """Get cached status for a printer.""" conn = get_db_connection() cursor = conn.cursor() @@ -190,7 +190,7 @@ def get_history(self, printer_id: str, limit: int = 100) -> List[Dict]: conn.close() return [dict(row) for row in rows] - def cleanup_old_history(self, days: int = 30): + def cleanup(self, days: int = 30): """Remove health check history older than specified days.""" conn = get_db_connection() cursor = conn.cursor() @@ -264,11 +264,57 @@ def _check_all_printers(self): break try: + # Get previous status before checking + previous_status = self.checker.get_status(printer.id) + was_online = previous_status and previous_status.get('is_online', False) + result = self.checker.check_printer(printer.id, printer.ip) self.checker.save_result(result) + # Detect status change and send notifications if not result.is_online: logger.warning(f"Printer {printer.id} ({printer.ip}) is OFFLINE") + + # Send offline notification if status changed from online to offline + if was_online: + from app.services.notification_sender import notify_printer_offline + try: + notify_printer_offline(printer.name, printer.ip) + except Exception as e: + logger.error(f"Failed to send offline notification: {e}") + + # Trigger workflows for printer offline + from app.services.workflow_engine import trigger_workflows_for_event + try: + trigger_workflows_for_event('printer_offline', { + 'printer_id': printer.id, + 'printer_name': printer.name, + 'printer_ip': printer.ip, + 'printer_state': 'offline' + }) + except Exception as e: + logger.error(f"Failed to trigger workflows: {e}") + else: + # Printer is online - send recovery notification if it was offline before + if previous_status and not was_online: + from app.services.notification_sender import notify_printer_online + try: + notify_printer_online(printer.name, printer.ip) + except Exception as e: + logger.error(f"Failed to send online notification: {e}") + + # Trigger workflows for printer online + from app.services.workflow_engine import trigger_workflows_for_event + try: + trigger_workflows_for_event('printer_online', { + 'printer_id': printer.id, + 'printer_name': printer.name, + 'printer_ip': printer.ip, + 'printer_state': 'online' + }) + except Exception as e: + logger.error(f"Failed to trigger workflows: {e}") + except Exception as e: logger.error(f"Error checking printer {printer.id}: {e}") @@ -283,7 +329,7 @@ def _maybe_cleanup(self): # Clean up once per day (approximated by checking a random condition) import random if random.random() < (1.0 / (24 * 60)): # ~once per day at 1-min interval - deleted = self.checker.cleanup_old_history(30) + deleted = self.checker.cleanup(30) if deleted: logger.info(f"Cleaned up {deleted} old health check records") @@ -314,13 +360,13 @@ def stop_health_checks(): _scheduler.stop() -def get_printer_health(printer_id: str) -> Optional[Dict]: +def get_status(printer_id: str) -> Optional[Dict]: """Get the latest health status for a printer.""" checker = HealthChecker() - return checker.get_cached_status(printer_id) + return checker.get_status(printer_id) -def get_printer_health_history(printer_id: str, limit: int = 100) -> List[Dict]: +def get_history(printer_id: str, limit: int = 100) -> List[Dict]: """Get health check history for a printer.""" checker = HealthChecker() return checker.get_history(printer_id, limit) diff --git a/app/job_history.py b/app/services/job_history.py similarity index 96% rename from app/job_history.py rename to app/services/job_history.py index 4f65a6e..d78f774 100644 --- a/app/job_history.py +++ b/app/services/job_history.py @@ -44,7 +44,7 @@ def to_dict(self) -> Dict[str, Any]: } -def get_job_history(printer_id: Optional[int] = None, limit: int = 100) -> List[JobHistoryEntry]: +def get_history(printer_id: Optional[int] = None, limit: int = 100) -> List[JobHistoryEntry]: """Get job history from the database. Args: @@ -90,7 +90,7 @@ def get_job_history(printer_id: Optional[int] = None, limit: int = 100) -> List[ return [] -def add_job_to_history( +def add( printer_id: int, job_id: int, document_name: str = "", @@ -155,7 +155,7 @@ def add_job_to_history( return None -def get_job_history_stats(printer_id: Optional[int] = None, days: int = 30) -> Dict[str, Any]: +def get_stats(printer_id: Optional[int] = None, days: int = 30) -> Dict[str, Any]: """Get job history statistics. Args: diff --git a/app/job_monitor.py b/app/services/job_monitor.py similarity index 90% rename from app/job_monitor.py rename to app/services/job_monitor.py index 856413b..130d736 100644 --- a/app/job_monitor.py +++ b/app/services/job_monitor.py @@ -63,6 +63,7 @@ def __init__(self, poll_interval: int = 30): self._printer_states: Dict[str, PrinterState] = {} self._lock = threading.Lock() self._app = None + self._event_loop: Optional[asyncio.AbstractEventLoop] = None def init_app(self, app): """Initialize with Flask app context.""" @@ -89,6 +90,15 @@ def stop(self): if self._thread: self._thread.join(timeout=5) self._thread = None + + # Clean up event loop + if self._event_loop and not self._event_loop.is_closed(): + try: + self._event_loop.close() + except Exception: + pass + self._event_loop = None + logger.info("Job monitor stopped") def _run(self): @@ -113,7 +123,7 @@ def _poll_printers(self): with self._app.app_context(): try: - from app.printers import get_registry + from app.services.printer_registry import get_registry registry = get_registry() printers = registry.get_all() @@ -234,6 +244,10 @@ def _get_page_count(self, ip: str) -> Optional[int]: ObjectType, ObjectIdentity ) + # Create event loop once per thread and reuse it + if self._event_loop is None or self._event_loop.is_closed(): + self._event_loop = asyncio.new_event_loop() + async def query(): snmp_engine = SnmpEngine() try: @@ -243,7 +257,7 @@ async def query(): err, status, idx, vb = await get_cmd( snmp_engine, CommunityData('public'), - await UdpTransportTarget.create((ip, 161), timeout=3, retries=1), + await UdpTransportTarget.create((ip, 161), timeout=2, retries=0), ContextData(), ObjectType(ObjectIdentity(page_count_oid)) ) @@ -254,13 +268,16 @@ async def query(): finally: snmp_engine.close_dispatcher() - # Run in new event loop - loop = asyncio.new_event_loop() + # Use existing event loop try: - result = loop.run_until_complete(query()) + result = self._event_loop.run_until_complete(asyncio.wait_for(query(), timeout=3.0)) return result - finally: - loop.close() + except asyncio.TimeoutError: + logger.debug(f"SNMP timeout for {ip}") + return None + except Exception as e: + logger.debug(f"SNMP error for {ip}: {e}") + return None except Exception as e: logger.debug(f"Error getting page count for {ip}: {e}") @@ -273,9 +290,9 @@ def _get_job_events(self, ip: str) -> List[Tuple[int, str]]: List of (event_code, status) tuples for job-related events """ try: - from app.event_logs import get_printer_logs + from app.services.event_logs import get_logs - events = get_printer_logs(ip) + events = get_logs(ip) job_events = [] for event in events: @@ -289,7 +306,7 @@ def _get_job_events(self, ip: str) -> List[Tuple[int, str]]: logger.debug(f"Error getting job events for {ip}: {e}") return [] - def get_printer_state(self, ip: str) -> Optional[PrinterState]: + def get_state(self, ip: str) -> Optional[PrinterState]: """Get the current state for a printer.""" with self._lock: return self._printer_states.get(ip) @@ -306,7 +323,7 @@ def force_check(self, ip: str = None): with self._app.app_context(): try: - from app.printers import get_registry + from app.services.printer_registry import get_registry registry = get_registry() if ip: diff --git a/app/network.py b/app/services/network_manager.py similarity index 50% rename from app/network.py rename to app/services/network_manager.py index a4c9c38..662f129 100644 --- a/app/network.py +++ b/app/services/network_manager.py @@ -3,7 +3,8 @@ """ import subprocess import shlex -from typing import Tuple, Optional +import json +from typing import Tuple, Optional, List, Dict, Any import logging from config.config import HELPER_SCRIPT, NETWORK_INTERFACE, DEFAULT_PORT @@ -152,6 +153,149 @@ def disable_redirect(self, source_ip: str, target_ip: str, return False, "; ".join(errors) return True, "Redirect disabled successfully" + + # ========================================================================= + # Network Information Methods (Read-Only) + # ========================================================================= + + def get_interface_info(self, interface: Optional[str] = None) -> Tuple[bool, List[Dict[str, Any]]]: + """ + Get detailed information about network interfaces. + Returns JSON array of interface information. + """ + args = ['interface-info'] + if interface: + args.append(interface) + + success, output = self._run_helper(*args) + if success: + try: + interfaces = json.loads(output) + return True, interfaces + except json.JSONDecodeError as e: + logger.error(f"Failed to parse interface info JSON: {e}") + return False, [] + return False, [] + + def get_arp_table(self) -> Tuple[bool, List[Dict[str, Any]]]: + """ + Get the ARP/neighbour table. + Returns JSON array of ARP entries. + """ + success, output = self._run_helper('arp-table') + if success: + try: + arp_entries = json.loads(output) + return True, arp_entries + except json.JSONDecodeError as e: + logger.error(f"Failed to parse ARP table JSON: {e}") + return False, [] + return False, [] + + def get_routing_info(self) -> Tuple[bool, Dict[str, Any]]: + """ + Get routing and forwarding status. + Returns JSON object with routing configuration. + """ + success, output = self._run_helper('routing-info') + if success: + try: + routing = json.loads(output) + return True, routing + except json.JSONDecodeError as e: + logger.error(f"Failed to parse routing info JSON: {e}") + return False, {} + return False, {} + + def get_connection_stats(self, source_ip: str, target_ip: str, + port: int = DEFAULT_PORT) -> Tuple[bool, Dict[str, Any]]: + """ + Get connection statistics for a specific redirect. + """ + success, output = self._run_helper('connection-stats', source_ip, target_ip, str(port)) + if success: + try: + stats = json.loads(output) + return True, stats + except json.JSONDecodeError as e: + logger.error(f"Failed to parse connection stats JSON: {e}") + return False, {} + return False, {} + + # ========================================================================= + # Diagnostic Methods + # ========================================================================= + + def ping_test(self, ip: str) -> Tuple[bool, Dict[str, Any]]: + """ + Perform a ping test to an IP address. + Returns result and RTT if successful. + """ + success, output = self._run_helper('ping-test', ip) + if success: + try: + result = json.loads(output) + return True, result + except json.JSONDecodeError as e: + logger.error(f"Failed to parse ping test JSON: {e}") + return False, {"ip": ip, "result": "error", "rtt_ms": None} + return False, {"ip": ip, "result": "error", "rtt_ms": None} + + def arp_probe(self, ip: str) -> Tuple[bool, Dict[str, Any]]: + """ + Perform an ARP probe for an IP address. + Returns whether there was a response and the MAC if available. + """ + success, output = self._run_helper('arp-probe', ip) + if success: + try: + result = json.loads(output) + return True, result + except json.JSONDecodeError as e: + logger.error(f"Failed to parse ARP probe JSON: {e}") + return False, {"ip": ip, "result": "error", "mac": ""} + return False, {"ip": ip, "result": "error", "mac": ""} + + def tcp_test(self, ip: str, port: int) -> Tuple[bool, Dict[str, Any]]: + """ + Test TCP connection to an IP and port. + Returns result and latency if successful. + """ + success, output = self._run_helper('tcp-test', ip, str(port)) + if success: + try: + result = json.loads(output) + return True, result + except json.JSONDecodeError as e: + logger.error(f"Failed to parse TCP test JSON: {e}") + return False, {"ip": ip, "port": port, "result": "error", "latency_ms": None} + return False, {"ip": ip, "port": port, "result": "error", "latency_ms": None} + + def re_announce_arp(self, ip: str) -> Tuple[bool, str]: + """ + Send gratuitous ARP for a claimed IP address. + """ + return self._run_helper('re-announce-arp', self.interface, ip) + + # ========================================================================= + # Advanced / Raw Output Methods + # ========================================================================= + + def get_nat_rules_raw(self) -> Tuple[bool, str]: + """Get raw iptables NAT rules output.""" + return self._run_helper('nat-rules-raw') + + def get_ip_addr_raw(self) -> Tuple[bool, str]: + """Get raw ip addr show output.""" + return self._run_helper('ip-addr-raw') + + def get_ip_route_raw(self) -> Tuple[bool, str]: + """Get raw ip route output.""" + return self._run_helper('ip-route-raw') + + def get_ip_rule_raw(self) -> Tuple[bool, str]: + """Get raw ip rule output.""" + return self._run_helper('ip-rule-raw') # Global instance diff --git a/app/services/notification_manager.py b/app/services/notification_manager.py new file mode 100644 index 0000000..b791a3c --- /dev/null +++ b/app/services/notification_manager.py @@ -0,0 +1,347 @@ +""" +Notification management system with SSE support + +This module handles storing notifications in the database, +managing real-time delivery via Server-Sent Events, and +queuing notifications for offline users. +""" +import json +import logging +import queue +import threading +from datetime import datetime +from typing import Dict, List, Optional, Any +from dataclasses import dataclass + +from app.models import get_db_connection + +logger = logging.getLogger(__name__) + + +@dataclass +class Notification: + """Represents a user notification.""" + id: Optional[int] = None + user_id: int = 0 + type: str = 'info' # info, success, warning, error + title: str = '' + message: str = '' + link: Optional[str] = None + is_read: bool = False + created_at: Optional[datetime] = None + read_at: Optional[datetime] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert notification to dictionary.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'type': self.type, + 'title': self.title, + 'message': self.message, + 'link': self.link, + 'is_read': self.is_read, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'read_at': self.read_at.isoformat() if self.read_at else None, + } + + @classmethod + def from_db_row(cls, row) -> 'Notification': + """Create Notification from database row.""" + return cls( + id=row[0], + user_id=row[1], + type=row[2], + title=row[3], + message=row[4], + link=row[5], + is_read=bool(row[6]), + created_at=datetime.fromisoformat(row[7]) if row[7] else None, + read_at=datetime.fromisoformat(row[8]) if row[8] else None, + ) + + +class NotificationManager: + """Manages notification storage and real-time delivery.""" + + def __init__(self): + """Initialize notification manager.""" + # SSE connections: {user_id: queue.Queue} + self._connections: Dict[int, List[queue.Queue]] = {} + self._lock = threading.Lock() + + def create_notification( + self, + user_id: int, + type: str, + title: str, + message: str, + link: Optional[str] = None + ) -> Optional[Notification]: + """ + Create and store a new notification. + + Args: + user_id: ID of the user to notify + type: Notification type (info, success, warning, error) + title: Notification title + message: Notification message + link: Optional link for the notification + + Returns: + Created Notification object or None on error + """ + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO notifications (user_id, type, title, message, link) + VALUES (?, ?, ?, ?, ?) + """, (user_id, type, title, message, link)) + + notification_id = cursor.lastrowid + conn.commit() + + # Fetch the created notification + cursor.execute(""" + SELECT id, user_id, type, title, message, link, is_read, created_at, read_at + FROM notifications WHERE id = ? + """, (notification_id,)) + + row = cursor.fetchone() + conn.close() + + if row: + notification = Notification.from_db_row(row) + # Broadcast to connected clients + self._broadcast_to_user(user_id, notification) + return notification + + return None + + except Exception as e: + logger.error(f"Error creating notification: {e}") + return None + + def broadcast_notification( + self, + user_ids: List[int], + type: str, + title: str, + message: str, + link: Optional[str] = None + ): + """ + Broadcast a notification to multiple users. + + Args: + user_ids: List of user IDs to notify + type: Notification type + title: Notification title + message: Notification message + link: Optional link + """ + for user_id in user_ids: + self.create_notification(user_id, type, title, message, link) + + def get_user_notifications( + self, + user_id: int, + limit: int = 50, + offset: int = 0, + unread_only: bool = False + ) -> List[Notification]: + """ + Get notifications for a user. + + Args: + user_id: User ID + limit: Maximum number of notifications to return + offset: Offset for pagination + unread_only: If True, return only unread notifications + + Returns: + List of Notification objects + """ + try: + conn = get_db_connection() + cursor = conn.cursor() + + if unread_only: + cursor.execute(""" + SELECT id, user_id, type, title, message, link, is_read, created_at, read_at + FROM notifications + WHERE user_id = ? AND is_read = 0 + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, (user_id, limit, offset)) + else: + cursor.execute(""" + SELECT id, user_id, type, title, message, link, is_read, created_at, read_at + FROM notifications + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, (user_id, limit, offset)) + + rows = cursor.fetchall() + conn.close() + + return [Notification.from_db_row(row) for row in rows] + + except Exception as e: + logger.error(f"Error fetching notifications: {e}") + return [] + + def get_unread_count(self, user_id: int) -> int: + """Get count of unread notifications for a user.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT COUNT(*) FROM notifications + WHERE user_id = ? AND is_read = 0 + """, (user_id,)) + + count = cursor.fetchone()[0] + conn.close() + + return count + + except Exception as e: + logger.error(f"Error getting unread count: {e}") + return 0 + + def mark_as_read(self, notification_id: int, user_id: int) -> bool: + """Mark a notification as read.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE notifications + SET is_read = 1, read_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + """, (notification_id, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + except Exception as e: + logger.error(f"Error marking notification as read: {e}") + return False + + def mark_all_as_read(self, user_id: int) -> bool: + """Mark all notifications as read for a user.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE notifications + SET is_read = 1, read_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND is_read = 0 + """, (user_id,)) + + conn.commit() + conn.close() + + return True + + except Exception as e: + logger.error(f"Error marking all notifications as read: {e}") + return False + + def delete_notification(self, notification_id: int, user_id: int) -> bool: + """Delete a notification.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM notifications + WHERE id = ? AND user_id = ? + """, (notification_id, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + except Exception as e: + logger.error(f"Error deleting notification: {e}") + return False + + # ============================================================================ + # SSE Connection Management + # ============================================================================ + + def register_connection(self, user_id: int) -> queue.Queue: + """ + Register a new SSE connection for a user. + + Returns: + Queue for sending notifications to this connection + """ + with self._lock: + if user_id not in self._connections: + self._connections[user_id] = [] + + # Create a new queue for this connection + q = queue.Queue(maxsize=100) + self._connections[user_id].append(q) + + logger.info(f"Registered SSE connection for user {user_id}") + return q + + def unregister_connection(self, user_id: int, q: queue.Queue): + """Unregister an SSE connection.""" + with self._lock: + if user_id in self._connections: + if q in self._connections[user_id]: + self._connections[user_id].remove(q) + + # Clean up empty lists + if not self._connections[user_id]: + del self._connections[user_id] + + logger.info(f"Unregistered SSE connection for user {user_id}") + + def _broadcast_to_user(self, user_id: int, notification: Notification): + """Broadcast notification to all connected clients for a user.""" + with self._lock: + if user_id in self._connections: + # Send to all active connections for this user + dead_queues = [] + for q in self._connections[user_id]: + try: + q.put_nowait(notification) + except queue.Full: + logger.warning(f"Queue full for user {user_id}, dropping notification") + dead_queues.append(q) + except Exception as e: + logger.error(f"Error broadcasting to user {user_id}: {e}") + dead_queues.append(q) + + # Clean up dead queues + for q in dead_queues: + if q in self._connections[user_id]: + self._connections[user_id].remove(q) + + +# Global notification manager instance +_notification_manager = None + + +def get_notification_manager() -> NotificationManager: + """Get the global notification manager instance.""" + global _notification_manager + if _notification_manager is None: + _notification_manager = NotificationManager() + return _notification_manager diff --git a/app/services/notification_sender.py b/app/services/notification_sender.py new file mode 100644 index 0000000..cd82d1e --- /dev/null +++ b/app/services/notification_sender.py @@ -0,0 +1,1050 @@ +""" +Notification system for Continuum + +This module provides a unified interface for sending notifications +through various channels (SMTP, Teams, etc.). New channels can be +added by implementing a new notifier class and registering it. +""" +import smtplib +import ssl +import logging +import json +import threading +import time +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta + +from app.services.settings import get_settings_manager +from app.models import get_db_connection + +logger = logging.getLogger(__name__) + + +class NotificationChannel(ABC): + """Base class for notification channels.""" + + @property + @abstractmethod + def channel_name(self) -> str: + """Return the channel name (e.g., 'smtp', 'teams').""" + pass + + @abstractmethod + def is_configured(self, settings: Dict[str, Any]) -> bool: + """Check if the channel is properly configured.""" + pass + + @abstractmethod + def is_enabled(self, settings: Dict[str, Any]) -> bool: + """Check if the channel is enabled.""" + pass + + @abstractmethod + def send(self, subject: str, message: str, settings: Dict[str, Any], + html_message: Optional[str] = None) -> bool: + """Send a notification. Returns True on success.""" + pass + + +class SMTPNotificationChannel(NotificationChannel): + """SMTP email notification channel.""" + + @property + def channel_name(self) -> str: + return 'smtp' + + def is_configured(self, settings: Dict[str, Any]) -> bool: + """Check if SMTP is properly configured.""" + smtp = settings.get('notifications', {}).get('smtp', {}) + # Remove to_addresses from required fields + required = ['host', 'port', 'from_address'] + return all(smtp.get(field) for field in required) + + def is_enabled(self, settings: Dict[str, Any]) -> bool: + """Check if SMTP notifications are enabled.""" + return settings.get('notifications', {}).get('smtp', {}).get('enabled', False) + + def send(self, subject: str, message: str, settings: Dict[str, Any], + html_message: Optional[str] = None, recipient_emails: Optional[list] = None) -> bool: + """Send email notification via SMTP. + + Args: + subject: Email subject + message: Plain text message + settings: Application settings dict + html_message: Optional HTML version of message + recipient_emails: List of recipient email addresses (required) + + Returns: + True if email sent successfully, False otherwise + """ + smtp_settings = settings.get('notifications', {}).get('smtp', {}) + + if not self.is_configured(settings): + logger.warning("SMTP not properly configured") + return False + + if not recipient_emails: + logger.warning("No recipient emails provided") + return False + + try: + host = smtp_settings['host'] + port = int(smtp_settings['port']) + from_address = smtp_settings['from_address'] + username = smtp_settings.get('username', '') + password = smtp_settings.get('password', '') + use_tls = smtp_settings.get('use_tls', True) + use_ssl = smtp_settings.get('use_ssl', False) + + # Ensure recipient_emails is a list + if isinstance(recipient_emails, str): + recipient_emails = [addr.strip() for addr in recipient_emails.split(',') if addr.strip()] + + # Create message + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = from_address + msg['To'] = ', '.join(recipient_emails) + msg['X-Mailer'] = 'Continuum-Notifier' + + # Add plain text part + msg.attach(MIMEText(message, 'plain')) + + # Add HTML part if provided + if html_message: + msg.attach(MIMEText(html_message, 'html')) + + # Connect and send + if use_ssl: + # Direct SSL connection (port 465) + context = ssl.create_default_context() + with smtplib.SMTP_SSL(host, port, context=context, timeout=30) as server: + if username and password: + server.login(username, password) + server.sendmail(from_address, recipient_emails, msg.as_string()) + else: + # Standard connection, optionally with STARTTLS (port 587 or 25) + with smtplib.SMTP(host, port, timeout=30) as server: + server.ehlo() + if use_tls: + context = ssl.create_default_context() + server.starttls(context=context) + server.ehlo() + if username and password: + server.login(username, password) + server.sendmail(from_address, recipient_emails, msg.as_string()) + + logger.info(f"Email notification sent to {recipient_emails}") + return True + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP authentication failed: {e}") + return False + except smtplib.SMTPRecipientsRefused as e: + logger.error(f"SMTP recipients refused: {e}") + return False + except smtplib.SMTPException as e: + logger.error(f"SMTP error: {e}") + return False + except Exception as e: + logger.error(f"Failed to send email notification: {e}") + return False + + +class NotificationManager: + """ + Unified notification manager that handles all notification channels. + + Usage: + from app.services.notification_sender import notify + + # Send to all enabled channels + notify("Printer Alert", "Printer HP-LaserJet is offline!") + + # Or with HTML + notify("Printer Alert", "Plain text", html_message="HTML version") + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self._channels: List[NotificationChannel] = [] + self._register_default_channels() + + def _register_default_channels(self): + """Register built-in notification channels.""" + self._channels.append(SMTPNotificationChannel()) + # Future: self._channels.append(TeamsNotificationChannel()) + # Future: self._channels.append(SlackNotificationChannel()) + + def register_channel(self, channel: NotificationChannel): + """Register a new notification channel.""" + self._channels.append(channel) + + def get_channels(self) -> List[NotificationChannel]: + """Get all registered channels.""" + return self._channels + + def get_enabled_channels(self) -> List[NotificationChannel]: + """Get all enabled and configured channels.""" + settings = get_settings_manager().get_all() + return [ + ch for ch in self._channels + if ch.is_enabled(settings) and ch.is_configured(settings) + ] + + def send(self, subject: str, message: str, html_message: Optional[str] = None) -> Dict[str, bool]: + """ + Send notification to all enabled channels. + + Args: + subject: Notification subject/title + message: Plain text message body + html_message: Optional HTML message body + + Returns: + Dict mapping channel names to success status + """ + settings = get_settings_manager().get_all() + results = {} + + for channel in self._channels: + if channel.is_enabled(settings) and channel.is_configured(settings): + try: + success = channel.send(subject, message, settings, html_message) + results[channel.channel_name] = success + except Exception as e: + logger.error(f"Error sending via {channel.channel_name}: {e}") + results[channel.channel_name] = False + else: + # Skip disabled/unconfigured channels silently + pass + + if not results: + logger.debug("No notification channels are enabled and configured") + + return results + + def test_channel(self, channel_name: str) -> tuple[bool, str]: + """ + Test a specific notification channel. + + Returns: + Tuple of (success, message) + """ + settings = get_settings_manager().get_all() + + for channel in self._channels: + if channel.channel_name == channel_name: + if not channel.is_configured(settings): + return False, f"{channel_name.upper()} is not properly configured" + + try: + success = channel.send( + subject="Continuum - Test Notification", + message="This is a test notification from Continuum. If you received this, your notification settings are working correctly.", + settings=settings, + html_message=""" + + +

Continuum - Test Notification

+

This is a test notification from Continuum.

+

If you received this, your notification settings are working correctly.

+
+

+ Sent at: """ + datetime.now().strftime('%Y-%m-%d %H:%M:%S') + """ +

+ + + """ + ) + if success: + return True, f"Test notification sent successfully via {channel_name.upper()}" + else: + return False, f"Failed to send test notification via {channel_name.upper()}" + except Exception as e: + return False, f"Error testing {channel_name.upper()}: {str(e)}" + + return False, f"Unknown channel: {channel_name}" + + +# Singleton instance +_notification_manager: Optional[NotificationManager] = None + + +def get_notification_manager() -> NotificationManager: + """Get the singleton notification manager instance.""" + global _notification_manager + if _notification_manager is None: + _notification_manager = NotificationManager() + return _notification_manager + + +def notify(subject: str, message: str, html_message: Optional[str] = None) -> Dict[str, bool]: + """ + Send notification to all enabled channels. + + This is the primary interface for sending notifications throughout the app. + + Usage: + from app.services.notification_sender import notify + + # Simple notification + notify("Alert", "Something happened!") + + # With HTML + notify("Alert", "Plain text", html_message="Rich HTML") + + Args: + subject: Notification subject/title + message: Plain text message body + html_message: Optional HTML message body + + Returns: + Dict mapping channel names to success status (empty if no channels enabled) + """ + return get_notification_manager().send(subject, message, html_message) + + +def notify_printer_offline(printer_name: str, printer_ip: str): + """Send notification when a printer goes offline to users with offline_alerts enabled.""" + from app.services.notification_manager import get_notification_manager as get_notif_mgr + + users = get_users_with_preference('offline_alerts') + if not users: + return + + group_id = get_printer_group_id_by_ip(printer_ip) + subscriptions = get_group_subscriptions('offline_alerts') + + # Create in-app notifications for all users with this preference + notif_mgr = get_notif_mgr() + for user in users: + user_groups = subscriptions.get(user['id'], []) + if user_groups: + if not group_id or group_id not in user_groups: + continue + notif_mgr.create_notification( + user_id=user['id'], + type='error', + title=f"{printer_name} is offline.", + message="", + link=f"/printers" + ) + + recipient_emails = [u['email'] for u in users if u['email'] and ( + not subscriptions.get(u['id']) or (group_id and group_id in subscriptions.get(u['id'], [])) + )] + if not recipient_emails: + return + + subject = f"Printer Offline: {printer_name}" + message = f"Printer '{printer_name}' ({printer_ip}) is now offline." + html_message = f""" + + +

Printer Offline Alert

+

The following printer is no longer responding:

+ + + + + + + + + + + + + +
Printer{printer_name}
IP Address{printer_ip}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ + + """ + + # Send via SMTP to users with offline_alerts enabled + settings = get_settings_manager().get_all() + if settings.get('notifications', {}).get('smtp', {}).get('enabled'): + channel = SMTPNotificationChannel() + if channel.is_configured(settings): + channel.send(subject, message, settings, html_message, recipient_emails=recipient_emails) + + +def notify_printer_online(printer_name: str, printer_ip: str): + """Send notification when a printer comes back online to users with offline_alerts enabled.""" + from app.services.notification_manager import get_notification_manager as get_notif_mgr + + users = get_users_with_preference('offline_alerts') + if not users: + return + + group_id = get_printer_group_id_by_ip(printer_ip) + subscriptions = get_group_subscriptions('offline_alerts') + + # Create in-app notifications for all users with this preference + notif_mgr = get_notif_mgr() + for user in users: + user_groups = subscriptions.get(user['id'], []) + if user_groups: + if not group_id or group_id not in user_groups: + continue + notif_mgr.create_notification( + user_id=user['id'], + type='success', + title=f"{printer_name} is now responding.", + message="", + link=f"/printers" + ) + + recipient_emails = [u['email'] for u in users if u['email'] and ( + not subscriptions.get(u['id']) or (group_id and group_id in subscriptions.get(u['id'], [])) + )] + if not recipient_emails: + return + + subject = f"Printer Online: {printer_name}" + message = f"Printer '{printer_name}' ({printer_ip}) is now back online." + html_message = f""" + + +

Printer Online

+

The following printer is now responding:

+ + + + + + + + + + + + + +
Printer{printer_name}
IP Address{printer_ip}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ + + """ + + # Send via SMTP to users with offline_alerts enabled + settings = get_settings_manager().get_all() + if settings.get('notifications', {}).get('smtp', {}).get('enabled'): + channel = SMTPNotificationChannel() + if channel.is_configured(settings): + channel.send(subject, message, settings, html_message, recipient_emails=recipient_emails) + + +def get_users_with_preference(preference_key: str) -> List[Dict[str, Any]]: + """ + Get all users who have a specific notification preference enabled. + + Args: + preference_key: The preference to check (e.g., 'security_events', 'offline_alerts') + + Returns: + List of user dicts with id, username, email + """ + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, username, email, notification_preferences FROM users WHERE is_active = 1") + rows = cursor.fetchall() + conn.close() + + default_prefs = { + 'health_alerts': True, + 'offline_alerts': True, + 'job_failures': True, + 'security_events': True, + 'weekly_reports': False + } + + users_with_pref = [] + for row in rows: + prefs_json = row['notification_preferences'] + if prefs_json: + try: + prefs = json.loads(prefs_json) + except (json.JSONDecodeError, TypeError): + prefs = default_prefs + else: + prefs = default_prefs + + if prefs.get(preference_key, False): + users_with_pref.append({ + 'id': row['id'], + 'username': row['username'], + 'email': row['email'] + }) + + return users_with_pref + + +def get_group_subscriptions(preference_key: str) -> Dict[int, List[int]]: + """Get group subscription mapping for users by preference key.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT user_id, group_id FROM user_group_subscriptions WHERE preference_key = ?", + (preference_key,) + ) + rows = cursor.fetchall() + conn.close() + + mapping: Dict[int, List[int]] = {} + for row in rows: + mapping.setdefault(row['user_id'], []).append(row['group_id']) + return mapping + + +def get_printer_group_id_by_ip(printer_ip: str) -> Optional[int]: + """Get group ID for a printer by IP (if assigned).""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id FROM printers WHERE ip = ?", (printer_ip,)) + printer_row = cursor.fetchone() + if not printer_row: + conn.close() + return None + + printer_id = printer_row['id'] + cursor.execute( + "SELECT group_id FROM printer_group_members WHERE printer_id = ?", + (printer_id,) + ) + group_row = cursor.fetchone() + conn.close() + return group_row['group_id'] if group_row else None + + +def notify_user_login(username: str, ip_address: str, user_agent: str, user_id: int): + """Send notification to a user when they log in (security event).""" + # Check if this user has security_events enabled + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT email, notification_preferences FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + conn.close() + + if not row: + return + + # Parse preferences + prefs_json = row['notification_preferences'] + if not prefs_json: + return + + try: + prefs = json.loads(prefs_json) + if not prefs.get('security_events', False): + return # User has security events disabled + except (json.JSONDecodeError, TypeError): + return + + email = row['email'] + if not email: + return # No email configured for this user + + # Send notification only to this user's email + subject = f"Security Alert: Login to Continuum" + message = f""" +A successful login was detected on your Continuum account. + +Username: {username} +IP Address: {ip_address} +User Agent: {user_agent} +Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + +If this was not you, please change your password immediately and contact your administrator. + """.strip() + + html_message = f""" + + +

Security Alert: Login Detected

+

A successful login was detected on your Continuum account.

+ + + + + + + + + + + + + + + + + +
Username{username}
IP Address{ip_address}
Device{user_agent}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+

If this was not you, please change your password immediately and contact your administrator.

+ + + """ + + # Send directly via SMTP to this user's email only + settings = get_settings_manager().get_all() + smtp_settings = settings.get('notifications', {}).get('smtp', {}) + + if not smtp_settings.get('enabled'): + return # SMTP not enabled + + channel = SMTPNotificationChannel() + if channel.is_configured(settings): + # Send only to this specific user's email + channel.send(subject, message, settings, html_message, recipient_emails=[email]) + + +def notify_printer_health_alert(printer_name: str, printer_ip: str, alert_type: str, details: str): + """Send health alert notification to users who have health_alerts enabled.""" + users = get_users_with_preference('health_alerts') + if not users: + return + + recipient_emails = [u['email'] for u in users if u['email']] + if not recipient_emails: + return + + subject = f"Health Alert: {printer_name} - {alert_type}" + message = f""" +Printer health alert detected: + +Printer: {printer_name} +IP Address: {printer_ip} +Alert Type: {alert_type} +Details: {details} +Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + """.strip() + + html_message = f""" + + +

Printer Health Alert

+ + + + + + + + + + + + + + + + + + + + + +
Printer{printer_name}
IP Address{printer_ip}
Alert Type{alert_type}
Details{details}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ + + """ + + # Send via SMTP to users with health_alerts enabled + settings = get_settings_manager().get_all() + if settings.get('notifications', {}).get('smtp', {}).get('enabled'): + channel = SMTPNotificationChannel() + if channel.is_configured(settings): + channel.send(subject, message, settings, html_message, recipient_emails=recipient_emails) + + +def notify_job_failure(printer_name: str, printer_ip: str, error_details: str): + """Send job failure notification to users who have job_failures enabled.""" + users = get_users_with_preference('job_failures') + if not users: + return + + recipient_emails = [u['email'] for u in users if u['email']] + if not recipient_emails: + return + + subject = f"Print Job Failure: {printer_name}" + message = f""" +A print job has failed: + +Printer: {printer_name} +IP Address: {printer_ip} +Error: {error_details} +Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + """.strip() + + html_message = f""" + + +

Print Job Failure

+ + + + + + + + + + + + + + + + + +
Printer{printer_name}
IP Address{printer_ip}
Error{error_details}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ + + """ + + # Send via SMTP to users with job_failures enabled + settings = get_settings_manager().get_all() + if settings.get('notifications', {}).get('smtp', {}).get('enabled'): + channel = SMTPNotificationChannel() + if channel.is_configured(settings): + channel.send(subject, message, settings, html_message, recipient_emails=recipient_emails) + + +def notify_redirect_created(source_printer: str, target_printer: str, created_by: str): + """Send notification when a redirect is created.""" + subject = f"Redirect Created: {source_printer} -> {target_printer}" + message = f"A redirect has been created from '{source_printer}' to '{target_printer}' by {created_by}." + html_message = f""" + + +

Redirect Created

+

A new print redirect has been configured:

+ + + + + + + + + + + + + + + + + +
From{source_printer}
To{target_printer}
Created By{created_by}
Time{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ + + """ + return notify(subject, message, html_message) + + +def send_weekly_report(): + """Generate and send weekly report to users who have weekly_reports enabled.""" + users = get_users_with_preference('weekly_reports') + if not users: + logger.info("No users have weekly reports enabled, skipping") + return + + subscriptions = get_group_subscriptions('weekly_reports') + + from app.services.printer_registry import get_registry + registry = get_registry() + all_printers = registry.get_all() + all_statuses = registry.get_statuses() + + settings = get_settings_manager().get_all() + if not settings.get('notifications', {}).get('smtp', {}).get('enabled'): + return + + channel = SMTPNotificationChannel() + if not channel.is_configured(settings): + return + + for user in users: + email = user.get('email') + if not email: + continue + + group_ids = subscriptions.get(user['id'], []) + printer_ids = None + if group_ids: + conn = get_db_connection() + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(group_ids)) + cursor.execute( + f"SELECT printer_id FROM printer_group_members WHERE group_id IN ({placeholders})", + group_ids + ) + printer_rows = cursor.fetchall() + conn.close() + printer_ids = [row['printer_id'] for row in printer_rows] + + if printer_ids is not None: + filtered_printers = [p for p in all_printers if p.id in printer_ids] + filtered_statuses = [s for s in all_statuses if s.get('printer', {}).get('id') in printer_ids] + else: + filtered_printers = all_printers + filtered_statuses = all_statuses + + total_printers = len(filtered_printers) + online_printers = sum(1 for p in filtered_statuses if p.get('is_online', False)) + + conn = get_db_connection() + cursor = conn.cursor() + + if printer_ids is not None and printer_ids: + placeholders = ",".join(["?"] * len(printer_ids)) + cursor.execute( + f""" + SELECT COUNT(*) as job_count, COALESCE(SUM(pages), 0) as total_pages + FROM print_job_history + WHERE recorded_at >= datetime('now', '-7 days') + AND printer_id IN ({placeholders}) + """, + printer_ids + ) + else: + cursor.execute(""" + SELECT COUNT(*) as job_count, COALESCE(SUM(pages), 0) as total_pages + FROM print_job_history + WHERE recorded_at >= datetime('now', '-7 days') + """) + job_row = cursor.fetchone() + job_count = job_row['job_count'] if job_row else 0 + total_pages = job_row['total_pages'] if job_row else 0 + + if printer_ids is not None and printer_ids: + placeholders = ",".join(["?"] * len(printer_ids)) + cursor.execute( + f"SELECT COUNT(*) as redirect_count FROM active_redirects WHERE source_printer_id IN ({placeholders})", + printer_ids + ) + else: + cursor.execute("SELECT COUNT(*) as redirect_count FROM active_redirects") + redirect_row = cursor.fetchone() + redirect_count = redirect_row['redirect_count'] if redirect_row else 0 + + if printer_ids is not None and printer_ids: + placeholders = ",".join(["?"] * len(printer_ids)) + cursor.execute( + f""" + SELECT action, timestamp, username, details + FROM audit_log + WHERE timestamp >= datetime('now', '-7 days') + AND (source_printer_id IN ({placeholders}) OR target_printer_id IN ({placeholders})) + ORDER BY timestamp DESC + LIMIT 10 + """, + (*printer_ids, *printer_ids) + ) + else: + cursor.execute(""" + SELECT action, timestamp, username, details + FROM audit_log + WHERE timestamp >= datetime('now', '-7 days') + AND action IN ('REDIRECT_CREATED', 'REDIRECT_REMOVED', 'PRINTER_ADDED', 'PRINTER_REMOVED') + ORDER BY timestamp DESC + LIMIT 10 + """) + recent_events = cursor.fetchall() + conn.close() + + week_start = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d') + week_end = datetime.now().strftime('%Y-%m-%d') + + scope_label = "All Printers" if printer_ids is None else "Group Scope" + subject = f"Continuum Weekly Report ({week_start} to {week_end})" + + message = f""" +Continuum Weekly Report +Period: {week_start} to {week_end} +Scope: {scope_label} + +=== Summary === +Total Printers: {total_printers} +Online Printers: {online_printers} +Offline Printers: {total_printers - online_printers} +Active Redirects: {redirect_count} + +=== Print Statistics === +Total Jobs: {job_count} +Total Pages: {total_pages:,} + +=== Recent Activity === +""" + + if recent_events: + for event in recent_events: + event_time = event['timestamp'][:19] if event['timestamp'] else 'Unknown' + message += f"\n{event_time} - {event['action']} by {event['username']}" + if event['details']: + message += f" ({event['details']})" + else: + message += "\nNo significant events this week." + + events_html = "" + if recent_events: + for event in recent_events: + event_time = event['timestamp'][:19] if event['timestamp'] else 'Unknown' + events_html += f""" + + {event_time} + {event['action']} + {event['username']} + {event['details'] or '—'} + + """ + else: + events_html = 'No significant events this week' + + html_message = f""" + + +

Continuum Weekly Report

+

Period: {week_start} to {week_end}

+

Scope: {scope_label}

+ +

Summary

+ + + + + + + + + + + + + + + + + +
Total Printers{total_printers}
Online Printers{online_printers}
Offline Printers{total_printers - online_printers}
Active Redirects{redirect_count}
+ +

Print Statistics

+ + + + + + + + + +
Total Jobs{job_count:,}
Total Pages{total_pages:,}
+ +

Recent Activity

+ + + + + + + + + + + {events_html} + +
TimeActionUserDetails
+ +
+

+ This is an automated weekly report from Continuum.
+ You can disable these reports in your notification preferences. +

+ + + """ + + channel.send(subject, message, settings, html_message, recipient_emails=[email]) + + +class WeeklyReportScheduler: + """Background scheduler for weekly reports.""" + + def __init__(self): + self._running = False + self._thread: Optional[threading.Thread] = None + + def start(self): + """Start the weekly report scheduler.""" + if self._running: + return + + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + logger.info("Weekly report scheduler started") + + def stop(self): + """Stop the weekly report scheduler.""" + self._running = False + if self._thread: + self._thread.join(timeout=5) + logger.info("Weekly report scheduler stopped") + + def _run_loop(self): + """Main loop - checks once per hour if it's time to send the weekly report.""" + last_sent_week = None + + while self._running: + try: + now = datetime.now() + # Send report on Monday at 8:00 AM + if now.weekday() == 0 and now.hour == 8: # Monday + current_week = now.isocalendar()[1] + if last_sent_week != current_week: + send_weekly_report() + last_sent_week = current_week + except Exception as e: + logger.error(f"Error in weekly report scheduler: {e}") + + # Sleep for 1 hour + for _ in range(3600): + if not self._running: + break + time.sleep(1) + + +# Global weekly report scheduler instance +_weekly_scheduler: Optional[WeeklyReportScheduler] = None + + +def start_weekly_reports(): + """Start the weekly report scheduler.""" + global _weekly_scheduler + if _weekly_scheduler is None: + _weekly_scheduler = WeeklyReportScheduler() + _weekly_scheduler.start() + + +def stop_weekly_reports(): + """Stop the weekly report scheduler.""" + global _weekly_scheduler + if _weekly_scheduler: + _weekly_scheduler.stop() diff --git a/app/print_queue.py b/app/services/print_queue.py similarity index 97% rename from app/print_queue.py rename to app/services/print_queue.py index f5a88c3..0d03ffa 100644 --- a/app/print_queue.py +++ b/app/services/print_queue.py @@ -155,14 +155,14 @@ def get_queue_collector() -> PrintQueueCollector: return _queue_collector -def get_print_queue(ip: str) -> List[PrintJob]: +def get_queue(ip: str) -> List[PrintJob]: """Get print queue for a printer by IP.""" return get_queue_collector().get_queue(ip) # Backwards compatibility - import from event_logs module # This allows existing code that imports from print_queue to still work -from app.event_logs import get_printer_logs, get_printer_errors, PrinterEvent +from app.services.event_logs import get_logs, get_errors, PrinterEvent # Legacy alias for PrinterError -> PrinterEvent PrinterError = PrinterEvent diff --git a/app/printers.py b/app/services/printer_registry.py similarity index 87% rename from app/printers.py rename to app/services/printer_registry.py index 6f0669a..38311b4 100644 --- a/app/printers.py +++ b/app/services/printer_registry.py @@ -119,7 +119,7 @@ def check_tcp_reachability(self, ip: str, port: int = DEFAULT_PORT) -> bool: except Exception: return False - def get_printer_status(self, printer: Printer, use_cache: bool = True) -> Dict[str, Any]: + def get_status(self, printer: Printer, use_cache: bool = True) -> Dict[str, Any]: """Get comprehensive status for a printer. Args: @@ -135,7 +135,9 @@ def get_printer_status(self, printer: Printer, use_cache: bool = True) -> Dict[s is_target = ActiveRedirect.is_target_in_use(printer.id) cached = self._get_cached_status(printer.id) if use_cache else None - return self._build_status(printer, redirect, is_target, cached, use_cache) + group_map = self._get_group_mappings([printer.id]) + group_info = group_map.get(printer.id) + return self._build_status(printer, redirect, is_target, cached, use_cache, group_info) def _get_cached_status(self, printer_id: str) -> Optional[Dict[str, Any]]: """Get cached health status for a printer.""" @@ -167,7 +169,8 @@ def _build_status( redirect: Optional[ActiveRedirect], is_target: bool, cached: Optional[Dict[str, Any]], - use_cache: bool + use_cache: bool, + group_info: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Build a normalized status payload for a printer.""" is_redirected = redirect is not None @@ -177,26 +180,32 @@ def _build_status( # Redirected printers are considered offline icmp_reachable = False tcp_reachable = False + status_state = "offline" elif use_cache: # Use cached status from background health checks (FAST) if cached: icmp_reachable = cached.get('icmp_ok', False) tcp_reachable = cached.get('tcp_9100_ok', False) + status_state = "online" if (icmp_reachable or tcp_reachable) else "offline" else: - # No cache yet, assume unknown (will be updated by background check) - icmp_reachable = False - tcp_reachable = False + # No cache yet, show as probing (health check will update soon) + icmp_reachable = None + tcp_reachable = None + status_state = "probing" else: # Live check (SLOW - only use for specific operations) icmp_reachable = self.check_icmp_reachability(printer.ip) tcp_reachable = self.check_tcp_reachability(printer.ip) + status_state = "online" if (icmp_reachable or tcp_reachable) else "offline" return { "printer": printer.to_dict(), + "group": group_info, "status": { "icmp_reachable": icmp_reachable, "tcp_reachable": tcp_reachable, - "is_online": icmp_reachable or tcp_reachable, + "is_online": icmp_reachable or tcp_reachable if icmp_reachable is not None else None, + "state": status_state, "is_redirected": is_redirected, "is_redirect_target": is_target, "redirect_info": { @@ -207,8 +216,34 @@ def _build_status( } if redirect else None } } + + def _get_group_mappings(self, printer_ids: List[str]) -> Dict[str, Dict[str, Any]]: + """Get group mappings for a list of printer IDs.""" + if not printer_ids: + return {} + conn = get_db_connection() + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(printer_ids)) + cursor.execute( + f""" + SELECT pgm.printer_id, pg.id AS group_id, pg.name AS group_name + FROM printer_group_members pgm + JOIN printer_groups pg ON pg.id = pgm.group_id + WHERE pgm.printer_id IN ({placeholders}) + """, + printer_ids + ) + rows = cursor.fetchall() + conn.close() + return { + row['printer_id']: { + 'id': row['group_id'], + 'name': row['group_name'] + } + for row in rows + } - def get_all_statuses(self, use_cache: bool = True) -> List[Dict[str, Any]]: + def get_statuses(self, use_cache: bool = True) -> List[Dict[str, Any]]: """Get status for all printers. Args: @@ -223,13 +258,15 @@ def get_all_statuses(self, use_cache: bool = True) -> List[Dict[str, Any]]: targets_in_use = {r.target_printer_id for r in redirects} cached_by_id = self._get_cached_statuses([p.id for p in printers]) if use_cache else {} + group_map = self._get_group_mappings([p.id for p in printers]) statuses = [] for printer in printers: redirect = redirects_by_source.get(printer.id) is_target = printer.id in targets_in_use cached = cached_by_id.get(printer.id) - statuses.append(self._build_status(printer, redirect, is_target, cached, use_cache)) + group_info = group_map.get(printer.id) + statuses.append(self._build_status(printer, redirect, is_target, cached, use_cache, group_info)) return statuses diff --git a/app/printer_stats.py b/app/services/printer_stats.py similarity index 99% rename from app/printer_stats.py rename to app/services/printer_stats.py index 05f3825..adf3389 100644 --- a/app/printer_stats.py +++ b/app/services/printer_stats.py @@ -338,7 +338,7 @@ def get_collector() -> PrinterStatsCollector: return _collector -def get_printer_stats(ip: str) -> PrinterStats: +def get_stats(ip: str) -> PrinterStats: """Get statistics for a printer by IP.""" return get_collector().get_stats(ip) diff --git a/app/services/schedulers/__init__.py b/app/services/schedulers/__init__.py new file mode 100644 index 0000000..9b13e46 --- /dev/null +++ b/app/services/schedulers/__init__.py @@ -0,0 +1,40 @@ +""" +Background schedulers for Continuum + +These services run in the background to manage scheduled redirects +and workflow executions. +""" + +from app.services.schedulers.group_redirect import ( + GroupRedirectScheduler, + init_group_redirect_scheduler, + stop_group_redirect_scheduler, +) + +from app.services.schedulers.printer_redirect import ( + PrinterRedirectScheduler, + init_printer_redirect_scheduler, + stop_printer_redirect_scheduler, +) + +from app.services.schedulers.workflow import ( + WorkflowScheduler, + get_workflow_scheduler, + reload_workflow_schedules, +) + + +__all__ = [ + # Group redirect scheduler + 'GroupRedirectScheduler', + 'init_group_redirect_scheduler', + 'stop_group_redirect_scheduler', + # Printer redirect scheduler + 'PrinterRedirectScheduler', + 'init_printer_redirect_scheduler', + 'stop_printer_redirect_scheduler', + # Workflow scheduler + 'WorkflowScheduler', + 'get_workflow_scheduler', + 'reload_workflow_schedules', +] diff --git a/app/services/schedulers/group_redirect.py b/app/services/schedulers/group_redirect.py new file mode 100644 index 0000000..e51f76b --- /dev/null +++ b/app/services/schedulers/group_redirect.py @@ -0,0 +1,227 @@ +""" +Group Redirect Scheduler + +Applies and removes redirects based on group schedules. +""" +import logging +import threading +import time +from datetime import datetime +from typing import Optional, List + +from app.models import get_db_connection, ActiveRedirect +from app.services.printer_registry import get_registry +from app.services.network_manager import get_network_manager + +logger = logging.getLogger(__name__) + + +class GroupRedirectScheduler: + def __init__(self, interval_seconds: int = 60): + self.interval = interval_seconds + self._running = False + self._thread: Optional[threading.Thread] = None + + def start(self): + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + logger.info("Group redirect scheduler started") + + def stop(self): + self._running = False + if self._thread: + self._thread.join(timeout=5) + self._thread = None + logger.info("Group redirect scheduler stopped") + + def _run_loop(self): + while self._running: + try: + self._tick() + except Exception as exc: + logger.error(f"Group redirect scheduler error: {exc}") + time.sleep(self.interval) + + def _tick(self): + conn = get_db_connection() + cursor = conn.cursor() + now = datetime.utcnow().isoformat() + + cursor.execute(""" + SELECT * FROM group_redirect_schedules + WHERE enabled = 1 + """) + schedules = cursor.fetchall() + + for schedule in schedules: + schedule_id = schedule['id'] + group_id = schedule['group_id'] + target_printer_id = schedule['target_printer_id'] + start_at = schedule['start_at'] + end_at = schedule['end_at'] + is_active = bool(schedule['is_active']) + + should_be_active = start_at <= now and (end_at is None or end_at >= now) + + if should_be_active and not is_active: + self._activate_schedule(schedule_id, group_id, target_printer_id) + cursor.execute( + "UPDATE group_redirect_schedules SET is_active = 1, last_activated_at = CURRENT_TIMESTAMP WHERE id = ?", + (schedule_id,) + ) + elif not should_be_active and is_active: + self._deactivate_schedule(schedule_id) + cursor.execute( + "UPDATE group_redirect_schedules SET is_active = 0, last_deactivated_at = CURRENT_TIMESTAMP WHERE id = ?", + (schedule_id,) + ) + elif should_be_active and is_active: + self._reconcile_schedule(schedule_id, group_id, target_printer_id) + + conn.commit() + conn.close() + + def _get_group_printers(self, group_id: int) -> List[str]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT printer_id FROM printer_group_members WHERE group_id = ?", + (group_id,) + ) + rows = cursor.fetchall() + conn.close() + return [row['printer_id'] for row in rows] + + def _activate_schedule(self, schedule_id: int, group_id: int, target_printer_id: str): + registry = get_registry() + network = get_network_manager() + printer_ids = self._get_group_printers(group_id) + + target_printer = registry.get_by_id(target_printer_id) + if not target_printer: + logger.warning(f"Schedule {schedule_id}: target printer not found") + return + + for source_id in printer_ids: + if source_id == target_printer_id: + continue + source_printer = registry.get_by_id(source_id) + if not source_printer: + continue + + if ActiveRedirect.get_by_source_printer(source_id): + continue + + if ActiveRedirect.is_target_in_use(target_printer_id): + continue + + success, _ = network.enable_redirect( + source_ip=source_printer.ip, + target_ip=target_printer.ip, + port=9100 + ) + if success: + redirect_obj = ActiveRedirect.create( + source_printer_id=source_id, + source_ip=source_printer.ip, + target_printer_id=target_printer_id, + target_ip=target_printer.ip, + protocol='raw', + port=9100, + enabled_by=f"schedule:{schedule_id}" + ) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT OR IGNORE INTO group_redirect_instances (schedule_id, redirect_id, source_printer_id) VALUES (?, ?, ?)", + (schedule_id, redirect_obj.id, source_id) + ) + conn.commit() + conn.close() + + def _deactivate_schedule(self, schedule_id: int): + network = get_network_manager() + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT redirect_id FROM group_redirect_instances WHERE schedule_id = ?", + (schedule_id,) + ) + rows = cursor.fetchall() + redirect_ids = [row['redirect_id'] for row in rows] + + for redirect_id in redirect_ids: + redirect_obj = ActiveRedirect.get_by_id(redirect_id) + if not redirect_obj: + continue + network.disable_redirect( + source_ip=redirect_obj.source_ip, + target_ip=redirect_obj.target_ip, + port=redirect_obj.port + ) + ActiveRedirect.delete(redirect_obj.id) + + cursor.execute("DELETE FROM group_redirect_instances WHERE schedule_id = ?", (schedule_id,)) + conn.commit() + conn.close() + + def _reconcile_schedule(self, schedule_id: int, group_id: int, target_printer_id: str): + current_sources = set(self._get_group_printers(group_id)) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT redirect_id, source_printer_id FROM group_redirect_instances WHERE schedule_id = ?", + (schedule_id,) + ) + rows = cursor.fetchall() + tracked_sources = {row['source_printer_id']: row['redirect_id'] for row in rows} + conn.close() + + # Remove redirects for printers no longer in group + for source_id, redirect_id in tracked_sources.items(): + if source_id not in current_sources: + redirect_obj = ActiveRedirect.get_by_id(redirect_id) + if redirect_obj: + network = get_network_manager() + network.disable_redirect( + source_ip=redirect_obj.source_ip, + target_ip=redirect_obj.target_ip, + port=redirect_obj.port + ) + ActiveRedirect.delete(redirect_obj.id) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "DELETE FROM group_redirect_instances WHERE schedule_id = ? AND source_printer_id = ?", + (schedule_id, source_id) + ) + conn.commit() + conn.close() + + # Add redirects for new printers in group + for source_id in current_sources: + if source_id == target_printer_id: + continue + if source_id in tracked_sources: + continue + self._activate_schedule(schedule_id, group_id, target_printer_id) + break + + +_scheduler: Optional[GroupRedirectScheduler] = None + + +def init_group_redirect_scheduler(start_background: bool = True): + global _scheduler + if _scheduler is None: + _scheduler = GroupRedirectScheduler() + if start_background: + _scheduler.start() + + +def stop_group_redirect_scheduler(): + if _scheduler: + _scheduler.stop() diff --git a/app/services/schedulers/printer_redirect.py b/app/services/schedulers/printer_redirect.py new file mode 100644 index 0000000..4085c4e --- /dev/null +++ b/app/services/schedulers/printer_redirect.py @@ -0,0 +1,191 @@ +""" +Printer Redirect Scheduler + +Applies and removes redirects based on printer schedules. +""" +import logging +import threading +import time +from datetime import datetime +from typing import Optional + +from app.models import get_db_connection, ActiveRedirect +from app.services.printer_registry import get_registry +from app.services.network_manager import get_network_manager + +logger = logging.getLogger(__name__) + + +class PrinterRedirectScheduler: + def __init__(self, interval_seconds: int = 60): + self.interval = interval_seconds + self._running = False + self._thread: Optional[threading.Thread] = None + + def start(self): + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + logger.info("Printer redirect scheduler started") + + def stop(self): + self._running = False + if self._thread: + self._thread.join(timeout=5) + self._thread = None + logger.info("Printer redirect scheduler stopped") + + def _run_loop(self): + while self._running: + try: + self._tick() + except Exception as exc: + logger.error(f"Printer redirect scheduler error: {exc}") + time.sleep(self.interval) + + def _tick(self): + conn = get_db_connection() + cursor = conn.cursor() + now = datetime.utcnow().isoformat() + + cursor.execute(""" + SELECT * FROM printer_redirect_schedules + WHERE enabled = 1 + """) + schedules = cursor.fetchall() + + for schedule in schedules: + schedule_id = schedule['id'] + source_printer_id = schedule['source_printer_id'] + target_printer_id = schedule['target_printer_id'] + start_at = schedule['start_at'] + end_at = schedule['end_at'] + is_active = bool(schedule['is_active']) + + should_be_active = start_at <= now and (end_at is None or end_at >= now) + + if should_be_active and not is_active: + self._activate_schedule(schedule_id, source_printer_id, target_printer_id) + cursor.execute( + "UPDATE printer_redirect_schedules SET is_active = 1, last_activated_at = CURRENT_TIMESTAMP WHERE id = ?", + (schedule_id,) + ) + elif not should_be_active and is_active: + self._deactivate_schedule(schedule_id) + cursor.execute( + "UPDATE printer_redirect_schedules SET is_active = 0, last_deactivated_at = CURRENT_TIMESTAMP WHERE id = ?", + (schedule_id,) + ) + elif should_be_active and is_active: + self._reconcile_schedule(schedule_id, source_printer_id, target_printer_id) + + conn.commit() + conn.close() + + def _activate_schedule(self, schedule_id: int, source_printer_id: str, target_printer_id: str): + registry = get_registry() + network = get_network_manager() + + source_printer = registry.get_by_id(source_printer_id) + target_printer = registry.get_by_id(target_printer_id) + if not source_printer or not target_printer: + logger.warning(f"Schedule {schedule_id}: printer(s) not found") + return + + if ActiveRedirect.get_by_source_printer(source_printer_id): + return + + if ActiveRedirect.is_target_in_use(target_printer_id): + return + + success, _ = network.enable_redirect( + source_ip=source_printer.ip, + target_ip=target_printer.ip, + port=9100 + ) + if success: + redirect_obj = ActiveRedirect.create( + source_printer_id=source_printer_id, + source_ip=source_printer.ip, + target_printer_id=target_printer_id, + target_ip=target_printer.ip, + protocol='raw', + port=9100, + enabled_by=f"schedule:{schedule_id}" + ) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT OR IGNORE INTO printer_redirect_instances (schedule_id, redirect_id, source_printer_id) VALUES (?, ?, ?)", + (schedule_id, redirect_obj.id, source_printer_id) + ) + conn.commit() + conn.close() + + def _deactivate_schedule(self, schedule_id: int): + network = get_network_manager() + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT redirect_id FROM printer_redirect_instances WHERE schedule_id = ?", + (schedule_id,) + ) + rows = cursor.fetchall() + redirect_ids = [row['redirect_id'] for row in rows] + + for redirect_id in redirect_ids: + redirect_obj = ActiveRedirect.get_by_id(redirect_id) + if not redirect_obj: + continue + network.disable_redirect( + source_ip=redirect_obj.source_ip, + target_ip=redirect_obj.target_ip, + port=redirect_obj.port + ) + ActiveRedirect.delete(redirect_obj.id) + + cursor.execute("DELETE FROM printer_redirect_instances WHERE schedule_id = ?", (schedule_id,)) + conn.commit() + conn.close() + + def _reconcile_schedule(self, schedule_id: int, source_printer_id: str, target_printer_id: str): + existing_redirect = ActiveRedirect.get_by_source_printer(source_printer_id) + if not existing_redirect: + self._activate_schedule(schedule_id, source_printer_id, target_printer_id) + return + + if existing_redirect.target_printer_id != target_printer_id: + network = get_network_manager() + network.disable_redirect( + source_ip=existing_redirect.source_ip, + target_ip=existing_redirect.target_ip, + port=existing_redirect.port + ) + ActiveRedirect.delete(existing_redirect.id) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "DELETE FROM printer_redirect_instances WHERE schedule_id = ?", + (schedule_id,) + ) + conn.commit() + conn.close() + self._activate_schedule(schedule_id, source_printer_id, target_printer_id) + + +_scheduler: Optional[PrinterRedirectScheduler] = None + + +def init_printer_redirect_scheduler(start_background: bool = True): + global _scheduler + if _scheduler is None: + _scheduler = PrinterRedirectScheduler() + if start_background: + _scheduler.start() + + +def stop_printer_redirect_scheduler(): + if _scheduler: + _scheduler.stop() diff --git a/app/services/schedulers/workflow.py b/app/services/schedulers/workflow.py new file mode 100644 index 0000000..97c1bc0 --- /dev/null +++ b/app/services/schedulers/workflow.py @@ -0,0 +1,182 @@ +""" +Workflow Scheduler Service +Manages scheduled workflow triggers using APScheduler. +""" + +import logging +from typing import Optional +from datetime import datetime +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.date import DateTrigger +from app.models import get_db_connection +import json + +logger = logging.getLogger(__name__) + +class WorkflowScheduler: + """Manages scheduled workflow executions.""" + + def __init__(self): + self.scheduler = BackgroundScheduler() + self.scheduler.start() + logger.info("Workflow scheduler started") + + def schedule_workflow(self, workflow_id: str, schedule_config: dict) -> bool: + """ + Schedule a workflow based on configuration. + + Args: + workflow_id: ID of the workflow to schedule + schedule_config: Schedule configuration from trigger.schedule node + { + 'schedule_type': 'cron' | 'interval' | 'once', + 'cron': '0 9 * * *', # For cron type + 'interval': 60, # For interval type (seconds) + 'interval_unit': 'seconds' | 'minutes' | 'hours' | 'days', + 'start_date': '2026-01-21T09:00:00' # For once type + } + + Returns: + bool: True if scheduled successfully + """ + try: + job_id = f"workflow_{workflow_id}" + + # Remove existing job if present + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + + schedule_type = schedule_config.get('schedule_type', 'cron') + + if schedule_type == 'cron': + # Cron-style scheduling + cron_expr = schedule_config.get('cron', '0 0 * * *') # Default: daily at midnight + trigger = CronTrigger.from_crontab(cron_expr) + + elif schedule_type == 'interval': + # Interval-based scheduling + interval = int(schedule_config.get('interval', 60)) + unit = schedule_config.get('interval_unit', 'seconds') + + kwargs = {unit: interval} + trigger = IntervalTrigger(**kwargs) + + elif schedule_type == 'once': + # One-time execution + start_date = schedule_config.get('start_date') + if not start_date: + logger.error(f"No start_date provided for once schedule on workflow {workflow_id}") + return False + + trigger = DateTrigger(run_date=start_date) + + else: + logger.error(f"Unknown schedule type: {schedule_type}") + return False + + # Add the job + self.scheduler.add_job( + func=self._execute_scheduled_workflow, + trigger=trigger, + args=[workflow_id], + id=job_id, + name=f"Workflow {workflow_id}", + replace_existing=True + ) + + logger.info(f"Scheduled workflow {workflow_id} with {schedule_type} trigger") + return True + + except Exception as e: + logger.error(f"Error scheduling workflow {workflow_id}: {e}", exc_info=True) + return False + + def unschedule_workflow(self, workflow_id: str) -> bool: + """Remove a scheduled workflow.""" + try: + job_id = f"workflow_{workflow_id}" + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + logger.info(f"Unscheduled workflow {workflow_id}") + return True + return False + except Exception as e: + logger.error(f"Error unscheduling workflow {workflow_id}: {e}") + return False + + def _execute_scheduled_workflow(self, workflow_id: str): + """Execute a scheduled workflow.""" + try: + from app.services.workflow_engine import get_workflow_engine + + logger.info(f"Executing scheduled workflow {workflow_id}") + + engine = get_workflow_engine() + context = { + 'trigger': 'schedule', + 'workflow_id': workflow_id, + 'timestamp': datetime.now().isoformat(), + 'scheduled': True + } + + engine.execute_workflow(workflow_id, context) + + except Exception as e: + logger.error(f"Error executing scheduled workflow {workflow_id}: {e}", exc_info=True) + + def reload_all_schedules(self): + """Load all scheduled workflows from database.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Simple schema with JSON nodes + cursor.execute("SELECT id, nodes FROM workflows WHERE enabled = 1") + rows = cursor.fetchall() + conn.close() + + scheduled_count = 0 + + for row in rows: + workflow_id, nodes_json = row + nodes = json.loads(nodes_json) if nodes_json else [] + + # Find schedule trigger node + schedule_node = next( + (n for n in nodes if n['type'] == 'trigger.schedule'), + None + ) + + if schedule_node: + schedule_config = schedule_node.get('properties', {}) + if self.schedule_workflow(workflow_id, schedule_config): + scheduled_count += 1 + + logger.info(f"Loaded {scheduled_count} scheduled workflows") + + except Exception as e: + logger.error(f"Error reloading schedules: {e}", exc_info=True) + + def shutdown(self): + """Shutdown the scheduler.""" + if self.scheduler.running: + self.scheduler.shutdown() + logger.info("Workflow scheduler stopped") + + +# Global scheduler instance +_workflow_scheduler: Optional[WorkflowScheduler] = None + +def get_workflow_scheduler() -> WorkflowScheduler: + """Get the global workflow scheduler instance.""" + global _workflow_scheduler + if _workflow_scheduler is None: + _workflow_scheduler = WorkflowScheduler() + return _workflow_scheduler + +def reload_workflow_schedules(): + """Reload all workflow schedules from database.""" + scheduler = get_workflow_scheduler() + scheduler.reload_all_schedules() diff --git a/app/settings.py b/app/services/settings.py similarity index 95% rename from app/settings.py rename to app/services/settings.py index b4d8479..cbbc5aa 100644 --- a/app/settings.py +++ b/app/services/settings.py @@ -1,5 +1,5 @@ """ -Settings management for Printer Proxy +Settings management for Continuum Stores application settings in the database with JSON serialization. Settings are persisted across updates. @@ -27,19 +27,22 @@ 'use_tls': True, 'use_ssl': False, }, - # Future notification channels can be added here - # 'teams': { - # 'enabled': False, - # 'webhook_url': '', - # }, - # 'slack': { - # 'enabled': False, - # 'webhook_url': '', - # }, + 'teams': { + 'enabled': False, + 'webhook_url': '', + }, + 'slack': { + 'enabled': False, + 'webhook_url': '', + }, + 'discord': { + 'enabled': False, + 'webhook_url': '', + }, }, # Future settings categories can be added here # 'general': { - # 'site_name': 'Printer Proxy', + # 'site_name': 'Continuum', # 'timezone': 'UTC', # }, } diff --git a/app/services/updater.py b/app/services/updater.py new file mode 100644 index 0000000..33d7f77 --- /dev/null +++ b/app/services/updater.py @@ -0,0 +1,253 @@ +""" +Auto-Update System - Uses APT repository for updates. + +Architecture: +- Background thread periodically runs 'apt update' to check for new versions +- Updates are performed via 'apt upgrade continuum' +- The update process spawns systemctl restart +""" +import time +import logging +import threading +import subprocess +import re +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional, Tuple + +from app.version import __version__ + +logger = logging.getLogger(__name__) + +# APT Configuration +APT_REPO_URL = "https://apt.jordonh.me" +PACKAGE_NAME = "continuum" + +# Check intervals +CHECK_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours between checks + + +class UpdateManager: + """Manages checking for and applying updates via APT. + + Thread-safe singleton. + """ + _instance: Optional['UpdateManager'] = None + _instance_lock = threading.Lock() + + def __init__(self): + self._check_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._last_check: Optional[datetime] = None + self._available_version: Optional[str] = None + self._update_in_progress = False + + @classmethod + def get_instance(cls) -> 'UpdateManager': + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def get_state(self) -> dict: + """Get current update state for API responses.""" + return { + 'current_version': __version__, + 'available_version': self._available_version, + 'update_available': self._is_newer_version(self._available_version, __version__) if self._available_version else False, + 'update_in_progress': self._update_in_progress, + 'last_check': self._last_check.isoformat() if self._last_check else None, + 'apt_repo_url': APT_REPO_URL + } + + def _parse_version(self, version: str) -> Tuple[Tuple[int, ...], str]: + """Parse version string into comparable tuple.""" + version = version.lstrip('v') + pre_release = '' + if '-' in version: + version, pre_release = version.split('-', 1) + try: + parts = tuple(int(p) for p in version.split('.')) + except ValueError: + parts = (0,) + return parts, pre_release + + def _is_newer_version(self, new_version: Optional[str], current_version: str) -> bool: + """Compare semantic versions.""" + if not new_version: + return False + try: + new_parts, new_pre = self._parse_version(new_version) + cur_parts, cur_pre = self._parse_version(current_version) + + if new_parts != cur_parts: + return new_parts > cur_parts + + # Pre-release versions are older than release versions + if cur_pre and not new_pre: + return True + if new_pre and not cur_pre: + return False + return new_pre > cur_pre + except Exception as e: + logger.error(f"Version comparison failed: {e}") + return False + + def _get_apt_available_version(self) -> Optional[str]: + """Query APT for the available version of continuum.""" + try: + result = subprocess.run( + ['apt-cache', 'policy', PACKAGE_NAME], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + return None + + # Parse output to find candidate version + for line in result.stdout.split('\n'): + if 'Candidate:' in line: + version = line.split('Candidate:')[1].strip() + if version and version != '(none)': + return version + + return None + except Exception as e: + logger.error(f"Failed to get APT version: {e}") + return None + + def check_for_updates(self, force: bool = False) -> Tuple[bool, Optional[str]]: + """Check APT repository for available updates.""" + # Rate limiting (check at most every 5 minutes unless forced) + if not force and self._last_check: + if datetime.now() - self._last_check < timedelta(seconds=300): + has_update = self._is_newer_version(self._available_version, __version__) + return has_update, None + + logger.info("Checking for updates via APT...") + + try: + # Refresh APT cache using helper (non-interactive) + helper_path = Path("/opt/continuum/scripts/update_helper.sh") + if helper_path.exists(): + result = subprocess.run( + ['sudo', '-n', str(helper_path), 'check'], + capture_output=True, + text=True, + timeout=120 + ) + if result.returncode != 0: + logger.warning(f"APT cache refresh failed: {result.stderr.strip()}") + + # Check available version + available = self._get_apt_available_version() + + self._last_check = datetime.now() + + if available and self._is_newer_version(available, __version__): + self._available_version = available + logger.info(f"Update available: {__version__} -> {available}") + return True, None + elif available: + self._available_version = available + return False, None + + return False, None + + except subprocess.TimeoutExpired: + return False, "apt update timed out" + except Exception as e: + logger.error(f"Update check failed: {e}") + return False, str(e) + + def start_update(self) -> Tuple[bool, str]: + """Start the update process by spawning apt upgrade + systemctl restart.""" + if self._update_in_progress: + return False, "Update already in progress" + + if not self._available_version: + return False, "No update available" + + if not self._is_newer_version(self._available_version, __version__): + return False, "Already on latest version" + + version = self._available_version + self._update_in_progress = True + + logger.info(f"Starting update to version {version}") + + # Trigger the update service (which does apt upgrade + restart) + try: + result = subprocess.run( + ['sudo', 'systemctl', 'start', 'continuum-update.service'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode != 0: + error_msg = f"Failed to start update service: {result.stderr}" + logger.error(error_msg) + self._update_in_progress = False + return False, error_msg + + logger.info("Update service started successfully") + return True, f"Update to version {version} started" + + except subprocess.TimeoutExpired: + self._update_in_progress = False + return False, "Timed out starting update service" + except Exception as e: + error_msg = f"Failed to start update: {e}" + logger.error(error_msg, exc_info=True) + self._update_in_progress = False + return False, error_msg + + def start_background_checks(self): + """Start background thread for periodic update checks.""" + if self._check_thread and self._check_thread.is_alive(): + return + + self._stop_event.clear() + self._check_thread = threading.Thread( + target=self._background_check_loop, + name="UpdateChecker", + daemon=True + ) + self._check_thread.start() + logger.info("Background update checker started") + + def stop_background_checks(self): + """Stop the background check thread.""" + self._stop_event.set() + if self._check_thread: + self._check_thread.join(timeout=5) + + def _background_check_loop(self): + """Background loop that periodically checks for updates.""" + time.sleep(30) # Initial delay + + while not self._stop_event.is_set(): + try: + self.check_for_updates() + except Exception as e: + logger.error(f"Background update check failed: {e}") + + self._stop_event.wait(CHECK_INTERVAL_SECONDS) + + +def get_update_manager() -> UpdateManager: + """Get the update manager singleton.""" + return UpdateManager.get_instance() + + +def init_updater(start_background: bool = True) -> UpdateManager: + """Initialize the update system.""" + manager = get_update_manager() + + if start_background: + manager.start_background_checks() + + return manager diff --git a/app/services/workflow_engine.py b/app/services/workflow_engine.py new file mode 100644 index 0000000..a21eed2 --- /dev/null +++ b/app/services/workflow_engine.py @@ -0,0 +1,831 @@ +""" +Workflow execution engine for Continuum. +Handles workflow triggers, execution, and action processing. +""" +import json +import logging +import hmac +import hashlib +from typing import Dict, Any, Optional, List +from datetime import datetime +import requests +from flask import current_app +from app.models import get_db_connection +from app.services.notification_manager import get_notification_manager +from app.services.network_manager import get_network_manager + +logger = logging.getLogger(__name__) + + +class WorkflowEngine: + """Executes workflows based on triggers and events.""" + + def __init__(self): + self.running_workflows = {} + + def execute_workflow(self, workflow_id: str, context: Dict[str, Any]) -> bool: + """ + Execute a workflow with the given context. + + Args: + workflow_id: ID of the workflow to execute + context: Execution context containing trigger data + + Returns: + True if execution succeeded, False otherwise + """ + try: + workflow = self._get_workflow(workflow_id) + if not workflow or not workflow.get('enabled'): + logger.warning(f"Workflow {workflow_id} not found or disabled") + return False + + logger.info(f"Executing workflow: {workflow['name']}") + + # Find trigger node + trigger_node = self._find_trigger_node(workflow) + if not trigger_node: + logger.error(f"No trigger node found in workflow {workflow_id}") + return False + + # Start execution from trigger + return self._execute_node(workflow, trigger_node['id'], context) + + except Exception as e: + logger.error(f"Error executing workflow {workflow_id}: {e}", exc_info=True) + return False + + def verify_webhook_signature(self, payload: str, signature: str, secret: str) -> bool: + """Verify HMAC signature for webhook.""" + expected = hmac.new( + secret.encode('utf-8'), + payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(signature, expected) + + def _get_workflow(self, workflow_id: str) -> Optional[Dict]: + """Get workflow from database.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, name, description, enabled, nodes, edges FROM workflows WHERE id = ?", + (workflow_id,) + ) + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return { + 'id': row[0], + 'name': row[1], + 'description': row[2], + 'enabled': bool(row[3]), + 'nodes': json.loads(row[4]) if row[4] else [], + 'edges': json.loads(row[5]) if row[5] else [] + } + + def _find_trigger_node(self, workflow: Dict) -> Optional[Dict]: + """Find the trigger node in a workflow.""" + for node in workflow['nodes']: + if node['type'].startswith('trigger.'): + return node + return None + + def _execute_node(self, workflow: Dict, node_id: str, context: Dict[str, Any]) -> bool: + """ + Execute a single node and follow edges. + + Context accumulates as the workflow executes - each node's outputs + are merged into context for downstream nodes to reference via {{variable}}. + """ + node = self._get_node_by_id(workflow, node_id) + if not node: + return False + + logger.debug(f"Executing node: {node.get('label', node_id)} ({node['type']})") + + # Execute based on node type + node_type = node['type'] + raw_properties = node.get('properties', {}) + + # Render all properties with current context (supports {{variable}} syntax) + properties = self._render_properties(raw_properties, context) + + # Triggers (already fired, continue to next) + if node_type.startswith('trigger.'): + return self._execute_next_nodes(workflow, node_id, context) + + # Actions + elif node_type.startswith('action.'): + # Special handling for end node - terminates workflow + if node_type == 'action.end': + logger.info("Workflow terminated by End node") + return True # Success, but don't continue + + result = self._execute_action(node_type, properties, context) + if isinstance(result, dict): + # Action returned outputs - merge into context + context.update(result) + return self._execute_next_nodes(workflow, node_id, context) + elif result: + return self._execute_next_nodes(workflow, node_id, context) + return False + + # Conditionals + elif node_type.startswith('logic.'): + output_handle, condition_result = self._evaluate_conditional(node_type, properties, context) + # Merge conditional outputs into context + context['condition_result'] = condition_result + context['branch'] = output_handle + if output_handle: + return self._execute_next_nodes(workflow, node_id, context, output_handle) + return False + + # Transforms + elif node_type.startswith('transform.'): + context = self._apply_transform(node_type, properties, context) + return self._execute_next_nodes(workflow, node_id, context) + + # Integrations + elif node_type.startswith('integration.'): + result = self._execute_integration(node_type, properties, context) + if isinstance(result, dict): + # Integration returned outputs - merge into context + context.update(result) + return self._execute_next_nodes(workflow, node_id, context) + elif result: + return self._execute_next_nodes(workflow, node_id, context) + return False + + return True + + def _execute_action(self, action_type: str, properties: Dict, context: Dict) -> bool: + """Execute an action node.""" + try: + # Redirect actions + if action_type == 'action.redirect.create' or action_type == 'action.redirect': + return self._action_create_redirect(properties, context) + elif action_type == 'action.redirect.delete' or action_type == 'action.redirect.disable': + return self._action_delete_redirect(properties, context) + + # Queue actions + elif action_type == 'action.queue.pause': + return self._action_pause_queue(properties, context) + elif action_type == 'action.queue.resume': + return self._action_resume_queue(properties, context) + elif action_type == 'action.queue.clear': + return self._action_clear_queue(properties, context) + + # Notification actions + elif action_type == 'action.notify.email': + return self._action_send_email(properties, context) + elif action_type == 'action.notify.inapp': + return self._action_send_inapp_notification(properties, context) + + # Note/printer note actions + elif action_type == 'action.note' or action_type == 'action.printer.note': + return self._action_add_printer_note(properties, context) + + # Audit action + elif action_type == 'action.audit': + return self._action_audit_log(properties, context) + + # HTTP request action + elif action_type == 'action.http': + return self._action_http_request(properties, context) + + # Print job action + elif action_type == 'action.print': + return self._action_print_job(properties, context) + + # End action (terminates the workflow) + elif action_type == 'action.end': + logger.info("Workflow ended via End node") + return True # Success but don't continue + + else: + logger.warning(f"Unknown action type: {action_type}") + return False + except Exception as e: + logger.error(f"Error executing action {action_type}: {e}", exc_info=True) + return False + + def _action_create_redirect(self, properties: Dict, context: Dict) -> Dict: + """ + Create a printer redirect. + + Returns dict with output variables for downstream nodes. + """ + # Use rendered properties - may contain {{variable}} references + source_printer_id = properties.get('source_printer_id') or properties.get('printer_id') + target_printer_id = properties.get('target_printer_id') + port = properties.get('port', 9100) + + if not source_printer_id or not target_printer_id: + logger.error("Missing source_printer_id or target_printer_id") + return {'success': False} + + try: + network = get_network_manager() + # Get printer details + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, name, ip FROM printers WHERE id = ?", (source_printer_id,)) + source_row = cursor.fetchone() + cursor.execute("SELECT id, name, ip FROM printers WHERE id = ?", (target_printer_id,)) + target_row = cursor.fetchone() + conn.close() + + if not source_row or not target_row: + return {'success': False} + + source_name, source_ip = source_row[1], source_row[2] + target_name, target_ip = target_row[1], target_row[2] + + # Create redirect in database + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO redirects (printer_id, target_printer_id, active, created_at) VALUES (?, ?, 1, ?)", + (source_printer_id, target_printer_id, datetime.now().isoformat()) + ) + redirect_id = cursor.lastrowid + conn.commit() + conn.close() + + # Apply NAT rules + success, _ = network.add_nat_rule(source_ip, target_ip, port) + + # Return output variables for downstream nodes + return { + 'redirect_id': str(redirect_id), + 'source_printer_id': source_printer_id, + 'source_printer_name': source_name, + 'source_printer_ip': source_ip, + 'target_printer_id': target_printer_id, + 'target_printer_name': target_name, + 'target_printer_ip': target_ip, + 'port': port, + 'success': success + } + + except Exception as e: + logger.error(f"Error creating redirect: {e}") + return {'success': False} + + def _action_delete_redirect(self, properties: Dict, context: Dict) -> Dict: + """Delete a printer redirect.""" + source_printer_id = properties.get('source_printer_id') or properties.get('printer_id') + + if not source_printer_id: + return {'success': False} + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Get printer info + cursor.execute("SELECT name, ip FROM printers WHERE id = ?", (source_printer_id,)) + row = cursor.fetchone() + printer_name = row[0] if row else '' + printer_ip = row[1] if row else '' + + cursor.execute("UPDATE redirects SET active = 0 WHERE printer_id = ?", (source_printer_id,)) + conn.commit() + conn.close() + + # Remove NAT rules + network = get_network_manager() + if printer_ip: + success, _ = network.remove_nat_rule(printer_ip, 9100) + else: + success = False + + return { + 'source_printer_id': source_printer_id, + 'source_printer_name': printer_name, + 'success': success + } + + except Exception as e: + logger.error(f"Error deleting redirect: {e}") + return {'success': False} + + def _action_pause_queue(self, properties: Dict, context: Dict) -> Dict: + """Pause printer queue.""" + printer_id = properties.get('printer_id') or context.get('printer_id') + logger.info(f"Pausing queue for printer {printer_id}") + return { + 'printer_id': printer_id, + 'success': True + } + + def _action_resume_queue(self, properties: Dict, context: Dict) -> Dict: + """Resume printer queue.""" + printer_id = properties.get('printer_id') or context.get('printer_id') + logger.info(f"Resuming queue for printer {printer_id}") + return { + 'printer_id': printer_id, + 'success': True + } + + def _action_clear_queue(self, properties: Dict, context: Dict) -> Dict: + """Clear printer queue.""" + printer_id = properties.get('printer_id') or context.get('printer_id') + logger.info(f"Clearing queue for printer {printer_id}") + return { + 'printer_id': printer_id, + 'jobs_cleared': 0, # Would be actual count from queue operations + 'success': True + } + + def _action_send_email(self, properties: Dict, context: Dict) -> Dict: + """Send email notification.""" + try: + notif_manager = get_notification_manager() + # Properties are already rendered, no need to render again + message = properties.get('body', properties.get('message', '')) + subject = properties.get('subject', 'Printer Alert') + to_email = properties.get('to', '') + + notif_manager.send_notification( + 'email', + subject, + message, + {'to': to_email} + ) + return { + 'to': to_email, + 'subject': subject, + 'success': True + } + except Exception as e: + logger.error(f"Error sending email: {e}") + return {'success': False} + + def _action_send_inapp_notification(self, properties: Dict, context: Dict) -> Dict: + """Send in-app notification.""" + try: + from app.services.notification_sender import create_notification + + # Properties are already rendered + message = properties.get('message', '') + title = properties.get('title', 'Workflow Notification') + notification_type = properties.get('type', 'info') + + notification = create_notification( + notification_type=notification_type, + title=title, + message=message, + printer_id=context.get('printer_id') + ) + return { + 'notification_id': str(notification.get('id', '')) if isinstance(notification, dict) else '', + 'title': title, + 'success': True + } + except Exception as e: + logger.error(f"Error sending in-app notification: {e}") + return {'success': False} + + def _action_add_printer_note(self, properties: Dict, context: Dict) -> Dict: + """Add a note to a printer.""" + try: + printer_id = properties.get('printer_id') or context.get('printer_id') + # Properties are already rendered + note = properties.get('note', properties.get('message', '')) + + if not printer_id or not note: + logger.warning("Missing printer_id or message for printer note") + return {'success': True} # Non-critical, don't fail workflow + + # Get printer name + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT name FROM printers WHERE id = ?", (printer_id,)) + row = cursor.fetchone() + printer_name = row[0] if row else '' + + cursor.execute( + "UPDATE printers SET notes = COALESCE(notes || char(10), '') || ? WHERE id = ?", + (f"[Workflow] {note}", printer_id) + ) + conn.commit() + conn.close() + + logger.info(f"Added note to printer {printer_id}: {note}") + return { + 'printer_id': printer_id, + 'printer_name': printer_name, + 'note': note, + 'success': True + } + except Exception as e: + logger.error(f"Error adding printer note: {e}") + return {'success': False} + + def _action_audit_log(self, properties: Dict, context: Dict) -> Dict: + """Create an audit log entry.""" + try: + from app.models import AuditLog + + # Properties are already rendered + details = properties.get('details', properties.get('message', 'Workflow action executed')) + action = properties.get('action', 'workflow_action') + + AuditLog.log( + username='workflow_engine', + action=action, + details=details, + success=True + ) + return { + 'action': action, + 'details': details, + 'success': True + } + except Exception as e: + logger.error(f"Error creating audit log: {e}", exc_info=True) + return {'success': False} + + def _action_http_request(self, properties: Dict, context: Dict) -> Dict: + """Make HTTP request.""" + try: + # Properties are already rendered + url = properties.get('url', '') + method = properties.get('method', 'POST') + body = properties.get('body', '{}') + + response = requests.request( + method, + url, + data=body, + headers={'Content-Type': 'application/json'}, + timeout=10 + ) + + # Try to parse response as JSON + try: + response_body = response.json() + except: + response_body = response.text + + return { + 'status_code': response.status_code, + 'response_body': response_body, + 'success': response.status_code < 400 + } + except Exception as e: + logger.error(f"Error making HTTP request: {e}") + return {'success': False, 'status_code': 0} + + def _action_print_job(self, properties: Dict, context: Dict) -> Dict: + """Submit print job.""" + printer_id = properties.get('printer_id') or context.get('printer_id') + document_path = properties.get('document_path', '') + copies = properties.get('copies', 1) + + logger.info(f"Printing to {printer_id}: {document_path}") + return { + 'job_id': '', # Would be actual job ID from print system + 'printer_id': printer_id, + 'document_path': document_path, + 'success': True + } + + def _evaluate_conditional(self, conditional_type: str, properties: Dict, context: Dict) -> tuple: + """ + Evaluate conditional and return (output_handle, condition_result). + + Returns: + Tuple of (output_handle: str, condition_result: bool) + """ + if conditional_type == 'logic.if': + expression = properties.get('expression', '') + result = self._evaluate_expression(expression, context) + return ('true' if result else 'false', result) + + elif conditional_type == 'logic.switch': + switch_on = properties.get('value', '') + cases = properties.get('cases', []) + + value = context.get(switch_on) + for i, case in enumerate(cases): + if value == case: + return (f'case{i+1}', True) + return ('default', False) + + return (None, False) + + def _evaluate_expression(self, expression: str, context: Dict) -> bool: + """Evaluate a condition expression.""" + # Map expression values to context checks + printer_id = context.get('printer_id') + + if expression == 'printer_offline': + return context.get('printer_state') == 'offline' + elif expression == 'printer_online': + return context.get('printer_state') == 'online' + elif expression == 'queue_high': + return context.get('queue_count', 0) > 10 + elif expression == 'queue_empty': + return context.get('queue_count', 0) == 0 + elif expression == 'redirect_active': + return context.get('redirect_active', False) + elif expression == 'redirect_inactive': + return not context.get('redirect_active', False) + elif expression == 'job_failed': + return context.get('job_status') == 'failed' + + return False + + def _apply_transform(self, transform_type: str, properties: Dict, context: Dict) -> Dict: + """Apply data transformation. Returns updated context.""" + try: + if transform_type == 'transform.filter': + # Filter: only continue if condition is met + expression = properties.get('expression', '') + matched = self._evaluate_expression(expression, context) + context['matched'] = matched + if not matched: + # Filter didn't match - set a flag for downstream + context['_filtered'] = True + return context + + elif transform_type == 'transform.map_fields': + # Map fields: rename or restructure context fields + mappings_str = properties.get('mappings', '{}') + # Parse mappings if it's a string (JSON) + if isinstance(mappings_str, str): + try: + import json + field_mappings = json.loads(mappings_str) + except: + field_mappings = {} + else: + field_mappings = mappings_str + + for source_field, target_field in field_mappings.items(): + if source_field in context: + context[target_field] = context[source_field] + return context + + elif transform_type == 'transform.template': + # Template: render a template and store result + # Properties are already rendered, but template content should be rendered again + template = properties.get('template', '') + output_key = properties.get('output_key', 'result') + # The template itself needs to be rendered with context + result = self._render_template(template, context) + context[output_key] = result + context['result'] = result # Also store in standard 'result' key + return context + + else: + logger.warning(f"Unknown transform type: {transform_type}") + return context + + except Exception as e: + logger.error(f"Error applying transform {transform_type}: {e}") + return context + + def _execute_integration(self, integration_type: str, properties: Dict, context: Dict) -> Dict: + """Execute integration action. Returns dict with output variables.""" + try: + # Properties are already rendered by _execute_node + message = properties.get('message', '') + + if integration_type == 'integration.slack': + webhook_url = properties.get('webhook_url', '') + response = requests.post(webhook_url, json={'text': message}, timeout=10) + return { + 'status_code': response.status_code, + 'success': response.status_code == 200 + } + + elif integration_type == 'integration.teams': + webhook_url = properties.get('webhook_url', '') + response = requests.post(webhook_url, json={'text': message}, timeout=10) + return { + 'status_code': response.status_code, + 'success': response.status_code == 200 + } + + elif integration_type == 'integration.discord': + webhook_url = properties.get('webhook_url', '') + response = requests.post(webhook_url, json={'content': message}, timeout=10) + return { + 'status_code': response.status_code, + 'success': response.status_code in [200, 204] + } + + elif integration_type == 'integration.api': + # Generic API call integration + url = properties.get('url', '') + method = properties.get('method', 'POST').upper() + headers = properties.get('headers', {}) + body = properties.get('body', '{}') + + # Add default content type if not specified + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + + response = requests.request( + method, + url, + data=body, + headers=headers, + timeout=int(properties.get('timeout', 10)) + ) + + # Try to parse response as JSON + try: + response_body = response.json() + except: + response_body = response.text + + logger.info(f"API integration {method} {url} returned {response.status_code}") + return { + 'status_code': response.status_code, + 'response_body': response_body, + 'success': response.status_code < 400 + } + + return {'success': False} + except Exception as e: + logger.error(f"Error executing integration {integration_type}: {e}") + return {'success': False} + + def _render_template(self, template: str, context: Dict) -> str: + """ + Render template with context variables. + Supports both simple {{variable}} and nested {{object.key}} syntax. + """ + import re + + def get_nested_value(obj: Dict, path: str) -> Any: + """Get a nested value from a dictionary using dot notation.""" + keys = path.split('.') + current = obj + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return None + return current + + result = template + # Find all {{variable}} patterns + pattern = r'\{\{([a-zA-Z_][a-zA-Z0-9_\.]*)\}\}' + matches = re.findall(pattern, template) + + for match in matches: + value = get_nested_value(context, match) + if value is not None: + result = result.replace(f'{{{{{match}}}}}', str(value)) + + return result + + def _render_properties(self, properties: Dict, context: Dict) -> Dict: + """ + Render all string properties that may contain {{variable}} templates. + Returns a new dictionary with rendered values. + """ + rendered = {} + for key, value in properties.items(): + if isinstance(value, str): + rendered[key] = self._render_template(value, context) + elif isinstance(value, dict): + rendered[key] = self._render_properties(value, context) + elif isinstance(value, list): + rendered[key] = [ + self._render_template(item, context) if isinstance(item, str) else item + for item in value + ] + else: + rendered[key] = value + return rendered + + def _get_node_by_id(self, workflow: Dict, node_id: str) -> Optional[Dict]: + """Get node by ID from workflow.""" + for node in workflow['nodes']: + if node['id'] == node_id: + return node + return None + + def _execute_next_nodes(self, workflow: Dict, current_node_id: str, context: Dict, source_handle: Optional[str] = None) -> bool: + """Execute all nodes connected to the current node's outputs.""" + edges = workflow['edges'] + next_edges = [ + e for e in edges + if e['source'] == current_node_id and (source_handle is None or e.get('sourceHandle', '').split(':')[0] == source_handle) + ] + + if not next_edges: + logger.debug("No more nodes to execute, workflow complete") + return True + + success = True + for edge in next_edges: + if not self._execute_node(workflow, edge['target'], context): + success = False + + return success + + +# Global engine instance +_workflow_engine = None + +def get_workflow_engine() -> WorkflowEngine: + """Get the global workflow engine instance.""" + global _workflow_engine + if _workflow_engine is None: + _workflow_engine = WorkflowEngine() + return _workflow_engine + + +def trigger_workflows_for_event(event_type: str, context: Dict[str, Any]) -> None: + """ + Trigger all workflows that match the given event type. + + Args: + event_type: Type of event (printer_offline, printer_online, job_failed, etc.) + context: Event context data + """ + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Get all enabled workflows + cursor.execute("SELECT id, nodes, enabled FROM workflows WHERE enabled = 1") + rows = cursor.fetchall() + conn.close() + + engine = get_workflow_engine() + + for row in rows: + workflow_id, nodes_json, enabled = row + + # Double-check enabled status (safety check) + if not enabled: + logger.warning(f"Skipping disabled workflow {workflow_id}") + continue + + nodes = json.loads(nodes_json) if nodes_json else [] + + # Check if workflow has matching trigger + for node in nodes: + should_trigger = False + + # Event trigger + if node['type'] == 'trigger.event': + node_event = node.get('properties', {}).get('event_type', '') + if node_event == event_type: + should_trigger = True + + # Health change trigger + elif node['type'] == 'trigger.health_change': + node_state = node.get('properties', {}).get('state', '') + node_printer = node.get('properties', {}).get('printer_id', '') + + # Match state (offline/online) + if event_type == 'printer_offline' and node_state == 'offline': + # If specific printer, check it matches + if not node_printer or node_printer == context.get('printer_id'): + should_trigger = True + elif event_type == 'printer_online' and node_state == 'online': + if not node_printer or node_printer == context.get('printer_id'): + should_trigger = True + + # Queue threshold trigger + elif node['type'] == 'trigger.queue_threshold': + node_printer = node.get('properties', {}).get('printer_id', '') + threshold = int(node.get('properties', {}).get('threshold', 10)) + direction = node.get('properties', {}).get('direction', 'above') + + if event_type == 'queue_threshold': + # Check if printer matches + if not node_printer or node_printer == context.get('printer_id'): + queue_count = context.get('queue_count', 0) + if direction == 'above' and queue_count >= threshold: + should_trigger = True + elif direction == 'below' and queue_count <= threshold: + should_trigger = True + + if should_trigger: + logger.info(f"Triggering workflow {workflow_id} for event {event_type}") + try: + engine.execute_workflow(workflow_id, { + 'trigger': event_type, + **context, + 'timestamp': datetime.now().isoformat() + }) + except Exception as e: + logger.error(f"Error executing workflow {workflow_id}: {e}", exc_info=True) + break # Only trigger once per workflow + + except Exception as e: + logger.error(f"Error triggering workflows for event {event_type}: {e}", exc_info=True) diff --git a/app/updater.py b/app/updater.py deleted file mode 100644 index 9589678..0000000 --- a/app/updater.py +++ /dev/null @@ -1,464 +0,0 @@ -""" -Auto-Update System - Uses APT repository for updates. - -Architecture: -- Background thread periodically runs 'apt update' to check for new versions -- Updates are performed via 'apt upgrade printer-proxy' -- The update process is handled by a separate systemd service -- This ensures the update process survives when the main service restarts -""" -import json -import time -import logging -import threading -import fcntl -import subprocess -import re -import urllib.request -from pathlib import Path -from datetime import datetime, timedelta -from typing import Optional, Dict, Any, Tuple -from dataclasses import dataclass, asdict - -from app.version import __version__ - -logger = logging.getLogger(__name__) - -# APT Configuration -APT_REPO_URL = "https://jordonh18.github.io/printer-proxy" -PACKAGE_NAME = "printer-proxy" - -# Paths -DATA_DIR = Path("/var/lib/printer-proxy") -DEV_DATA_DIR = Path(__file__).parent.parent / "data" -UPDATE_STATE_FILE = "update_state.json" -UPDATE_SERVICE = "printer-proxy-update.service" - -# Check intervals -CHECK_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours between checks -UPDATE_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for update - - -@dataclass -class UpdateState: - """Persistent state for the update system.""" - last_check: Optional[str] = None - available_version: Optional[str] = None - release_notes: Optional[str] = None - update_in_progress: bool = False - update_started_at: Optional[str] = None - update_error: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'UpdateState': - known_fields = {k for k in cls.__dataclass_fields__} - filtered = {k: v for k, v in data.items() if k in known_fields} - return cls(**filtered) - - def is_update_stale(self) -> bool: - """Check if an in-progress update has timed out.""" - if not self.update_in_progress or not self.update_started_at: - return False - try: - started = datetime.fromisoformat(self.update_started_at) - return datetime.now() - started > timedelta(seconds=UPDATE_TIMEOUT_SECONDS) - except (ValueError, TypeError): - return True - - -class UpdateManager: - """Manages checking for and applying updates via APT. - - Thread-safe singleton with file-based state persistence. - """ - _instance: Optional['UpdateManager'] = None - _instance_lock = threading.Lock() - - def __init__(self): - self._data_dir = self._get_data_dir() - self._state = UpdateState() - self._state_lock = threading.Lock() - self._check_thread: Optional[threading.Thread] = None - self._stop_event = threading.Event() - self._load_state() - - @classmethod - def get_instance(cls) -> 'UpdateManager': - with cls._instance_lock: - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def _get_data_dir(self) -> Path: - """Get the appropriate data directory.""" - if DATA_DIR.exists(): - try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - return DATA_DIR - except PermissionError: - logger.warning(f"No write permission for {DATA_DIR}, using dev directory") - DEV_DATA_DIR.mkdir(parents=True, exist_ok=True) - return DEV_DATA_DIR - - def _load_state(self): - """Load persisted update state with file locking.""" - state_file = self._data_dir / UPDATE_STATE_FILE - try: - if state_file.exists(): - with open(state_file, 'r') as f: - fcntl.flock(f.fileno(), fcntl.LOCK_SH) - try: - data = json.load(f) - self._state = UpdateState.from_dict(data) - finally: - fcntl.flock(f.fileno(), fcntl.LOCK_UN) - except Exception as e: - logger.warning(f"Failed to load update state: {e}") - self._state = UpdateState() - - def _save_state(self): - """Save update state with atomic write.""" - state_file = self._data_dir / UPDATE_STATE_FILE - try: - self._data_dir.mkdir(parents=True, exist_ok=True) - temp_file = state_file.with_suffix('.tmp') - with open(temp_file, 'w') as f: - fcntl.flock(f.fileno(), fcntl.LOCK_EX) - try: - json.dump(self._state.to_dict(), f, indent=2) - finally: - fcntl.flock(f.fileno(), fcntl.LOCK_UN) - temp_file.replace(state_file) - except Exception as e: - logger.error(f"Failed to save update state: {e}") - - def get_state(self) -> Dict[str, Any]: - """Get current update state for API responses.""" - with self._state_lock: - self._load_state() - - # Auto-clear stale updates - if self._state.update_in_progress and self._state.is_update_stale(): - logger.warning("Clearing stale update state") - self._state.update_in_progress = False - self._state.update_error = "Update timed out" - self._save_state() - - state = self._state.to_dict() - state['current_version'] = __version__ - state['update_available'] = self._is_newer_version( - self._state.available_version, __version__ - ) if self._state.available_version else False - state['apt_repo_url'] = APT_REPO_URL - return state - - def _parse_version(self, version: str) -> Tuple[Tuple[int, ...], str]: - """Parse version string into comparable tuple.""" - version = version.lstrip('v') - pre_release = '' - if '-' in version: - version, pre_release = version.split('-', 1) - try: - parts = tuple(int(p) for p in version.split('.')) - except ValueError: - parts = (0,) - return parts, pre_release - - def _is_newer_version(self, new_version: Optional[str], current_version: str) -> bool: - """Compare semantic versions.""" - if not new_version: - return False - try: - new_parts, new_pre = self._parse_version(new_version) - cur_parts, cur_pre = self._parse_version(current_version) - - if new_parts != cur_parts: - return new_parts > cur_parts - - # Pre-release versions are older than release versions - if cur_pre and not new_pre: - return True - if new_pre and not cur_pre: - return False - return new_pre > cur_pre - except Exception as e: - logger.error(f"Version comparison failed: {e}") - return False - - def _get_apt_available_version(self) -> Optional[str]: - """Query APT for the available version of printer-proxy.""" - try: - result = subprocess.run( - ['apt-cache', 'policy', PACKAGE_NAME], - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode != 0: - return None - - # Parse output to find candidate version - for line in result.stdout.split('\n'): - if 'Candidate:' in line: - version = line.split('Candidate:')[1].strip() - if version and version != '(none)': - return version - - return None - except Exception as e: - logger.error(f"Failed to get APT version: {e}") - return None - - def _get_upgrade_candidate_version(self) -> Optional[str]: - """Use a dry-run upgrade to detect the candidate version.""" - try: - result = subprocess.run( - ['apt-get', '-s', 'install', '--only-upgrade', PACKAGE_NAME], - capture_output=True, - text=True, - timeout=60 - ) - - if result.returncode != 0: - return None - - # Example: Inst printer-proxy [1.0.0-beta.2] (1.0.1 stable [all]) - match = re.search(rf"Inst\s+{re.escape(PACKAGE_NAME)}\s+\[[^\]]+\]\s+\(([^)\s]+)", result.stdout) - if match: - return match.group(1) - - # Example: Inst printer-proxy (1.0.1 stable [all]) - match = re.search(rf"Inst\s+{re.escape(PACKAGE_NAME)}\s+\(([^)\s]+)", result.stdout) - if match: - return match.group(1) - - return None - except Exception as e: - logger.warning(f"Failed to get upgrade candidate: {e}") - return None - - def _get_repo_available_version(self) -> Optional[str]: - """Query the APT repository Packages file for available version.""" - try: - packages_url = f"{APT_REPO_URL}/dists/stable/main/binary-all/Packages" - with urllib.request.urlopen(packages_url, timeout=15) as resp: - content = resp.read().decode('utf-8', errors='ignore') - - current_pkg = False - for line in content.splitlines(): - if line.startswith('Package:'): - current_pkg = line.split(':', 1)[1].strip() == PACKAGE_NAME - elif current_pkg and line.startswith('Version:'): - return line.split(':', 1)[1].strip() - - return None - except Exception as e: - logger.warning(f"Failed to read repo Packages file: {e}") - return None - - def check_for_updates(self, force: bool = False) -> Tuple[bool, Optional[str]]: - """Check APT repository for available updates.""" - with self._state_lock: - self._load_state() - - if self._state.update_in_progress and not self._state.is_update_stale(): - return False, "Update already in progress" - - # Rate limiting (check at most every 5 minutes unless forced) - if not force and self._state.last_check: - try: - last = datetime.fromisoformat(self._state.last_check) - if datetime.now() - last < timedelta(seconds=300): - has_update = self._is_newer_version(self._state.available_version, __version__) - return has_update, None - except ValueError: - pass - - logger.info("Checking for updates via APT...") - - try: - # Refresh APT cache using helper (non-interactive) - helper_path = Path("/opt/printer-proxy/scripts/update_helper.sh") - if helper_path.exists(): - result = subprocess.run( - ['sudo', '-n', str(helper_path), 'check'], - capture_output=True, - text=True, - timeout=120 - ) - if result.returncode != 0: - logger.warning(f"APT cache refresh failed: {result.stderr.strip()}") - - # Check available version - available = self._get_apt_available_version() - repo_available = None - upgrade_available = self._get_upgrade_candidate_version() - if upgrade_available and self._is_newer_version(upgrade_available, available or __version__): - available = upgrade_available - if not available: - repo_available = self._get_repo_available_version() - elif not self._is_newer_version(available, __version__): - repo_available = self._get_repo_available_version() - if repo_available and self._is_newer_version(repo_available, available): - available = repo_available - - with self._state_lock: - self._state.last_check = datetime.now().isoformat() - - if available and self._is_newer_version(available, __version__): - self._state.available_version = available - self._state.update_error = None - logger.info(f"Update available: {__version__} -> {available}") - elif available: - self._state.available_version = available - elif repo_available: - self._state.available_version = repo_available - - self._save_state() - return self._is_newer_version(self._state.available_version, __version__), None - - except subprocess.TimeoutExpired: - return False, "apt update timed out" - except Exception as e: - logger.error(f"Update check failed: {e}") - return False, str(e) - - def start_update(self) -> Tuple[bool, str]: - """Start the update process by triggering the update service.""" - with self._state_lock: - self._load_state() - - if self._state.update_in_progress and not self._state.is_update_stale(): - return False, "Update already in progress" - - if not self._state.available_version: - return False, "No update available" - - if not self._is_newer_version(self._state.available_version, __version__): - return False, "Already on latest version" - - version = self._state.available_version - - # Mark update as in progress - self._state.update_in_progress = True - self._state.update_started_at = datetime.now().isoformat() - self._state.update_error = None - self._save_state() - - logger.info(f"Starting update to version {version}") - - # Write update request file for update service - try: - request_file = self._data_dir / "update_request.json" - request_file.parent.mkdir(parents=True, exist_ok=True) - with open(request_file, 'w') as f: - json.dump({ - "version": version, - "requested_at": datetime.now().isoformat() - }, f) - except Exception as e: - error_msg = f"Failed to write update request: {e}" - logger.error(error_msg) - with self._state_lock: - self._state.update_in_progress = False - self._state.update_error = error_msg - self._save_state() - return False, error_msg - - # Trigger the update service - try: - result = subprocess.run( - ['sudo', 'systemctl', 'start', UPDATE_SERVICE], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode != 0: - error_msg = f"Failed to start update service: {result.stderr}" - logger.error(error_msg) - with self._state_lock: - self._state.update_in_progress = False - self._state.update_error = error_msg - self._save_state() - return False, error_msg - - logger.info("Update service started successfully") - return True, f"Update to version {version} started" - - except subprocess.TimeoutExpired: - return False, "Timed out starting update service" - except Exception as e: - error_msg = f"Failed to start update: {e}" - logger.error(error_msg, exc_info=True) - with self._state_lock: - self._state.update_in_progress = False - self._state.update_error = error_msg - self._save_state() - return False, error_msg - - def clear_update_state(self): - """Clear update state after restart.""" - with self._state_lock: - self._state.update_in_progress = False - self._state.update_started_at = None - self._state.update_error = None - self._save_state() - - def start_background_checks(self): - """Start background thread for periodic update checks.""" - if self._check_thread and self._check_thread.is_alive(): - return - - self._stop_event.clear() - self._check_thread = threading.Thread( - target=self._background_check_loop, - name="UpdateChecker", - daemon=True - ) - self._check_thread.start() - logger.info("Background update checker started") - - def stop_background_checks(self): - """Stop the background check thread.""" - self._stop_event.set() - if self._check_thread: - self._check_thread.join(timeout=5) - - def _background_check_loop(self): - """Background loop that periodically checks for updates.""" - time.sleep(30) # Initial delay - - while not self._stop_event.is_set(): - try: - self.check_for_updates() - except Exception as e: - logger.error(f"Background update check failed: {e}") - - self._stop_event.wait(CHECK_INTERVAL_SECONDS) - - -def get_update_manager() -> UpdateManager: - """Get the update manager singleton.""" - return UpdateManager.get_instance() - - -def init_updater(start_background: bool = True) -> UpdateManager: - """Initialize the update system.""" - manager = get_update_manager() - - # Clear stale update state - with manager._state_lock: - manager._load_state() - if manager._state.update_in_progress and manager._state.is_update_stale(): - logger.info("Clearing stale update state on startup") - manager.clear_update_state() - - if start_background: - manager.start_background_checks() - - return manager diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..ed8ccdb --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,47 @@ +""" +Utility modules for Continuum + +Contains authentication helpers, decorators, rate limiting, and API token utilities. +""" + +from app.utils.auth import ( + role_required, + hash_password, + verify_password, + validate_password_strength, + authenticate_user, + create_initial_admin, + login_manager, +) + +from app.utils.rate_limiting import ( + get_ip_for_ratelimit, + handle_rate_limit_exceeded, + RATE_LIMITS, +) + +from app.utils.api_tokens import ( + APIToken, + get_available_permissions, + validate_permissions, +) + + +__all__ = [ + # Auth utilities + 'role_required', + 'hash_password', + 'verify_password', + 'validate_password_strength', + 'authenticate_user', + 'create_initial_admin', + 'login_manager', + # Rate limiting + 'get_ip_for_ratelimit', + 'handle_rate_limit_exceeded', + 'RATE_LIMITS', + # API tokens + 'APIToken', + 'get_available_permissions', + 'validate_permissions', +] diff --git a/app/utils/api_tokens.py b/app/utils/api_tokens.py new file mode 100644 index 0000000..b73a49f --- /dev/null +++ b/app/utils/api_tokens.py @@ -0,0 +1,231 @@ +""" +API Token management for programmatic access +""" +import secrets +import hashlib +import json +import sqlite3 +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from dataclasses import dataclass + +from app.models import get_db_connection + + +# Permission scopes by role +PERMISSION_SCOPES = { + 'viewer': [ + 'printers:read', + 'redirects:read', + 'stats:read', + ], + 'operator': [ + 'printers:read', + 'printers:write', + 'redirects:read', + 'redirects:write', + 'stats:read', + ], + 'admin': [ + 'printers:read', + 'printers:write', + 'redirects:read', + 'redirects:write', + 'users:read', + 'users:write', + 'settings:read', + 'settings:write', + 'stats:read', + 'audit:read', + ], +} + + +def get_available_permissions(user_role: str) -> List[str]: + """Get list of permissions available to a user based on their role.""" + return PERMISSION_SCOPES.get(user_role, []) + + +def validate_permissions(user_role: str, requested_permissions: List[str]) -> bool: + """Validate that requested permissions are available to the user's role.""" + available = set(get_available_permissions(user_role)) + requested = set(requested_permissions) + return requested.issubset(available) + + +@dataclass +class APIToken: + """API Token model.""" + id: int + user_id: int + name: str + token_hash: str + permissions: List[str] + created_at: str + last_used_at: Optional[str] = None + expires_at: Optional[str] = None + + def to_dict(self, include_token: bool = False, token_value: Optional[str] = None) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + data = { + 'id': self.id, + 'user_id': self.user_id, + 'name': self.name, + 'permissions': self.permissions, + 'created_at': self.created_at, + 'last_used_at': self.last_used_at, + 'expires_at': self.expires_at, + } + if include_token and token_value: + data['token'] = token_value + return data + + @staticmethod + def generate_token() -> tuple[str, str]: + """Generate a new token and its hash. + + Returns: + tuple: (plain_token, token_hash) + """ + # Generate 32-byte (64 hex chars) random token + plain_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plain_token.encode()).hexdigest() + return plain_token, token_hash + + @staticmethod + def create(user_id: int, name: str, permissions: List[str], + expires_at: Optional[str] = None) -> tuple['APIToken', str]: + """Create a new API token. + + Returns: + tuple: (APIToken instance, plain_token) + """ + plain_token, token_hash = APIToken.generate_token() + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """INSERT INTO api_tokens + (user_id, name, token_hash, permissions, expires_at) + VALUES (?, ?, ?, ?, ?)""", + (user_id, name, token_hash, json.dumps(permissions), expires_at) + ) + conn.commit() + token_id = cursor.lastrowid + + # Fetch the created token + cursor.execute( + "SELECT * FROM api_tokens WHERE id = ?", + (token_id,) + ) + row = cursor.fetchone() + conn.close() + + token = APIToken( + id=row['id'], + user_id=row['user_id'], + name=row['name'], + token_hash=row['token_hash'], + permissions=json.loads(row['permissions']), + created_at=row['created_at'], + last_used_at=row['last_used_at'], + expires_at=row['expires_at'] + ) + + return token, plain_token + + @staticmethod + def get_by_hash(token_hash: str) -> Optional['APIToken']: + """Get token by hash.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM api_tokens WHERE token_hash = ?", + (token_hash,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return APIToken( + id=row['id'], + user_id=row['user_id'], + name=row['name'], + token_hash=row['token_hash'], + permissions=json.loads(row['permissions']), + created_at=row['created_at'], + last_used_at=row['last_used_at'], + expires_at=row['expires_at'] + ) + return None + + @staticmethod + def get_by_user(user_id: int) -> List['APIToken']: + """Get all tokens for a user.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC", + (user_id,) + ) + rows = cursor.fetchall() + conn.close() + + return [ + APIToken( + id=row['id'], + user_id=row['user_id'], + name=row['name'], + token_hash=row['token_hash'], + permissions=json.loads(row['permissions']), + created_at=row['created_at'], + last_used_at=row['last_used_at'], + expires_at=row['expires_at'] + ) + for row in rows + ] + + @staticmethod + def update_last_used(token_id: int): + """Update the last_used_at timestamp (non-blocking).""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", + (token_id,) + ) + conn.commit() + conn.close() + except sqlite3.OperationalError: + # Skip update if database is locked - this is non-critical + pass + + @staticmethod + def delete(token_id: int, user_id: int) -> bool: + """Delete a token. Returns True if deleted.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + "DELETE FROM api_tokens WHERE id = ? AND user_id = ?", + (token_id, user_id) + ) + conn.commit() + deleted = cursor.rowcount > 0 + conn.close() + return deleted + + def is_expired(self) -> bool: + """Check if token is expired.""" + if not self.expires_at: + return False + + try: + expires = datetime.fromisoformat(self.expires_at.replace('Z', '+00:00')) + return datetime.utcnow() > expires + except: + return False + + def has_permission(self, required_permission: str) -> bool: + """Check if token has a specific permission.""" + return required_permission in self.permissions diff --git a/app/auth.py b/app/utils/auth.py similarity index 96% rename from app/auth.py rename to app/utils/auth.py index 7f2884d..aeb072d 100644 --- a/app/auth.py +++ b/app/utils/auth.py @@ -151,7 +151,7 @@ def authenticate_user(username: str, password: str, client_ip: str = None) -> Tu return user, "" -def create_initial_admin(username: str, password: str) -> Tuple[bool, str]: +def create_initial_admin(username: str, password: str, email: Optional[str] = None, full_name: Optional[str] = None) -> Tuple[bool, str]: """Create the initial admin user if none exists.""" # Check if any users exist existing = User.get_by_username(username) @@ -165,7 +165,7 @@ def create_initial_admin(username: str, password: str) -> Tuple[bool, str]: # Create user password_hash = hash_password(password) - User.create(username, password_hash, role='admin') + User.create(username, password_hash, role='admin', email=email, full_name=full_name) AuditLog.log( username="SYSTEM", diff --git a/app/utils/rate_limiting.py b/app/utils/rate_limiting.py new file mode 100644 index 0000000..f372b37 --- /dev/null +++ b/app/utils/rate_limiting.py @@ -0,0 +1,40 @@ +""" +Rate limiting configuration and custom handlers +""" +from flask import jsonify, request +from werkzeug.exceptions import TooManyRequests + + +def handle_rate_limit_exceeded(e): + """Custom handler for rate limit errors.""" + return jsonify({ + 'error': 'Rate limit exceeded', + 'message': str(e.description), + 'retry_after': e.retry_after if hasattr(e, 'retry_after') else None + }), 429 + + +def get_ip_for_ratelimit(): + """Get client IP for rate limiting (handles proxies).""" + # Check for X-Forwarded-For header (proxy/load balancer) + if request.headers.get('X-Forwarded-For'): + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Check for X-Real-IP header + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Fall back to remote_addr + return request.remote_addr or '127.0.0.1' + + +# Rate limit configurations for different endpoint types +RATE_LIMITS = { + 'login': '5 per minute', # Strict limit to prevent brute force + 'setup': '10 per minute', # Setup endpoint + 'mfa_verify': '10 per minute', # MFA verification + 'api_token_create': '10 per hour', # Token creation + 'discovery': '5 per minute', # Resource-heavy network scans + 'audit_logs': '30 per minute', # Database-heavy queries + 'user_create': '20 per hour', # User management + 'password_change': '5 per minute', # Password changes + 'api_default': '100 per minute', # Default for authenticated API calls +} diff --git a/app/version.py b/app/version.py index f3f730f..8c1b652 100644 --- a/app/version.py +++ b/app/version.py @@ -5,8 +5,8 @@ The build script and templates read from here. """ -__version__ = "1.0.3" -__version_info__ = (1, 0, 0) +__version__ = "1.1.0-alpha1" +__version_info__ = (2, 0, 0) # For display -VERSION_STRING = f"Printer Proxy v{__version__}" +VERSION_STRING = f"Continuum v{__version__}" diff --git a/config/__init__.py b/config/__init__.py index 4935c47..524424f 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,3 +1,3 @@ """ -Printer Proxy configuration package +Continuum configuration package """ diff --git a/config/config.py b/config/config.py index eec81ad..b73f6a9 100644 --- a/config/config.py +++ b/config/config.py @@ -1,5 +1,5 @@ """ -Printer Proxy Configuration +Continuum Configuration Auto-detects network settings from the host machine. """ import os @@ -85,21 +85,21 @@ def _get_or_create_secret_key(data_dir: Path) -> str: # ============================================================================= # Detect if running from installed location or development -_installed_path = Path('/opt/printer-proxy') +_installed_path = Path('/opt/continuum') _is_installed = _installed_path.exists() and ( - Path('/etc/systemd/system/printer-proxy.service').exists() or - Path('/lib/systemd/system/printer-proxy.service').exists() + Path('/etc/systemd/system/continuum.service').exists() or + Path('/lib/systemd/system/continuum.service').exists() ) if _is_installed: BASE_DIR = _installed_path - CONFIG_DIR = Path('/etc/printer-proxy') - DATA_DIR = Path('/var/lib/printer-proxy') - LOG_DIR = Path('/var/log/printer-proxy') + CONFIG_DIR = Path('/etc/continuum') + DATA_DIR = Path('/var/lib/continuum') + LOG_DIR = Path('/var/log/continuum') else: BASE_DIR = Path(__file__).parent.parent CONFIG_DIR = BASE_DIR / 'config' - DATA_DIR = BASE_DIR / 'data' + DATA_DIR = BASE_DIR / 'app' / 'data' LOG_DIR = BASE_DIR / 'logs' # Ensure directories exist @@ -123,7 +123,7 @@ def _get_or_create_secret_key(data_dir: Path) -> str: # Application Paths # ============================================================================= -DATABASE_PATH = DATA_DIR / 'printer_proxy.db' +DATABASE_PATH = DATA_DIR / 'continuum.db' HELPER_SCRIPT = BASE_DIR / 'scripts' / 'network_helper.sh' # ============================================================================= @@ -173,3 +173,11 @@ def _get_or_create_secret_key(data_dir: Path) -> str: MAX_LOGIN_ATTEMPTS = 5 LOCKOUT_DURATION_MINUTES = 15 + +# ============================================================================= +# JWT Settings +# ============================================================================= + +JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or _get_or_create_secret_key(DATA_DIR) + '-jwt' +JWT_ACCESS_TOKEN_EXPIRES_HOURS = 24 +JWT_REFRESH_TOKEN_EXPIRES_DAYS = 30 diff --git a/debian/README.Debian b/debian/README.Debian index 9582fc5..6136716 100644 --- a/debian/README.Debian +++ b/debian/README.Debian @@ -1,4 +1,4 @@ -printer-proxy for Debian +continuum for Debian ------------------------ First-time setup: @@ -15,11 +15,11 @@ Creating redirects: send jobs to the original IP, but traffic will be forwarded. Logs: - Application logs: /var/log/printer-proxy/ - Nginx access logs: /var/log/nginx/printer-proxy-access.log + Application logs: /var/log/continuum/ + Nginx access logs: /var/log/nginx/continuum-access.log Configuration: - The application stores its configuration in /var/lib/printer-proxy/ + The application stores its configuration in /var/lib/continuum/ No manual configuration file editing is required. -- Jordon Harrison Sun, 19 Jan 2026 12:00:00 +0000 diff --git a/debian/README.source b/debian/README.source index cb8f337..f4cf316 100644 --- a/debian/README.source +++ b/debian/README.source @@ -1,10 +1,10 @@ -printer-proxy for Debian +continuum for Debian ------------------------ Building from source: dpkg-buildpackage -us -uc -b -Note: This package installs to /opt/printer-proxy rather than standard +Note: This package installs to /opt/continuum rather than standard Debian paths (/usr/share or /usr/lib) because: 1. The application uses a Python virtual environment for dependency diff --git a/debian/changelog b/debian/changelog index 6948f12..2bed84e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,11 @@ -printer-proxy (1.0.0-beta.1) unstable; urgency=medium +continuum (1.1.0-alpha1) unstable; urgency=medium + + * Version 1.1.0 alpha release 1.1.0-alpha1 + * Major feature updates and improvements + + -- Jordon Harrison Wed, 22 Jan 2026 00:00:00 +0000 + +continuum (1.0.0-beta.1) unstable; urgency=medium * Initial beta release with new versioning scheme * APT repository integration for automated updates diff --git a/debian/printer-proxy-update.service b/debian/continuum-update.service similarity index 66% rename from debian/printer-proxy-update.service rename to debian/continuum-update.service index 514ebf1..be0996d 100644 --- a/debian/printer-proxy-update.service +++ b/debian/continuum-update.service @@ -1,6 +1,6 @@ [Unit] -Description=Printer Proxy Update Service -Documentation=https://github.com/Jordonh18/printer-proxy +Description=Continuum Update Service +Documentation=https://github.com/Jordonh18/continuum After=network-online.target Wants=network-online.target @@ -15,15 +15,15 @@ User=root Group=root # Read update parameters from the request file -ExecStart=/opt/printer-proxy/scripts/update_runner.sh +ExecStart=/opt/continuum/scripts/update_runner.sh # Clean up on success or failure -ExecStopPost=/bin/rm -f /var/lib/printer-proxy/update_request.json +ExecStopPost=/bin/rm -f /var/lib/continuum/update_request.json # Logging StandardOutput=journal StandardError=journal -SyslogIdentifier=printer-proxy-update +SyslogIdentifier=continuum-update # Security hardening ProtectSystem=false diff --git a/debian/continuum.install b/debian/continuum.install new file mode 100644 index 0000000..1817ff7 --- /dev/null +++ b/debian/continuum.install @@ -0,0 +1,14 @@ +# Application files +app/ opt/continuum/ +config/__init__.py opt/continuum/config/ +config/config.py opt/continuum/config/ +scripts/*.sh opt/continuum/scripts/ +requirements.txt opt/continuum/ +wsgi.py opt/continuum/ +run.py opt/continuum/ + +# React frontend build +frontend/dist/* opt/continuum/frontend/dist/ + +# Systemd service files for update functionality +debian/continuum-update.service opt/continuum/debian/ diff --git a/debian/printer-proxy.lintian-overrides b/debian/continuum.lintian-overrides similarity index 59% rename from debian/printer-proxy.lintian-overrides rename to debian/continuum.lintian-overrides index db2ac1b..df9e433 100644 --- a/debian/printer-proxy.lintian-overrides +++ b/debian/continuum.lintian-overrides @@ -1,4 +1,4 @@ -# Lintian overrides for printer-proxy +# Lintian overrides for continuum # # This package uses /opt for installation, which is non-standard but # intentional for this self-contained web application that requires @@ -7,25 +7,25 @@ # We install to /opt which is allowed by FHS 3.0 section 3.13 for # "add-on application software packages" -printer-proxy: dir-or-file-in-opt [opt/printer-proxy/*] +continuum: dir-or-file-in-opt [opt/continuum/*] # Python bytecode is regenerated at runtime in the venv -printer-proxy: package-installs-python-pycache-dir [opt/printer-proxy/app/__pycache__/] +continuum: package-installs-python-pycache-dir [opt/continuum/app/__pycache__/] # run.py is imported by gunicorn, not run directly -printer-proxy: script-not-executable [opt/printer-proxy/run.py] +continuum: script-not-executable [opt/continuum/run.py] # External CDN resources - this is a local-network appliance, not a redistributable # desktop package. CDN delivery is intentional for offline-capable deployment. -printer-proxy: privacy-breach-uses-embedded-file * -printer-proxy: privacy-breach-generic * +continuum: privacy-breach-uses-embedded-file * +continuum: privacy-breach-generic * -# We use recursive chown for /var/lib/printer-proxy and /var/log/printer-proxy +# We use recursive chown for /var/lib/continuum and /var/log/continuum # directories which are owned entirely by this package -printer-proxy: recursive-privilege-change * +continuum: recursive-privilege-change * # adduser is in Depends, this is a false positive due to command detection -printer-proxy: maintainer-script-needs-depends-on-adduser * +continuum: maintainer-script-needs-depends-on-adduser * # We handle systemd service ourselves since the service file lives in /opt -printer-proxy: maintainer-script-calls-systemctl * +continuum: maintainer-script-calls-systemctl * diff --git a/debian/continuum.service b/debian/continuum.service new file mode 100644 index 0000000..5d69f0a --- /dev/null +++ b/debian/continuum.service @@ -0,0 +1,29 @@ +[Unit] +Description=Continuum +Documentation=https://github.com/jordonh18/continuum +After=network.target + +[Service] +Type=simple +User=continuum +Group=continuum +WorkingDirectory=/opt/continuum +Environment="PATH=/opt/continuum/venv/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/opt/continuum/venv/bin/gunicorn \ + --bind 127.0.0.1:8080 \ + --workers 2 \ + --timeout 30 \ + --access-logfile /var/log/continuum/access.log \ + --error-logfile /var/log/continuum/error.log \ + "app:create_app()" + +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/log/continuum /var/lib/continuum + +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/debian/control b/debian/control index f989aff..011895c 100644 --- a/debian/control +++ b/debian/control @@ -1,18 +1,20 @@ -Source: printer-proxy +Source: continuum Section: net Priority: optional Maintainer: Jordon Harrison Build-Depends: debhelper-compat (= 13), dh-python, python3, - python3-setuptools + python3-setuptools, + nodejs, + npm Standards-Version: 4.6.2 -Homepage: https://github.com/Jordonh18/printer-proxy -Vcs-Browser: https://github.com/Jordonh18/printer-proxy -Vcs-Git: https://github.com/Jordonh18/printer-proxy.git +Homepage: https://github.com/Jordonh18/continuum +Vcs-Browser: https://github.com/Jordonh18/continuum +Vcs-Git: https://github.com/Jordonh18/continuum.git Rules-Requires-Root: no -Package: printer-proxy +Package: continuum Architecture: all Depends: ${python3:Depends}, ${misc:Depends}, @@ -30,7 +32,7 @@ Depends: ${python3:Depends}, Recommends: snmp Suggests: ufw Description: Web-based network printer traffic redirection tool - Printer Proxy is a web application that redirects network print traffic + Continuum is a web application that redirects network print traffic from one printer IP address to another using NAT/iptables rules. This allows print clients to continue using the same IP address when a printer fails, while traffic is transparently forwarded to a working backup printer. diff --git a/debian/copyright b/debian/copyright index 6406081..14a111a 100644 --- a/debian/copyright +++ b/debian/copyright @@ -1,7 +1,7 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: printer-proxy +Upstream-Name: continuum Upstream-Contact: Jordon Harrison -Source: https://github.com/Jordonh18/printer-proxy +Source: https://github.com/Jordonh18/continuum Files: * Copyright: 2024-2026 Jordon Harrison diff --git a/debian/postinst b/debian/postinst index c45e6d9..8beac2d 100755 --- a/debian/postinst +++ b/debian/postinst @@ -1,16 +1,16 @@ #!/bin/sh -# postinst script for printer-proxy +# postinst script for continuum # # See: dh_installdeb(1) set -e -APP_USER="printer-proxy" -APP_GROUP="printer-proxy" -APP_DIR="/opt/printer-proxy" -CONFIG_DIR="/etc/printer-proxy" -DATA_DIR="/var/lib/printer-proxy" -LOG_DIR="/var/log/printer-proxy" +APP_USER="continuum" +APP_GROUP="continuum" +APP_DIR="/opt/continuum" +CONFIG_DIR="/etc/continuum" +DATA_DIR="/var/lib/continuum" +LOG_DIR="/var/log/continuum" case "$1" in configure) @@ -20,7 +20,7 @@ case "$1" in fi if ! getent passwd "$APP_USER" > /dev/null 2>&1; then adduser --system --ingroup "$APP_GROUP" --home "$APP_DIR" \ - --no-create-home --gecos "Printer Proxy" "$APP_USER" + --no-create-home --gecos "Continuum" "$APP_USER" fi # Create required directories @@ -46,35 +46,35 @@ case "$1" in chown -R "$APP_USER:$APP_GROUP" "$APP_DIR/venv" # Install sudoers configuration for network helper - install -m 0440 /dev/stdin /etc/sudoers.d/printer-proxy << 'EOF' -# Printer Proxy - allow web app to run privileged helper scripts -Defaults:printer-proxy !requiretty -printer-proxy ALL=(root) NOPASSWD: /opt/printer-proxy/scripts/network_helper.sh -printer-proxy ALL=(root) NOPASSWD: /usr/bin/systemctl start printer-proxy-update.service -printer-proxy ALL=(root) NOPASSWD: /opt/printer-proxy/scripts/update_helper.sh + install -m 0440 /dev/stdin /etc/sudoers.d/continuum << 'EOF' +# Continuum - allow web app to run privileged helper scripts +Defaults:continuum !requiretty +continuum ALL=(root) NOPASSWD: /opt/continuum/scripts/network_helper.sh +continuum ALL=(root) NOPASSWD: /usr/bin/systemctl start continuum-update.service +continuum ALL=(root) NOPASSWD: /opt/continuum/scripts/update_helper.sh EOF # Install the update service - if [ -f "$APP_DIR/debian/printer-proxy-update.service" ]; then - install -m 0644 "$APP_DIR/debian/printer-proxy-update.service" \ - /etc/systemd/system/printer-proxy-update.service + if [ -f "$APP_DIR/debian/continuum-update.service" ]; then + install -m 0644 "$APP_DIR/debian/continuum-update.service" \ + /etc/systemd/system/continuum-update.service fi # Generate self-signed SSL certificate if it doesn't exist - if [ ! -f /etc/ssl/certs/printer-proxy.crt ]; then + if [ ! -f /etc/ssl/certs/continuum.crt ]; then echo "Generating self-signed SSL certificate..." CERT_CN=$(hostname -f 2>/dev/null || echo "localhost") openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ - -keyout /etc/ssl/private/printer-proxy.key \ - -out /etc/ssl/certs/printer-proxy.crt \ + -keyout /etc/ssl/private/continuum.key \ + -out /etc/ssl/certs/continuum.crt \ -subj "/CN=${CERT_CN}" 2>/dev/null || \ echo "Warning: Could not generate SSL certificate" - chmod 600 /etc/ssl/private/printer-proxy.key 2>/dev/null || true + chmod 600 /etc/ssl/private/continuum.key 2>/dev/null || true fi # Configure nginx if available if [ -d /etc/nginx/sites-available ] && [ -d /etc/nginx/sites-enabled ]; then - cat > /etc/nginx/sites-available/printer-proxy << 'NGINX_EOF' + cat > /etc/nginx/sites-available/continuum << 'NGINX_EOF' server { listen 80; server_name _; @@ -85,8 +85,8 @@ server { listen 443 ssl http2; server_name _; - ssl_certificate /etc/ssl/certs/printer-proxy.crt; - ssl_certificate_key /etc/ssl/private/printer-proxy.key; + ssl_certificate /etc/ssl/certs/continuum.crt; + ssl_certificate_key /etc/ssl/private/continuum.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; @@ -94,10 +94,19 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - access_log /var/log/nginx/printer-proxy-access.log; - error_log /var/log/nginx/printer-proxy-error.log; + access_log /var/log/nginx/continuum-access.log; + error_log /var/log/nginx/continuum-error.log; + # Serve React frontend location / { + root /opt/continuum/frontend/dist; + try_files $uri /index.html; + expires 1h; + add_header Cache-Control "public, must-revalidate"; + } + + # Proxy API requests to Flask backend + location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -108,13 +117,22 @@ server { proxy_read_timeout 30s; } + # Static assets with long cache location /static/ { - alias /opt/printer-proxy/static/; - expires 1d; + alias /opt/continuum/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Frontend assets with cache + location /assets/ { + root /opt/continuum/frontend/dist; + expires 1y; + add_header Cache-Control "public, immutable"; } } NGINX_EOF - ln -sf /etc/nginx/sites-available/printer-proxy /etc/nginx/sites-enabled/ + ln -sf /etc/nginx/sites-available/continuum /etc/nginx/sites-enabled/ rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true if nginx -t 2>/dev/null; then @@ -126,12 +144,12 @@ NGINX_EOF # Reload systemd and enable service systemctl daemon-reload - systemctl enable printer-proxy.service 2>/dev/null || true - systemctl start printer-proxy.service 2>/dev/null || true + systemctl enable continuum.service 2>/dev/null || true + systemctl start continuum.service 2>/dev/null || true echo "" echo "==============================================" - echo " Printer Proxy installed successfully!" + echo " Continuum installed successfully!" echo "==============================================" echo "" HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}') diff --git a/debian/postrm b/debian/postrm index 7bd9819..e645ae1 100755 --- a/debian/postrm +++ b/debian/postrm @@ -1,12 +1,12 @@ #!/bin/sh -# postrm script for printer-proxy +# postrm script for continuum # # See: dh_installdeb(1) set -e -APP_USER="printer-proxy" -APP_GROUP="printer-proxy" +APP_USER="continuum" +APP_GROUP="continuum" case "$1" in purge) @@ -19,34 +19,34 @@ case "$1" in fi # Remove data directories - rm -rf /var/lib/printer-proxy - rm -rf /var/log/printer-proxy - rm -rf /opt/printer-proxy/venv + rm -rf /var/lib/continuum + rm -rf /var/log/continuum + rm -rf /opt/continuum/venv # Remove configuration - rm -rf /etc/printer-proxy - rm -f /etc/sudoers.d/printer-proxy + rm -rf /etc/continuum + rm -f /etc/sudoers.d/continuum # Remove nginx configuration - rm -f /etc/nginx/sites-enabled/printer-proxy - rm -f /etc/nginx/sites-available/printer-proxy + rm -f /etc/nginx/sites-enabled/continuum + rm -f /etc/nginx/sites-available/continuum if systemctl is-active --quiet nginx 2>/dev/null; then systemctl reload nginx 2>/dev/null || true fi # Remove SSL certificates - rm -f /etc/ssl/certs/printer-proxy.crt - rm -f /etc/ssl/private/printer-proxy.key + rm -f /etc/ssl/certs/continuum.crt + rm -f /etc/ssl/private/continuum.key # Remove update service - rm -f /etc/systemd/system/printer-proxy-update.service + rm -f /etc/systemd/system/continuum-update.service systemctl daemon-reload 2>/dev/null || true ;; remove) # Stop and disable update service - systemctl stop printer-proxy-update.service 2>/dev/null || true - rm -f /etc/systemd/system/printer-proxy-update.service + systemctl stop continuum-update.service 2>/dev/null || true + rm -f /etc/systemd/system/continuum-update.service systemctl daemon-reload 2>/dev/null || true ;; diff --git a/debian/prerm b/debian/prerm index 02a4acc..3dd3a7f 100755 --- a/debian/prerm +++ b/debian/prerm @@ -1,5 +1,5 @@ #!/bin/sh -# prerm script for printer-proxy +# prerm script for continuum # # See: dh_installdeb(1) @@ -8,8 +8,8 @@ set -e case "$1" in remove|upgrade|deconfigure) # Stop the service gracefully - if systemctl is-active --quiet printer-proxy.service 2>/dev/null; then - systemctl stop printer-proxy.service || true + if systemctl is-active --quiet continuum.service 2>/dev/null; then + systemctl stop continuum.service || true fi ;; diff --git a/debian/printer-proxy.install b/debian/printer-proxy.install deleted file mode 100644 index 41c6899..0000000 --- a/debian/printer-proxy.install +++ /dev/null @@ -1,14 +0,0 @@ -# Application files -app/* opt/printer-proxy/app/ -config/__init__.py opt/printer-proxy/config/ -config/config.py opt/printer-proxy/config/ -scripts/*.sh opt/printer-proxy/scripts/ -templates/* opt/printer-proxy/templates/ -templates/errors/* opt/printer-proxy/templates/errors/ -static/* opt/printer-proxy/static/ -requirements.txt opt/printer-proxy/ -wsgi.py opt/printer-proxy/ -run.py opt/printer-proxy/ - -# Systemd service files for update functionality -debian/printer-proxy-update.service opt/printer-proxy/debian/ diff --git a/debian/printer-proxy.service b/debian/printer-proxy.service deleted file mode 100644 index 2a9740a..0000000 --- a/debian/printer-proxy.service +++ /dev/null @@ -1,29 +0,0 @@ -[Unit] -Description=Printer Proxy -Documentation=https://github.com/jordonh18/printer-proxy -After=network.target - -[Service] -Type=simple -User=printer-proxy -Group=printer-proxy -WorkingDirectory=/opt/printer-proxy -Environment="PATH=/opt/printer-proxy/venv/bin:/usr/local/bin:/usr/bin:/bin" -ExecStart=/opt/printer-proxy/venv/bin/gunicorn \ - --bind 127.0.0.1:8080 \ - --workers 2 \ - --timeout 30 \ - --access-logfile /var/log/printer-proxy/access.log \ - --error-logfile /var/log/printer-proxy/error.log \ - "app:create_app()" - -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/var/log/printer-proxy /var/lib/printer-proxy - -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules index c8e69cb..013f2fb 100755 --- a/debian/rules +++ b/debian/rules @@ -26,4 +26,4 @@ override_dh_installsystemd: # Ensure scripts are executable override_dh_fixperms: dh_fixperms - chmod 755 debian/printer-proxy/opt/printer-proxy/scripts/*.sh || true + chmod 755 debian/continuum/opt/continuum/scripts/*.sh || true diff --git a/debian/watch b/debian/watch index 8c18987..4f860e1 100644 --- a/debian/watch +++ b/debian/watch @@ -3,6 +3,6 @@ # # For future reference, if converting to non-native: # version=4 -# opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/printer-proxy-$1\.tar\.gz/ \ -# https://github.com/Jordonh18/printer-proxy/releases \ +# opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/continuum-$1\.tar\.gz/ \ +# https://github.com/Jordonh18/continuum/releases \ # .*/v?(\d\S+)\.tar\.gz diff --git a/docs/README.md b/docs/README.md index de7c9e2..6f52eb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,25 +1,25 @@ -# Printer Proxy APT Repository +# Continuum APT Repository -This directory contains the APT repository for Printer Proxy, hosted via GitHub Pages. +This directory contains the APT repository for Continuum, hosted via GitHub Pages. ## Repository URL ``` -https://jordonh18.github.io/printer-proxy +[https://apt.jordonh.me](https://apt.jordonh.me) ``` ## Installation ```bash # Add the GPG signing key -curl -fsSL https://jordonh18.github.io/printer-proxy/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/printer-proxy.gpg +curl -fsSL https://apt.jordonh.me/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/continuum.gpg # Add the repository -echo "deb [signed-by=/usr/share/keyrings/printer-proxy.gpg] https://jordonh18.github.io/printer-proxy stable main" | sudo tee /etc/apt/sources.list.d/printer-proxy.list +echo "deb [signed-by=/usr/share/keyrings/continuum.gpg] https://apt.jordonh.me stable main" | sudo tee /etc/apt/sources.list.d/continuum.list # Install sudo apt update -sudo apt install printer-proxy +sudo apt install continuum ``` ## Structure @@ -39,7 +39,7 @@ apt-repo/ │ └── Packages.gz # Compressed package index └── pool/ └── main/ - └── printer-proxy_*.deb # Package files + └── continuum_*.deb # Package files ``` ## GPG Signing diff --git a/docs/WORKFLOW_VARIABLES.md b/docs/WORKFLOW_VARIABLES.md new file mode 100644 index 0000000..d62da3f --- /dev/null +++ b/docs/WORKFLOW_VARIABLES.md @@ -0,0 +1,268 @@ +# Workflow Variables & Data Flow System + +This document explains how data flows between nodes in the Continuum workflow engine. + +## Overview + +The workflow engine supports a **key-based variable system** that allows nodes to: +1. **Output data** - Each node can produce outputs that downstream nodes can access +2. **Reference variables** - Use `{{variable_name}}` syntax to reference data from upstream nodes +3. **Mix static and dynamic values** - Some inputs support dropdown selection while still allowing variable references + +## Variable Syntax + +Variables use double curly braces: `{{variable_name}}` + +### Basic Variables +- `{{printer_id}}` - The ID of a printer +- `{{printer_name}}` - The name of a printer +- `{{timestamp}}` - ISO timestamp of the event + +### Nested Variables +- `{{source_printer.name}}` - Access nested object properties +- `{{source_printer.ip}}` - Printer IP address +- `{{payload.custom_field}}` - Access webhook payload fields + +## Node Outputs + +### Trigger Nodes + +#### `trigger.event` - Event Trigger +Fires when a printer or system event occurs. + +| Output Key | Type | Description | +|------------|------|-------------| +| `event_type` | string | Type of event (printer_offline, printer_online, etc.) | +| `printer_id` | string | ID of the affected printer | +| `printer_name` | string | Name of the affected printer | +| `printer_ip` | string | IP address of the printer | +| `timestamp` | string | ISO timestamp when event occurred | + +#### `trigger.health_change` - Health Change +Fires when a printer's health state changes. + +| Output Key | Type | Description | +|------------|------|-------------| +| `printer_id` | string | ID of the printer | +| `printer_name` | string | Name of the printer | +| `printer_ip` | string | IP address of the printer | +| `previous_state` | string | Previous health state (online/offline) | +| `new_state` | string | New health state (online/offline) | +| `timestamp` | string | ISO timestamp of state change | + +#### `trigger.webhook` - Webhook Trigger +Fires when an external webhook is received. + +| Output Key | Type | Description | +|------------|------|-------------| +| `payload` | object | Full JSON payload from the webhook | +| `headers` | object | HTTP headers from the request | +| `timestamp` | string | ISO timestamp of receipt | + +#### `trigger.schedule` - Schedule Trigger +Fires on a cron schedule. + +| Output Key | Type | Description | +|------------|------|-------------| +| `scheduled_time` | string | Scheduled execution time | +| `actual_time` | string | Actual execution time | +| `timestamp` | string | ISO timestamp | + +#### `trigger.queue_threshold` - Queue Threshold +Fires when print queue exceeds threshold. + +| Output Key | Type | Description | +|------------|------|-------------| +| `printer_id` | string | ID of the printer | +| `printer_name` | string | Name of the printer | +| `queue_count` | number | Current jobs in queue | +| `threshold` | number | Configured threshold | +| `timestamp` | string | ISO timestamp | + +### Action Nodes + +#### `action.redirect` - Activate Redirect +Creates a printer redirect. Accepts dynamic source printer. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `source_printer_id` | string | ✅ Yes | Use `{{printer_id}}` or select from dropdown | +| `target_printer_id` | string | ✅ Yes | Target printer for redirect | +| `port` | number | ❌ No | Port number (default 9100) | + +| Output Key | Type | Description | +|------------|------|-------------| +| `redirect_id` | string | ID of created redirect | +| `source_printer_id` | string | Source printer ID | +| `source_printer_name` | string | Source printer name | +| `source_printer_ip` | string | Source printer IP | +| `target_printer_id` | string | Target printer ID | +| `target_printer_name` | string | Target printer name | +| `target_printer_ip` | string | Target printer IP | +| `success` | boolean | Whether redirect was created | + +#### `action.redirect.disable` - Deactivate Redirect +Disables an active redirect. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `source_printer_id` | string | ✅ Yes | Printer with active redirect | + +| Output Key | Type | Description | +|------------|------|-------------| +| `source_printer_id` | string | Source printer ID | +| `success` | boolean | Whether redirect was disabled | + +#### `action.notify.inapp` - In-App Notification +Creates an in-app notification for all users. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `title` | string | ✅ Yes | Notification title | +| `message` | string | ✅ Yes | Notification message (supports variables) | +| `link` | string | ✅ Yes | Optional link URL | + +**Example message:** `Traffic from {{printer_name}} redirected to {{target_printer_name}}` + +#### `action.notify.email` - Send Email +Sends an email notification. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `to` | string | ✅ Yes | Recipient email address | +| `subject` | string | ✅ Yes | Email subject | +| `body` | string | ✅ Yes | Email body (supports variables) | + +#### `action.audit` - Audit Log Entry +Records an entry to the audit log. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `action` | string | ✅ Yes | Action type for audit | +| `details` | string | ✅ Yes | Details message (supports variables) | + +### Logic Nodes + +#### `logic.condition` - Condition +Branches workflow based on a condition. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `field` | string | ✅ Yes | Field to evaluate | +| `operator` | string | ❌ No | equals, not_equals, contains, etc. | +| `value` | string | ✅ Yes | Value to compare | + +| Output Handle | Description | +|---------------|-------------| +| `true` | Condition matched | +| `false` | Condition not matched | + +#### `logic.switch` - Switch +Routes to different outputs based on value. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `field` | string | ✅ Yes | Field to switch on | +| `cases` | object | ❌ No | Case definitions | + +### Transform Nodes + +#### `transform.template` - Template +Renders a template with variables. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `template` | string | ✅ Yes | Template with {{variables}} | + +| Output Key | Type | Description | +|------------|------|-------------| +| `result` | string | Rendered template output | + +#### `transform.set_variable` - Set Variable +Sets a custom variable for downstream nodes. + +| Input Key | Type | Dynamic | Description | +|-----------|------|---------|-------------| +| `name` | string | ❌ No | Variable name | +| `value` | string | ✅ Yes | Variable value | + +| Output Key | Type | Description | +|------------|------|-------------| +| `[name]` | any | The variable you set | + +## Example Workflows + +### Auto-Redirect on Printer Failure + +``` +[Health Change] → [Activate Redirect] → [In-App Notification] + ↓ ↓ ↓ + Outputs: Inputs: Inputs: + - printer_id - source_printer_id - message: "Traffic from + - printer_name = {{printer_id}} {{source_printer_name}} + - printer_ip - target_printer_id redirected to + = (dropdown) {{target_printer_name}}" +``` + +**Health Change Node:** +- Configured to detect "offline" state for a specific printer +- When triggered, outputs: `printer_id`, `printer_name`, `printer_ip` + +**Activate Redirect Node:** +- `source_printer_id`: Set to `{{printer_id}}` (from Health Change) +- `target_printer_id`: Selected from dropdown (static backup printer) +- After execution, outputs: `source_printer_name`, `target_printer_name`, `redirect_id` + +**In-App Notification Node:** +- `message`: `Traffic from {{source_printer_name}} redirected to {{target_printer_name}}` +- Variables are resolved from the cumulative context + +### Webhook Integration + +``` +[Webhook Trigger] → [Condition] → [Activate Redirect] + ↓ false + [Audit Log] +``` + +**Webhook Trigger:** +- Receives external payload with `printer_id` and `action` fields +- Outputs: `payload.printer_id`, `payload.action`, `headers`, `timestamp` + +**Condition Node:** +- `field`: `{{payload.action}}` +- `operator`: `equals` +- `value`: `redirect` + +## Accessing Variables in the UI + +When editing a node in the workflow editor: + +1. **Text fields** - Type `{{` to see available variables from upstream nodes +2. **Select fields** - Some dropdowns show a "Use Variable" toggle +3. **Template fields** - Full variable syntax support with auto-complete + +## Context Accumulation + +Variables accumulate as the workflow executes: + +1. **Trigger fires** → Context contains trigger outputs +2. **Action 1 executes** → Context now includes trigger + action 1 outputs +3. **Action 2 executes** → Context includes trigger + action 1 + action 2 outputs + +Each node can access all variables from nodes that executed before it in the flow. + +## Debugging Variables + +To debug what variables are available at any point: + +1. Add an **Audit Log** node +2. Set details to: `Context: {{__debug__}}` +3. Check the audit log for full context dump + +## Best Practices + +1. **Use descriptive node labels** - Helps identify which node produced which output +2. **Test with real data** - Create test workflows to verify variable resolution +3. **Check for required variables** - Ensure upstream nodes provide needed data +4. **Use Set Variable nodes** - Create aliases for complex variable paths diff --git a/docs/dists/stable/Release b/docs/dists/stable/Release index 9e73790..d6dfd7a 100644 --- a/docs/dists/stable/Release +++ b/docs/dists/stable/Release @@ -1,11 +1,11 @@ -Origin: Printer Proxy -Label: Printer Proxy +Origin: Continuum +Label: Continuum Suite: stable Codename: stable Date: Mon, 19 Jan 2026 09:13:21 +0000 Architectures: all Components: main -Description: Printer Proxy APT Repository +Description: Continuum APT Repository MD5Sum: 0d0b6460db0bf57243ca8cacf34bd0f7 791 main/binary-all/Packages 50f17c4f844e9986fec454b75344d6cb 540 main/binary-all/Packages.gz diff --git a/docs/dists/stable/main/binary-all/Packages b/docs/dists/stable/main/binary-all/Packages index 8ed11e1..031b8d6 100644 --- a/docs/dists/stable/main/binary-all/Packages +++ b/docs/dists/stable/main/binary-all/Packages @@ -1,15 +1,15 @@ -Package: printer-proxy +Package: continuum Version: 1.4.24 Architecture: all Maintainer: jordonh18 Depends: python3 (>= 3.9), python3-venv, python3-pip, iptables, iproute2, iputils-ping, arping, nginx, openssl Priority: optional Section: admin -Filename: pool/main/printer-proxy_1.4.24_all.deb +Filename: pool/main/continuum_1.4.24_all.deb Size: 163928 MD5sum: 34387565f367a795e8f7feac9bae5692 SHA256: 796d640a8f4b85ac7651b1ee1aef58e2aa95a3738ad8dad64e5c4fa66722834c -Description: Printer Proxy +Description: Continuum A web-based tool to redirect print traffic from one network printer to another without reconfiguring clients. . diff --git a/docs/index.html b/docs/index.html index 76eebe7..343a424 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,7 +1,7 @@ - Printer Proxy APT Repository + Continuum APT Repository -

Printer Proxy APT Repository

+

Continuum APT Repository

-

Official APT repository for Printer Proxy - a network traffic redirection solution for network printers.

+

Official APT repository for Continuum - a network traffic redirection solution for network printers.

Installation

@@ -27,24 +27,24 @@

Installation

1 Download and add the GPG signing key: -
curl -fsSL https://jordonh18.github.io/printer-proxy/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/printer-proxy.gpg
+
curl -fsSL https://apt.jordonh.me/gpg-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/continuum.gpg
2 Add the repository:
-
echo "deb [signed-by=/usr/share/keyrings/printer-proxy.gpg] https://jordonh18.github.io/printer-proxy stable main" | sudo tee /etc/apt/sources.list.d/printer-proxy.list
+
echo "deb [signed-by=/usr/share/keyrings/continuum.gpg] https://apt.jordonh.me stable main" | sudo tee /etc/apt/sources.list.d/continuum.list
3 Update and install:
sudo apt update
-sudo apt install printer-proxy
+sudo apt install continuum

Upgrading

sudo apt update
-sudo apt upgrade printer-proxy
+sudo apt upgrade continuum

After Installation

    @@ -56,7 +56,7 @@

    After Installation

    Repository Information

      -
    • Repository URL: https://jordonh18.github.io/printer-proxy
    • +
    • Repository URL: https://apt.jordonh.me
    • Distribution: stable
    • Component: main
    • Architecture: all (platform independent)
    • @@ -66,15 +66,15 @@

      Available Packages

      View the Packages file for available versions.

      Source Code

      -

      View the source code and report issues on GitHub.

      +

      View the source code and report issues on GitHub.

      Author

      Jordon Harrison (@Jordonh18)

      Uninstallation

      -
      sudo apt remove printer-proxy
      -sudo rm /etc/apt/sources.list.d/printer-proxy.list
      -sudo rm /usr/share/keyrings/printer-proxy.gpg
      +
      sudo apt remove continuum
      +sudo rm /etc/apt/sources.list.d/continuum.list
      +sudo rm /usr/share/keyrings/continuum.gpg
      diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f033710 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "hugeicons", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f2de878 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Continuum + + +
      + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0393a93 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,10318 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@base-ui/react": "^1.1.0", + "@fontsource-variable/nunito-sans": "^5.2.7", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.11", + "@tanstack/react-query": "^5.90.19", + "@xyflow/react": "^12.4.4", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.27.1", + "i18next": "^25.7.4", + "lucide-react": "^0.562.0", + "moment-timezone": "^0.5.48", + "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.3", + "react-router-dom": "^7.12.0", + "recharts": "^2.15.4", + "shadcn": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@antfu/ni": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", + "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", + "license": "MIT", + "dependencies": { + "ansis": "^4.0.0", + "fzf": "^0.5.2", + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "na": "bin/na.mjs", + "nci": "bin/nci.mjs", + "ni": "bin/ni.mjs", + "nlx": "bin/nlx.mjs", + "nr": "bin/nr.mjs", + "nun": "bin/nun.mjs", + "nup": "bin/nup.mjs" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.1.0.tgz", + "integrity": "sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@base-ui/utils": "0.2.4", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.4.tgz", + "integrity": "sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.4.tgz", + "integrity": "sha512-AoziS8lRQ3ew/lY5J4JSlzYSN9Fo0oiyMBY37L3Bwq4mOQJT5GSrdZYLFPt6pH1LApDI3ZJceNyx+rHRACZSeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", + "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/nunito-sans": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito-sans/-/nunito-sans-5.2.7.tgz", + "integrity": "sha512-rflH5HeAoNjIkwftStzQvxqCJhIS+usMr/lfslWg/RCa499B5qxyjo+74R/QMM0geQt4AY1URlG7wAR1iq6KPQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hugeicons/core-free-icons": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz", + "integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==", + "license": "MIT" + }, + "node_modules/@hugeicons/react": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hugeicons/react/-/react-1.1.4.tgz", + "integrity": "sha512-gsc3eZyd2fGqRUThW9+lfjxxsOkz6KNVmRXRgJjP32GL0OnnLJnl3hytKt47CBbiQj2xE2kCw+rnP3UQCThcKw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", + "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", + "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", + "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", + "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", + "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", + "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", + "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", + "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", + "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", + "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", + "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", + "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", + "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", + "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", + "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", + "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", + "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", + "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", + "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", + "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", + "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", + "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", + "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", + "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", + "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz", + "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", + "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "license": "MIT" + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", + "integrity": "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.4", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/framer-motion": { + "version": "12.27.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.1.tgz", + "integrity": "sha512-cEAqO69kcZt3gL0TGua8WTgRQfv4J57nqt1zxHtLKwYhAwA0x9kDS/JbMa1hJbwkGY74AGJKvZ9pX/IqWZtZWQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.27.1", + "motion-utils": "^12.24.10", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/fzf": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", + "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", + "license": "BSD-3-Clause" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/i18next": { + "version": "25.7.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz", + "integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "12.27.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.1.tgz", + "integrity": "sha512-V/53DA2nBqKl9O2PMJleSUb/G0dsMMeZplZwgIQf5+X0bxIu7Q1cTv6DrjvTTGYRm3+7Y5wMlRZ1wT61boU/bQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.24.10" + } + }, + "node_modules/motion-utils": { + "version": "12.24.10", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz", + "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz", + "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-i18next": { + "version": "16.5.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.3.tgz", + "integrity": "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", + "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.2", + "@rollup/rollup-android-arm64": "4.55.2", + "@rollup/rollup-darwin-arm64": "4.55.2", + "@rollup/rollup-darwin-x64": "4.55.2", + "@rollup/rollup-freebsd-arm64": "4.55.2", + "@rollup/rollup-freebsd-x64": "4.55.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", + "@rollup/rollup-linux-arm-musleabihf": "4.55.2", + "@rollup/rollup-linux-arm64-gnu": "4.55.2", + "@rollup/rollup-linux-arm64-musl": "4.55.2", + "@rollup/rollup-linux-loong64-gnu": "4.55.2", + "@rollup/rollup-linux-loong64-musl": "4.55.2", + "@rollup/rollup-linux-ppc64-gnu": "4.55.2", + "@rollup/rollup-linux-ppc64-musl": "4.55.2", + "@rollup/rollup-linux-riscv64-gnu": "4.55.2", + "@rollup/rollup-linux-riscv64-musl": "4.55.2", + "@rollup/rollup-linux-s390x-gnu": "4.55.2", + "@rollup/rollup-linux-x64-gnu": "4.55.2", + "@rollup/rollup-linux-x64-musl": "4.55.2", + "@rollup/rollup-openbsd-x64": "4.55.2", + "@rollup/rollup-openharmony-arm64": "4.55.2", + "@rollup/rollup-win32-arm64-msvc": "4.55.2", + "@rollup/rollup-win32-ia32-msvc": "4.55.2", + "@rollup/rollup-win32-x64-gnu": "4.55.2", + "@rollup/rollup-win32-x64-msvc": "4.55.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.7.0.tgz", + "integrity": "sha512-zOXNAIFclguSYmmoibyXyKiYA6qjEJtXDSvloAMziSREW9Q0R/dLqBUYdb81lOejmZkDYuZApGabbMLH7G8qvQ==", + "license": "MIT", + "dependencies": { + "@antfu/ni": "^25.0.0", + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.17.2", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "msw": "^2.10.4", + "node-fetch": "^3.3.2", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz", + "integrity": "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", + "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.53.0", + "@typescript-eslint/parser": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..033a02e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,56 @@ +{ + "name": "continuum-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@base-ui/react": "^1.1.0", + "@fontsource-variable/nunito-sans": "^5.2.7", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.11", + "@tanstack/react-query": "^5.90.19", + "@xyflow/react": "^12.4.4", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.27.1", + "i18next": "^25.7.4", + "lucide-react": "^0.562.0", + "moment-timezone": "^0.5.48", + "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.3", + "react-router-dom": "^7.12.0", + "recharts": "^2.15.4", + "shadcn": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5a653d4 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,159 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '@/contexts/AuthContext'; +import { BackendStatusProvider, useBackendStatus } from '@/contexts/BackendStatusContext'; +import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { DashboardLayout } from '@/components/layout/DashboardLayout'; +import { BackendUnavailable } from '@/components/layout/BackendUnavailable'; +import { LoginPage } from '@/pages/LoginPage'; +import { SetupPage } from '@/pages/SetupPage'; +import { DashboardPage } from '@/pages/DashboardPage'; +import { PrintersPage } from '@/pages/PrintersPage'; +import { PrinterDetailPage } from '@/pages/PrinterDetailPage'; +import { RedirectsPage } from '@/pages/RedirectsPage'; +import { GroupsPage } from '@/pages/GroupsPage'; +import { WorkflowsPage } from '@/pages/WorkflowsPage'; +import { WorkflowEditorPage } from '@/pages/WorkflowEditorPage'; +import { UsersPage } from '@/pages/UsersPage'; +import { SettingsPage } from '@/pages/SettingsPage'; +import { AdminGeneralPage } from '@/pages/AdminGeneralPage'; +import { AdminNotificationsPage } from '@/pages/AdminNotificationsPage'; +import { AdminIntegrationsPage } from '@/pages/AdminIntegrationsPage'; +import { AuditLogPage } from '@/pages/AuditLogPage'; +import { NotificationsPage } from '@/pages/NotificationsPage'; +import NetworkingPage from '@/pages/NetworkingPage'; +import { Toaster } from '@/components/ui/sonner'; +import { useNotificationStream } from '@/hooks/useNotifications'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + retry: 1, + }, + }, +}); + +// Component to initialize notification stream for authenticated users +function NotificationStreamProvider({ children }: { children: React.ReactNode }) { + useNotificationStream(); + return <>{children}; +} + +// Wrapper that checks backend status before rendering children +function BackendStatusGate({ children }: { children: React.ReactNode }) { + const { isBackendAvailable, isChecking } = useBackendStatus(); + + // Show loading state during initial check + if (isChecking && !isBackendAvailable) { + return ( +
      +
      +
      + ); + } + + // Show backend unavailable page if backend is down + if (!isBackendAvailable) { + return ; + } + + return <>{children}; +} + +function App() { + return ( + + + + + + + + {/* Public routes */} + } /> + } /> + + {/* Protected routes */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + {/* Default redirect */} + } /> + } /> + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..28109ad --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,31 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import { Loader2 } from 'lucide-react'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRoles?: ('admin' | 'operator' | 'viewer')[]; +} + +export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, user } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
      + +
      + ); + } + + if (!isAuthenticated) { + return ; + } + + if (requiredRoles && user && !requiredRoles.includes(user.role)) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/layout/BackendUnavailable.tsx b/frontend/src/components/layout/BackendUnavailable.tsx new file mode 100644 index 0000000..1175345 --- /dev/null +++ b/frontend/src/components/layout/BackendUnavailable.tsx @@ -0,0 +1,31 @@ +import { ServerOff } from 'lucide-react'; + +export function BackendUnavailable() { + return ( +
      +
      +
      + +
      + +
      +

      + Backend Unavailable +

      +

      + The Continuum backend service is not responding. Please check that the service is running. +

      +
      + + + Need help? Report an issue → + +
      +
      + ); +} diff --git a/frontend/src/components/layout/DashboardLayout.tsx b/frontend/src/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..b93dec2 --- /dev/null +++ b/frontend/src/components/layout/DashboardLayout.tsx @@ -0,0 +1,366 @@ +import * as React from 'react'; +import { Link, useLocation, Outlet } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import type { LucideIcon } from 'lucide-react'; +import { + LayoutDashboard, + Printer, + Layers, + ArrowRightLeft, + GitBranch, + ClipboardList, + Users, + Settings, + LogOut, + ArrowLeft, + User, + Shield, + Bell, + KeyRound, + Plug, + ChevronUp, + Server, + PanelLeftClose, + PanelLeftOpen, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuAction, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from '@/components/ui/sidebar'; +import { NotificationBell } from './NotificationBell'; + +interface NavItem { + name: string; + href: string; + icon: LucideIcon; + roles?: string[]; +} + +interface NavGroup { + label: string; + items: NavItem[]; +} + +const navigationGroups: NavGroup[] = [ + { + label: 'Overview', + items: [ + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + ], + }, + { + label: 'Fleet', + items: [ + { name: 'Printers', href: '/printers', icon: Printer }, + { name: 'Groups', href: '/groups', icon: Layers }, + { name: 'Redirects', href: '/redirects', icon: ArrowRightLeft, roles: ['admin', 'operator'] }, + { name: 'Workflows', href: '/workflows', icon: GitBranch }, + ], + }, + { + label: 'Administration', + items: [ + { name: 'General', href: '/admin/general', icon: Settings, roles: ['admin'] }, + { name: 'Notifications', href: '/admin/notifications', icon: Bell, roles: ['admin'] }, + { name: 'Integrations', href: '/admin/integrations', icon: Plug, roles: ['admin'] }, + { name: 'Networking', href: '/networking', icon: Server, roles: ['admin', 'operator'] }, + { name: 'Audit Log', href: '/audit-logs', icon: ClipboardList, roles: ['admin'] }, + { name: 'Users', href: '/users', icon: Users, roles: ['admin'] }, + ], + }, +]; + +export function DashboardLayout() { + const { user, logout } = useAuth(); + const location = useLocation(); + const { t } = useTranslation(); + const isSettingsRoute = location.pathname.startsWith('/settings'); + const isWorkflowEditorRoute = location.pathname.match(/^\/workflows\/\d+$/); + const activeSettingsTab = new URLSearchParams(location.search).get('tab') || 'account'; + + const filteredNavigationGroups = navigationGroups.map(group => ({ + ...group, + items: group.items.filter( + (item) => !item.roles || (user && item.roles.includes(user.role)) + ), + })).filter(group => group.items.length > 0); + + return ( + + + + +
      + + Menu +
      +
      + +
      +
      +
      + ); +} + +interface AppSidebarProps { + user: { username: string; role: string } | null; + logout: () => void; + location: ReturnType; + isSettingsRoute: boolean; + filteredNavigationGroups: NavGroup[]; + activeSettingsTab: string; + t: (key: string) => string; +} + +function AppSidebar({ + user, + logout, + location, + isSettingsRoute, + filteredNavigationGroups, + activeSettingsTab, + t, +}: AppSidebarProps) { + const { state, toggleSidebar } = useSidebar(); + const [isHovering, setIsHovering] = React.useState(false); + const isCollapsed = state === 'collapsed'; + + return ( + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + + + + +
      + + {isCollapsed && isHovering ? ( + { + e.preventDefault(); + toggleSidebar(); + }} + className="cursor-pointer flex items-center justify-center" + > + + + ) : ( + + + + )} + +
      +
      + Continuum + Fleet Management +
      + +
      + + + Collapse Sidebar + +
      +
      +
      + + + + {isSettingsRoute ? ( + + + {t('settingsNav')} + + + + + + + {t('settingsBack')} + + + + + + + + + {t('general')} + + + + + + + {t('accountInfo')} + + + + + + + + {t('security')} + + + + + + + + {t('notifications')} + + + + + + + + {t('apiTokens')} + + + + + + + + ) : ( + + {filteredNavigationGroups.map((group) => ( + + {group.label} + + + {group.items.map((item) => { + const isActive = + location.pathname === item.href || + (item.href !== '/dashboard' && location.pathname.startsWith(item.href)); + + return ( + + + + + {item.name} + + + + ); + })} + + + + ))} + + )} + + + + + + + + + + + + + + + {user?.username.charAt(0).toUpperCase()} + + +
      + {user?.username} + {user?.role} +
      + +
      +
      + + + + + User Settings + + + + + + Sign out + + +
      +
      +
      +
      +
      + ); +} diff --git a/frontend/src/components/layout/NotificationBell.tsx b/frontend/src/components/layout/NotificationBell.tsx new file mode 100644 index 0000000..7156f00 --- /dev/null +++ b/frontend/src/components/layout/NotificationBell.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Inbox } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { SidebarMenuButton } from '@/components/ui/sidebar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useNavigate } from 'react-router-dom'; +import { useUnreadCount, useNotifications, useMarkAsRead, useMarkAllAsRead } from '@/hooks/useNotifications'; +import { cn } from '@/lib/utils'; + +export function NotificationBell() { + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + const { data: unreadData } = useUnreadCount(); + const { data: notificationsData } = useNotifications({ limit: 5 }); + const markAsRead = useMarkAsRead(); + const markAllAsRead = useMarkAllAsRead(); + + const unreadCount = unreadData?.count || 0; + const notifications = notificationsData?.notifications || []; + + const handleNotificationClick = (notification: any) => { + // Mark as read + if (!notification.is_read) { + markAsRead.mutate(notification.id); + } + + // Navigate if link provided + if (notification.link) { + navigate(notification.link); + } + + setOpen(false); + }; + + const handleMarkAllRead = () => { + markAllAsRead.mutate(); + }; + + const handleViewAll = () => { + navigate('/notifications'); + setOpen(false); + }; + + const formatTimeAgo = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + return date.toLocaleDateString(); + }; + + return ( + + + + + Inbox + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + + +
      +

      Notifications

      + {unreadCount > 0 && ( + + )} +
      + + + {notifications.length === 0 ? ( +
      + No notifications +
      + ) : ( +
      + {notifications.map((notification) => ( + handleNotificationClick(notification)} + > +
      +
      +
      +

      + {notification.title} +

      + {!notification.is_read && ( + + )} +
      + {notification.message && notification.message.trim() && ( +

      + {notification.message} +

      + )} +

      + {formatTimeAgo(notification.created_at)} +

      +
      +
      +
      + ))} +
      + )} + + + + View all notifications + +
      +
      + ); +} diff --git a/frontend/src/components/printers/PrinterCard.tsx b/frontend/src/components/printers/PrinterCard.tsx new file mode 100644 index 0000000..c81d7de --- /dev/null +++ b/frontend/src/components/printers/PrinterCard.tsx @@ -0,0 +1,79 @@ +import { Link } from 'react-router-dom'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { StatusBadge } from './StatusBadge'; +import { Printer, MapPin, ArrowRight } from 'lucide-react'; +import type { PrinterStatus } from '@/types/api'; + +interface PrinterCardProps { + printerStatus: PrinterStatus; +} + +export function PrinterCard({ printerStatus }: PrinterCardProps) { + const { printer, status, redirect_target, redirect_source } = printerStatus; + const group = printerStatus.group; + const isOnline = status?.is_online ?? (printerStatus as unknown as { is_online?: boolean }).is_online ?? false; + const hasRedirect = status?.is_redirected ?? (printerStatus as unknown as { has_redirect?: boolean }).has_redirect ?? false; + const isTarget = status?.is_redirect_target ?? (printerStatus as unknown as { is_target?: boolean }).is_target ?? false; + + const getStatus = () => { + if (hasRedirect) return 'redirected'; + if (isTarget) return 'target'; + if (isOnline) return 'online'; + return 'offline'; + }; + + return ( + + + +
      +
      + +
      +
      +

      {printer.name}

      +

      {printer.ip}

      + {group?.name && ( + + {group.name} + + )} +
      +
      + +
      + + {printer.location && ( +
      + + {printer.location} +
      + )} + + {hasRedirect && redirect_target && ( +
      + + + Redirecting to {redirect_target.name} + +
      + )} + + {isTarget && redirect_source && ( +
      + + + Receiving from {redirect_source.name} + +
      + )} + + {printer.model && ( +

      {printer.model}

      + )} +
      +
      + + ); +} diff --git a/frontend/src/components/printers/StatusBadge.tsx b/frontend/src/components/printers/StatusBadge.tsx new file mode 100644 index 0000000..e5efcc3 --- /dev/null +++ b/frontend/src/components/printers/StatusBadge.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; + +interface StatusBadgeProps { + status: 'online' | 'offline' | 'redirected' | 'target'; + className?: string; +} + +export function StatusBadge({ status, className }: StatusBadgeProps) { + const variants = { + online: { variant: 'outline' as const, label: 'Online' }, + offline: { variant: 'outline' as const, label: 'Offline' }, + redirected: { variant: 'outline' as const, label: 'Redirected' }, + target: { variant: 'outline' as const, label: 'Target' }, + }; + + const { variant, label } = variants[status]; + + return ( + + {label} + + ); +} diff --git a/frontend/src/components/settings/APITokensTab.tsx b/frontend/src/components/settings/APITokensTab.tsx new file mode 100644 index 0000000..09d14ac --- /dev/null +++ b/frontend/src/components/settings/APITokensTab.tsx @@ -0,0 +1,362 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiTokensApi } from '@/lib/api'; +import type { APIToken, TokenPermissions } from '@/types/api'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Loader2, Key, Trash2, Copy, Check } from 'lucide-react'; +import { useState } from 'react'; + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'} ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} ${months === 1 ? 'month' : 'months'} ago`; + const years = Math.floor(months / 12); + return `${years} ${years === 1 ? 'year' : 'years'} ago`; +} + +export function APITokensTab() { + const queryClient = useQueryClient(); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showTokenDialog, setShowTokenDialog] = useState(false); + const [deleteTokenId, setDeleteTokenId] = useState(null); + const [newToken, setNewToken] = useState(''); + const [copied, setCopied] = useState(false); + + const [tokenForm, setTokenForm] = useState({ + name: '', + permissions: [] as string[], + expires_in_days: 'never', + }); + + const { data: tokensData, isLoading } = useQuery({ + queryKey: ['api-tokens'], + queryFn: apiTokensApi.list, + }); + + const { data: permissionsData } = useQuery({ + queryKey: ['api-token-permissions'], + queryFn: apiTokensApi.getPermissions, + }); + + const createMutation = useMutation({ + mutationFn: apiTokensApi.create, + onSuccess: (data) => { + setNewToken(data.token.token); + setShowCreateDialog(false); + setShowTokenDialog(true); + setTokenForm({ name: '', permissions: [], expires_in_days: 'never' }); + queryClient.invalidateQueries({ queryKey: ['api-tokens'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: apiTokensApi.delete, + onSuccess: () => { + setDeleteTokenId(null); + queryClient.invalidateQueries({ queryKey: ['api-tokens'] }); + }, + }); + + const handleCopyToken = () => { + if (newToken) { + navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleCreateToken = () => { + if (!tokenForm.name || tokenForm.permissions.length === 0) return; + + createMutation.mutate({ + name: tokenForm.name, + permissions: tokenForm.permissions, + expires_in_days: tokenForm.expires_in_days && tokenForm.expires_in_days !== 'never' + ? parseInt(tokenForm.expires_in_days) + : undefined, + }); + }; + + const togglePermission = (perm: string) => { + setTokenForm(prev => ({ + ...prev, + permissions: prev.permissions.includes(perm) + ? prev.permissions.filter(p => p !== perm) + : [...prev.permissions, perm], + })); + }; + + const tokens = tokensData?.tokens || []; + const grouped = permissionsData?.grouped || {}; + + return ( + <> +
      + + +
      +
      + API Tokens + + Create tokens for programmatic access to the API. Each token can have granular permissions based on your role. + +
      + +
      +
      + + {isLoading ? ( +
      + +
      + ) : tokens.length === 0 ? ( +
      + +

      No API tokens yet

      +

      Create a token to get started with API access

      +
      + ) : ( +
      + {tokens.map((token: APIToken) => ( +
      +
      +
      +

      {token.name}

      + {token.expires_at && new Date(token.expires_at) < new Date() && ( + Expired + )} +
      +
      + {token.last_used_at ? ( + Last used {formatTimeAgo(token.last_used_at)} + ) : ( + Never used + )} + {token.expires_at && ( + Expires {formatTimeAgo(token.expires_at)} + )} + {token.permissions.length} {token.permissions.length === 1 ? 'permission' : 'permissions'} +
      +
      + +
      + ))} +
      + )} +
      +
      +
      + + {/* Create Token Dialog */} + + + + Create API Token + + Choose a name and select the permissions for this token. Permissions are limited to those available to your role. + + + +
      +
      + + setTokenForm({ ...tokenForm, name: e.target.value })} + /> +
      + +
      + + +
      + +
      + +
      + {Object.entries(grouped).map(([resource, actions]) => ( +
      +

      {resource}

      +
      + {(actions as string[]).map((action) => { + const perm = `${resource}:${action}`; + return ( +
      + togglePermission(perm)} + /> + +
      + ); + })} +
      +
      + ))} +
      + {tokenForm.permissions.length === 0 && ( +

      At least one permission is required

      + )} +
      +
      + + + + + +
      +
      + + {/* Show Token Dialog */} + { + if (!open) { + setShowTokenDialog(false); + setNewToken(''); + setCopied(false); + } + }}> + + + Token Created Successfully + + Save this token now - you won't be able to see it again! + + + +
      +
      + {newToken} +
      + +
      + + + + +
      +
      + + {/* Delete Confirmation */} + setDeleteTokenId(null)}> + + + Delete API Token? + + This action cannot be undone. Applications using this token will lose access immediately. + + + + Cancel + { + if (deleteTokenId) { + deleteMutation.mutate(deleteTokenId); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + ); +} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..ac4d380 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,182 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..75c377d --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..b61bcc8 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + success: "bg-success/10 text-success [a]:hover:bg-success/20", + warning: "bg-warning/10 text-warning [a]:hover:bg-warning/20", + info: "bg-info/10 text-info [a]:hover:bg-info/20", + error: "bg-error/10 text-error [a]:hover:bg-error/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..b326a81 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", + destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..84bfaa4 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,94 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
      img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx new file mode 100644 index 0000000..1914890 --- /dev/null +++ b/frontend/src/components/ui/chart.tsx @@ -0,0 +1,354 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
      + + + {children} + +
      +
      + ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + -{% endblock %} - -{% block content %} - - -
      - {% if logs %} -
      - - - - - - - - - - - - - - {% for log in logs %} - - - - - - - - - - {% endfor %} - -
      TimestampUserActionSourceTargetDetailsStatus
      {{ log.timestamp }}{{ log.username }} - {% if 'REDIRECT_ENABLED' in log.action %} - Enabled - {% elif 'REDIRECT_DISABLED' in log.action %} - Disabled - {% elif 'FAILED' in log.action %} - Failed - {% elif 'LOGIN' in log.action %} - - {% elif 'LOGOUT' in log.action %} - Logout - {% else %} - {{ log.action }} - {% endif %} - - {% if log.source_printer_id %} - - {{ log.source_ip or log.source_printer_id }} - - {% else %} - - {% endif %} - - {% if log.target_printer_id %} - - {{ log.target_ip or log.target_printer_id }} - - {% else %} - - {% endif %} - {{ log.details or '—' }} - {% if log.success %} - - {% else %} - - {% endif %} -
      -
      - {% else %} -
      - -
      Nothing to review yet
      -

      Audit activity will appear here as redirects and logins occur across the fleet.

      -
      - {% endif %} -
      - - -{% endblock %} - diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index b0e918c..0000000 --- a/templates/base.html +++ /dev/null @@ -1,726 +0,0 @@ - - - - - - - {% block title %}Printer Proxy{% endblock %} - - - - - {% block extra_css %}{% endblock %} - - - {% if active_redirects %} -
      - REDIRECT ACTIVE — {{ active_redirects|length }} redirect(s) in effect -
      - {% endif %} - - - -
      - {% block content %}{% endblock %} -
      - - -
      - - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - - {% endif %} - {% endwith %} - - - - - - {% block extra_js %}{% endblock %} - - diff --git a/templates/change_password.html b/templates/change_password.html deleted file mode 100644 index 3f94dd8..0000000 --- a/templates/change_password.html +++ /dev/null @@ -1,152 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Change Password - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -

      Change Password

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

      - Minimum 8 characters with at least one uppercase and lowercase letter. -

      -
      - -
      - - -
      - -
      - - - Cancel - -
      -
      -
      -
      -
      -{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html deleted file mode 100644 index bd5fb5e..0000000 --- a/templates/dashboard.html +++ /dev/null @@ -1,731 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Dashboard - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - -{% set counts = namespace(total=printers|length, online=0, offline=0, redirected=0, target=0) %} -{% for item in printers %} - {% if item.status.is_redirected %} - {% set counts.redirected = counts.redirected + 1 %} - {% elif item.status.is_redirect_target %} - {% set counts.target = counts.target + 1 %} - {% endif %} - {% if item.status.is_online %} - {% set counts.online = counts.online + 1 %} - {% else %} - {% set counts.offline = counts.offline + 1 %} - {% endif %} -{% endfor %} - -
      -
      -
      Total Printers
      -
      {{ counts.total }}
      -
      -
      -
      Online
      -
      {{ counts.online }}
      -
      -
      -
      Offline
      -
      {{ counts.offline }}
      -
      -
      -
      Redirected
      -
      {{ counts.redirected }}
      -
      -
      -
      Redirect Targets
      -
      {{ counts.target }}
      -
      -
      -
      Active Redirects
      -
      {{ active_redirects|length }}
      -
      -
      -
      Last Refresh
      -
      Just now
      -
      -
      - -{% if current_user.role in ['admin', 'operator'] %} -
      - {% if current_user.role == 'admin' %} - -
      -
      -
      Add Printer
      -
      Register a new device
      -
      -
      - -
      -
      -
      Discover
      -
      Scan your network
      -
      -
      - {% endif %} - {% if current_user.role in ['admin', 'operator'] %} - -
      -
      -
      Manage Fleet
      -
      Edit, tag, organize
      -
      -
      - {% endif %} - {% if current_user.role == 'admin' %} - -
      -
      -
      Settings
      -
      Alerts and system
      -
      -
      - {% endif %} -
      -{% endif %} - -{% if active_redirects %} -
      -
      -
      Active Redirects
      -
      -
      - - - - - - - - - - - - - - - {% for redirect in active_redirects %} - - - - - - - - - - - {% endfor %} - -
      Source PrinterSource IPTarget PrinterTarget IPEnabledByAction
      {{ redirect.source_printer_id }}{{ redirect.source_ip }}{{ redirect.target_printer_id }}{{ redirect.target_ip }}{{ redirect.enabled_at }}{{ redirect.enabled_by }} - {% if current_user.role in ['admin', 'operator'] %} -
      - - -
      - {% else %} - - {% endif %} -
      -
      -
      -{% endif %} - -{% if printers %} -
      -
      - - - - - -
      -
      -{% endif %} - - - -{% if not printers %} -
      - -
      -

      It’s looking quiet in here

      -

      Your printer fleet hasn’t landed yet.

      -

      - Once printers are added, this space becomes your real-time command center for health, - redirects, and activity in one calm, focused view. -

      -
      -
      -{% endif %} -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/discover_printers.html b/templates/discover_printers.html deleted file mode 100644 index d1b2e2d..0000000 --- a/templates/discover_printers.html +++ /dev/null @@ -1,427 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Discover Printers - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - -
      -
      - Network Scan -
      -
      -
      - -
      -
      - - -
      -
      - OR -
      -
      - - -
      -
      - -
      -
      -
      -
      -
      - -{% if discovered is not none %} -
      -
      - {{ discovered|length }} printer(s) found -
      -
      - -{% if discovered %} -
      -
      - - - - - - - - - - - - - - {% for printer in discovered %} - - - - - - - - - - {% endfor %} - -
      IP AddressNameModelLocationMethodCaps
      {{ printer.ip }} - - - - - - - {% if printer.discovery_method == 'mDNS' %} - mDNS - {% elif printer.discovery_method == 'SNMP' %} - SNMP - {% else %} - TCP - {% endif %} - - {% if printer.tcp_9100_open %} - 9100 - {% endif %} - {% if printer.snmp_available %} - SNMP - {% endif %} - -
      - - - - - - -
      -
      -
      -
      -{% else %} -
      -
      - -
      No new printers found
      -

      Everything responding on this network is already registered or offline.

      -
      -
      -{% endif %} -{% endif %} -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/errors/403.html b/templates/errors/403.html deleted file mode 100644 index 0af99c8..0000000 --- a/templates/errors/403.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Forbidden - Printer Proxy{% endblock %} - -{% block content %} -
      -

      403

      -

      Access Forbidden

      -

      You don't have permission to access this resource.

      -
      -{% endblock %} diff --git a/templates/errors/404.html b/templates/errors/404.html deleted file mode 100644 index 1ccbcaa..0000000 --- a/templates/errors/404.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Page Not Found - Printer Proxy{% endblock %} - -{% block content %} -
      -

      404

      -

      Page Not Found

      -

      The page you're looking for doesn't exist.

      -
      -{% endblock %} diff --git a/templates/errors/500.html b/templates/errors/500.html deleted file mode 100644 index 1fd4dc7..0000000 --- a/templates/errors/500.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Server Error - Printer Proxy{% endblock %} - -{% block content %} -
      -

      500

      -

      Internal Server Error

      -

      Something went wrong. Please try again or contact IT support.

      -
      -{% endblock %} diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index 9b5045f..0000000 --- a/templates/login.html +++ /dev/null @@ -1,281 +0,0 @@ - - - - - - Login - Printer Proxy - - - - - - - - -
      - - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - - {% endif %} - {% endwith %} - - - - - diff --git a/templates/manage_printers.html b/templates/manage_printers.html deleted file mode 100644 index a26d62f..0000000 --- a/templates/manage_printers.html +++ /dev/null @@ -1,385 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Manage Printers - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - -{% if printers %} -
      - -
      - {{ printers|length }} total -
      - - - - - -
      -
      -
      -
      -
      - - - - - - - - - - - - - - {% for item in printers %} - {% set printer = item.printer %} - {% set status = item.status %} - {% if status.is_redirected %} - {% set status_key = 'redirected' %} - {% elif status.is_redirect_target %} - {% set status_key = 'target' %} - {% elif status.is_online %} - {% set status_key = 'online' %} - {% else %} - {% set status_key = 'offline' %} - {% endif %} - - - - - - - - - - {% endfor %} - -
      NameIDIP AddressLocationModelStatus
      - {{ printer.name }} - {% if printer.department %} · {{ printer.department }}{% endif %} - {{ printer.id }}{{ printer.ip }}{{ printer.location or '—' }}{{ printer.model or '—' }} - {% if status.is_redirected %} - Redirected - {% elif status.is_redirect_target %} - Target - {% elif status.is_online %} - Online - {% else %} - Offline - {% endif %} - - - - - {% if current_user.role == 'admin' %} - - - - {% if not status.is_redirected and not status.is_redirect_target %} -
      - - -
      - {% else %} - - {% endif %} - {% endif %} -
      -
      -
      -{% else %} -
      -
      - -
      No printers yet
      -

      Discover printers on your network or add one manually to start monitoring health and redirects.

      - {% if current_user.role == 'admin' %} - - {% endif %} -
      -
      -{% endif %} -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/printer_detail.html b/templates/printer_detail.html deleted file mode 100644 index 0d2e48a..0000000 --- a/templates/printer_detail.html +++ /dev/null @@ -1,756 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ printer.name }} - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - - - - - - -
      - -
      - -
      -
      - {{ printer.name }} - {% if status.status.is_redirected %} - Redirected - {% elif status.status.is_redirect_target %} - Redirect Target - {% elif status.status.is_online %} - Online - {% else %} - Offline - {% endif %} -
      -
      -
      -
      -
      Printer Information
      - - - - - - - - - - -
      ID{{ printer.id }}
      IP Address{{ printer.ip }}
      Model{{ printer.model or 'N/A' }}
      Location{{ printer.location or 'N/A' }}
      Department{{ printer.department or 'N/A' }}
      Health - {% if status.status.is_online %} - ● OK - {% else %} - ● Critical - {% endif %} -
      -
      -
      -
      Network Status
      - - - - - - - - - - {% if health_status %} - - - - - {% if health_status.consecutive_failures > 0 %} - - - - - {% endif %} - {% endif %} -
      ICMP - {% if status.status.icmp_reachable %} - ● Reachable - {% else %} - ● Unreachable - {% endif %} -
      TCP 9100 - {% if status.status.tcp_reachable %} - ● Open - {% else %} - ● Closed - {% endif %} -
      Last Check{{ health_status.last_checked[:16] if health_status.last_checked else 'Never' }}
      Failures{{ health_status.consecutive_failures }} consecutive
      -
      -
      - - {% if status.status.is_redirected %} -
      -
      Active Redirect
      -
      - Traffic to {{ printer.ip }}{{ status.status.redirect_info.target_ip }} - ({{ status.status.redirect_info.target_printer_id }}) -
      -
      - Since {{ status.status.redirect_info.enabled_at }} by {{ status.status.redirect_info.enabled_by }} -
      -
      - {% endif %} -
      -
      - - -
      -
      - Printer Statistics - -
      -
      -
      -
      -
      -
      -
      -
      -
      -
      Loading printer statistics...
      -
      - - -
      -
      - - - {% if not status.status.is_redirected and not status.status.is_redirect_target and current_user.role in ['admin', 'operator'] %} -
      -
      - Create Redirect -
      -
      - {% if status.status.is_online %} -
      -
      Warning
      -
      - This printer appears to be online. Redirecting may cause conflicts. -
      -
      - {% endif %} - - {% if available_targets %} -
      - - -
      - - -
      - Choose a healthy target to keep print traffic flowing without changing client IPs. -
      -
      - - -
      - {% else %} -

      - No available printers for redirect. -

      - {% endif %} -
      -
      - {% elif status.status.is_redirected and current_user.role in ['admin', 'operator'] %} -
      -
      - Remove Redirect -
      -
      -
      - - -
      -
      -
      - {% elif status.status.is_redirect_target and current_user.role in ['admin', 'operator'] %} -
      -
      Redirect Target
      -
      - This printer is receiving redirected traffic and cannot be redirected itself. -
      -
      - {% endif %} -
      - - -
      -
      -
      Status Timeline
      -
        - {% if status.status.is_redirected %} -
      • - -
        -
        Redirect enabled to {{ status.status.redirect_info.target_printer_id }}
        -
        {{ status.status.redirect_info.enabled_at }} · {{ status.status.redirect_info.enabled_by }}
        -
        -
      • - {% endif %} - {% if health_history %} - {% for entry in health_history[:6] %} -
      • - -
        -
        {{ 'Online check passed' if entry.is_online else 'Offline check detected' }}
        -
        - {% if entry.checked_at is string %} - {{ entry.checked_at[:16] }} - {% else %} - {{ entry.checked_at.strftime('%Y-%m-%d %H:%M') }} - {% endif %} - {% if entry.response_time_ms %} · {{ entry.response_time_ms|round(0) }} ms{% endif %} -
        -
        -
      • - {% endfor %} - {% else %} -
      • - -
        -
        No recent checks
        -
        Health monitoring will appear here as checks run.
        -
        -
      • - {% endif %} -
      -
      - - -
      -
      - Audit History -
      -
      - {% if audit_history %} -
        - {% for entry in audit_history[:10] %} -
      • -
        {{ entry.action }}
        -
        {{ entry.timestamp[:16] }} · {{ entry.username }}
        -
      • - {% endfor %} -
      - {% else %} -
      - No audit history -
      - {% endif %} -
      -
      -
      -
      -{% endblock %} -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/templates/printer_form.html b/templates/printer_form.html deleted file mode 100644 index 51fdcaf..0000000 --- a/templates/printer_form.html +++ /dev/null @@ -1,267 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ 'Edit' if mode == 'edit' else 'Add' }} Printer - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      -
      -
      -
      - {{ 'Edit Printer' if mode == 'edit' else 'Add New Printer' }} -
      -
      - {% if mode == 'edit' and (has_redirect or is_target) %} -
      - Warning -

      - This printer - {% if has_redirect %}has an active redirect{% endif %} - {% if has_redirect and is_target %}and {% endif %} - {% if is_target %}is being used as a redirect target{% endif %}. - IP address cannot be changed while redirects are active. -

      -
      - {% endif %} - -
      - - - {% if mode == 'add' %} -
      - - -
      Use lowercase letters, numbers, and hyphens only.
      -
      Unique identifier (lowercase letters, numbers, hyphens only)
      -
      - {% else %} -
      - - -
      Printer ID cannot be changed
      -
      - {% endif %} - -
      - - -
      Please provide a printer name.
      -
      This is the friendly name shown to staff.
      -
      - -
      - - -
      Enter a valid IPv4 address.
      -
      IPv4 address reachable on your network.
      -
      - -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      - -
      - - -
      - -
      - - -
      - -
      - - Cancel - - -
      -
      -
      -
      -
      -
      -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/printer_jobs.html b/templates/printer_jobs.html deleted file mode 100644 index 1b8331d..0000000 --- a/templates/printer_jobs.html +++ /dev/null @@ -1,232 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ printer.name }} - Job History{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - - - - -
      -
      -
      {{ stats.total_jobs }}
      -
      Total Jobs
      -
      -
      -
      {{ stats.total_pages }}
      -
      Total Pages
      -
      -
      -
      {{ stats.jobs_today }}
      -
      Today
      -
      -
      -
      {{ stats.completed_jobs }}
      -
      Completed
      -
      -
      - - -
      -
      -

      Recent Print Jobs

      -
      - {% if jobs %} - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - {% endfor %} - -
      Job IDNameOwnerPagesStatusCompleted
      #{{ job.job_id }}{{ job.name or 'Unknown' }}{{ job.owner or 'Unknown' }}{{ job.pages }} - - {{ job.status }} - - - {% if job.completed_at %} - {{ job.completed_at.strftime('%Y-%m-%d %H:%M') if job.completed_at is not string else job.completed_at[:16] }} - {% else %} - — - {% endif %} -
      - {% else %} -
      - -
      No job history yet
      -

      Completed print jobs will be recorded here as the queue runs.

      -
      - {% endif %} -
      -{% endblock %} - diff --git a/templates/printer_logs.html b/templates/printer_logs.html deleted file mode 100644 index 57483f4..0000000 --- a/templates/printer_logs.html +++ /dev/null @@ -1,220 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ printer.name }} - Logs{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - - - -
      - {% if logs %} -
      - - - - - - - - - - - - {% for log in logs %} - - - - - - - - {% endfor %} - -
      SeverityCodeEventDetailsTime
      - {{ log.severity }} - {{ log.code }}{{ log.message }}{{ log.description }}{{ log.time_display or '—' }}
      -
      - {% else %} -
      - -
      All clear
      -

      No active events reported. This printer is operating normally.

      -
      - {% endif %} -
      - - -{% endblock %} - diff --git a/templates/printer_queue.html b/templates/printer_queue.html deleted file mode 100644 index 7b8a99e..0000000 --- a/templates/printer_queue.html +++ /dev/null @@ -1,309 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ printer.name }} - Print Queue{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - - - -
      -
      -
      -

      Print Queue

      - LIVE -
      - {{ queue|length }} job(s) -
      -
      - {% if queue %} - - - - - - - - - - - - - {% for job in queue %} - - - - - - - - - {% endfor %} - -
      Job IDNameOwnerPagesStatusSubmitted
      #{{ job.job_id }}{{ job.name or 'Unknown' }}{{ job.owner or 'Unknown' }}{{ job.pages }} - - {{ job.status }} - - - {% if job.submitted_at %} - {{ job.submitted_at.strftime('%H:%M:%S') }} - {% else %} - — - {% endif %} -
      - {% else %} -
      - -
      No active print jobs
      -

      This queue stays empty until a job is in progress. Check Job History for past prints.

      -
      - {% endif %} -
      -
      -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/settings.html b/templates/settings.html deleted file mode 100644 index b96e471..0000000 --- a/templates/settings.html +++ /dev/null @@ -1,822 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Settings - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      - - -
      - - - - -
      -
      - -
      -
      -
      -
      Notification Settings
      -
      -
      -

      - Configure how Printer Proxy sends notifications for events like printer status changes, - redirect activations, and system alerts. -

      - - -
      -
      -
      - - Email (SMTP) -
      -
      - - -
      -
      - -
      -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - -
      - - -
      -
      Leave blank to keep existing password
      -
      -
      - - -
      -
      - - -
      Comma-separated list of recipients
      -
      -
      - -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      -
      - -
      - - -
      -
      -
      - - -
      -
      -
      - - Microsoft Teams - Coming Soon -
      -
      -
      - - -
      -
      -
      - - Slack - Coming Soon -
      -
      -
      -
      -
      -
      - - -
      -
      -
      -
      Software Updates
      -
      -
      -

      - Printer Proxy automatically checks for new releases from GitHub every 6 hours. - Updates are never applied automatically - you must manually trigger installation. -

      -
      - - Note: During an update, the site will be temporarily unavailable for approximately 30-60 seconds while the service restarts. Please refresh the page after a minute. -
      - - -
      -
      Current Version
      -
      -
      - v{{ app_version }} -
      - - Checking... - -
      -
      - - -
      - -
      -
      -
      -
      - - - -
      -
      -
      -
      -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/setup.html b/templates/setup.html deleted file mode 100644 index 83dd4db..0000000 --- a/templates/setup.html +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - Initial Setup - Printer Proxy - - - - - -
      -
      -

      Initial Setup

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

      - Minimum 8 characters with at least one uppercase and lowercase letter. -

      -
      - -
      - - -
      - - -
      -
      -
      - - -
      - - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - - {% endif %} - {% endwith %} - - - - - diff --git a/templates/statistics.html b/templates/statistics.html deleted file mode 100644 index 23f9d27..0000000 --- a/templates/statistics.html +++ /dev/null @@ -1,260 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Redirect Statistics - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - - -
      -
      -
      -
      {{ stats.total_redirects }}
      -
      Total Redirects
      -
      -
      -
      -
      -
      {{ stats.active_redirects }}
      -
      Currently Active
      -
      -
      -
      -
      -
      {{ stats.redirects_this_month }}
      -
      This Month
      -
      -
      -
      -
      -
      {{ "%.1f"|format(stats.total_hours) }}
      -
      Total Hours
      -
      -
      -
      -
      -
      {{ "%.0f"|format(stats.avg_duration_minutes) }}
      -
      Avg Duration (min)
      -
      -
      -
      -
      -
      - {% if stats.most_redirected_printer %} - {{ stats.most_redirected_printer[:10] }}{% if stats.most_redirected_printer|length > 10 %}...{% endif %} - {% else %} - N/A - {% endif %} -
      -
      Most Redirected
      -
      -
      -
      - - -
      -
      - Redirect History -
      - {% if history %} -
      - - - - - - - - - - - - - - - {% for record in history %} - - - - - - - - - - - {% endfor %} - -
      Source PrinterTarget PrinterEnabled ByStartedEndedDurationDisabled ByReason
      - {{ record.source_name or 'Unknown' }} -
      {{ record.source_ip }} -
      - {{ record.target_name or 'Unknown' }} -
      {{ record.target_ip }} -
      {{ record.enabled_by or 'Unknown' }} - {{ record.enabled_at.strftime('%Y-%m-%d %H:%M') if record.enabled_at else 'N/A' }} - - {% if record.disabled_at %} - {{ record.disabled_at.strftime('%Y-%m-%d %H:%M') }} - {% else %} - Active - {% endif %} - - {% if record.duration_minutes %} - {% if record.duration_minutes < 60 %} - {{ record.duration_minutes }} min - {% elif record.duration_minutes < 1440 %} - {{ "%.1f"|format(record.duration_minutes / 60) }} hrs - {% else %} - {{ "%.1f"|format(record.duration_minutes / 1440) }} days - {% endif %} - {% else %} - - {% endif %} - {{ record.disabled_by or '—' }} - {% if record.reason %} - {{ record.reason }} - {% else %} - - {% endif %} -
      -
      - {% else %} -
      - -
      No redirect history yet
      -

      Completed redirects will appear here once enabled and disabled.

      -
      - {% endif %} -
      -{% endblock %} diff --git a/templates/user_form.html b/templates/user_form.html deleted file mode 100644 index 6334383..0000000 --- a/templates/user_form.html +++ /dev/null @@ -1,140 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ 'Add User' if mode == 'add' else 'Edit User' }} - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      -
      -
      -
      - {{ 'Add User' if mode == 'add' else 'Edit User' }} -
      -
      -
      - - -
      - - - {% if mode == 'edit' %} -
      Usernames cannot be changed.
      - {% endif %} -
      - -
      - - -
      Admins can manage users and settings. Operators can manage printers and redirects. Viewers are read-only.
      -
      - -
      - -
      - - -
      -
      - -
      - - -
      {{ 'Required for new users.' if mode == 'add' else 'Leave blank to keep the current password.' }}
      - {% if password_requirements %} -
        - {% for req in password_requirements %} -
      • {{ req }}
      • - {% endfor %} -
      - {% endif %} -
      - -
      - - -
      - -
      - Cancel - -
      -
      -
      -
      -
      -
      -{% endblock %} diff --git a/templates/user_management.html b/templates/user_management.html deleted file mode 100644 index 57ae418..0000000 --- a/templates/user_management.html +++ /dev/null @@ -1,238 +0,0 @@ -{% extends "base.html" %} - -{% block title %}User Management - Printer Proxy{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - - -{% if users %} -
      - - {{ users|length }} total -
      - -
      -
      - - - - - - - - - - - - {% for user in users %} - - - - - - - - {% endfor %} - -
      UserRoleStatusLast LoginActions
      - {{ user.username }} - {% if user.id == current_user.id %} - (you) - {% endif %} - {{ user.role }} - {% if user.is_active %} - Active - {% else %} - Disabled - {% endif %} - {{ user.last_login[:16] if user.last_login else 'Never' }} - - - - {% if user.id != current_user.id %} -
      - - -
      - {% endif %} -
      -
      -
      -{% else %} -
      -
      - -
      No users yet
      -

      Create your first operator or viewer to share access safely.

      - Add User -
      -
      -{% endif %} -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/wsgi.py b/wsgi.py index 547ae34..28c29eb 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI entry point for Printer Proxy +WSGI entry point for Continuum """ from app import create_app