commit f2be2414e4651d679581bbce272f4479ce6b0710 Author: Jesús Pérez Date: Tue Oct 7 10:59:52 2025 +0100 core: init repo and codebase diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c53e19a --- /dev/null +++ b/.env.example @@ -0,0 +1,319 @@ +# Provisioning Platform Environment Configuration +# Copy this file to .env and customize for your deployment + +#============================================================================== +# PLATFORM MODE +#============================================================================== +# Deployment mode: solo, multi-user, cicd, enterprise +PROVISIONING_MODE=solo + +# Platform metadata +PLATFORM_NAME=provisioning +PLATFORM_VERSION=3.0.0 +PLATFORM_ENVIRONMENT=development + +#============================================================================== +# NETWORK CONFIGURATION +#============================================================================== +# Docker network subnet +NETWORK_SUBNET=172.20.0.0/16 +NETWORK_GATEWAY=172.20.0.1 + +# External access +EXTERNAL_DOMAIN=provisioning.local +ENABLE_TLS=false +TLS_CERT_PATH=/etc/ssl/certs/provisioning.crt +TLS_KEY_PATH=/etc/ssl/private/provisioning.key + +#============================================================================== +# ORCHESTRATOR SERVICE +#============================================================================== +ORCHESTRATOR_ENABLED=true +ORCHESTRATOR_HOST=0.0.0.0 +ORCHESTRATOR_PORT=8080 +ORCHESTRATOR_WORKERS=4 +ORCHESTRATOR_LOG_LEVEL=info +ORCHESTRATOR_DATA_DIR=/data +ORCHESTRATOR_STORAGE_TYPE=filesystem +ORCHESTRATOR_MAX_CONCURRENT_TASKS=5 +ORCHESTRATOR_RETRY_ATTEMPTS=3 + +# CPU and memory limits +ORCHESTRATOR_CPU_LIMIT=2000m +ORCHESTRATOR_MEMORY_LIMIT=2048M + +#============================================================================== +# CONTROL CENTER SERVICE +#============================================================================== +CONTROL_CENTER_ENABLED=true +CONTROL_CENTER_HOST=0.0.0.0 +CONTROL_CENTER_PORT=8081 +CONTROL_CENTER_LOG_LEVEL=info +CONTROL_CENTER_DATABASE_TYPE=rocksdb +CONTROL_CENTER_SESSION_TIMEOUT=3600 + +# JWT Configuration +CONTROL_CENTER_JWT_SECRET=CHANGE_ME_RANDOM_SECRET_HERE +CONTROL_CENTER_ACCESS_TOKEN_EXPIRATION=3600 +CONTROL_CENTER_REFRESH_TOKEN_EXPIRATION=86400 + +# CPU and memory limits +CONTROL_CENTER_CPU_LIMIT=1000m +CONTROL_CENTER_MEMORY_LIMIT=1024M + +#============================================================================== +# COREDNS SERVICE +#============================================================================== +COREDNS_ENABLED=true +COREDNS_PORT=53 +COREDNS_TCP_PORT=53 +COREDNS_ZONES_DIR=/zones +COREDNS_LOG_LEVEL=info + +# CPU and memory limits +COREDNS_CPU_LIMIT=500m +COREDNS_MEMORY_LIMIT=512M + +#============================================================================== +# GITEA SERVICE (Multi-user mode and above) +#============================================================================== +GITEA_ENABLED=false +GITEA_HTTP_PORT=3000 +GITEA_SSH_PORT=222 +GITEA_DOMAIN=localhost +GITEA_ROOT_URL=http://localhost:3000/ +GITEA_DB_TYPE=sqlite3 +GITEA_SECRET_KEY=CHANGE_ME_GITEA_SECRET_KEY + +# Admin user (created on first run) +GITEA_ADMIN_USERNAME=provisioning +GITEA_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD +GITEA_ADMIN_EMAIL=admin@provisioning.local + +# CPU and memory limits +GITEA_CPU_LIMIT=1000m +GITEA_MEMORY_LIMIT=1024M + +#============================================================================== +# OCI REGISTRY SERVICE +#============================================================================== +OCI_REGISTRY_ENABLED=true +OCI_REGISTRY_TYPE=zot +OCI_REGISTRY_PORT=5000 +OCI_REGISTRY_NAMESPACE=provisioning-extensions +OCI_REGISTRY_LOG_LEVEL=info + +# Authentication (disabled for solo mode) +OCI_REGISTRY_AUTH_ENABLED=false +OCI_REGISTRY_AUTH_HTPASSWD_PATH=/etc/registry/htpasswd + +# Storage +OCI_REGISTRY_STORAGE_ROOT=/var/lib/registry +OCI_REGISTRY_DEDUPE_ENABLED=true + +# CPU and memory limits +OCI_REGISTRY_CPU_LIMIT=1000m +OCI_REGISTRY_MEMORY_LIMIT=1024M + +#============================================================================== +# EXTENSION REGISTRY SERVICE +#============================================================================== +EXTENSION_REGISTRY_ENABLED=true +EXTENSION_REGISTRY_HOST=0.0.0.0 +EXTENSION_REGISTRY_PORT=8082 +EXTENSION_REGISTRY_LOG_LEVEL=info +EXTENSION_REGISTRY_DATA_DIR=/app/data + +# OCI integration +EXTENSION_REGISTRY_OCI_URL=http://oci-registry:5000 +EXTENSION_REGISTRY_NAMESPACE=provisioning-extensions + +# CPU and memory limits +EXTENSION_REGISTRY_CPU_LIMIT=500m +EXTENSION_REGISTRY_MEMORY_LIMIT=512M + +#============================================================================== +# PROVISIONING API SERVER SERVICE +#============================================================================== +API_SERVER_ENABLED=false +API_SERVER_HOST=0.0.0.0 +API_SERVER_PORT=8083 +API_SERVER_LOG_LEVEL=info + +# JWT Configuration +API_SERVER_JWT_SECRET=CHANGE_ME_API_SERVER_JWT_SECRET +API_SERVER_TOKEN_EXPIRATION=3600 + +# Integration +API_SERVER_ORCHESTRATOR_URL=http://orchestrator:8080 +API_SERVER_CONTROL_CENTER_URL=http://control-center:8081 + +# CPU and memory limits +API_SERVER_CPU_LIMIT=1000m +API_SERVER_MEMORY_LIMIT=1024M + +#============================================================================== +# MCP SERVER SERVICE (Optional) +#============================================================================== +MCP_SERVER_ENABLED=false +MCP_SERVER_HOST=0.0.0.0 +MCP_SERVER_PORT=8084 +MCP_SERVER_PROTOCOL=http +MCP_SERVER_LOG_LEVEL=info + +# Capabilities +MCP_SERVER_TOOLS_ENABLED=true +MCP_SERVER_PROMPTS_ENABLED=true +MCP_SERVER_RESOURCES_ENABLED=true + +# CPU and memory limits +MCP_SERVER_CPU_LIMIT=500m +MCP_SERVER_MEMORY_LIMIT=512M + +#============================================================================== +# DATABASE SERVICE (PostgreSQL for enterprise mode) +#============================================================================== +POSTGRES_ENABLED=false +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=provisioning +POSTGRES_USER=provisioning +POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD + +# CPU and memory limits +POSTGRES_CPU_LIMIT=2000m +POSTGRES_MEMORY_LIMIT=2048M + +#============================================================================== +# COSMIAN KMS SERVICE (Enterprise mode) +#============================================================================== +KMS_ENABLED=false +KMS_SERVER=http://kms:9998 +KMS_AUTH_METHOD=certificate +KMS_CERT_PATH=/etc/kms/client.crt +KMS_KEY_PATH=/etc/kms/client.key + +# CPU and memory limits +KMS_CPU_LIMIT=1000m +KMS_MEMORY_LIMIT=1024M + +#============================================================================== +# HARBOR REGISTRY (Enterprise mode alternative to Zot) +#============================================================================== +HARBOR_ENABLED=false +HARBOR_ADMIN_PASSWORD=CHANGE_ME_HARBOR_ADMIN_PASSWORD +HARBOR_DATABASE_PASSWORD=CHANGE_ME_HARBOR_DB_PASSWORD +HARBOR_CORE_SECRET=CHANGE_ME_HARBOR_CORE_SECRET +HARBOR_JOBSERVICE_SECRET=CHANGE_ME_HARBOR_JOBSERVICE_SECRET + +# CPU and memory limits +HARBOR_CORE_CPU_LIMIT=2000m +HARBOR_CORE_MEMORY_LIMIT=2048M + +#============================================================================== +# MONITORING STACK (Prometheus, Grafana) +#============================================================================== +MONITORING_ENABLED=false +PROMETHEUS_PORT=9090 +PROMETHEUS_RETENTION_TIME=15d +GRAFANA_PORT=3001 +GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_PASSWORD + +# CPU and memory limits +PROMETHEUS_CPU_LIMIT=2000m +PROMETHEUS_MEMORY_LIMIT=2048M +GRAFANA_CPU_LIMIT=500m +GRAFANA_MEMORY_LIMIT=512M + +#============================================================================== +# LOGGING STACK (Loki, Promtail) +#============================================================================== +LOGGING_ENABLED=false +LOKI_PORT=3100 +LOKI_RETENTION_PERIOD=168h + +# CPU and memory limits +LOKI_CPU_LIMIT=1000m +LOKI_MEMORY_LIMIT=1024M + +#============================================================================== +# ELASTICSEARCH + KIBANA (Enterprise audit logs) +#============================================================================== +ELASTICSEARCH_ENABLED=false +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_CLUSTER_NAME=provisioning-logs +ELASTICSEARCH_HEAP_SIZE=1g +KIBANA_PORT=5601 + +# CPU and memory limits +ELASTICSEARCH_CPU_LIMIT=2000m +ELASTICSEARCH_MEMORY_LIMIT=2048M +KIBANA_CPU_LIMIT=1000m +KIBANA_MEMORY_LIMIT=1024M + +#============================================================================== +# NGINX REVERSE PROXY +#============================================================================== +NGINX_ENABLED=false +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +NGINX_WORKER_PROCESSES=4 +NGINX_WORKER_CONNECTIONS=1024 + +# Rate limiting +NGINX_RATE_LIMIT_ENABLED=true +NGINX_RATE_LIMIT_REQUESTS=100 +NGINX_RATE_LIMIT_PERIOD=1m + +# CPU and memory limits +NGINX_CPU_LIMIT=500m +NGINX_MEMORY_LIMIT=256M + +#============================================================================== +# BACKUP CONFIGURATION +#============================================================================== +BACKUP_ENABLED=false +BACKUP_SCHEDULE=0 2 * * * +BACKUP_RETENTION_DAYS=7 +BACKUP_STORAGE_PATH=/backup + +#============================================================================== +# SECURITY CONFIGURATION +#============================================================================== +# Enable security scanning +SECURITY_SCAN_ENABLED=false + +# Secrets encryption +SECRETS_ENCRYPTION_ENABLED=false +SECRETS_KEY_PATH=/etc/provisioning/secrets.key + +# Network policies +NETWORK_POLICIES_ENABLED=false + +#============================================================================== +# RESOURCE LIMITS DEFAULTS +#============================================================================== +DEFAULT_CPU_LIMIT=1000m +DEFAULT_MEMORY_LIMIT=1024M +DEFAULT_RESTART_POLICY=unless-stopped + +#============================================================================== +# HEALTHCHECK CONFIGURATION +#============================================================================== +HEALTHCHECK_INTERVAL=30s +HEALTHCHECK_TIMEOUT=10s +HEALTHCHECK_RETRIES=3 +HEALTHCHECK_START_PERIOD=30s + +#============================================================================== +# LOGGING CONFIGURATION +#============================================================================== +LOG_DRIVER=json-file +LOG_MAX_SIZE=10m +LOG_MAX_FILE=3 + +#============================================================================== +# USER AND PERMISSION CONFIGURATION +#============================================================================== +USER_UID=1000 +USER_GID=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cc555d --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +.p +.claude +.vscode +.shellcheckrc +.coder +.migration +.zed +ai_demo.nu +CLAUDE.md +.cache +.coder +wrks +ROOT +OLD +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ +# Encryption keys and related files (CRITICAL - NEVER COMMIT) +.k +.k.backup +*.k +*.key.backup + +config.*.toml +config.*back + +# where book is written +_book + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +node_modules/ + +**/output.css +**/input.css + +# Environment files +.env +.env.local +.env.production +.env.development +.env.staging + +# Keep example files +!.env.example + +# Configuration files (may contain sensitive data) +config.prod.toml +config.production.toml +config.local.toml +config.*.local.toml + +# Keep example configuration files +!config.toml +!config.dev.toml +!config.example.toml + +# Log files +logs/ +*.log + +# TLS certificates and keys +certs/ +*.pem +*.crt +*.key +*.p12 +*.pfx + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Backup files +*.bak +*.backup +*.tmp +*~ + +# Encryption and security related files +*.encrypted +*.enc +secrets/ +private/ +security/ + +# Configuration backups that may contain secrets +config.*.backup +config.backup.* + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +# Documentation build output +book-output/ +# Generated setup report +SETUP_COMPLETE.md diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6793331 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,219 @@ +[workspace] +resolver = "2" +members = [ + "orchestrator", + "control-center", + "control-center-ui", + "mcp-server", + "installer", +] + +# Exclude any directories that shouldn't be part of the workspace +exclude = [] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Jesus Perez "] +license = "MIT" +repository = "https://github.com/jesusperezlorenzo/provisioning" + +[workspace.dependencies] +# ============================================================================ +# SHARED ASYNC RUNTIME AND CORE LIBRARIES +# ============================================================================ +tokio = { version = "1.40", features = ["full"] } +tokio-util = "0.7" +futures = "0.3" +async-trait = "0.1" + +# ============================================================================ +# SERIALIZATION AND DATA HANDLING +# ============================================================================ +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.9" +uuid = { version = "1.18", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# ============================================================================ +# ERROR HANDLING +# ============================================================================ +anyhow = "1.0" +thiserror = "2.0" + +# ============================================================================ +# LOGGING AND TRACING +# ============================================================================ +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" + +# ============================================================================ +# WEB SERVER AND NETWORKING +# ============================================================================ +axum = { version = "0.8", features = ["ws", "macros"] } +tower = { version = "0.5", features = ["full"] } +tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip", "timeout"] } +hyper = "1.7" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# ============================================================================ +# CLI AND CONFIGURATION +# ============================================================================ +clap = { version = "4.5", features = ["derive", "env"] } +config = "0.15" + +# ============================================================================ +# DATABASE AND STORAGE +# ============================================================================ +surrealdb = { version = "2.3", features = ["kv-rocksdb", "kv-mem", "protocol-ws", "protocol-http"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] } + +# ============================================================================ +# SECURITY AND CRYPTOGRAPHY +# ============================================================================ +ring = "0.17" +jsonwebtoken = "9.3" +argon2 = "0.5" +base64 = "0.22" +rand = "0.8" +aes-gcm = "0.10" +sha2 = "0.10" +hmac = "0.12" + +# ============================================================================ +# VALIDATION AND REGEX +# ============================================================================ +validator = { version = "0.20", features = ["derive"] } +regex = "1.11" + +# ============================================================================ +# GRAPH ALGORITHMS AND UTILITIES +# ============================================================================ +petgraph = "0.8" + +# ============================================================================ +# ADDITIONAL SHARED DEPENDENCIES +# ============================================================================ + + +# System utilities +dirs = "6.0" + +# Filesystem operations +walkdir = "2.5" + +# Statistics and templates +statistics = "0.4" +tera = "1.20" + +# Additional cryptography +hkdf = "0.12" +rsa = "0.9" +zeroize = { version = "1.8", features = ["derive"] } + +# Additional security +constant_time_eq = "0.4" +subtle = "2.6" + +# Caching and storage +redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] } +rocksdb = "0.24" + +# Tower services +tower-service = "0.3" +tower_governor = "0.4" + +# Scheduling +cron = "0.15" +tokio-cron-scheduler = "0.14" + +# Policy engine +cedar-policy = "4.5" + +# URL handling +url = "2.5" + +# Icons and UI +icondata = "0.6" +leptos_icons = "0.3" + +# Image processing +image = { version = "0.25", default-features = false, features = ["png"] } +qrcode = "0.14" + +# Authentication +totp-rs = { version = "5.7", features = ["qr"] } + +# Additional serialization +serde-wasm-bindgen = "0.6" + +# Gloo utilities (for WASM) +gloo-net = { version = "0.6", features = ["http", "websocket"] } +gloo-storage = "0.3" +gloo-utils = { version = "0.2", features = ["serde"] } +gloo-timers = "0.3" + +# Plotting and canvas +plotters = "0.3" +plotters-canvas = "0.3" + +# WASM utilities +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +tracing-wasm = "0.2" +console_error_panic_hook = "0.1" + +# Random number generation +getrandom = { version = "0.2", features = ["js"] } + +# ============================================================================ +# WASM AND FRONTEND DEPENDENCIES (for control-center-ui) +# ============================================================================ +wasm-bindgen = "0.2" +leptos = { version = "0.6", features = ["csr"] } +leptos_meta = { version = "0.6", features = ["csr"] } +leptos_router = { version = "0.6", features = ["csr"] } + +# ============================================================================ +# DEVELOPMENT AND TESTING DEPENDENCIES +# ============================================================================ +tokio-test = "0.4" +tempfile = "3.10" +criterion = { version = "0.7", features = ["html_reports"] } +assert_matches = "1.5" + +[workspace.metadata] +description = "Provisioning Platform - Rust workspace for cloud infrastructure automation tools" + +# Profile configurations shared across all workspace members +[profile.dev] +opt-level = 0 +debug = true +debug-assertions = true +overflow-checks = true +lto = false +panic = 'unwind' +incremental = true +codegen-units = 256 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" +strip = "debuginfo" + +# Fast release profile for development +[profile.dev-release] +inherits = "release" +opt-level = 2 +lto = "thin" +debug = true + +# Profile for benchmarks +[profile.bench] +inherits = "release" +debug = true + diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..e2ec0c7 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,267 @@ +# Provisioning Platform - Quick Start + +Fast deployment guide for all modes. + +--- + +## Prerequisites + +```bash +# Verify Docker is installed and running +docker --version # 20.10+ +docker-compose --version # 2.0+ +docker ps # Should work without errors +``` + +--- + +## 1. Solo Mode (Local Development) + +**Services**: Orchestrator, Control Center, CoreDNS, OCI Registry, Extension Registry + +**Resources**: 2 CPU cores, 4GB RAM, 20GB disk + +```bash +cd /Users/Akasha/project-provisioning/provisioning/platform + +# Generate secrets +./scripts/generate-secrets.nu + +# Deploy +./scripts/deploy-platform.nu --mode solo + +# Verify +./scripts/health-check.nu + +# Access +open http://localhost:8080 # Orchestrator +open http://localhost:8081 # Control Center +``` + +**Stop**: +```bash +docker-compose down +``` + +--- + +## 2. Multi-User Mode (Team Collaboration) + +**Services**: Solo + Gitea, PostgreSQL + +**Resources**: 4 CPU cores, 8GB RAM, 50GB disk + +```bash +cd /Users/Akasha/project-provisioning/provisioning/platform + +# Generate secrets +./scripts/generate-secrets.nu + +# Deploy +./scripts/deploy-platform.nu --mode multi-user + +# Verify +./scripts/health-check.nu + +# Access +open http://localhost:3000 # Gitea +open http://localhost:8081 # Control Center +``` + +**Configure Gitea**: +1. Visit http://localhost:3000 +2. Complete initial setup wizard +3. Create admin account + +--- + +## 3. CI/CD Mode (Automated Pipelines) + +**Services**: Multi-User + API Server, Jenkins (optional), GitLab Runner (optional) + +**Resources**: 8 CPU cores, 16GB RAM, 100GB disk + +```bash +cd /Users/Akasha/project-provisioning/provisioning/platform + +# Generate secrets +./scripts/generate-secrets.nu + +# Deploy +./scripts/deploy-platform.nu --mode cicd --build + +# Verify +./scripts/health-check.nu + +# Access +open http://localhost:8083 # API Server +``` + +--- + +## 4. Enterprise Mode (Production) + +**Services**: Full stack (15+ services) + +**Resources**: 16 CPU cores, 32GB RAM, 500GB disk + +```bash +cd /Users/Akasha/project-provisioning/provisioning/platform + +# Generate production secrets +./scripts/generate-secrets.nu --output .env.production + +# Review and customize +nano .env.production + +# Deploy with build +./scripts/deploy-platform.nu --mode enterprise \ + --env-file .env.production \ + --build \ + --wait 600 + +# Verify +./scripts/health-check.nu + +# Access +open http://localhost:3001 # Grafana (admin / password from .env) +open http://localhost:9090 # Prometheus +open http://localhost:5601 # Kibana +``` + +--- + +## Common Commands + +### View Logs +```bash +docker-compose logs -f +docker-compose logs -f orchestrator +docker-compose logs --tail=100 orchestrator +``` + +### Restart Services +```bash +docker-compose restart orchestrator +docker-compose restart +``` + +### Update Platform +```bash +docker-compose pull +./scripts/deploy-platform.nu --mode --pull +``` + +### Stop Platform +```bash +docker-compose down +``` + +### Clean Everything (WARNING: data loss) +```bash +docker-compose down --volumes +``` + +--- + +## Systemd (Linux Production) + +```bash +# Install services +cd systemd +sudo ./install-services.sh + +# Enable and start +sudo systemctl enable --now provisioning-platform + +# Check status +sudo systemctl status provisioning-platform + +# View logs +sudo journalctl -u provisioning-platform -f + +# Restart +sudo systemctl restart provisioning-platform + +# Stop +sudo systemctl stop provisioning-platform +``` + +--- + +## Troubleshooting + +### Services not starting +```bash +# Check Docker +systemctl status docker + +# Check logs +docker-compose logs orchestrator + +# Check resources +docker stats +``` + +### Port conflicts +```bash +# Find what's using port +lsof -i :8080 + +# Change port in .env +nano .env +# Set ORCHESTRATOR_PORT=9080 + +# Restart +docker-compose down && docker-compose up -d +``` + +### Health checks failing +```bash +# Check individual service +curl http://localhost:8080/health + +# Wait longer +./scripts/deploy-platform.nu --wait 600 + +# Check networks +docker network inspect provisioning-net +``` + +--- + +## Access URLs + +### Solo Mode +- Orchestrator: http://localhost:8080 +- Control Center: http://localhost:8081 +- OCI Registry: http://localhost:5000 + +### Multi-User Mode +- Gitea: http://localhost:3000 +- PostgreSQL: localhost:5432 + +### CI/CD Mode +- API Server: http://localhost:8083 + +### Enterprise Mode +- Prometheus: http://localhost:9090 +- Grafana: http://localhost:3001 +- Kibana: http://localhost:5601 +- Nginx: http://localhost:80 + +--- + +## Next Steps + +- **Full Guide**: See `docs/deployment/DEPLOYMENT_GUIDE.md` +- **Configuration**: Edit `.env` file for customization +- **Monitoring**: Access Grafana dashboards (enterprise mode) +- **API**: Use API Server for automation (CI/CD mode) + +--- + +**Need Help?** +- Health Check: `./scripts/health-check.nu` +- Logs: `docker-compose logs -f` +- Documentation: `docs/deployment/` diff --git a/README.md b/README.md new file mode 100644 index 0000000..848b4a8 --- /dev/null +++ b/README.md @@ -0,0 +1,556 @@ +

+ Provisioning Logo +

+

+ Provisioning +

+ + +--- + +# Platform Services + +Platform-level services for the [Provisioning project](https://repo.jesusperez.pro/jesus/provisioning) infrastructure automation platform. These services provide the high-performance execution layer, management interfaces, and supporting infrastructure for the entire provisioning system. + +## Overview + +The Platform layer consists of **production-ready services** built primarily in Rust, providing: + +- **Workflow Execution** - High-performance orchestration and task coordination +- **Management Interfaces** - Web UI and REST APIs for infrastructure management +- **Security & Authorization** - Enterprise-grade access control and permissions +- **Installation & Distribution** - Multi-mode installer with TUI, CLI, and unattended modes +- **AI Integration** - Model Context Protocol (MCP) server for intelligent assistance +- **Extension Management** - OCI-based registry for distributing modules + +--- + +## Core Platform Services + +### 1. **Orchestrator** (`orchestrator/`) + +High-performance Rust/Nushell hybrid orchestrator for workflow execution. + +**Language**: Rust + Nushell integration + +**Purpose**: Workflow execution, task scheduling, state management + +**Key Features**: +- File-based persistence for reliability +- Priority processing with retry logic +- Checkpoint recovery and automatic rollback +- REST API endpoints for external integration +- Solves deep call stack limitations +- Parallel task execution with dependency resolution + +**Status**: ✅ Production Ready (v3.0.0) + +**Documentation**: See [.claude/features/orchestrator-architecture.md](../../.claude/features/orchestrator-architecture.md) + +**Quick Start**: +```bash +cd orchestrator +./scripts/start-orchestrator.nu --background +``` + +**REST API**: +- `GET http://localhost:8080/health` - Health check +- `GET http://localhost:8080/tasks` - List all tasks +- `POST http://localhost:8080/workflows/servers/create` - Server workflow +- `POST http://localhost:8080/workflows/taskserv/create` - Taskserv workflow + +--- + +### 2. **Control Center** (`control-center/`) + +Backend control center service with authorization and permissions management. + +**Language**: Rust + +**Purpose**: Web-based infrastructure management with RBAC + +**Key Features**: +- **Authorization and permissions control** (enterprise security) +- Role-Based Access Control (RBAC) +- Audit logging and compliance tracking +- System management APIs +- Configuration management +- Resource monitoring + +**Status**: ✅ Active Development + +**Security Features**: +- Fine-grained permissions system +- User authentication and session management +- API key management +- Activity audit logs + +--- + +### 3. **Control Center UI** (`control-center-ui/`) + +Frontend web interface for infrastructure management. + +**Language**: Web (HTML/CSS/JavaScript) + +**Purpose**: User-friendly dashboard and administration interface + +**Key Features**: +- Dashboard with real-time monitoring +- Configuration management interface +- System administration tools +- Workflow visualization +- Log viewing and search + +**Status**: ✅ Active Development + +**Integration**: Communicates with Control Center backend and Orchestrator APIs + +--- + +### 4. **Installer** (`installer/`) + +Multi-mode platform installation system with interactive TUI, headless CLI, and unattended modes. + +**Language**: Rust (Ratatui TUI) + Nushell scripts + +**Purpose**: Platform installation and configuration generation + +**Key Features**: +- **Interactive TUI Mode**: Beautiful terminal UI with 7 screens +- **Headless Mode**: CLI automation for scripted installations +- **Unattended Mode**: Zero-interaction CI/CD deployments +- **Deployment Modes**: Solo (2 CPU/4GB), MultiUser (4 CPU/8GB), CICD (8 CPU/16GB), Enterprise (16 CPU/32GB) +- **MCP Integration**: 7 AI-powered settings tools for intelligent configuration +- **Nushell Scripts**: Complete deployment automation for Docker, Podman, Kubernetes, OrbStack + +**Status**: ✅ Production Ready (v3.5.0) + +**Quick Start**: +```bash +# Interactive TUI +provisioning-installer + +# Headless mode +provisioning-installer --headless --mode solo --yes + +# Unattended CI/CD +provisioning-installer --unattended --config config.toml +``` + +**Documentation**: `installer/docs/` - Complete guides and references + +--- + +### 5. **MCP Server** (`mcp-server/`) + +Model Context Protocol server for AI-powered assistance. + +**Language**: Nushell + +**Purpose**: AI integration for intelligent configuration and assistance + +**Key Features**: +- 7 AI-powered settings tools +- Intelligent config completion +- Natural language infrastructure queries +- Configuration validation and suggestions +- Context-aware help system + +**Status**: ✅ Active Development + +**MCP Tools**: +- Settings generation +- Configuration validation +- Best practice recommendations +- Infrastructure planning assistance +- Error diagnosis and resolution + +--- + +### 6. **OCI Registry** (`oci-registry/`) + +OCI-compliant registry for extension distribution and versioning. + +**Purpose**: Distributing and managing extensions + +**Key Features**: +- Task service packages +- Provider packages +- Cluster templates +- Workflow definitions +- Version management and updates +- Dependency resolution + +**Status**: 🔄 Planned + +**Benefits**: +- Centralized extension management +- Version control and rollback +- Dependency tracking +- Community marketplace ready + +--- + +### 7. **API Gateway** (`api-gateway/`) + +Unified REST API gateway for external integration. + +**Language**: Rust + +**Purpose**: API routing, authentication, and rate limiting + +**Key Features**: +- Request routing to backend services +- Authentication and authorization +- Rate limiting and throttling +- API versioning +- Request validation +- Metrics and monitoring + +**Status**: 🔄 Planned + +**Endpoints** (Planned): +- `/api/v1/servers/*` - Server management +- `/api/v1/taskservs/*` - Task service operations +- `/api/v1/clusters/*` - Cluster operations +- `/api/v1/workflows/*` - Workflow management + +--- + +### 8. **Extension Registry** (`extension-registry/`) + +Registry and catalog for browsing and discovering extensions. + +**Purpose**: Extension discovery and metadata management + +**Key Features**: +- Extension catalog +- Search and filtering +- Version history +- Dependency information +- Documentation links +- Community ratings (future) + +**Status**: 🔄 Planned + +--- + +### 9. **Provisioning Server** (`provisioning-server/`) + +Alternative provisioning service implementation. + +**Purpose**: Additional provisioning service capabilities + +**Status**: 🔄 In Development + +--- + +## Supporting Services + +### CoreDNS (`coredns/`) + +DNS service configuration for cluster environments. + +**Purpose**: Service discovery and DNS resolution + +**Status**: ✅ Configuration Ready + +--- + +### Monitoring (`monitoring/`) + +Observability and monitoring infrastructure. + +**Purpose**: Metrics, logging, and alerting + +**Components**: +- Prometheus configuration +- Grafana dashboards +- Alert rules + +**Status**: ✅ Configuration Ready + +--- + +### Nginx (`nginx/`) + +Reverse proxy and load balancer configurations. + +**Purpose**: HTTP routing and SSL termination + +**Status**: ✅ Configuration Ready + +--- + +### Docker Compose (`docker-compose/`) + +Docker Compose configurations for local development. + +**Purpose**: Quick local platform deployment + +**Status**: ✅ Ready for Development + +--- + +### Systemd (`systemd/`) + +Systemd service units for platform services. + +**Purpose**: Production deployment with systemd + +**Status**: ✅ Ready for Production + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Interfaces │ +│ • CLI (provisioning command) │ +│ • Web UI (Control Center UI) │ +│ • API Clients │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ API Gateway │ +│ • Request Routing │ +│ • Authentication & Authorization │ +│ • Rate Limiting │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Platform Services Layer │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Orchestrator │ │Control Center│ │ MCP Server │ │ +│ │ (Rust) │ │ (Rust) │ │ (Nushell) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Installer │ │ OCI Registry │ │ Extension │ │ +│ │(Rust/Nushell)│ │ │ │ Registry │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Data & State Layer │ +│ • SurrealDB (State Management) │ +│ • File-based Persistence (Checkpoints) │ +│ • Configuration Storage │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Technology Stack + +### Primary Languages + +| Language | Usage | Services | +|----------|-------|----------| +| **Rust** | Platform services, performance layer | Orchestrator, Control Center, Installer, API Gateway | +| **Nushell** | Scripting, automation, MCP integration | MCP Server, Installer scripts | +| **Web** | Frontend interfaces | Control Center UI | + +### Key Dependencies + +- **tokio** - Async runtime for Rust services +- **axum** / **actix-web** - Web frameworks +- **serde** - Serialization/deserialization +- **bollard** - Docker API client (test environments) +- **ratatui** - Terminal UI framework (installer) +- **SurrealDB** - State management database + +--- + +## Deployment Modes + +### 1. **Development Mode** + +```bash +# Docker Compose for local development +docker-compose -f docker-compose/dev.yml up +``` + +### 2. **Production Mode (Systemd)** + +```bash +# Install systemd units +sudo cp systemd/*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now provisioning-orchestrator +sudo systemctl enable --now provisioning-control-center +``` + +### 3. **Kubernetes Deployment** + +```bash +# Deploy platform services to Kubernetes +kubectl apply -f k8s/ +``` + +--- + +## Security Features + +### Enterprise Security Stack + +1. **Authorization & Permissions** (Control Center) + - Role-Based Access Control (RBAC) + - Fine-grained permissions + - Audit logging + +2. **Authentication** + - API key management + - Session management + - Token-based auth (JWT) + +3. **Secrets Management** + - Integration with SOPS/Age + - Cosmian KMS support + - Secure configuration storage + +4. **Policy Enforcement** + - Cedar policy engine integration + - Compliance checking + - Anomaly detection + +--- + +## Getting Started + +### Prerequisites + +- **Rust** - Latest stable (for building platform services) +- **Nushell 0.107.1+** - For MCP server and scripts +- **Docker** (optional) - For containerized deployment +- **Kubernetes** (optional) - For K8s deployment + +### Building Platform Services + +```bash +# Build all Rust services +cd orchestrator && cargo build --release +cd ../control-center && cargo build --release +cd ../installer && cargo build --release +``` + +### Running Services + +```bash +# Start orchestrator +cd orchestrator +./scripts/start-orchestrator.nu --background + +# Start control center +cd control-center +cargo run --release + +# Start MCP server +cd mcp-server +nu run.nu +``` + +--- + +## Development + +### Project Structure + +``` +platform/ +├── orchestrator/ # Rust orchestrator service +├── control-center/ # Rust control center backend +├── control-center-ui/ # Web frontend +├── installer/ # Rust/Nushell installer +├── mcp-server/ # Nushell MCP server +├── api-gateway/ # Rust API gateway (planned) +├── oci-registry/ # OCI registry (planned) +├── extension-registry/ # Extension catalog (planned) +├── provisioning-server/# Alternative service +├── docker-compose/ # Docker Compose configs +├── k8s/ # Kubernetes manifests +├── systemd/ # Systemd units +└── docs/ # Platform documentation +``` + +### Adding New Services + +1. Create service directory in `platform/` +2. Add README.md with service description +3. Implement service following architecture patterns +4. Add tests and documentation +5. Update platform/README.md (this file) +6. Add deployment configurations (docker-compose, k8s, systemd) + +--- + +## Integration with [Provisioning](../../PROVISIONING.md) + +Platform services integrate seamlessly with the [Provisioning](../../PROVISIONING.md) system: + +- **Core Engine** (`../core/`) provides CLI and libraries +- **Extensions** (`../extensions/`) provide providers, taskservs, clusters +- **Platform Services** (this directory) provide execution and management +- **Configuration** (`../kcl/`, `../config/`) defines infrastructure + +--- + +## Documentation + +### Platform Documentation + +- **Orchestrator**: [.claude/features/orchestrator-architecture.md](../../.claude/features/orchestrator-architecture.md) +- **Installer**: `installer/docs/` directory +- **Test Environments**: [.claude/features/test-environment-service.md](../../.claude/features/test-environment-service.md) + +### API Documentation + +- **REST API Reference**: `docs/api/` (when orchestrator is running) +- **MCP Tools Reference**: `mcp-server/docs/` + +### Architecture Documentation + +- **Main Project**: [PROVISIONING.md](../../PROVISIONING.md) +- **Project Architecture**: [CLAUDE.md](../../CLAUDE.md) + +--- + +## Contributing + +When contributing to platform services: + +1. **Follow Rust Best Practices** - Idiomatic Rust, proper error handling +2. **Security First** - Always consider security implications +3. **Performance Matters** - Platform services are performance-critical +4. **Document APIs** - All REST endpoints must be documented +5. **Add Tests** - Unit tests and integration tests required +6. **Update Docs** - Keep README and API docs current + +--- + +## Status Legend + +- ✅ **Production Ready** - Fully implemented and tested +- ✅ **Active Development** - Working implementation, ongoing improvements +- ✅ **Configuration Ready** - Configuration files ready for deployment +- 🔄 **Planned** - Design phase, implementation pending +- 🔄 **In Development** - Early implementation stage + +--- + +## Support + +For platform service issues: +- Check service-specific README in service directory +- Review logs: `journalctl -u provisioning-*` (systemd) +- API documentation: `http://localhost:8080/docs` (when running) +- See [PROVISIONING.md](../../PROVISIONING.md) for general support + +--- + +**Maintained By**: Platform Team +**Last Updated**: 2025-10-07 +**Platform Version**: 3.5.0 diff --git a/api-gateway/.gitkeep b/api-gateway/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/api-gateway/Dockerfile b/api-gateway/Dockerfile new file mode 100644 index 0000000..eb38c06 --- /dev/null +++ b/api-gateway/Dockerfile @@ -0,0 +1,58 @@ +# Build stage +FROM rust:1.75-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy manifests +COPY Cargo.toml Cargo.lock ./ + +# Create dummy source to cache dependencies +RUN mkdir -p src && \ + echo "fn main() {}" > src/main.rs && \ + cargo build --release && \ + rm -rf src + +# Copy actual source code +COPY src ./src + +# Build release binary +RUN cargo build --release --bin api-gateway + +# Runtime stage +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 provisioning + +# Copy binary from builder +COPY --from=builder /app/target/release/api-gateway /usr/local/bin/ + +# Switch to non-root user +USER provisioning +WORKDIR /app + +# Expose port +EXPOSE 8085 + +# Set environment variables +ENV RUST_LOG=info +ENV SERVER_PORT=8085 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8085/health || exit 1 + +# Run the binary +CMD ["api-gateway"] diff --git a/control-center-ui/AUTH_SYSTEM.md b/control-center-ui/AUTH_SYSTEM.md new file mode 100644 index 0000000..617a3da --- /dev/null +++ b/control-center-ui/AUTH_SYSTEM.md @@ -0,0 +1,346 @@ +# Control Center UI - Leptos Authentication System + +A comprehensive authentication system built with Leptos and WebAssembly for cloud infrastructure management. + +## 🔐 Features Overview + +### Core Authentication +- **Email/Password Login** with comprehensive validation +- **JWT Token Management** with automatic refresh +- **Secure Token Storage** with AES-256-GCM encryption in localStorage +- **401 Response Interceptor** for automatic logout and token refresh + +### Multi-Factor Authentication (MFA) +- **TOTP-based MFA** with QR code generation +- **Backup Codes** for account recovery +- **Mobile App Integration** (Google Authenticator, Authy, etc.) + +### Biometric Authentication +- **WebAuthn/FIDO2 Support** for passwordless authentication +- **Platform Authenticators** (Touch ID, Face ID, Windows Hello) +- **Cross-Platform Security Keys** (USB, NFC, Bluetooth) +- **Credential Management** with device naming and removal + +### Advanced Security Features +- **Device Trust Management** with fingerprinting +- **Session Timeout Warnings** with countdown timers +- **Password Reset Flow** with email verification +- **SSO Integration** (OAuth2, SAML, OpenID Connect) +- **Session Management** with active session monitoring + +### Route Protection +- **Auth Guards** for protected routes +- **Permission-based Access Control** with role validation +- **Conditional Rendering** based on authentication state +- **Automatic Redirects** for unauthorized access + +## 📁 Architecture Overview + +``` +src/ +├── auth/ # Authentication core +│ ├── mod.rs # Type definitions and exports +│ ├── token_manager.rs # JWT token handling with auto-refresh +│ ├── storage.rs # Encrypted token storage +│ ├── webauthn.rs # WebAuthn/FIDO2 implementation +│ ├── crypto.rs # Cryptographic utilities +│ └── http_interceptor.rs # HTTP request/response interceptor +├── components/auth/ # Authentication components +│ ├── mod.rs # Component exports +│ ├── login_form.rs # Email/password login form +│ ├── mfa_setup.rs # TOTP MFA configuration +│ ├── password_reset.rs # Password reset flow +│ ├── auth_guard.rs # Route protection components +│ ├── session_timeout.rs # Session management modal +│ ├── sso_buttons.rs # SSO provider buttons +│ ├── device_trust.rs # Device trust management +│ ├── biometric_auth.rs # WebAuthn biometric auth +│ ├── logout_button.rs # Logout functionality +│ └── user_profile.rs # User profile management +├── utils/ # Utility modules +└── lib.rs # Main application entry +``` + +## 🚀 Implemented Components + +All authentication components have been successfully implemented: + +### ✅ Core Authentication Infrastructure +- **Secure Token Storage** (`src/auth/storage.rs`) - AES-256-GCM encrypted localStorage with session-based keys +- **JWT Token Manager** (`src/auth/token_manager.rs`) - Automatic token refresh, expiry monitoring, context management +- **Crypto Utilities** (`src/auth/crypto.rs`) - Secure random generation, hashing, HMAC, device fingerprinting +- **HTTP Interceptor** (`src/auth/http_interceptor.rs`) - 401 handling, automatic logout, request/response middleware + +### ✅ Authentication Components +- **Login Form** (`src/components/auth/login_form.rs`) - Email/password validation, remember me, SSO integration +- **MFA Setup** (`src/components/auth/mfa_setup.rs`) - TOTP with QR codes, backup codes, verification flow +- **Password Reset** (`src/components/auth/password_reset.rs`) - Email verification, secure token flow, validation +- **Session Timeout** (`src/components/auth/session_timeout.rs`) - Countdown modal, automatic logout, session extension + +### ✅ Advanced Security Features +- **Device Trust** (`src/components/auth/device_trust.rs`) - Device fingerprinting, trust management, auto-generated names +- **Biometric Auth** (`src/components/auth/biometric_auth.rs`) - WebAuthn/FIDO2 integration, credential management +- **SSO Buttons** (`src/components/auth/sso_buttons.rs`) - OAuth2/SAML/OIDC providers with branded icons +- **User Profile** (`src/components/auth/user_profile.rs`) - Comprehensive profile management with tabbed interface + +### ✅ Route Protection System +- **Auth Guard** (`src/components/auth/auth_guard.rs`) - Protected routes, permission guards, role-based access +- **Logout Button** (`src/components/auth/logout_button.rs`) - Secure logout with server notification and cleanup + +### ✅ WebAuthn Integration +- **WebAuthn Manager** (`src/auth/webauthn.rs`) - Complete FIDO2 implementation with browser compatibility +- **Biometric Registration** - Platform and cross-platform authenticator support +- **Credential Management** - Device naming, usage tracking, removal capabilities + +## 🔒 Security Implementation + +### Token Security +- **AES-256-GCM Encryption**: All tokens encrypted before storage +- **Session-based Keys**: Encryption keys unique per browser session +- **Automatic Rotation**: Keys regenerated on each application load +- **Secure Cleanup**: Complete token removal on logout + +### Device Trust +- **Hardware Fingerprinting**: Based on browser, platform, screen, timezone +- **Trust Duration**: Configurable trust periods (7, 30, 90, 365 days) +- **Trust Tokens**: Separate tokens for device trust validation +- **Remote Revocation**: Server-side device trust management + +### Session Management +- **Configurable Timeouts**: Adjustable session timeout periods +- **Activity Monitoring**: Tracks user activity for session extension +- **Concurrent Sessions**: Multiple session tracking and management +- **Graceful Logout**: Clean session termination with server notification + +### WebAuthn Security +- **Hardware Security**: Leverages hardware security modules +- **Biometric Verification**: Touch ID, Face ID, Windows Hello support +- **Security Key Support**: USB, NFC, Bluetooth FIDO2 keys +- **Attestation Validation**: Hardware authenticity verification + +## 📱 Component Usage Examples + +### Basic Authentication Flow +```rust +use leptos::*; +use control_center_ui::auth::provide_auth_context; +use control_center_ui::components::auth::*; + +#[component] +fn App() -> impl IntoView { + provide_meta_context(); + + // Initialize auth context with API base URL + provide_auth_context("http://localhost:8080".to_string()).unwrap(); + + view! { + + + + + + + + } +} +``` + +### Login Page Implementation +```rust +#[component] +fn LoginPage() -> impl IntoView { + view! { +
+
+

+ "Control Center" +

+
+
+
+ +
+
+
+ } +} +``` + +### Protected Dashboard +```rust +#[component] +fn DashboardPage() -> impl IntoView { + view! { + +
+ +
+ + // Dashboard content +
+
+
+ } +} +``` + +### User Profile Management +```rust +#[component] +fn ProfilePage() -> impl IntoView { + view! { + +
+
+ +
+
+
+ } +} +``` + +## 🔧 Required Backend API + +The authentication system expects the following backend endpoints: + +### Authentication Endpoints +``` +POST /auth/login # Email/password authentication +POST /auth/refresh # JWT token refresh +POST /auth/logout # Session termination +POST /auth/extend-session # Session timeout extension +``` + +### Password Management +``` +POST /auth/password-reset # Password reset request +POST /auth/password-reset/confirm # Password reset confirmation +``` + +### Multi-Factor Authentication +``` +POST /auth/mfa/setup # MFA setup initiation +POST /auth/mfa/verify # MFA verification +``` + +### SSO Integration +``` +GET /auth/sso/providers # Available SSO providers +POST /auth/sso/{provider}/login # SSO authentication initiation +``` + +### WebAuthn/FIDO2 +``` +POST /auth/webauthn/register/begin # WebAuthn registration start +POST /auth/webauthn/register/complete # WebAuthn registration finish +POST /auth/webauthn/authenticate/begin # WebAuthn authentication start +POST /auth/webauthn/authenticate/complete # WebAuthn authentication finish +GET /auth/webauthn/credentials # List WebAuthn credentials +DELETE /auth/webauthn/credentials/{id} # Remove WebAuthn credential +``` + +### Device Trust Management +``` +GET /auth/devices # List trusted devices +POST /auth/devices/trust # Trust current device +DELETE /auth/devices/{id}/revoke # Revoke device trust +``` + +### User Profile Management +``` +GET /user/profile # Get user profile +PUT /user/profile # Update user profile +POST /user/change-password # Change password +POST /user/mfa/enable # Enable MFA +POST /user/mfa/disable # Disable MFA +GET /user/sessions # List active sessions +DELETE /user/sessions/{id}/revoke # Revoke session +``` + +## 📊 Implementation Statistics + +### Component Coverage +- **13/13 Core Components** ✅ Complete +- **4/4 Auth Infrastructure** ✅ Complete +- **9/9 Security Features** ✅ Complete +- **3/3 Route Protection** ✅ Complete +- **2/2 WebAuthn Features** ✅ Complete + +### Security Features +- **Encrypted Storage** ✅ AES-256-GCM with session keys +- **Automatic Token Refresh** ✅ Background refresh with retry logic +- **Device Fingerprinting** ✅ Hardware-based unique identification +- **Session Management** ✅ Timeout warnings and extensions +- **Biometric Authentication** ✅ WebAuthn/FIDO2 integration +- **Multi-Factor Auth** ✅ TOTP with QR codes and backup codes +- **SSO Integration** ✅ OAuth2/SAML/OIDC providers +- **Route Protection** ✅ Guards with permission/role validation + +### Performance Optimizations +- **Lazy Loading** ✅ Components loaded on demand +- **Reactive Updates** ✅ Leptos fine-grained reactivity +- **Efficient Re-renders** ✅ Minimal component updates +- **Background Operations** ✅ Non-blocking authentication flows +- **Connection Management** ✅ Automatic retry and fallback + +## 🎯 Key Features Highlights + +### Advanced Authentication +- **Passwordless Login**: WebAuthn biometric authentication +- **Device Memory**: Skip MFA on trusted devices +- **Session Continuity**: Automatic token refresh without interruption +- **Multi-Provider SSO**: Google, Microsoft, GitHub, GitLab, etc. + +### Enterprise Security +- **Hardware Security**: FIDO2 security keys and platform authenticators +- **Device Trust**: Configurable trust periods with remote revocation +- **Session Monitoring**: Real-time session management and monitoring +- **Audit Trail**: Complete authentication event logging + +### Developer Experience +- **Type Safety**: Full TypeScript-equivalent safety with Rust +- **Component Reusability**: Modular authentication components +- **Easy Integration**: Simple context provider setup +- **Comprehensive Documentation**: Detailed implementation guide + +### User Experience +- **Smooth Flows**: Intuitive authentication workflows +- **Mobile Support**: Responsive design for all devices +- **Accessibility**: WCAG 2.1 compliant components +- **Error Handling**: User-friendly error messages and recovery + +## 🚀 Getting Started + +### Prerequisites +- **Rust 1.70+** with wasm-pack +- **Leptos 0.6** framework +- **Compatible browser** (Chrome 67+, Firefox 60+, Safari 14+, Edge 18+) + +### Quick Setup +1. Add the authentication dependencies to your `Cargo.toml` +2. Initialize the authentication context in your app +3. Use the provided components in your routes +4. Configure your backend API endpoints +5. Test the complete authentication flow + +### Production Deployment +- **HTTPS Required**: WebAuthn requires secure connections +- **CORS Configuration**: Proper cross-origin setup +- **CSP Headers**: Content security policy for XSS protection +- **Rate Limiting**: API endpoint protection + +--- + +**A complete, production-ready authentication system built with modern Rust and WebAssembly technologies.** \ No newline at end of file diff --git a/control-center-ui/Cargo.toml b/control-center-ui/Cargo.toml new file mode 100644 index 0000000..bd02b54 --- /dev/null +++ b/control-center-ui/Cargo.toml @@ -0,0 +1,176 @@ +[package] +name = "control-center-ui" +version.workspace = true +edition.workspace = true +description = "Control Center UI - Leptos CSR App for Cloud Infrastructure Management" +authors = ["Control Center Team"] + +[lib] +crate-type = ["cdylib"] + +[[bin]] +name = "control-center-ui" +path = "src/main.rs" + +[dependencies] +# ============================================================================ +# WORKSPACE DEPENDENCIES +# ============================================================================ + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["js"] } +chrono = { workspace = true, features = ["wasm-bindgen"] } + +# Error handling and async +thiserror = { workspace = true } +futures = { workspace = true } + +# Logging and debugging +tracing = { workspace = true } + +# Security and cryptography +base64 = { workspace = true } +regex = { workspace = true } +rand = { workspace = true } +sha2 = { workspace = true } +hmac = { workspace = true } +aes-gcm = { workspace = true, features = ["aes", "std"] } + +# ============================================================================ +# WASM-SPECIFIC DEPENDENCIES +# ============================================================================ + +# Leptos Framework with CSR features +leptos = { workspace = true } +leptos_meta = { workspace = true } +leptos_router = { workspace = true } + +# WASM utilities +wasm-bindgen = { workspace = true } + +# ============================================================================ +# ADDITIONAL WORKSPACE DEPENDENCIES +# ============================================================================ + +# URL handling +url = { workspace = true } + +# Icons and UI utilities +icondata = { workspace = true } +leptos_icons = { workspace = true } + +# Authentication and cryptography +qrcode = { workspace = true } +image = { workspace = true } +totp-rs = { workspace = true } + +# Serialization utilities +serde-wasm-bindgen = { workspace = true } + +# Logging for WASM +tracing-wasm = { workspace = true } +console_error_panic_hook = { workspace = true } + +# HTTP client and networking +gloo-net = { workspace = true } +gloo-storage = { workspace = true } +gloo-utils = { workspace = true } +gloo-timers = { workspace = true } + +# Chart.js bindings and canvas utilities +plotters = { workspace = true } +plotters-canvas = { workspace = true } + +# WASM utilities +wasm-bindgen-futures = { workspace = true } +js-sys = { workspace = true } + +# Random number generation +getrandom = { workspace = true } + +# ============================================================================ +# PROJECT-SPECIFIC DEPENDENCIES (not in workspace) +# ============================================================================ + +# Web APIs +web-sys = { version = "0.3", features = [ + "console", + "Window", + "Document", + "Element", + "HtmlElement", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "EventTarget", + "Event", + "DragEvent", + "DataTransfer", + "HtmlInputElement", + "HtmlSelectElement", + "HtmlTextAreaElement", + "HtmlButtonElement", + "HtmlDivElement", + "Storage", + "Location", + "History", + "Navigator", + "ServiceWorkerRegistration", + "ServiceWorker", + "NotificationPermission", + "Notification", + "Headers", + "Request", + "RequestInit", + "RequestMode", + "Response", + "AbortController", + "AbortSignal", + "WebSocket", + "MessageEvent", + "CloseEvent", + "ErrorEvent", + "Blob", + "Url", + "FileReader", + "File", + "HtmlAnchorElement", + "MouseEvent", + "TouchEvent", + "KeyboardEvent", + "ResizeObserver", + "ResizeObserverEntry", + "IntersectionObserver", + "IntersectionObserverEntry", + # Media Query APIs + "MediaQueryList", + "MediaQueryListEvent", + # WebAuthn APIs + "CredentialsContainer", + "PublicKeyCredential", + "PublicKeyCredentialCreationOptions", + "PublicKeyCredentialRequestOptions", + "AuthenticatorResponse", + "AuthenticatorAttestationResponse", + "AuthenticatorAssertionResponse", + # Crypto APIs + "Crypto", + "SubtleCrypto", + "CryptoKey", +] } + +# HTTP client (project-specific for WASM features) +reqwest = { version = "0.12", features = ["json"] } + +# Tokio with time features for WASM (project-specific version) +tokio = { version = "1.47", features = ["time"] } + +# Profile configurations moved to workspace root + +# WASM pack settings +[package.metadata.wasm-pack.profile.release] +wasm-opt = ['-Oz', '--enable-mutable-globals'] + +[package.metadata.wasm-pack.profile.dev] +wasm-opt = false \ No newline at end of file diff --git a/control-center-ui/README.md b/control-center-ui/README.md new file mode 100644 index 0000000..c25b069 --- /dev/null +++ b/control-center-ui/README.md @@ -0,0 +1,335 @@ +# Control Center UI - Audit Log Viewer + +A comprehensive React-based audit log viewer for the Cedar Policy Engine with advanced search, real-time streaming, compliance reporting, and visualization capabilities. + +## 🚀 Features + +### 🔍 Advanced Search & Filtering +- **Multi-dimensional Filters**: Date range, users, actions, resources, severity, compliance frameworks +- **Real-time Search**: Debounced search with instant results +- **Saved Searches**: Save and reuse complex filter combinations +- **Quick Filters**: One-click access to common time ranges and filters +- **Correlation Search**: Find logs by request ID, session ID, or trace correlation + +### 📊 High-Performance Data Display +- **Virtual Scrolling**: Handle millions of log entries with smooth scrolling +- **Infinite Loading**: Automatic pagination with optimized data fetching +- **Column Sorting**: Sort by any field with persistent state +- **Bulk Selection**: Select multiple logs for batch operations +- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile + +### 🔴 Real-time Streaming +- **WebSocket Integration**: Live log updates without page refresh +- **Connection Management**: Automatic reconnection with exponential backoff +- **Real-time Indicators**: Visual status of live connection +- **Message Queuing**: Handles high-volume log streams efficiently +- **Alert Notifications**: Critical events trigger immediate notifications + +### 📋 Detailed Log Inspection +- **JSON Viewer**: Syntax-highlighted JSON with collapsible sections +- **Multi-tab Interface**: Overview, Context, Metadata, Compliance, Raw JSON +- **Sensitive Data Toggle**: Hide/show sensitive information +- **Copy Utilities**: One-click copying of IDs, values, and entire records +- **Deep Linking**: Direct URLs to specific log entries + +### 📤 Export & Reporting +- **Multiple Formats**: CSV, JSON, PDF export with customizable fields +- **Template System**: Pre-built templates for different report types +- **Batch Export**: Export filtered results or selected logs +- **Progress Tracking**: Real-time export progress indication +- **Custom Fields**: Choose exactly which data to include + +### 🛡️ Compliance Management +- **Framework Support**: SOC2, HIPAA, PCI DSS, GDPR compliance templates +- **Report Generation**: Automated compliance reports with evidence +- **Finding Tracking**: Track violations and remediation status +- **Attestation Management**: Digital signatures and certifications +- **Template Library**: Customizable report templates for different frameworks + +### 🔗 Log Correlation & Tracing +- **Request Tracing**: Follow request flows across services +- **Session Analysis**: View all activity for a user session +- **Dependency Mapping**: Understand log relationships and causality +- **Timeline Views**: Chronological visualization of related events + +### 📈 Visualization & Analytics +- **Dashboard Metrics**: Real-time statistics and KPIs +- **Timeline Charts**: Visual representation of log patterns +- **Geographic Distribution**: Location-based log analysis +- **Severity Trends**: Track security event patterns over time +- **User Activity**: Monitor user behavior and access patterns + +## 🛠 Technology Stack + +### Frontend Framework +- **React 18.3.1**: Modern React with hooks and concurrent features +- **TypeScript 5.5.4**: Type-safe development with advanced types +- **Vite 5.4.1**: Lightning-fast build tool and dev server + +### UI Components & Styling +- **TailwindCSS 3.4.9**: Utility-first CSS framework +- **DaisyUI 4.4.19**: Beautiful component library built on Tailwind +- **Framer Motion 11.3.24**: Smooth animations and transitions +- **Lucide React 0.427.0**: Beautiful, customizable icons + +### Data Management +- **TanStack Query 5.51.23**: Powerful data fetching and caching +- **TanStack Table 8.20.1**: Headless table utilities for complex data +- **TanStack Virtual 3.8.4**: Virtual scrolling for performance +- **Zustand 4.5.4**: Lightweight state management + +### Forms & Validation +- **React Hook Form 7.52.2**: Performant forms with minimal re-renders +- **React Select 5.8.0**: Flexible select components with search + +### Real-time & Networking +- **Native WebSocket API**: Direct WebSocket integration +- **Custom Hooks**: Reusable WebSocket management with reconnection + +### Export & Reporting +- **jsPDF 2.5.1**: Client-side PDF generation +- **jsPDF AutoTable 3.8.2**: Table formatting for PDF reports +- **Native Blob API**: File download and export functionality + +### Date & Time +- **date-fns 3.6.0**: Modern date utility library with tree shaking + +## 📁 Project Structure + +``` +src/ +├── components/audit/ # Audit log components +│ ├── AuditLogViewer.tsx # Main viewer component +│ ├── SearchFilters.tsx # Advanced search interface +│ ├── VirtualizedLogTable.tsx # High-performance table +│ ├── LogDetailModal.tsx # Detailed log inspection +│ ├── ExportModal.tsx # Export functionality +│ ├── ComplianceReportGenerator.tsx # Compliance reports +│ └── RealTimeIndicator.tsx # WebSocket status +├── hooks/ # Custom React hooks +│ └── useWebSocket.ts # WebSocket management +├── services/ # API integration +│ └── api.ts # Audit API client +├── types/ # TypeScript definitions +│ └── audit.ts # Audit-specific types +├── utils/ # Utility functions +├── store/ # State management +└── styles/ # CSS and styling +``` + +## 🔧 Setup and Development + +### Prerequisites +- **Node.js 18+** and **npm 9+** +- **Control Center backend** running on `http://localhost:8080` + +### Installation + +```bash +# Clone the repository +git clone +cd control-center-ui + +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +The application will be available at `http://localhost:3000` + +### Building for Production + +```bash +# Type check +npm run type-check + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +## 🌐 API Integration + +The UI integrates with the Control Center backend and expects the following endpoints: + +- `GET /audit/logs` - Fetch audit logs with filtering and pagination +- `GET /audit/logs/{id}` - Get specific log entry details +- `POST /audit/search` - Advanced search functionality +- `GET /audit/saved-searches` - Manage saved search queries +- `POST /audit/export` - Export logs in various formats (CSV, JSON, PDF) +- `GET /compliance/reports` - Compliance report management +- `POST /compliance/reports/generate` - Generate compliance reports +- `WS /audit/stream` - Real-time log streaming via WebSocket +- `GET /health` - Health check endpoint + +### WebSocket Integration + +Real-time log streaming is implemented using WebSocket connections: + +```typescript +import { useWebSocket } from './hooks/useWebSocket'; + +const { isConnected, lastMessage } = useWebSocket({ + url: 'ws://localhost:8080/ws/audit', + onNewAuditLog: (log) => { + // Handle new log entry in real-time + updateLogsList(log); + } +}); +``` + +## ✅ Features Implemented + +### Core Audit Log Viewer System +- ✅ **Advanced Search Filters**: Multi-dimensional filtering with date range, users, actions, resources, severity, compliance frameworks +- ✅ **Virtual Scrolling Component**: High-performance rendering capable of handling millions of log entries +- ✅ **Real-time Log Streaming**: WebSocket integration with automatic reconnection and live status indicators +- ✅ **Detailed Log Modal**: Multi-tab interface with JSON syntax highlighting, sensitive data toggle, and copy utilities +- ✅ **Export Functionality**: Support for CSV, JSON, and PDF formats with customizable fields and templates +- ✅ **Saved Search Queries**: User preference system for saving and reusing complex search combinations + +### Compliance & Security Features +- ✅ **Compliance Report Generator**: Automated report generation with SOC2, HIPAA, PCI DSS, and GDPR templates +- ✅ **Violation Tracking**: Remediation workflow system with task management and progress tracking +- ✅ **Timeline Visualization**: Chronological visualization of audit trails with correlation mapping +- ✅ **Request ID Correlation**: Cross-service request tracing and session analysis +- ✅ **Attestation Management**: Digital signature system for compliance certifications +- ✅ **Log Retention Management**: Archival policies and retention period management + +### Performance & User Experience +- ✅ **Dashboard Analytics**: Real-time metrics including success rates, critical events, and compliance scores +- ✅ **Responsive Design**: Mobile-first design that works across all device sizes +- ✅ **Loading States**: Comprehensive loading indicators and skeleton screens +- ✅ **Error Handling**: Robust error boundaries with user-friendly error messages +- ✅ **Keyboard Shortcuts**: Accessibility features and keyboard navigation support + +## 🎨 Styling and Theming + +### TailwindCSS Configuration +The application uses a comprehensive TailwindCSS setup with: +- **DaisyUI Components**: Pre-built, accessible UI components +- **Custom Color Palette**: Primary, secondary, success, warning, error themes +- **Custom Animations**: Smooth transitions and loading states +- **Dark/Light Themes**: Automatic theme switching with system preference detection +- **Responsive Grid System**: Mobile-first responsive design + +### Component Design System +- **Consistent Spacing**: Standardized margin and padding scales +- **Typography Scale**: Hierarchical text sizing and weights +- **Icon System**: Comprehensive icon library with consistent styling +- **Form Controls**: Validated, accessible form components +- **Data Visualization**: Charts and metrics with consistent styling + +## 📱 Performance Optimization + +### Virtual Scrolling +- Renders only visible rows for optimal performance +- Handles datasets with millions of entries smoothly +- Maintains smooth scrolling with momentum preservation +- Automatic cleanup of off-screen elements + +### Efficient Data Fetching +- Infinite queries with intelligent pagination +- Aggressive caching with TanStack Query +- Optimistic updates for better user experience +- Background refetching for fresh data + +### Bundle Optimization +- Code splitting by route and feature +- Tree shaking for minimal bundle size +- Lazy loading of heavy components +- Optimized production builds + +## 🔒 Security Considerations + +### Data Protection +- Sensitive data masking in UI components +- Secure WebSocket connections (WSS in production) +- Content Security Policy headers for XSS protection +- Input sanitization for search queries + +### API Security +- JWT token authentication support (when implemented) +- Request rate limiting awareness +- Secure file downloads with proper headers +- CORS configuration for cross-origin requests + +## 🚀 Deployment + +### Docker Deployment +```dockerfile +FROM node:18-alpine as builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/nginx.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +### Kubernetes Deployment +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: control-center-ui +spec: + replicas: 3 + selector: + matchLabels: + app: control-center-ui + template: + metadata: + labels: + app: control-center-ui + spec: + containers: + - name: control-center-ui + image: control-center-ui:latest + ports: + - containerPort: 80 + env: + - name: VITE_API_BASE_URL + value: "https://api.example.com" +``` + +## 🤝 Contributing + +### Development Guidelines +- Follow TypeScript strict mode conventions +- Use existing component patterns and design system +- Maintain accessibility standards (WCAG 2.1 AA) +- Add proper error boundaries for robust error handling +- Write meaningful commit messages following conventional commits + +### Code Style +- Use Prettier for consistent code formatting +- Follow ESLint rules for code quality +- Use semantic HTML elements for accessibility +- Maintain consistent naming conventions +- Document complex logic with comments + +## 📄 License + +This project follows the same license as the parent Control Center repository. + +## 🆘 Support + +For questions, issues, or contributions: +1. Check existing issues in the repository +2. Review the comprehensive documentation +3. Create detailed bug reports or feature requests +4. Follow the established contribution guidelines + +--- + +Built with ❤️ for comprehensive audit log management, compliance monitoring, and security analytics. \ No newline at end of file diff --git a/control-center-ui/REFERENCE.md b/control-center-ui/REFERENCE.md new file mode 100644 index 0000000..250fe59 --- /dev/null +++ b/control-center-ui/REFERENCE.md @@ -0,0 +1,29 @@ +# Control Center UI Reference + +This directory will reference the existing control center UI implementation. + +## Current Implementation Location +`/Users/Akasha/repo-cnz/src/control-center-ui/` + +## Implementation Details +- **Language**: Web frontend (likely React/Vue/Leptos) +- **Purpose**: Web interface for system management +- **Features**: + - Dashboard and monitoring UI + - Configuration management interface + - System administration controls + +## Integration Status +- **Current**: Fully functional in original location +- **New Structure**: Reference established +- **Migration**: Planned for future phase + +## Usage +The control center UI remains fully functional at its original location. + +```bash +cd /Users/Akasha/repo-cnz/src/control-center-ui +# Use existing UI development commands +``` + +See original implementation for development setup and usage instructions. \ No newline at end of file diff --git a/control-center-ui/Trunk.toml b/control-center-ui/Trunk.toml new file mode 100644 index 0000000..f9801ce --- /dev/null +++ b/control-center-ui/Trunk.toml @@ -0,0 +1,46 @@ +[build] +target = "index.html" +dist = "dist" +minify = "on_release" +filehash = true + +[watch] +watch = ["src", "style", "assets"] +ignore = ["dist", "target"] + +[serve] +address = "127.0.0.1" +port = 3000 +open = false +# Proxy API calls to the Rust orchestrator +[[serve.proxy]] +backend = "http://127.0.0.1:8080/" +rewrite = "/api/{tail}" +ws = true + +[clean] +dist = "dist" +cargo = true + +# Release mode optimizations are already set in main [build] section above + +# TailwindCSS processing - temporarily disabled to test build +# [[hooks]] +# stage = "pre_build" +# command = "npx" +# command_arguments = ["tailwindcss", "-i", "./style/input.css", "-o", "./style/output.css", "--watch"] + +# [[hooks]] +# stage = "build" +# command = "npx" +# command_arguments = ["tailwindcss", "-i", "./style/input.css", "-o", "./style/output.css", "--minify"] + +# PostCSS processing for production - temporarily disabled to test build +# [[hooks]] +# stage = "post_build" +# command = "npx" +# command_arguments = ["postcss", "dist/*.css", "--use", "autoprefixer", "--replace"] + +# Service Worker registration +[build.tools] +sass = "style/input.scss" \ No newline at end of file diff --git a/control-center-ui/assets/manifest.json b/control-center-ui/assets/manifest.json new file mode 100644 index 0000000..c85fc5b --- /dev/null +++ b/control-center-ui/assets/manifest.json @@ -0,0 +1,131 @@ +{ + "name": "Control Center UI", + "short_name": "Control Center", + "description": "Cloud Infrastructure Management Dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3b82f6", + "orientation": "portrait-primary", + "scope": "/", + "categories": ["productivity", "utilities", "business"], + "lang": "en", + "icons": [ + { + "src": "/assets/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View infrastructure dashboard", + "url": "/dashboard", + "icons": [ + { + "src": "/assets/shortcut-dashboard.png", + "sizes": "96x96" + } + ] + }, + { + "name": "Servers", + "short_name": "Servers", + "description": "Manage servers", + "url": "/servers", + "icons": [ + { + "src": "/assets/shortcut-servers.png", + "sizes": "96x96" + } + ] + }, + { + "name": "Clusters", + "short_name": "Clusters", + "description": "Manage Kubernetes clusters", + "url": "/clusters", + "icons": [ + { + "src": "/assets/shortcut-clusters.png", + "sizes": "96x96" + } + ] + } + ], + "screenshots": [ + { + "src": "/assets/screenshot-desktop.png", + "sizes": "1280x800", + "type": "image/png", + "form_factor": "wide", + "label": "Control Center Dashboard" + }, + { + "src": "/assets/screenshot-mobile.png", + "sizes": "375x812", + "type": "image/png", + "form_factor": "narrow", + "label": "Control Center Mobile View" + } + ], + "prefer_related_applications": false, + "edge_side_panel": { + "preferred_width": 400 + }, + "launch_handler": { + "client_mode": "navigate-existing" + }, + "handle_links": "preferred", + "protocol_handlers": [ + { + "protocol": "control-center", + "url": "/?protocol=%s" + } + ] +} \ No newline at end of file diff --git a/control-center-ui/assets/sw.js b/control-center-ui/assets/sw.js new file mode 100644 index 0000000..3d86c1b --- /dev/null +++ b/control-center-ui/assets/sw.js @@ -0,0 +1,353 @@ +// Service Worker for Control Center UI +// Version: 1.0.0 + +const CACHE_NAME = 'control-center-ui-v1.0.0'; +const STATIC_CACHE = `${CACHE_NAME}-static`; +const DYNAMIC_CACHE = `${CACHE_NAME}-dynamic`; + +// Static assets to cache on install +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/style/input.css', + '/assets/icon-192.png', + '/assets/icon-512.png' +]; + +// API endpoints that should be cached with network-first strategy +const API_ENDPOINTS = [ + '/api/health', + '/api/dashboard', + '/api/servers', + '/api/clusters' +]; + +// Assets that should never be cached +const NO_CACHE_PATTERNS = [ + '/api/auth/', + '/api/logout', + '/api/websocket' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => { + console.log('[SW] Installation complete'); + return self.skipWaiting(); // Force activation + }) + .catch((error) => { + console.error('[SW] Installation failed:', error); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter(cacheName => + cacheName.startsWith('control-center-ui-') && + cacheName !== STATIC_CACHE && + cacheName !== DYNAMIC_CACHE + ) + .map(cacheName => { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + }) + ); + }) + .then(() => { + console.log('[SW] Activation complete'); + return self.clients.claim(); // Take control of all clients + }) + ); +}); + +// Fetch event - handle network requests +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Skip non-GET requests and chrome-extension requests + if (request.method !== 'GET' || url.protocol === 'chrome-extension:') { + return; + } + + // Skip requests that should never be cached + if (NO_CACHE_PATTERNS.some(pattern => url.pathname.includes(pattern))) { + return; + } + + // Handle different types of requests + if (isStaticAsset(url)) { + event.respondWith(handleStaticAsset(request)); + } else if (isAPIRequest(url)) { + event.respondWith(handleAPIRequest(request)); + } else { + event.respondWith(handleNavigation(request)); + } +}); + +// Check if request is for a static asset +function isStaticAsset(url) { + const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2']; + return staticExtensions.some(ext => url.pathname.endsWith(ext)) || + url.pathname.includes('/assets/'); +} + +// Check if request is for API +function isAPIRequest(url) { + return url.pathname.startsWith('/api/'); +} + +// Handle static assets with cache-first strategy +async function handleStaticAsset(request) { + try { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + console.log('[SW] Serving static asset from cache:', request.url); + return cachedResponse; + } + + // Fetch from network and cache + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(STATIC_CACHE); + cache.put(request, networkResponse.clone()); + console.log('[SW] Cached static asset:', request.url); + } + + return networkResponse; + } catch (error) { + console.error('[SW] Failed to handle static asset:', error); + + // Return a cached fallback if available + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + // Return a generic error response for images + if (request.url.includes('.png') || request.url.includes('.jpg') || request.url.includes('.svg')) { + return new Response('', { status: 404, statusText: 'Image not found' }); + } + + throw error; + } +} + +// Handle API requests with network-first strategy +async function handleAPIRequest(request) { + try { + // Always try network first for API requests + const networkResponse = await fetch(request); + + if (networkResponse.ok && API_ENDPOINTS.some(endpoint => request.url.includes(endpoint))) { + // Cache successful responses for specific endpoints + const cache = await caches.open(DYNAMIC_CACHE); + cache.put(request, networkResponse.clone()); + console.log('[SW] Cached API response:', request.url); + } + + return networkResponse; + } catch (error) { + console.error('[SW] Network request failed, trying cache:', error); + + // Fallback to cache if network fails + const cachedResponse = await caches.match(request); + if (cachedResponse) { + console.log('[SW] Serving API response from cache:', request.url); + return cachedResponse; + } + + // Return a generic offline response + return new Response( + JSON.stringify({ + error: 'Offline', + message: 'This request is not available offline' + }), + { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'application/json' } + } + ); + } +} + +// Handle navigation requests (SPA routing) +async function handleNavigation(request) { + try { + // Try network first + const networkResponse = await fetch(request); + return networkResponse; + } catch (error) { + console.log('[SW] Network failed for navigation, serving index.html from cache'); + + // For navigation requests, serve index.html from cache (SPA routing) + const cachedResponse = await caches.match('/index.html'); + if (cachedResponse) { + return cachedResponse; + } + + // Fallback offline page + return new Response(` + + + + Control Center - Offline + + + +
+

You're Offline

+

Please check your internet connection and try again.

+ +
+ + + `, { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/html' } + }); + } +} + +// Handle messages from the main thread +self.addEventListener('message', (event) => { + const { type, payload } = event.data; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'CLEAR_CACHE': + clearAllCaches(); + break; + + case 'GET_CACHE_STATUS': + getCacheStatus().then(status => { + event.ports[0].postMessage(status); + }); + break; + + default: + console.log('[SW] Unknown message type:', type); + } +}); + +// Clear all caches +async function clearAllCaches() { + try { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map(cacheName => caches.delete(cacheName)) + ); + console.log('[SW] All caches cleared'); + } catch (error) { + console.error('[SW] Failed to clear caches:', error); + } +} + +// Get cache status +async function getCacheStatus() { + try { + const cacheNames = await caches.keys(); + const status = {}; + + for (const cacheName of cacheNames) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + status[cacheName] = keys.length; + } + + return status; + } catch (error) { + console.error('[SW] Failed to get cache status:', error); + return {}; + } +} + +// Handle background sync (if supported) +self.addEventListener('sync', (event) => { + if (event.tag === 'background-sync') { + console.log('[SW] Background sync triggered'); + event.waitUntil(performBackgroundSync()); + } +}); + +// Perform background sync +async function performBackgroundSync() { + try { + // Sync any pending data when back online + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ type: 'BACKGROUND_SYNC' }); + }); + } catch (error) { + console.error('[SW] Background sync failed:', error); + } +} + +// Handle push notifications (if needed in the future) +self.addEventListener('push', (event) => { + if (event.data) { + const data = event.data.json(); + const options = { + body: data.body, + icon: '/assets/icon-192.png', + badge: '/assets/icon-72.png', + tag: 'control-center-notification', + requireInteraction: true, + actions: data.actions || [] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); + } +}); + +// Handle notification clicks +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.openWindow('/') + ); +}); + +console.log('[SW] Service worker script loaded successfully'); \ No newline at end of file diff --git a/control-center-ui/dist/control-center-ui-d1956c1b430684b9.js b/control-center-ui/dist/control-center-ui-d1956c1b430684b9.js new file mode 100644 index 0000000..9d06791 --- /dev/null +++ b/control-center-ui/dist/control-center-ui-d1956c1b430684b9.js @@ -0,0 +1,798 @@ +let wasm; + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_export_2.set(idx, obj); + return idx; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function getFromExternrefTable0(idx) { return wasm.__wbindgen_export_2.get(idx); } + +let cachedUint8ArrayMemory0 = null; + +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + +cachedTextDecoder.decode(); + +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +function getCachedStringFromWasm0(ptr, len) { + if (ptr === 0) { + return getFromExternrefTable0(len); + } else { + return getStringFromWasm0(ptr, len); + } +} + +let WASM_VECTOR_LEN = 0; + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + } +} + +function passStringToWasm0(arg, malloc, realloc) { + + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +let cachedDataViewMemory0 = null; + +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry( +state => { + wasm.__wbindgen_export_6.get(state.dtor)(state.a, state.b); +} +); + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_6.get(state.dtor)(a, state.b); + CLOSURE_DTORS.unregister(state); + } else { + state.a = a; + } + } + }; + real.original = state; + CLOSURE_DTORS.register(real, state, state); + return real; +} +/** + * @param {string} name + */ +export function mark_performance(name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + wasm.mark_performance(ptr0, len0); +} + +export function start() { + wasm.start(); +} + +function __wbg_adapter_8(arg0, arg1, arg2) { + wasm.closure251_externref_shim(arg0, arg1, arg2); +} + +function __wbg_adapter_109(arg0, arg1, arg2, arg3) { + wasm.closure258_externref_shim(arg0, arg1, arg2, arg3); +} + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; + +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1)); + +export class IntoUnderlyingByteSource { + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, controller); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, controller); + return ret; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr >>> 0, 1)); + +export class IntoUnderlyingSink { + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk); + return ret; + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return ret; + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, reason); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr >>> 0, 1)); + +export class IntoUnderlyingSource { + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, controller); + return ret; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; + +const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']); + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + + } catch (e) { + const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + + } else { + return instance; + } + } +} + +function __wbg_get_imports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbg_appendChild_87a6cc0aeb132c06 = function() { return handleError(function (arg0, arg1) { + const ret = arg0.appendChild(arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_append_a3566a825e5fb7ba = function() { return handleError(function (arg0, arg1, arg2) { + arg0.append(arg1, arg2); + }, arguments) }; + imports.wbg.__wbg_before_9a9e82feba2f4a5e = function() { return handleError(function (arg0, arg1) { + arg0.before(arg1); + }, arguments) }; + imports.wbg.__wbg_body_8822ca55cb3730d2 = function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_buffer_8d40b1d762fb3c66 = function(arg0) { + const ret = arg0.buffer; + return ret; + }; + imports.wbg.__wbg_byobRequest_2c036bceca1e6037 = function(arg0) { + const ret = arg0.byobRequest; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_byteLength_331a6b5545834024 = function(arg0) { + const ret = arg0.byteLength; + return ret; + }; + imports.wbg.__wbg_byteOffset_49a5b5608000358b = function(arg0) { + const ret = arg0.byteOffset; + return ret; + }; + imports.wbg.__wbg_call_13410aac570ffff7 = function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_call_a5400b25a865cfd8 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments) }; + imports.wbg.__wbg_childNodes_5c44c2ec67a90732 = function(arg0) { + const ret = arg0.childNodes; + return ret; + }; + imports.wbg.__wbg_cloneNode_79d46b18d5619863 = function() { return handleError(function (arg0) { + const ret = arg0.cloneNode(); + return ret; + }, arguments) }; + imports.wbg.__wbg_close_cccada6053ee3a65 = function() { return handleError(function (arg0) { + arg0.close(); + }, arguments) }; + imports.wbg.__wbg_close_d71a78219dc23e91 = function() { return handleError(function (arg0) { + arg0.close(); + }, arguments) }; + imports.wbg.__wbg_createComment_08abf524559fd4d7 = function(arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + const ret = arg0.createComment(v0); + return ret; + }; + imports.wbg.__wbg_createDocumentFragment_08df3891d3e00ee8 = function(arg0) { + const ret = arg0.createDocumentFragment(); + return ret; + }; + imports.wbg.__wbg_createElement_4909dfa2011f2abe = function() { return handleError(function (arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + const ret = arg0.createElement(v0); + return ret; + }, arguments) }; + imports.wbg.__wbg_createTextNode_c71a51271fadf515 = function(arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + const ret = arg0.createTextNode(v0); + return ret; + }; + imports.wbg.__wbg_document_7d29d139bd619045 = function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_enqueue_452bc2343d1c2ff9 = function() { return handleError(function (arg0, arg1) { + arg0.enqueue(arg1); + }, arguments) }; + imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + if (arg0 !== 0) { wasm.__wbindgen_free(arg0, arg1, 1); } + console.error(v0); + }; + imports.wbg.__wbg_instanceof_Window_12d20d558ef92592 = function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_is_8346b6c36feaf71a = function(arg0, arg1) { + const ret = Object.is(arg0, arg1); + return ret; + }; + imports.wbg.__wbg_length_6bb7e81f9d7713e4 = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_length_e7f4a6e30ea139e7 = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_log_0cc1b7768397bcfe = function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + if (arg0 !== 0) { wasm.__wbindgen_free(arg0, arg1, 1); } + var v1 = getCachedStringFromWasm0(arg2, arg3); + var v2 = getCachedStringFromWasm0(arg4, arg5); + var v3 = getCachedStringFromWasm0(arg6, arg7); + console.log(v0, v1, v2, v3); + }; + imports.wbg.__wbg_log_6c7b5f4f00b8ce3f = function(arg0) { + console.log(arg0); + }; + imports.wbg.__wbg_log_cb9e190acc5753fb = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + if (arg0 !== 0) { wasm.__wbindgen_free(arg0, arg1, 1); } + console.log(v0); + }; + imports.wbg.__wbg_mark_7438147ce31e9d4b = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + performance.mark(v0); + }; + imports.wbg.__wbg_mark_cd609a6d46114f36 = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + window.performance.mark(v0); + }; + imports.wbg.__wbg_measure_fb7825c11612c823 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + if (arg0 !== 0) { wasm.__wbindgen_free(arg0, arg1, 1); } + var v1 = getCachedStringFromWasm0(arg2, arg3); + if (arg2 !== 0) { wasm.__wbindgen_free(arg2, arg3, 1); } + performance.measure(v0, v1); + }, arguments) }; + imports.wbg.__wbg_namespaceURI_020a81e6d28c2c96 = function(arg0, arg1) { + const ret = arg1.namespaceURI; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_new_2e3c58a15f39f5f9 = function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_109(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = state0.b = 0; + } + }; + imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { + const ret = new Error(); + return ret; + }; + imports.wbg.__wbg_new_da9dc54c5db29dfa = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + const ret = new Error(v0); + return ret; + }; + imports.wbg.__wbg_newnoargs_254190557c45b4ec = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + const ret = new Function(v0); + return ret; + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_e8f53910b4d42b45 = function(arg0, arg1, arg2) { + const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbg_nextSibling_1fb03516719cac0f = function(arg0) { + const ret = arg0.nextSibling; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_outerHTML_5fe297cb1fc146f2 = function(arg0, arg1) { + const ret = arg1.outerHTML; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_queueMicrotask_25d0739ac89e8c88 = function(arg0) { + queueMicrotask(arg0); + }; + imports.wbg.__wbg_queueMicrotask_4488407636f5bf24 = function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }; + imports.wbg.__wbg_removeAttribute_cf35412842be6ae4 = function() { return handleError(function (arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + arg0.removeAttribute(v0); + }, arguments) }; + imports.wbg.__wbg_resolve_4055c623acdd6a1b = function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }; + imports.wbg.__wbg_respond_6c2c4e20ef85138e = function() { return handleError(function (arg0, arg1) { + arg0.respond(arg1 >>> 0); + }, arguments) }; + imports.wbg.__wbg_setAttribute_d1baf9023ad5696f = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + var v1 = getCachedStringFromWasm0(arg3, arg4); + arg0.setAttribute(v0, v1); + }, arguments) }; + imports.wbg.__wbg_set_1353b2a5e96bc48c = function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }; + imports.wbg.__wbg_setinnerHTML_34e240d6b8e8260c = function(arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + arg0.innerHTML = v0; + }; + imports.wbg.__wbg_settextContent_b55fe2f5f1399466 = function(arg0, arg1, arg2) { + var v0 = getCachedStringFromWasm0(arg1, arg2); + arg0.textContent = v0; + }; + imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_static_accessor_GLOBAL_8921f820c2ce3f12 = function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_GLOBAL_THIS_f0a4409105898184 = function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_SELF_995b214ae681ff99 = function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_WINDOW_cde3890479c675ea = function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_then_e22500defe16819f = function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }; + imports.wbg.__wbg_view_91cc97d57ab30530 = function(arg0) { + const ret = arg0.view; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_warn_e2ada06313f92f09 = function(arg0) { + console.warn(arg0); + }; + imports.wbg.__wbg_wbindgencbdrop_eb10308566512b88 = function(arg0) { + const obj = arg0.original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + const ret = false; + return ret; + }; + imports.wbg.__wbg_wbindgendebugstring_99ef257a3ddda34d = function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }; + imports.wbg.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function(arg0) { + const ret = arg0 === undefined; + return ret; + }; + imports.wbg.__wbg_wbindgenthrow_451ec1a8469d7eb6 = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + throw new Error(v0); + }; + imports.wbg.__wbindgen_cast_7e9c58eeb11b0a6f = function(arg0, arg1) { + var v0 = getCachedStringFromWasm0(arg0, arg1); + // Cast intrinsic for `Ref(CachedString) -> Externref`. + const ret = v0; + return ret; + }; + imports.wbg.__wbindgen_cast_9843a8e5ee4f8c29 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 249, function: Function { arguments: [Externref], shim_idx: 251, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, 249, __wbg_adapter_8); + return ret; + }; + imports.wbg.__wbindgen_init_externref_table = function() { + const table = wasm.__wbindgen_export_2; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + ; + }; + + return imports; +} + +function __wbg_init_memory(imports, memory) { + +} + +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + __wbg_init.__wbindgen_wasm_module = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + + + wasm.__wbindgen_start(); + return wasm; +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (typeof module !== 'undefined') { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + + __wbg_init_memory(imports); + + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + + const instance = new WebAssembly.Instance(module, imports); + + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (typeof module_or_path !== 'undefined') { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (typeof module_or_path === 'undefined') { + module_or_path = new URL('control-center-ui_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + __wbg_init_memory(imports); + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync }; +export default __wbg_init; diff --git a/control-center-ui/dist/control-center-ui-d1956c1b430684b9_bg.wasm b/control-center-ui/dist/control-center-ui-d1956c1b430684b9_bg.wasm new file mode 100644 index 0000000..f2ec904 Binary files /dev/null and b/control-center-ui/dist/control-center-ui-d1956c1b430684b9_bg.wasm differ diff --git a/control-center-ui/dist/index-956be635a01ed8a8.css b/control-center-ui/dist/index-956be635a01ed8a8.css new file mode 100644 index 0000000..81753bf --- /dev/null +++ b/control-center-ui/dist/index-956be635a01ed8a8.css @@ -0,0 +1,44 @@ +/* Basic CSS for Control Center UI */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: #333; + background-color: #fff; +} + +#leptos { + min-height: 100vh; +} + +/* Basic styling */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + margin-bottom: 0.5rem; +} + +p { + margin-bottom: 1rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +/* Loading state */ +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + color: #666; +} \ No newline at end of file diff --git a/control-center-ui/dist/index.html b/control-center-ui/dist/index.html new file mode 100644 index 0000000..f3ff4af --- /dev/null +++ b/control-center-ui/dist/index.html @@ -0,0 +1,161 @@ + + + + + + + Control Center - Infrastructure Management + + + + + + + + +
+
+ Loading Leptos app... +
+
+ + \ No newline at end of file diff --git a/control-center-ui/index.html b/control-center-ui/index.html new file mode 100644 index 0000000..792ca72 --- /dev/null +++ b/control-center-ui/index.html @@ -0,0 +1,38 @@ + + + + + + + Control Center - Infrastructure Management + + + + + + + +
+
+ Loading Leptos app... +
+
+ + + + \ No newline at end of file diff --git a/control-center-ui/manifest.json b/control-center-ui/manifest.json new file mode 100644 index 0000000..c85fc5b --- /dev/null +++ b/control-center-ui/manifest.json @@ -0,0 +1,131 @@ +{ + "name": "Control Center UI", + "short_name": "Control Center", + "description": "Cloud Infrastructure Management Dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3b82f6", + "orientation": "portrait-primary", + "scope": "/", + "categories": ["productivity", "utilities", "business"], + "lang": "en", + "icons": [ + { + "src": "/assets/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View infrastructure dashboard", + "url": "/dashboard", + "icons": [ + { + "src": "/assets/shortcut-dashboard.png", + "sizes": "96x96" + } + ] + }, + { + "name": "Servers", + "short_name": "Servers", + "description": "Manage servers", + "url": "/servers", + "icons": [ + { + "src": "/assets/shortcut-servers.png", + "sizes": "96x96" + } + ] + }, + { + "name": "Clusters", + "short_name": "Clusters", + "description": "Manage Kubernetes clusters", + "url": "/clusters", + "icons": [ + { + "src": "/assets/shortcut-clusters.png", + "sizes": "96x96" + } + ] + } + ], + "screenshots": [ + { + "src": "/assets/screenshot-desktop.png", + "sizes": "1280x800", + "type": "image/png", + "form_factor": "wide", + "label": "Control Center Dashboard" + }, + { + "src": "/assets/screenshot-mobile.png", + "sizes": "375x812", + "type": "image/png", + "form_factor": "narrow", + "label": "Control Center Mobile View" + } + ], + "prefer_related_applications": false, + "edge_side_panel": { + "preferred_width": 400 + }, + "launch_handler": { + "client_mode": "navigate-existing" + }, + "handle_links": "preferred", + "protocol_handlers": [ + { + "protocol": "control-center", + "url": "/?protocol=%s" + } + ] +} \ No newline at end of file diff --git a/control-center-ui/package.json b/control-center-ui/package.json new file mode 100644 index 0000000..8f56219 --- /dev/null +++ b/control-center-ui/package.json @@ -0,0 +1,35 @@ +{ + "name": "control-center-ui", + "version": "0.1.0", + "description": "Control Center UI - Leptos CSR with TailwindCSS and DaisyUI", + "private": true, + "scripts": { + "dev": "trunk serve", + "dev:open": "trunk serve --open", + "build": "trunk build --release", + "clean": "trunk clean", + "css:build": "tailwindcss -i ./style/input.css -o ./style/output.css", + "css:watch": "tailwindcss -i ./style/input.css -o ./style/output.css --watch", + "css:minify": "tailwindcss -i ./style/input.css -o ./style/output.css --minify", + "lint:css": "stylelint 'style/**/*.css'", + "format:css": "prettier --write 'style/**/*.css'", + "install:deps": "npm install" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.10", + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/aspect-ratio": "^0.4.2", + "autoprefixer": "^10.4.16", + "daisyui": "^4.4.19", + "postcss": "^8.4.31", + "postcss-cli": "^11.0.0", + "prettier": "^3.1.0", + "stylelint": "^16.0.0", + "stylelint-config-standard": "^34.0.0", + "tailwindcss": "^3.3.6" + }, + "browserslist": [ + "defaults", + "not IE 11" + ] +} \ No newline at end of file diff --git a/control-center-ui/pnpm-lock.yaml b/control-center-ui/pnpm-lock.yaml new file mode 100644 index 0000000..605fd81 --- /dev/null +++ b/control-center-ui/pnpm-lock.yaml @@ -0,0 +1,1829 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@tailwindcss/aspect-ratio': + specifier: ^0.4.2 + version: 0.4.2(tailwindcss@3.4.17) + '@tailwindcss/forms': + specifier: ^0.5.7 + version: 0.5.10(tailwindcss@3.4.17) + '@tailwindcss/typography': + specifier: ^0.5.10 + version: 0.5.19(tailwindcss@3.4.17) + autoprefixer: + specifier: ^10.4.16 + version: 10.4.21(postcss@8.5.6) + daisyui: + specifier: ^4.4.19 + version: 4.12.24(postcss@8.5.6) + postcss: + specifier: ^8.4.31 + version: 8.5.6 + postcss-cli: + specifier: ^11.0.0 + version: 11.0.1(jiti@1.21.7)(postcss@8.5.6) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + stylelint: + specifier: ^16.0.0 + version: 16.24.0 + stylelint-config-standard: + specifier: ^34.0.0 + version: 34.0.0(stylelint@16.24.0) + tailwindcss: + specifier: ^3.3.6 + version: 3.4.17 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@cacheable/memoize@2.0.2': + resolution: {integrity: sha512-wPrr7FUiq3Qt4yQyda2/NcOLTJCFcQSU3Am2adP+WLy+sz93/fKTokVTHmtz+rjp4PD7ee0AEOeRVNN6IvIfsg==} + + '@cacheable/memory@2.0.2': + resolution: {integrity: sha512-sJTITLfeCI1rg7P3ssaGmQryq235EGT8dXGcx6oZwX5NRnKq9IE6lddlllcOl+oXW+yaeTRddCjo0xrfU6ZySA==} + + '@cacheable/utils@2.0.2': + resolution: {integrity: sha512-JTFM3raFhVv8LH95T7YnZbf2YoE9wEtkPPStuRF9a6ExZ103hFvs+QyCuYJ6r0hA9wRtbzgZtwUCoDWxssZd4Q==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.0.2': + resolution: {integrity: sha512-KR03xkEZlAZNF4IxXgVXb+uNIVNvwdh8UwI0cnc7WI6a+aQcDp8GL80qVfeB4E5NpsKJzou5jU0r6yLSSbMOtA==} + engines: {node: '>= 18'} + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@tailwindcss/aspect-ratio@0.4.2': + resolution: {integrity: sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==} + peerDependencies: + tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' + + '@tailwindcss/forms@0.5.10': + resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.8.7: + resolution: {integrity: sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.26.2: + resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.0.2: + resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001745: + resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-selector-tokenizer@0.8.0: + resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + culori@3.3.0: + resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + daisyui@4.12.24: + resolution: {integrity: sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==} + engines: {node: '>=16.9.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dependency-graph@1.0.0: + resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} + engines: {node: '>=4'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.224: + resolution: {integrity: sha512-kWAoUu/bwzvnhpdZSIc6KUyvkI1rbRXMT0Eq8pKReyOyaPZcctMli+EgvcN1PAvwVc7Tdo4Fxi2PsLNDU05mdg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastparse@1.1.2: + resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@10.1.4: + resolution: {integrity: sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.14: + resolution: {integrity: sha512-ExZSCSV9e7v/Zt7RzCbX57lY2dnPdxzU/h3UE6WJ6NtEMfwBd8jmi1n4otDEUfz+T/R+zxrFDpICFdjhD3H/zw==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.12.1: + resolution: {integrity: sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@5.5.2: + resolution: {integrity: sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.21: + resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-cli@11.0.1: + resolution: {integrity: sha512-0UnkNPSayHKRe/tc2YGW6XnSqqOA9eqpiRMgRlV1S6HdGi16vwJBx7lviARzbV1HpQHqLLRH3o8vTcB0cLc+5g==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + postcss: ^8.0.0 + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@5.1.0: + resolution: {integrity: sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-reporter@7.1.0: + resolution: {integrity: sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==} + engines: {node: '>=10'} + peerDependencies: + postcss: ^8.1.0 + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.0: + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-hrtime@1.0.3: + resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} + engines: {node: '>= 0.8'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + stylelint-config-recommended@13.0.0: + resolution: {integrity: sha512-EH+yRj6h3GAe/fRiyaoO2F9l9Tgg50AOFhaszyfov9v6ayXJ1IkSHwTxd7lB48FmOeSGDPLjatjO11fJpmarkQ==} + engines: {node: ^14.13.1 || >=16.0.0} + peerDependencies: + stylelint: ^15.10.0 + + stylelint-config-standard@34.0.0: + resolution: {integrity: sha512-u0VSZnVyW9VSryBG2LSO+OQTjN7zF9XJaAJRX/4EwkmU0R2jYwmBSN10acqZisDitS0CLiEiGjX7+Hrq8TAhfQ==} + engines: {node: ^14.13.1 || >=16.0.0} + peerDependencies: + stylelint: ^15.10.0 + + stylelint@16.24.0: + resolution: {integrity: sha512-7ksgz3zJaSbTUGr/ujMXvLVKdDhLbGl3R/3arNudH7z88+XZZGNLMTepsY28WlnvEFcuOmUe7fg40Q3lfhOfSQ==} + engines: {node: '>=18.12.0'} + hasBin: true + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tailwindcss@3.4.17: + resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenby@1.3.4: + resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.27.1': {} + + '@cacheable/memoize@2.0.2': + dependencies: + '@cacheable/utils': 2.0.2 + + '@cacheable/memory@2.0.2': + dependencies: + '@cacheable/memoize': 2.0.2 + '@cacheable/utils': 2.0.2 + '@keyv/bigmap': 1.0.2 + hookified: 1.12.1 + keyv: 5.5.2 + + '@cacheable/utils@2.0.2': {} + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.0)': + dependencies: + postcss-selector-parser: 7.1.0 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.0.2': + dependencies: + hookified: 1.12.1 + + '@keyv/serialize@1.1.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@tailwindcss/aspect-ratio@0.4.2(tailwindcss@3.4.17)': + dependencies: + tailwindcss: 3.4.17 + + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.17 + + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.17)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.17 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + autoprefixer@10.4.21(postcss@8.5.6): + dependencies: + browserslist: 4.26.2 + caniuse-lite: 1.0.30001745 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.8.7: {} + + binary-extensions@2.3.0: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.26.2: + dependencies: + baseline-browser-mapping: 2.8.7 + caniuse-lite: 1.0.30001745 + electron-to-chromium: 1.5.224 + node-releases: 2.0.21 + update-browserslist-db: 1.1.3(browserslist@4.26.2) + + cacheable@2.0.2: + dependencies: + '@cacheable/memoize': 2.0.2 + '@cacheable/memory': 2.0.2 + '@cacheable/utils': 2.0.2 + hookified: 1.12.1 + keyv: 5.5.2 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001745: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + commander@4.1.1: {} + + cosmiconfig@9.0.0: + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-functions-list@3.2.3: {} + + css-selector-tokenizer@0.8.0: + dependencies: + cssesc: 3.0.0 + fastparse: 1.1.2 + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + culori@3.3.0: {} + + daisyui@4.12.24(postcss@8.5.6): + dependencies: + css-selector-tokenizer: 0.8.0 + culori: 3.3.0 + picocolors: 1.1.1 + postcss-js: 4.1.0(postcss@8.5.6) + transitivePeerDependencies: + - postcss + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dependency-graph@1.0.0: {} + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.224: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastparse@1.1.2: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@10.1.4: + dependencies: + flat-cache: 6.1.14 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.14: + dependencies: + cacheable: 2.0.2 + flatted: 3.3.3 + hookified: 1.12.1 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fraction.js@4.3.7: {} + + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.12.1: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@5.5.2: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@10.4.3: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mini-svg-data-uri@1.4.4: {} + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + node-releases@2.0.21: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-cli@11.0.1(jiti@1.21.7)(postcss@8.5.6): + dependencies: + chokidar: 3.6.0 + dependency-graph: 1.0.0 + fs-extra: 11.3.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-load-config: 5.1.0(jiti@1.21.7)(postcss@8.5.6) + postcss-reporter: 7.1.0(postcss@8.5.6) + pretty-hrtime: 1.0.3 + read-cache: 1.0.0 + slash: 5.1.0 + tinyglobby: 0.2.15 + yargs: 17.7.2 + transitivePeerDependencies: + - jiti + - tsx + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@4.0.2(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.1 + optionalDependencies: + postcss: 8.5.6 + + postcss-load-config@5.1.0(jiti@1.21.7)(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.1 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-reporter@7.1.0(postcss@8.5.6): + dependencies: + picocolors: 1.1.1 + postcss: 8.5.6 + thenby: 1.3.4 + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.6.2: {} + + pretty-hrtime@1.0.3: {} + + queue-microtask@1.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + stylelint-config-recommended@13.0.0(stylelint@16.24.0): + dependencies: + stylelint: 16.24.0 + + stylelint-config-standard@34.0.0(stylelint@16.24.0): + dependencies: + stylelint: 16.24.0 + stylelint-config-recommended: 13.0.0(stylelint@16.24.0) + + stylelint@16.24.0: + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.0) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0 + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 10.1.4 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.0 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tailwindcss@3.4.17: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + thenby@1.3.4: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.1.3(browserslist@4.26.2): + dependencies: + browserslist: 4.26.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yaml@2.8.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/control-center-ui/setup.sh b/control-center-ui/setup.sh new file mode 100755 index 0000000..56b2a85 --- /dev/null +++ b/control-center-ui/setup.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +echo "🚀 Control Center UI - Setup Script" +echo "==================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if we're in the right directory +if [ ! -f "Cargo.toml" ]; then + print_error "Please run this script from the control-center-ui directory" + exit 1 +fi + +print_status "Checking prerequisites..." + +# Check for Rust +if ! command -v rustc &> /dev/null; then + print_error "Rust is not installed. Please install Rust first:" + echo "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + exit 1 +fi + +# Check for Node.js +if ! command -v node &> /dev/null; then + print_error "Node.js is not installed. Please install Node.js first." + exit 1 +fi + +# Check for trunk +if ! command -v trunk &> /dev/null; then + print_status "Installing Trunk..." + cargo install trunk +fi + +# Add wasm32 target +print_status "Adding wasm32-unknown-unknown target..." +rustup target add wasm32-unknown-unknown + +# Install Node.js dependencies +print_status "Installing Node.js dependencies..." +pnpm install + +# Build TailwindCSS +print_status "Building TailwindCSS..." +pnpm run css:build + +# Check Rust dependencies +print_status "Checking Rust dependencies..." +cargo check + +print_success "Setup completed successfully!" +echo "" +echo "🎉 Ready to develop!" +echo "" +echo "Available commands:" +echo " npm run dev - Start development server" +echo " npm run dev:open - Start development server and open browser" +echo " npm run build - Build for production" +echo " npm run css:watch - Watch TailwindCSS changes" +echo " npm run clean - Clean build artifacts" +echo "" +echo "Development server will be available at: http://localhost:3000" +echo "API proxy configured for: http://localhost:8080" +echo "" +print_status "Happy coding! 🦀" diff --git a/control-center-ui/src/App.css b/control-center-ui/src/App.css new file mode 100644 index 0000000..f24dc32 --- /dev/null +++ b/control-center-ui/src/App.css @@ -0,0 +1,41 @@ +#root { + max-width: 1280px; + margin: 0 auto; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} \ No newline at end of file diff --git a/control-center-ui/src/App.tsx b/control-center-ui/src/App.tsx new file mode 100644 index 0000000..3d96ed6 --- /dev/null +++ b/control-center-ui/src/App.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AuditLogViewer from './components/audit/AuditLogViewer'; +import './App.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + retry: (failureCount, error: any) => { + if (error?.status >= 400 && error?.status < 500) { + return false; + } + return failureCount < 3; + }, + }, + }, +}); + +function App() { + return ( +
+
+
+
+
+

+ Control Center +

+

+ Audit Log Viewer & Compliance Management +

+
+
+
+ Cedar Policy Engine +
+
+ v1.0.0 +
+
+
+
+ + + } /> + } /> + } /> + } /> + +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/control-center-ui/src/api/auth.rs b/control-center-ui/src/api/auth.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/auth.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/client.rs b/control-center-ui/src/api/client.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/client.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/clusters.rs b/control-center-ui/src/api/clusters.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/clusters.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/dashboard.rs b/control-center-ui/src/api/dashboard.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/dashboard.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/mod.rs b/control-center-ui/src/api/mod.rs new file mode 100644 index 0000000..ac5f345 --- /dev/null +++ b/control-center-ui/src/api/mod.rs @@ -0,0 +1,15 @@ +pub mod client; +pub mod types; +pub mod auth; +pub mod dashboard; +pub mod servers; +pub mod clusters; +pub mod workflows; + +pub use client::*; +pub use types::*; +pub use auth::*; +pub use dashboard::*; +pub use servers::*; +pub use clusters::*; +pub use workflows::*; \ No newline at end of file diff --git a/control-center-ui/src/api/orchestrator.rs b/control-center-ui/src/api/orchestrator.rs new file mode 100644 index 0000000..9eb2598 --- /dev/null +++ b/control-center-ui/src/api/orchestrator.rs @@ -0,0 +1,503 @@ +use leptos::*; +use serde::{Deserialize, Serialize}; +use gloo_net::http::Request; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowTask { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub dependencies: Vec, + pub status: TaskStatus, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub output: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TaskStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateServerWorkflow { + pub infra: String, + pub settings: String, + pub servers: Vec, + pub check_mode: bool, + pub wait: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskservWorkflow { + pub infra: String, + pub settings: String, + pub taskserv: String, + pub operation: String, // create, delete, generate, check-updates + pub check_mode: bool, + pub wait: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterWorkflow { + pub infra: String, + pub settings: String, + pub cluster_type: String, + pub operation: String, // create, delete + pub check_mode: bool, + pub wait: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchWorkflowRequest { + pub workflow: BatchWorkflow, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchWorkflow { + pub name: String, + pub version: String, + pub storage_backend: String, + pub parallel_limit: u32, + pub rollback_enabled: bool, + pub operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchOperation { + pub id: String, + pub operation_type: String, + pub provider: String, + pub dependencies: Vec, + pub server_configs: Option>, + pub taskservs: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub name: String, + pub plan: String, + pub zone: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Infrastructure { + pub id: String, + pub name: String, + pub provider: String, + pub status: String, + pub servers: Vec, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Server { + pub id: String, + pub name: String, + pub status: String, + pub ip_address: Option, + pub plan: String, + pub zone: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderCredentials { + pub provider: String, + pub credentials: HashMap, + pub encrypted: bool, + pub last_used: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +pub struct OrchestratorClient { + base_url: String, +} + +impl OrchestratorClient { + pub fn new(base_url: String) -> Self { + Self { base_url } + } + + // Workflow Management + pub async fn create_server_workflow(&self, workflow: CreateServerWorkflow) -> Result { + let url = format!("{}/workflows/servers/create", self.base_url); + + let response = Request::post(&url) + .json(&workflow) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No task ID returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn create_taskserv_workflow(&self, workflow: TaskservWorkflow) -> Result { + let url = format!("{}/workflows/taskserv/create", self.base_url); + + let response = Request::post(&url) + .json(&workflow) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No task ID returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn create_cluster_workflow(&self, workflow: ClusterWorkflow) -> Result { + let url = format!("{}/workflows/cluster/create", self.base_url); + + let response = Request::post(&url) + .json(&workflow) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No task ID returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + // Batch Workflows + pub async fn submit_batch_workflow(&self, request: BatchWorkflowRequest) -> Result { + let url = format!("{}/workflows/batch/submit", self.base_url); + + let response = Request::post(&url) + .json(&request) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No batch ID returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn get_batch_status(&self, batch_id: &str) -> Result { + let url = format!("{}/workflows/batch/{}", self.base_url, batch_id); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No status data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + // Task Management + pub async fn get_task_status(&self, task_id: &str) -> Result { + let url = format!("{}/tasks/{}", self.base_url, task_id); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No task data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn list_tasks(&self) -> Result, String> { + let url = format!("{}/tasks", self.base_url); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse> = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No tasks data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + // Health Check + pub async fn health_check(&self) -> Result { + let url = format!("{}/health", self.base_url); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Health check failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse health response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No health data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Health check failed".to_string())) + } + } + + // Rollback Operations + pub async fn create_checkpoint(&self, name: String, description: Option) -> Result { + let url = format!("{}/rollback/checkpoints", self.base_url); + + let request_body = serde_json::json!({ + "name": name, + "description": description + }); + + let response = Request::post(&url) + .json(&request_body) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No checkpoint ID returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn execute_rollback(&self, checkpoint_id: Option, operation_ids: Option>) -> Result { + let url = format!("{}/rollback/execute", self.base_url); + + let request_body = serde_json::json!({ + "checkpoint_id": checkpoint_id, + "operation_ids": operation_ids + }); + + let response = Request::post(&url) + .json(&request_body) + .map_err(|e| format!("Request creation failed: {}", e))? + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No rollback result returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + // System Metrics + pub async fn get_system_metrics(&self) -> Result { + let url = format!("{}/state/system/metrics", self.base_url); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No metrics data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } + + pub async fn get_system_health(&self) -> Result { + let url = format!("{}/state/system/health", self.base_url); + + let response = Request::get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let api_response: ApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if api_response.success { + api_response.data.ok_or_else(|| "No health data returned".to_string()) + } else { + Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string())) + } + } +} + +// Global client instance +thread_local! { + static ORCHESTRATOR_CLIENT: OrchestratorClient = OrchestratorClient::new("http://localhost:8080".to_string()); +} + +pub fn get_orchestrator_client() -> &'static OrchestratorClient { + ORCHESTRATOR_CLIENT.with(|client| { + // This is a workaround for thread_local limitation + // In a real app, you might want to use a different approach + unsafe { &*(client as *const OrchestratorClient) } + }) +} + +// Reactive hooks for Leptos components +pub fn use_task_status(task_id: String) -> (ReadSignal>, ReadSignal, Action<(), ()>) { + let (task, set_task) = create_signal(None::); + let (loading, set_loading) = create_signal(false); + + let refresh_action = create_action(move |_: &()| { + let task_id = task_id.clone(); + async move { + set_loading.set(true); + match get_orchestrator_client().get_task_status(&task_id).await { + Ok(task_data) => { + set_task.set(Some(task_data)); + } + Err(err) => { + logging::log!("Failed to fetch task status: {}", err); + } + } + set_loading.set(false); + } + }); + + // Auto-refresh every 5 seconds for running tasks + create_effect(move |_| { + if let Some(t) = task.get() { + if matches!(t.status, TaskStatus::Pending | TaskStatus::Running) { + set_timeout(move || refresh_action.dispatch(()), std::time::Duration::from_secs(5)); + } + } + }); + + (task, loading, refresh_action) +} + +pub fn use_tasks_list() -> (ReadSignal>, ReadSignal, Action<(), ()>) { + let (tasks, set_tasks) = create_signal(Vec::::new()); + let (loading, set_loading) = create_signal(false); + + let refresh_action = create_action(move |_: &()| { + async move { + set_loading.set(true); + match get_orchestrator_client().list_tasks().await { + Ok(tasks_data) => { + set_tasks.set(tasks_data); + } + Err(err) => { + logging::log!("Failed to fetch tasks: {}", err); + } + } + set_loading.set(false); + } + }); + + // Initial load + refresh_action.dispatch(()); + + (tasks, loading, refresh_action) +} + +pub fn use_system_health() -> (ReadSignal>, ReadSignal, Action<(), ()>) { + let (health, set_health) = create_signal(None::); + let (loading, set_loading) = create_signal(false); + + let refresh_action = create_action(move |_: &()| { + async move { + set_loading.set(true); + match get_orchestrator_client().get_system_health().await { + Ok(health_data) => { + set_health.set(Some(health_data)); + } + Err(err) => { + logging::log!("Failed to fetch system health: {}", err); + } + } + set_loading.set(false); + } + }); + + // Auto-refresh every 30 seconds + create_effect(move |_| { + set_timeout(move || refresh_action.dispatch(()), std::time::Duration::from_secs(30)); + }); + + // Initial load + refresh_action.dispatch(()); + + (health, loading, refresh_action) +} \ No newline at end of file diff --git a/control-center-ui/src/api/orchestrator_client.rs b/control-center-ui/src/api/orchestrator_client.rs new file mode 100644 index 0000000..5513cef --- /dev/null +++ b/control-center-ui/src/api/orchestrator_client.rs @@ -0,0 +1,408 @@ +use crate::api::orchestrator_types::*; +use gloo_net::http::{Request, RequestBuilder}; +use serde_json; +use std::collections::HashMap; +use thiserror::Error; +use url::Url; +use wasm_bindgen_futures::JsFuture; +use web_sys::{console, window}; + +#[derive(Error, Debug)] +pub enum OrchestratorError { + #[error("Network error: {0}")] + Network(String), + #[error("Serialization error: {0}")] + Serialization(String), + #[error("API error: {0}")] + Api(String), + #[error("Configuration error: {0}")] + Config(String), +} + +/// Configuration for the orchestrator client +#[derive(Debug, Clone)] +pub struct OrchestratorConfig { + pub base_url: String, + pub timeout_seconds: u32, + pub retry_attempts: u32, +} + +impl Default for OrchestratorConfig { + fn default() -> Self { + Self { + base_url: "http://localhost:8080".to_string(), + timeout_seconds: 30, + retry_attempts: 3, + } + } +} + +/// HTTP client for orchestrator API +#[derive(Debug, Clone)] +pub struct OrchestratorClient { + config: OrchestratorConfig, +} + +impl OrchestratorClient { + /// Create a new orchestrator client with default configuration + pub fn new() -> Self { + Self { + config: OrchestratorConfig::default(), + } + } + + /// Create a new orchestrator client with custom configuration + pub fn with_config(config: OrchestratorConfig) -> Self { + Self { config } + } + + /// Build a request with common headers and configuration + fn build_request(&self, method: &str, path: &str) -> Result { + let url = format!("{}{}", self.config.base_url, path); + + let request = match method.to_uppercase().as_str() { + "GET" => Request::get(&url), + "POST" => Request::post(&url), + "PUT" => Request::put(&url), + "DELETE" => Request::delete(&url), + _ => return Err(OrchestratorError::Config(format!("Unsupported HTTP method: {}", method))), + }; + + Ok(request + .header("Content-Type", "application/json") + .header("Accept", "application/json")) + } + + /// Execute a request and parse the response + async fn execute_request(&self, request: RequestBuilder) -> Result, OrchestratorError> + where + T: serde::de::DeserializeOwned, + { + let response = request + .send() + .await + .map_err(|e| OrchestratorError::Network(format!("Request failed: {}", e)))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|e| OrchestratorError::Network(format!("Failed to read response: {}", e)))?; + + if !status.is_success() { + return Err(OrchestratorError::Api(format!( + "HTTP {} - {}", + status.as_u16(), + response_text + ))); + } + + serde_json::from_str::>(&response_text) + .map_err(|e| OrchestratorError::Serialization(format!("Failed to parse response: {}", e))) + } + + /// Execute a request with JSON body + async fn execute_json_request(&self, request: RequestBuilder, body: &B) -> Result, OrchestratorError> + where + T: serde::de::DeserializeOwned, + B: serde::Serialize, + { + let json_body = serde_json::to_string(body) + .map_err(|e| OrchestratorError::Serialization(format!("Failed to serialize request: {}", e)))?; + + let response = request + .body(json_body) + .send() + .await + .map_err(|e| OrchestratorError::Network(format!("Request failed: {}", e)))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|e| OrchestratorError::Network(format!("Failed to read response: {}", e)))?; + + if !status.is_success() { + return Err(OrchestratorError::Api(format!( + "HTTP {} - {}", + status.as_u16(), + response_text + ))); + } + + serde_json::from_str::>(&response_text) + .map_err(|e| OrchestratorError::Serialization(format!("Failed to parse response: {}", e))) + } + + // Health and Status Operations + + /// Check orchestrator health + pub async fn health_check(&self) -> Result { + let request = self.build_request("GET", "/health")?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get system health status + pub async fn get_system_health(&self) -> Result { + let request = self.build_request("GET", "/state/system/health")?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get system metrics + pub async fn get_system_metrics(&self) -> Result { + let request = self.build_request("GET", "/state/system/metrics")?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // Task Management Operations + + /// List all tasks + pub async fn list_tasks(&self) -> Result, OrchestratorError> { + let request = self.build_request("GET", "/tasks")?; + let response = self.execute_request::>(request).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get task status by ID + pub async fn get_task_status(&self, task_id: &str) -> Result { + let request = self.build_request("GET", &format!("/tasks/{}", task_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // Workflow Operations + + /// Create server workflow + pub async fn create_server_workflow(&self, workflow: &CreateServerWorkflow) -> Result { + let request = self.build_request("POST", "/workflows/servers/create")?; + let response = self.execute_json_request::(request, workflow).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Create taskserv workflow + pub async fn create_taskserv_workflow(&self, workflow: &TaskservWorkflow) -> Result { + let request = self.build_request("POST", "/workflows/taskserv/create")?; + let response = self.execute_json_request::(request, workflow).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Create cluster workflow + pub async fn create_cluster_workflow(&self, workflow: &ClusterWorkflow) -> Result { + let request = self.build_request("POST", "/workflows/cluster/create")?; + let response = self.execute_json_request::(request, workflow).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // Batch Operations + + /// Execute batch operation + pub async fn execute_batch_operation(&self, request: &BatchOperationRequest) -> Result { + let req = self.build_request("POST", "/batch/execute")?; + let response = self.execute_json_request::(req, request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// List batch operations + pub async fn list_batch_operations(&self) -> Result, OrchestratorError> { + let request = self.build_request("GET", "/batch/operations")?; + let response = self.execute_request::>(request).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get batch operation status + pub async fn get_batch_operation_status(&self, operation_id: &str) -> Result { + let request = self.build_request("GET", &format!("/batch/operations/{}", operation_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Cancel batch operation + pub async fn cancel_batch_operation(&self, operation_id: &str) -> Result { + let request = self.build_request("POST", &format!("/batch/operations/{}/cancel", operation_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // Progress Tracking + + /// Get workflow progress + pub async fn get_workflow_progress(&self, workflow_id: &str) -> Result { + let request = self.build_request("GET", &format!("/state/workflows/{}/progress", workflow_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get workflow snapshots + pub async fn get_workflow_snapshots(&self, workflow_id: &str) -> Result, OrchestratorError> { + let request = self.build_request("GET", &format!("/state/workflows/{}/snapshots", workflow_id))?; + let response = self.execute_request::>(request).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // Rollback Operations + + /// Create checkpoint + pub async fn create_checkpoint(&self, request: &CreateCheckpointRequest) -> Result { + let req = self.build_request("POST", "/rollback/checkpoints")?; + let response = self.execute_json_request::(req, request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// List checkpoints + pub async fn list_checkpoints(&self) -> Result, OrchestratorError> { + let request = self.build_request("GET", "/rollback/checkpoints")?; + let response = self.execute_request::>(request).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get checkpoint by ID + pub async fn get_checkpoint(&self, checkpoint_id: &str) -> Result { + let request = self.build_request("GET", &format!("/rollback/checkpoints/{}", checkpoint_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Execute rollback + pub async fn execute_rollback(&self, request: &RollbackRequest) -> Result { + let req = self.build_request("POST", "/rollback/execute")?; + let response = self.execute_json_request::(req, request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Get rollback statistics + pub async fn get_rollback_statistics(&self) -> Result { + let request = self.build_request("GET", "/rollback/statistics")?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + /// Restore from checkpoint + pub async fn restore_from_checkpoint(&self, checkpoint_id: &str) -> Result { + let request = self.build_request("POST", &format!("/rollback/restore/{}", checkpoint_id))?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + + // State Management + + /// Get state statistics + pub async fn get_state_statistics(&self) -> Result { + let request = self.build_request("GET", "/state/statistics")?; + let response = self.execute_request::(request).await?; + + if response.success { + Ok(response.data.unwrap()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } +} + +impl Default for OrchestratorClient { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/control-center-ui/src/api/orchestrator_types.rs b/control-center-ui/src/api/orchestrator_types.rs new file mode 100644 index 0000000..21c52bf --- /dev/null +++ b/control-center-ui/src/api/orchestrator_types.rs @@ -0,0 +1,220 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// API response wrapper used by the orchestrator +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +/// Task status enumeration +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TaskStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +/// Workflow task representation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowTask { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub dependencies: Vec, + pub status: TaskStatus, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub output: Option, + pub error: Option, +} + +/// Server creation workflow request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateServerWorkflow { + pub infra: String, + pub settings: String, + pub servers: Vec, + pub check_mode: bool, + pub wait: bool, +} + +/// Task service workflow request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskservWorkflow { + pub infra: String, + pub settings: String, + pub taskserv: String, + pub operation: String, // create, delete, generate, check-updates + pub check_mode: bool, + pub wait: bool, +} + +/// Cluster workflow request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterWorkflow { + pub infra: String, + pub settings: String, + pub cluster_type: String, + pub operation: String, // create, delete + pub check_mode: bool, + pub wait: bool, +} + +/// Batch operation request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchOperationRequest { + pub workflow_id: String, + pub operations: Vec, + pub parallel_limit: Option, + pub rollback_enabled: Option, +} + +/// Individual batch operation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchOperation { + pub id: String, + pub operation_type: String, + pub provider: String, + pub dependencies: Vec, + pub config: serde_json::Value, +} + +/// Batch operation result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchOperationResult { + pub operation_id: String, + pub status: String, + pub message: String, +} + +/// Workflow execution state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowExecutionState { + pub id: String, + pub status: String, + pub progress: f64, + pub current_operation: Option, + pub completed_operations: Vec, + pub failed_operations: Vec, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// System health status +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemHealthStatus { + pub overall_status: String, + pub checks: Vec, + pub last_updated: DateTime, +} + +/// Individual health check +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheck { + pub name: String, + pub status: String, + pub message: Option, + pub last_check: DateTime, +} + +/// Progress information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressInfo { + pub workflow_id: String, + pub current_step: String, + pub progress_percentage: f64, + pub estimated_completion: Option>, + pub step_details: Vec, +} + +/// Step detail information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepDetail { + pub step_name: String, + pub status: String, + pub progress: f64, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// System metrics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemMetrics { + pub cpu_usage: f64, + pub memory_usage: f64, + pub disk_usage: f64, + pub active_workflows: u32, + pub completed_workflows: u32, + pub failed_workflows: u32, + pub uptime_seconds: u64, +} + +/// State snapshot +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSnapshot { + pub id: String, + pub workflow_id: String, + pub timestamp: DateTime, + pub state_data: serde_json::Value, +} + +/// Checkpoint information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Checkpoint { + pub id: String, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub workflow_states: Vec, +} + +/// Create checkpoint request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateCheckpointRequest { + pub name: String, + pub description: Option, +} + +/// Rollback request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RollbackRequest { + pub checkpoint_id: Option, + pub operation_ids: Option>, +} + +/// Rollback result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RollbackResult { + pub success: bool, + pub operations_executed: u32, + pub operations_failed: u32, + pub rollback_id: String, + pub message: String, +} + +/// Rollback statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RollbackStatistics { + pub total_rollbacks: u32, + pub successful_rollbacks: u32, + pub failed_rollbacks: u32, + pub average_rollback_time_ms: u64, + pub checkpoints_created: u32, +} + +/// State manager statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateManagerStatistics { + pub total_snapshots: u32, + pub active_workflows: u32, + pub storage_size_bytes: u64, + pub average_snapshot_size_bytes: u64, +} \ No newline at end of file diff --git a/control-center-ui/src/api/servers.rs b/control-center-ui/src/api/servers.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/servers.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/types.rs b/control-center-ui/src/api/types.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/types.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/api/workflows.rs b/control-center-ui/src/api/workflows.rs new file mode 100644 index 0000000..247a7b0 --- /dev/null +++ b/control-center-ui/src/api/workflows.rs @@ -0,0 +1,6 @@ +use leptos::*; + +#[component] +pub fn Placeholder() -> impl IntoView { + view! {
"Placeholder"
} +} diff --git a/control-center-ui/src/app.rs b/control-center-ui/src/app.rs new file mode 100644 index 0000000..92e51df --- /dev/null +++ b/control-center-ui/src/app.rs @@ -0,0 +1,15 @@ +use leptos::*; + +/// Main application component - simplified for testing +#[component] +pub fn App() -> impl IntoView { + view! { +
+

"🚀 Control Center UI"

+

"Leptos app is working!"

+
+ "If you can see this, the basic Leptos rendering is functioning correctly." +
+
+ } +} \ No newline at end of file diff --git a/control-center-ui/src/auth/crypto.rs b/control-center-ui/src/auth/crypto.rs new file mode 100644 index 0000000..cbea02b --- /dev/null +++ b/control-center-ui/src/auth/crypto.rs @@ -0,0 +1,305 @@ +use crate::auth::{AuthError, AuthResult}; +use sha2::{Digest, Sha256}; +use hmac::{Hmac, Mac}; +use rand::{Rng, thread_rng}; +use base64::{Engine as _, engine::general_purpose}; + +type HmacSha256 = Hmac; + +pub struct CryptoUtils; + +impl CryptoUtils { + /// Generate a cryptographically secure random string + pub fn generate_random_string(length: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + let mut rng = thread_rng(); + + (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() + } + + /// Generate a cryptographically secure random bytes + pub fn generate_random_bytes(length: usize) -> Vec { + let mut rng = thread_rng(); + (0..length).map(|_| rng.gen()).collect() + } + + /// Create SHA256 hash of input data + pub fn sha256_hash(data: &[u8]) -> String { + let hash = Sha256::digest(data); + format!("{:x}", hash) + } + + /// Create SHA256 hash of a string + pub fn sha256_hash_string(data: &str) -> String { + Self::sha256_hash(data.as_bytes()) + } + + /// Create HMAC-SHA256 signature + pub fn hmac_sha256(key: &[u8], data: &[u8]) -> AuthResult> { + let mut mac = HmacSha256::new_from_slice(key) + .map_err(|e| AuthError { + message: format!("Failed to create HMAC: {}", e), + code: Some("HMAC_CREATE_ERROR".to_string()), + field: None, + })?; + + mac.update(data); + Ok(mac.finalize().into_bytes().to_vec()) + } + + /// Create HMAC-SHA256 signature of a string + pub fn hmac_sha256_string(key: &str, data: &str) -> AuthResult { + let signature = Self::hmac_sha256(key.as_bytes(), data.as_bytes())?; + Ok(general_purpose::STANDARD.encode(signature)) + } + + /// Verify HMAC-SHA256 signature + pub fn verify_hmac_sha256(key: &[u8], data: &[u8], signature: &[u8]) -> AuthResult { + let mut mac = HmacSha256::new_from_slice(key) + .map_err(|e| AuthError { + message: format!("Failed to create HMAC: {}", e), + code: Some("HMAC_CREATE_ERROR".to_string()), + field: None, + })?; + + mac.update(data); + + match mac.verify_slice(signature) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } + } + + /// Generate a secure token for password reset, email verification, etc. + pub fn generate_secure_token() -> String { + let random_bytes = Self::generate_random_bytes(32); + general_purpose::URL_SAFE_NO_PAD.encode(random_bytes) + } + + /// Generate a numeric code (for MFA, etc.) + pub fn generate_numeric_code(digits: usize) -> String { + let mut rng = thread_rng(); + (0..digits) + .map(|_| rng.gen_range(0..10).to_string()) + .collect() + } + + /// Constant-time string comparison to prevent timing attacks + pub fn secure_compare(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + + let mut result = 0u8; + for i in 0..a_bytes.len() { + result |= a_bytes[i] ^ b_bytes[i]; + } + + result == 0 + } + + /// Generate a device fingerprint based on available browser information + pub fn generate_device_fingerprint( + user_agent: &str, + screen_resolution: &str, + timezone_offset: i32, + language: &str, + ) -> String { + let combined = format!( + "{}|{}|{}|{}", + user_agent, + screen_resolution, + timezone_offset, + language + ); + + Self::sha256_hash_string(&combined) + } + + /// Create a time-based hash for session validation + pub fn create_time_based_hash(data: &str, timestamp: u64, secret: &str) -> AuthResult { + let combined = format!("{}|{}|{}", data, timestamp, secret); + Ok(Self::sha256_hash_string(&combined)) + } + + /// Verify a time-based hash + pub fn verify_time_based_hash( + data: &str, + timestamp: u64, + secret: &str, + expected_hash: &str, + max_age_seconds: u64, + ) -> AuthResult { + // Check if timestamp is within acceptable range + let current_timestamp = js_sys::Date::now() as u64 / 1000; + if current_timestamp.saturating_sub(timestamp) > max_age_seconds { + return Ok(false); + } + + let computed_hash = Self::create_time_based_hash(data, timestamp, secret)?; + Ok(Self::secure_compare(&computed_hash, expected_hash)) + } + + /// Generate a JWT-like token structure (simplified) + pub fn generate_token_with_expiry( + payload: &str, + secret: &str, + expires_in_seconds: u64, + ) -> AuthResult { + let current_timestamp = js_sys::Date::now() as u64 / 1000; + let expires_at = current_timestamp + expires_in_seconds; + + let header = r#"{"alg":"HS256","typ":"JWT"}"#; + let claims = format!( + r#"{{"sub":"{}","exp":{},"iat":{}}}"#, + payload, + expires_at, + current_timestamp + ); + + let header_b64 = general_purpose::URL_SAFE_NO_PAD.encode(header.as_bytes()); + let claims_b64 = general_purpose::URL_SAFE_NO_PAD.encode(claims.as_bytes()); + + let unsigned_token = format!("{}.{}", header_b64, claims_b64); + let signature = Self::hmac_sha256_string(secret, &unsigned_token)?; + let signature_b64 = general_purpose::URL_SAFE_NO_PAD.encode( + general_purpose::STANDARD.decode(signature) + .map_err(|e| AuthError { + message: format!("Failed to decode signature: {}", e), + code: Some("SIGNATURE_DECODE_ERROR".to_string()), + field: None, + })? + ); + + Ok(format!("{}.{}", unsigned_token, signature_b64)) + } + + /// Encode data using URL-safe base64 + pub fn base64_url_encode(data: &[u8]) -> String { + general_purpose::URL_SAFE_NO_PAD.encode(data) + } + + /// Decode URL-safe base64 data + pub fn base64_url_decode(data: &str) -> AuthResult> { + general_purpose::URL_SAFE_NO_PAD.decode(data) + .map_err(|e| AuthError { + message: format!("Base64 decode error: {}", e), + code: Some("BASE64_DECODE_ERROR".to_string()), + field: None, + }) + } + + /// Generate a CSRF token + pub fn generate_csrf_token() -> String { + Self::generate_secure_token() + } + + /// Create a secure session identifier + pub fn generate_session_id() -> String { + let timestamp = js_sys::Date::now() as u64; + let random_data = Self::generate_random_bytes(16); + let combined = format!("{}{}", timestamp, general_purpose::STANDARD.encode(random_data)); + Self::sha256_hash_string(&combined) + } + + /// Create a password hash (simplified - in production use bcrypt or similar) + pub fn hash_password(password: &str, salt: &str) -> String { + let combined = format!("{}${}", password, salt); + Self::sha256_hash_string(&combined) + } + + /// Generate a random salt for password hashing + pub fn generate_salt() -> String { + let salt_bytes = Self::generate_random_bytes(16); + general_purpose::STANDARD.encode(salt_bytes) + } + + /// Verify password against hash + pub fn verify_password(password: &str, hash: &str, salt: &str) -> bool { + let computed_hash = Self::hash_password(password, salt); + Self::secure_compare(&computed_hash, hash) + } +} + +/// Utility functions for working with browser crypto APIs +pub struct BrowserCrypto; + +impl BrowserCrypto { + /// Get random values using browser's crypto API + pub fn get_random_values(length: usize) -> AuthResult> { + use web_sys::window; + + let window = window().ok_or_else(|| AuthError { + message: "Window not available".to_string(), + code: Some("WINDOW_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let crypto = window.crypto().map_err(|_| AuthError { + message: "Crypto API not available".to_string(), + code: Some("CRYPTO_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let mut buffer = vec![0u8; length]; + let uint8_array = js_sys::Uint8Array::new_with_length(length as u32); + + crypto.get_random_values_with_u8_array(&uint8_array).map_err(|_| AuthError { + message: "Failed to get random values".to_string(), + code: Some("GET_RANDOM_VALUES_ERROR".to_string()), + field: None, + })?; + + uint8_array.copy_to(&mut buffer); + Ok(buffer) + } + + /// Check if Web Crypto API is available + pub fn is_available() -> bool { + web_sys::window() + .and_then(|w| w.crypto().ok()) + .is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_random_string() { + let result = CryptoUtils::generate_random_string(10); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_sha256_hash() { + let result = CryptoUtils::sha256_hash_string("hello world"); + assert_eq!(result.len(), 64); // SHA256 produces 32 bytes = 64 hex chars + } + + #[test] + fn test_secure_compare() { + assert!(CryptoUtils::secure_compare("hello", "hello")); + assert!(!CryptoUtils::secure_compare("hello", "world")); + assert!(!CryptoUtils::secure_compare("hello", "hello world")); + } + + #[test] + fn test_base64_url_encode_decode() { + let data = b"hello world"; + let encoded = CryptoUtils::base64_url_encode(data); + let decoded = CryptoUtils::base64_url_decode(&encoded).unwrap(); + assert_eq!(data, decoded.as_slice()); + } +} \ No newline at end of file diff --git a/control-center-ui/src/auth/http_interceptor.rs b/control-center-ui/src/auth/http_interceptor.rs new file mode 100644 index 0000000..40d355c --- /dev/null +++ b/control-center-ui/src/auth/http_interceptor.rs @@ -0,0 +1,430 @@ +use crate::auth::{use_auth_context, AuthResult, AuthError}; +use gloo_net::http::{Request, Response, Headers}; +use leptos::*; +use leptos_router::*; +use serde_json::Value; +use std::collections::HashMap; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{console, AbortController}; + +pub struct HttpInterceptor { + auth_context: crate::auth::token_manager::AuthContext, + navigate: leptos_router::NavigateOptions, +} + +impl HttpInterceptor { + pub fn new( + auth_context: crate::auth::token_manager::AuthContext, + navigate: leptos_router::NavigateOptions, + ) -> Self { + Self { + auth_context, + navigate, + } + } + + pub async fn request(&self, mut request: Request) -> AuthResult { + // Add authentication header if available + if let Ok(Some(auth_header)) = self.auth_context.token_manager.get_auth_header() { + request = request.header("Authorization", &auth_header); + } + + // Add common headers + request = request + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("X-Requested-With", "XMLHttpRequest"); + + Ok(request) + } + + pub async fn response(&self, response: Response) -> AuthResult { + match response.status() { + 401 => { + // Unauthorized - token might be expired or invalid + self.handle_401_response().await?; + Err(AuthError { + message: "Authentication required".to_string(), + code: Some("UNAUTHORIZED".to_string()), + field: None, + }) + } + 403 => { + // Forbidden - user doesn't have permission + console::warn_1(&"Access denied - insufficient permissions".into()); + Ok(response) + } + 429 => { + // Too Many Requests - rate limited + console::warn_1(&"Rate limit exceeded".into()); + Ok(response) + } + 500..=599 => { + // Server errors + console::error_1(&format!("Server error: {}", response.status()).into()); + Ok(response) + } + _ => Ok(response), + } + } + + async fn handle_401_response(&self) -> AuthResult<()> { + console::log_1(&"Handling 401 response - attempting token refresh".into()); + + // Try to refresh the token + match self.auth_context.token_manager.refresh_if_needed().await { + Ok(Some(_new_token)) => { + console::log_1(&"Token refreshed successfully".into()); + Ok(()) + } + Ok(None) => { + console::log_1(&"No token to refresh - redirecting to login".into()); + self.logout_and_redirect().await; + Err(AuthError { + message: "No authentication token available".to_string(), + code: Some("NO_TOKEN".to_string()), + field: None, + }) + } + Err(e) => { + console::error_1(&format!("Token refresh failed: {}", e).into()); + self.logout_and_redirect().await; + Err(e) + } + } + } + + async fn logout_and_redirect(&self) { + // Clear authentication state + let _ = self.auth_context.token_manager.clear_token(); + self.auth_context.auth_state.update(|state| { + *state = crate::auth::AuthState::default(); + }); + + // Redirect to login page + self.navigate("/login", Default::default()); + } +} + +// Global HTTP interceptor setup +pub fn setup_http_interceptor() { + let auth_context = use_auth_context(); + let navigate = use_navigate(); + + let interceptor = HttpInterceptor::new(auth_context, navigate); + + // Store interceptor in global context for use with HTTP requests + provide_context(interceptor); +} + +pub fn use_http_interceptor() -> HttpInterceptor { + expect_context::() +} + +// Enhanced HTTP client with automatic token refresh and error handling +pub struct AuthenticatedHttpClient { + interceptor: HttpInterceptor, + base_url: String, + default_headers: HashMap, +} + +impl AuthenticatedHttpClient { + pub fn new( + auth_context: crate::auth::token_manager::AuthContext, + navigate: leptos_router::NavigateOptions, + base_url: String, + ) -> Self { + let interceptor = HttpInterceptor::new(auth_context, navigate); + let mut default_headers = HashMap::new(); + default_headers.insert("Content-Type".to_string(), "application/json".to_string()); + default_headers.insert("Accept".to_string(), "application/json".to_string()); + + Self { + interceptor, + base_url, + default_headers, + } + } + + pub async fn get(&self, path: &str) -> AuthResult { + let url = format!("{}{}", self.base_url, path); + let mut request = Request::get(&url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + self.execute_request(request).await + } + + pub async fn post(&self, path: &str, body: &T) -> AuthResult { + let url = format!("{}{}", self.base_url, path); + let mut request = Request::post(&url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + let request = request.json(body).map_err(|e| AuthError { + message: format!("Failed to serialize request body: {}", e), + code: Some("SERIALIZATION_ERROR".to_string()), + field: None, + })?; + + self.execute_request(request).await + } + + pub async fn put(&self, path: &str, body: &T) -> AuthResult { + let url = format!("{}{}", self.base_url, path); + let mut request = Request::put(&url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + let request = request.json(body).map_err(|e| AuthError { + message: format!("Failed to serialize request body: {}", e), + code: Some("SERIALIZATION_ERROR".to_string()), + field: None, + })?; + + self.execute_request(request).await + } + + pub async fn delete(&self, path: &str) -> AuthResult { + let url = format!("{}{}", self.base_url, path); + let mut request = Request::delete(&url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + self.execute_request(request).await + } + + pub async fn patch(&self, path: &str, body: &T) -> AuthResult { + let url = format!("{}{}", self.base_url, path); + let mut request = Request::patch(&url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + let request = request.json(body).map_err(|e| AuthError { + message: format!("Failed to serialize request body: {}", e), + code: Some("SERIALIZATION_ERROR".to_string()), + field: None, + })?; + + self.execute_request(request).await + } + + async fn execute_request(&self, request: Request) -> AuthResult { + // Apply request interceptor + let request = self.interceptor.request(request).await?; + + // Execute request with retry logic for 401 responses + let mut retries = 0; + const MAX_RETRIES: u32 = 1; + + loop { + let response = request.send().await.map_err(|e| AuthError { + message: format!("HTTP request failed: {:?}", e), + code: Some("REQUEST_FAILED".to_string()), + field: None, + })?; + + // Check if this is a 401 and we haven't exceeded retry limit + if response.status() == 401 && retries < MAX_RETRIES { + console::log_1(&"Received 401, attempting token refresh and retry".into()); + + // Try to refresh token + match self.interceptor.auth_context.token_manager.refresh_if_needed().await { + Ok(Some(_)) => { + console::log_1(&"Token refreshed, retrying request".into()); + retries += 1; + continue; + } + _ => { + console::log_1(&"Token refresh failed, handling 401".into()); + return self.interceptor.response(response).await; + } + } + } + + // Apply response interceptor + return self.interceptor.response(response).await; + } + } +} + +// Request timeout handler +pub struct RequestTimeoutHandler { + timeout_ms: u32, +} + +impl RequestTimeoutHandler { + pub fn new(timeout_ms: u32) -> Self { + Self { timeout_ms } + } + + pub async fn execute_with_timeout(&self, future: F) -> AuthResult + where + F: std::future::Future>, + { + let timeout_future = self.create_timeout(); + + match futures::future::select( + Box::pin(future), + Box::pin(timeout_future) + ).await { + futures::future::Either::Left((result, _)) => result, + futures::future::Either::Right((_, _)) => Err(AuthError { + message: format!("Request timeout after {}ms", self.timeout_ms), + code: Some("REQUEST_TIMEOUT".to_string()), + field: None, + }), + } + } + + async fn create_timeout(&self) -> AuthResult<()> { + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let timeout = gloo_timers::callback::Timeout::new(self.timeout_ms, move || { + resolve.call0(&JsValue::UNDEFINED).unwrap(); + }); + timeout.forget(); + }); + + JsFuture::from(promise).await.map_err(|_| AuthError { + message: "Timeout promise failed".to_string(), + code: Some("TIMEOUT_PROMISE_FAILED".to_string()), + field: None, + })?; + + Ok(()) + } +} + +// Error response parser +pub fn parse_error_response(response: &Response) -> Option { + // Try to parse error from response body + // This is a simplified version - in practice you'd handle this asynchronously + None +} + +// Network status monitor +pub struct NetworkStatusMonitor { + is_online: RwSignal, +} + +impl NetworkStatusMonitor { + pub fn new() -> Self { + let is_online = create_rw_signal(true); + + // Monitor online/offline events + if let Some(window) = web_sys::window() { + let online_closure = { + let is_online = is_online.clone(); + Closure::wrap(Box::new(move |_: web_sys::Event| { + is_online.set(true); + console::log_1(&"Network connection restored".into()); + }) as Box) + }; + + let offline_closure = { + let is_online = is_online.clone(); + Closure::wrap(Box::new(move |_: web_sys::Event| { + is_online.set(false); + console::log_1(&"Network connection lost".into()); + }) as Box) + }; + + let _ = window.add_event_listener_with_callback("online", online_closure.as_ref().unchecked_ref()); + let _ = window.add_event_listener_with_callback("offline", offline_closure.as_ref().unchecked_ref()); + + online_closure.forget(); + offline_closure.forget(); + } + + Self { is_online } + } + + pub fn is_online(&self) -> bool { + self.is_online.get() + } + + pub fn on_network_change(&self, callback: F) + where + F: Fn(bool) + 'static, + { + create_effect({ + let is_online = self.is_online; + move |_| { + callback(is_online.get()); + } + }); + } +} + +// Request queue for offline support +pub struct OfflineRequestQueue { + queue: RwSignal>, + network_monitor: NetworkStatusMonitor, +} + +#[derive(Clone, Debug)] +pub struct QueuedRequest { + pub method: String, + pub url: String, + pub headers: HashMap, + pub body: Option, + pub timestamp: chrono::DateTime, +} + +impl OfflineRequestQueue { + pub fn new() -> Self { + let queue = create_rw_signal(Vec::new()); + let network_monitor = NetworkStatusMonitor::new(); + + // Process queue when network comes back online + network_monitor.on_network_change({ + let queue = queue.clone(); + move |is_online| { + if is_online { + // Process queued requests + let queued_requests = queue.get(); + for request in queued_requests { + // TODO: Implement request replay logic + console::log_1(&format!("Replaying request: {} {}", request.method, request.url).into()); + } + queue.set(Vec::new()); + } + } + }); + + Self { + queue, + network_monitor, + } + } + + pub fn enqueue_request(&self, request: QueuedRequest) { + self.queue.update(|queue| { + queue.push(request); + }); + } + + pub fn is_online(&self) -> bool { + self.network_monitor.is_online() + } + + pub fn queue_size(&self) -> usize { + self.queue.get().len() + } +} \ No newline at end of file diff --git a/control-center-ui/src/auth/mod.rs b/control-center-ui/src/auth/mod.rs new file mode 100644 index 0000000..8f5368c --- /dev/null +++ b/control-center-ui/src/auth/mod.rs @@ -0,0 +1,128 @@ +// pub mod token_manager; +// pub mod crypto; +// pub mod webauthn; +// pub mod storage; +// pub mod http_interceptor; + +// pub use token_manager::*; +// pub use crypto::*; +// pub use webauthn::*; +// pub use storage::*; +// pub use http_interceptor::*; + +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AuthToken { + pub access_token: String, + pub refresh_token: String, + pub expires_at: DateTime, + pub token_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginCredentials { + pub email: String, + pub password: String, + pub remember_me: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MfaCredentials { + pub token: String, + pub code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub email: String, + pub name: String, + pub mfa_enabled: bool, + pub device_trust_enabled: bool, + pub last_login: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthState { + pub user: Option, + pub token: Option, + pub is_authenticated: bool, + pub is_loading: bool, + pub mfa_required: bool, + pub trusted_device: bool, +} + +impl Default for AuthState { + fn default() -> Self { + Self { + user: None, + token: None, + is_authenticated: false, + is_loading: false, + mfa_required: false, + trusted_device: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthError { + pub message: String, + pub code: Option, + pub field: Option, +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for AuthError {} + +pub type AuthResult = Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedDevice { + pub id: Uuid, + pub name: String, + pub device_fingerprint: String, + pub user_agent: String, + pub ip_address: String, + pub created_at: DateTime, + pub last_used: DateTime, + pub is_current: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsoProvider { + pub id: String, + pub name: String, + pub provider_type: SsoProviderType, + pub icon: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SsoProviderType { + OAuth2, + SAML, + OIDC, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordResetRequest { + pub email: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordResetConfirm { + pub token: String, + pub new_password: String, + pub confirm_password: String, +} \ No newline at end of file diff --git a/control-center-ui/src/auth/storage.rs b/control-center-ui/src/auth/storage.rs new file mode 100644 index 0000000..68c0c15 --- /dev/null +++ b/control-center-ui/src/auth/storage.rs @@ -0,0 +1,196 @@ +use crate::auth::{AuthToken, AuthResult, AuthError}; +use aes_gcm::{ + aead::{Aead, KeyInit, OsRng}, + Aes256Gcm, Nonce +}; +use base64::{Engine as _, engine::general_purpose}; +use gloo_storage::{LocalStorage, Storage}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use web_sys::window; + +const TOKEN_KEY: &str = "cc_auth_token"; +const ENCRYPTION_KEY: &str = "cc_encryption_key"; + +#[derive(Serialize, Deserialize)] +struct EncryptedData { + data: String, + nonce: String, +} + +pub struct SecureTokenStorage { + cipher: Aes256Gcm, +} + +impl SecureTokenStorage { + pub fn new() -> AuthResult { + let key = Self::get_or_create_encryption_key()?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| AuthError { + message: format!("Failed to initialize cipher: {}", e), + code: Some("CIPHER_INIT_ERROR".to_string()), + field: None, + })?; + + Ok(Self { cipher }) + } + + fn get_or_create_encryption_key() -> AuthResult> { + // Try to get existing key from sessionStorage (more secure than localStorage for keys) + if let Some(storage) = window().and_then(|w| w.session_storage().ok().flatten()) { + if let Ok(encoded_key) = storage.get_item(ENCRYPTION_KEY) { + if let Some(key_str) = encoded_key { + if let Ok(key_bytes) = general_purpose::STANDARD.decode(key_str) { + if key_bytes.len() == 32 { + return Ok(key_bytes); + } + } + } + } + } + + // Generate new key if none exists + let mut key = vec![0u8; 32]; + OsRng.fill_bytes(&mut key); + let encoded_key = general_purpose::STANDARD.encode(&key); + + // Store in sessionStorage + if let Some(storage) = window().and_then(|w| w.session_storage().ok().flatten()) { + storage.set_item(ENCRYPTION_KEY, &encoded_key) + .map_err(|_| AuthError { + message: "Failed to store encryption key".to_string(), + code: Some("KEY_STORAGE_ERROR".to_string()), + field: None, + })?; + } + + Ok(key) + } + + pub fn store_token(&self, token: &AuthToken) -> AuthResult<()> { + let serialized = serde_json::to_string(token) + .map_err(|e| AuthError { + message: format!("Failed to serialize token: {}", e), + code: Some("SERIALIZATION_ERROR".to_string()), + field: None, + })?; + + let encrypted_data = self.encrypt_data(&serialized)?; + let encrypted_json = serde_json::to_string(&encrypted_data) + .map_err(|e| AuthError { + message: format!("Failed to serialize encrypted data: {}", e), + code: Some("SERIALIZATION_ERROR".to_string()), + field: None, + })?; + + LocalStorage::set(TOKEN_KEY, encrypted_json) + .map_err(|_| AuthError { + message: "Failed to store token in localStorage".to_string(), + code: Some("STORAGE_ERROR".to_string()), + field: None, + })?; + + Ok(()) + } + + pub fn retrieve_token(&self) -> AuthResult> { + let encrypted_json: String = match LocalStorage::get(TOKEN_KEY) { + Ok(data) => data, + Err(_) => return Ok(None), + }; + + let encrypted_data: EncryptedData = serde_json::from_str(&encrypted_json) + .map_err(|e| AuthError { + message: format!("Failed to deserialize encrypted data: {}", e), + code: Some("DESERIALIZATION_ERROR".to_string()), + field: None, + })?; + + let decrypted_data = self.decrypt_data(&encrypted_data)?; + let token: AuthToken = serde_json::from_str(&decrypted_data) + .map_err(|e| AuthError { + message: format!("Failed to deserialize token: {}", e), + code: Some("DESERIALIZATION_ERROR".to_string()), + field: None, + })?; + + Ok(Some(token)) + } + + pub fn remove_token(&self) -> AuthResult<()> { + LocalStorage::delete(TOKEN_KEY); + + // Also clear encryption key from sessionStorage + if let Some(storage) = window().and_then(|w| w.session_storage().ok().flatten()) { + let _ = storage.remove_item(ENCRYPTION_KEY); + } + + Ok(()) + } + + fn encrypt_data(&self, data: &str) -> AuthResult { + let mut nonce_bytes = vec![0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = self.cipher.encrypt(nonce, data.as_bytes()) + .map_err(|e| AuthError { + message: format!("Encryption failed: {}", e), + code: Some("ENCRYPTION_ERROR".to_string()), + field: None, + })?; + + Ok(EncryptedData { + data: general_purpose::STANDARD.encode(ciphertext), + nonce: general_purpose::STANDARD.encode(nonce_bytes), + }) + } + + fn decrypt_data(&self, encrypted_data: &EncryptedData) -> AuthResult { + let ciphertext = general_purpose::STANDARD.decode(&encrypted_data.data) + .map_err(|e| AuthError { + message: format!("Failed to decode ciphertext: {}", e), + code: Some("DECODE_ERROR".to_string()), + field: None, + })?; + + let nonce_bytes = general_purpose::STANDARD.decode(&encrypted_data.nonce) + .map_err(|e| AuthError { + message: format!("Failed to decode nonce: {}", e), + code: Some("DECODE_ERROR".to_string()), + field: None, + })?; + + let nonce = Nonce::from_slice(&nonce_bytes); + let plaintext = self.cipher.decrypt(nonce, ciphertext.as_ref()) + .map_err(|e| AuthError { + message: format!("Decryption failed: {}", e), + code: Some("DECRYPTION_ERROR".to_string()), + field: None, + })?; + + String::from_utf8(plaintext) + .map_err(|e| AuthError { + message: format!("Failed to convert decrypted data to string: {}", e), + code: Some("UTF8_ERROR".to_string()), + field: None, + }) + } + + pub fn clear_all_data(&self) -> AuthResult<()> { + LocalStorage::clear(); + + if let Some(storage) = window().and_then(|w| w.session_storage().ok().flatten()) { + let _ = storage.clear(); + } + + Ok(()) + } +} + +impl Default for SecureTokenStorage { + fn default() -> Self { + Self::new().expect("Failed to create SecureTokenStorage") + } +} \ No newline at end of file diff --git a/control-center-ui/src/auth/token_manager.rs b/control-center-ui/src/auth/token_manager.rs new file mode 100644 index 0000000..8ce2130 --- /dev/null +++ b/control-center-ui/src/auth/token_manager.rs @@ -0,0 +1,220 @@ +use crate::auth::{AuthToken, AuthResult, AuthError, SecureTokenStorage}; +use chrono::{DateTime, Utc, Duration}; +use gloo_net::http::Request; +use gloo_timers::callback::Timeout; +use leptos::*; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; +use wasm_bindgen::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TokenRefreshResponse { + access_token: String, + refresh_token: Option, + expires_in: i64, + token_type: String, +} + +#[derive(Debug, Clone)] +pub struct TokenManager { + storage: SecureTokenStorage, + refresh_timeout: RwSignal>, + api_base_url: String, +} + +impl TokenManager { + pub fn new(api_base_url: String) -> AuthResult { + let storage = SecureTokenStorage::new()?; + + Ok(Self { + storage, + refresh_timeout: create_rw_signal(None), + api_base_url, + }) + } + + pub fn store_token(&self, token: AuthToken) -> AuthResult<()> { + // Store the token securely + self.storage.store_token(&token)?; + + // Schedule automatic refresh + self.schedule_token_refresh(&token)?; + + Ok(()) + } + + pub fn get_current_token(&self) -> AuthResult> { + self.storage.retrieve_token() + } + + pub fn is_token_valid(&self, token: &AuthToken) -> bool { + let now = Utc::now(); + token.expires_at > now + Duration::minutes(5) // 5-minute buffer + } + + pub fn clear_token(&self) -> AuthResult<()> { + // Clear refresh timeout + self.refresh_timeout.set(None); + + // Remove token from storage + self.storage.remove_token()?; + + Ok(()) + } + + fn schedule_token_refresh(&self, token: &AuthToken) -> AuthResult<()> { + let refresh_time = token.expires_at - Duration::minutes(10); // Refresh 10 minutes before expiry + let now = Utc::now(); + + if refresh_time <= now { + // Token expires soon, refresh immediately + self.refresh_token_now(token.clone())?; + return Ok(()); + } + + let duration_ms = (refresh_time - now).num_milliseconds() as u32; + let token_clone = token.clone(); + let manager_clone = Rc::new(self.clone()); + + let timeout = Timeout::new(duration_ms, move || { + let manager = manager_clone.clone(); + let token = token_clone.clone(); + + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = manager.refresh_token_now(token).await { + web_sys::console::error_1(&format!("Token refresh failed: {}", e).into()); + } + }); + }); + + self.refresh_timeout.set(Some(timeout)); + + Ok(()) + } + + async fn refresh_token_now(&self, current_token: AuthToken) -> AuthResult { + let refresh_url = format!("{}/auth/refresh", self.api_base_url); + + let response = Request::post(&refresh_url) + .header("Content-Type", "application/json") + .header("Authorization", &format!("Bearer {}", current_token.refresh_token)) + .send() + .await + .map_err(|e| AuthError { + message: format!("Token refresh request failed: {}", e), + code: Some("REFRESH_REQUEST_ERROR".to_string()), + field: None, + })?; + + if !response.ok() { + return Err(AuthError { + message: format!("Token refresh failed with status: {}", response.status()), + code: Some("REFRESH_FAILED".to_string()), + field: None, + }); + } + + let refresh_data: TokenRefreshResponse = response.json() + .await + .map_err(|e| AuthError { + message: format!("Failed to parse refresh response: {}", e), + code: Some("REFRESH_PARSE_ERROR".to_string()), + field: None, + })?; + + let new_token = AuthToken { + access_token: refresh_data.access_token, + refresh_token: refresh_data.refresh_token.unwrap_or(current_token.refresh_token), + expires_at: Utc::now() + Duration::seconds(refresh_data.expires_in), + token_type: refresh_data.token_type, + }; + + // Store the new token and schedule next refresh + self.store_token(new_token.clone())?; + + Ok(new_token) + } + + pub async fn refresh_if_needed(&self) -> AuthResult> { + if let Some(current_token) = self.get_current_token()? { + if !self.is_token_valid(¤t_token) { + let new_token = self.refresh_token_now(current_token).await?; + return Ok(Some(new_token)); + } + return Ok(Some(current_token)); + } + Ok(None) + } + + pub fn get_auth_header(&self) -> AuthResult> { + if let Some(token) = self.get_current_token()? { + if self.is_token_valid(&token) { + return Ok(Some(format!("{} {}", token.token_type, token.access_token))); + } + } + Ok(None) + } +} + +impl Clone for TokenManager { + fn clone(&self) -> Self { + Self { + storage: SecureTokenStorage::new().expect("Failed to create storage"), + refresh_timeout: create_rw_signal(None), + api_base_url: self.api_base_url.clone(), + } + } +} + +// Global token manager context +#[derive(Clone)] +pub struct AuthContext { + pub token_manager: TokenManager, + pub auth_state: RwSignal, +} + +pub fn provide_auth_context(api_base_url: String) -> AuthResult<()> { + let token_manager = TokenManager::new(api_base_url)?; + let auth_state = create_rw_signal(crate::auth::AuthState::default()); + + // Check for existing token on initialization + if let Ok(Some(token)) = token_manager.get_current_token() { + if token_manager.is_token_valid(&token) { + auth_state.update(|state| { + state.token = Some(token); + state.is_authenticated = true; + }); + } else { + // Token exists but is invalid, try to refresh + let token_manager_clone = token_manager.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(Some(new_token)) = token_manager_clone.refresh_if_needed().await { + auth_state.update(|state| { + state.token = Some(new_token); + state.is_authenticated = true; + }); + } else { + // Failed to refresh, clear token + let _ = token_manager_clone.clear_token(); + } + }); + } + } + + let context = AuthContext { + token_manager, + auth_state, + }; + + provide_context(context); + Ok(()) +} + +pub fn use_auth_context() -> AuthContext { + expect_context::() +} + +pub fn use_token_manager() -> TokenManager { + let context = use_auth_context(); + context.token_manager +} \ No newline at end of file diff --git a/control-center-ui/src/auth/webauthn.rs b/control-center-ui/src/auth/webauthn.rs new file mode 100644 index 0000000..e05819f --- /dev/null +++ b/control-center-ui/src/auth/webauthn.rs @@ -0,0 +1,525 @@ +use crate::auth::{AuthError, AuthResult}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + window, AuthenticatorAttestationResponse, AuthenticatorAssertionResponse, + CredentialsContainer, PublicKeyCredential, PublicKeyCredentialCreationOptions, + PublicKeyCredentialRequestOptions, +}; +use base64::{Engine as _, engine::general_purpose}; +use js_sys::{Array, Object, Uint8Array}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebAuthnRegistrationOptions { + pub challenge: String, + pub rp: RelyingParty, + pub user: WebAuthnUser, + pub pub_key_cred_params: Vec, + pub timeout: Option, + pub exclude_credentials: Option>, + pub authenticator_selection: Option, + pub attestation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebAuthnAuthenticationOptions { + pub challenge: String, + pub timeout: Option, + pub rp_id: Option, + pub allow_credentials: Option>, + pub user_verification: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelyingParty { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebAuthnUser { + pub id: String, + pub name: String, + pub display_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PubKeyCredParam { + pub type_: String, + pub alg: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredentialDescriptor { + pub type_: String, + pub id: String, + pub transports: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthenticatorSelection { + pub authenticator_attachment: Option, + pub resident_key: Option, + pub require_resident_key: Option, + pub user_verification: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebAuthnRegistrationResult { + pub id: String, + pub raw_id: String, + pub type_: String, + pub response: AttestationResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebAuthnAuthenticationResult { + pub id: String, + pub raw_id: String, + pub type_: String, + pub response: AssertionResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttestationResponse { + pub client_data_json: String, + pub attestation_object: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssertionResponse { + pub client_data_json: String, + pub authenticator_data: String, + pub signature: String, + pub user_handle: Option, +} + +pub struct WebAuthnManager; + +impl WebAuthnManager { + pub fn new() -> Self { + Self + } + + pub fn is_supported(&self) -> bool { + window() + .and_then(|w| w.navigator().credentials().ok()) + .and_then(|c| js_sys::Reflect::has(&c, &"create".into()).ok()) + .unwrap_or(false) + } + + pub async fn register_credential( + &self, + options: WebAuthnRegistrationOptions, + ) -> AuthResult { + if !self.is_supported() { + return Err(AuthError { + message: "WebAuthn is not supported in this browser".to_string(), + code: Some("WEBAUTHN_NOT_SUPPORTED".to_string()), + field: None, + }); + } + + let window = window().ok_or_else(|| AuthError { + message: "Window not available".to_string(), + code: Some("WINDOW_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let credentials = window + .navigator() + .credentials() + .map_err(|_| AuthError { + message: "Credentials API not available".to_string(), + code: Some("CREDENTIALS_API_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let creation_options = self.build_creation_options(options)?; + + let credential_promise = credentials + .create_with_options(&creation_options) + .map_err(|e| AuthError { + message: format!("Failed to create credential: {:?}", e), + code: Some("CREDENTIAL_CREATION_FAILED".to_string()), + field: None, + })?; + + let credential = JsFuture::from(credential_promise) + .await + .map_err(|e| AuthError { + message: format!("Credential creation promise failed: {:?}", e), + code: Some("CREDENTIAL_PROMISE_FAILED".to_string()), + field: None, + })?; + + self.parse_registration_result(credential) + } + + pub async fn authenticate_credential( + &self, + options: WebAuthnAuthenticationOptions, + ) -> AuthResult { + if !self.is_supported() { + return Err(AuthError { + message: "WebAuthn is not supported in this browser".to_string(), + code: Some("WEBAUTHN_NOT_SUPPORTED".to_string()), + field: None, + }); + } + + let window = window().ok_or_else(|| AuthError { + message: "Window not available".to_string(), + code: Some("WINDOW_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let credentials = window + .navigator() + .credentials() + .map_err(|_| AuthError { + message: "Credentials API not available".to_string(), + code: Some("CREDENTIALS_API_NOT_AVAILABLE".to_string()), + field: None, + })?; + + let request_options = self.build_request_options(options)?; + + let credential_promise = credentials + .get_with_options(&request_options) + .map_err(|e| AuthError { + message: format!("Failed to get credential: {:?}", e), + code: Some("CREDENTIAL_GET_FAILED".to_string()), + field: None, + })?; + + let credential = JsFuture::from(credential_promise) + .await + .map_err(|e| AuthError { + message: format!("Credential get promise failed: {:?}", e), + code: Some("CREDENTIAL_PROMISE_FAILED".to_string()), + field: None, + })?; + + self.parse_authentication_result(credential) + } + + fn build_creation_options( + &self, + options: WebAuthnRegistrationOptions, + ) -> AuthResult { + let creation_options = PublicKeyCredentialCreationOptions::new(); + + // Set challenge + let challenge_bytes = general_purpose::STANDARD + .decode(&options.challenge) + .map_err(|e| AuthError { + message: format!("Failed to decode challenge: {}", e), + code: Some("CHALLENGE_DECODE_ERROR".to_string()), + field: None, + })?; + let challenge_array = Uint8Array::from(&challenge_bytes[..]); + creation_options.set_challenge(&challenge_array); + + // Set relying party + let rp = Object::new(); + js_sys::Reflect::set(&rp, &"id".into(), &options.rp.id.into()).unwrap(); + js_sys::Reflect::set(&rp, &"name".into(), &options.rp.name.into()).unwrap(); + creation_options.set_rp(&rp); + + // Set user + let user = Object::new(); + let user_id_bytes = general_purpose::STANDARD + .decode(&options.user.id) + .map_err(|e| AuthError { + message: format!("Failed to decode user ID: {}", e), + code: Some("USER_ID_DECODE_ERROR".to_string()), + field: None, + })?; + let user_id_array = Uint8Array::from(&user_id_bytes[..]); + js_sys::Reflect::set(&user, &"id".into(), &user_id_array).unwrap(); + js_sys::Reflect::set(&user, &"name".into(), &options.user.name.into()).unwrap(); + js_sys::Reflect::set(&user, &"displayName".into(), &options.user.display_name.into()).unwrap(); + creation_options.set_user(&user); + + // Set public key credential parameters + let cred_params = Array::new(); + for param in options.pub_key_cred_params { + let param_obj = Object::new(); + js_sys::Reflect::set(¶m_obj, &"type".into(), ¶m.type_.into()).unwrap(); + js_sys::Reflect::set(¶m_obj, &"alg".into(), ¶m.alg.into()).unwrap(); + cred_params.push(¶m_obj); + } + creation_options.set_pub_key_cred_params(&cred_params); + + // Set timeout + if let Some(timeout) = options.timeout { + creation_options.set_timeout(timeout); + } + + // Set exclude credentials + if let Some(exclude_creds) = options.exclude_credentials { + let exclude_array = Array::new(); + for cred in exclude_creds { + let cred_obj = Object::new(); + js_sys::Reflect::set(&cred_obj, &"type".into(), &cred.type_.into()).unwrap(); + + let cred_id_bytes = general_purpose::STANDARD + .decode(&cred.id) + .map_err(|e| AuthError { + message: format!("Failed to decode credential ID: {}", e), + code: Some("CRED_ID_DECODE_ERROR".to_string()), + field: None, + })?; + let cred_id_array = Uint8Array::from(&cred_id_bytes[..]); + js_sys::Reflect::set(&cred_obj, &"id".into(), &cred_id_array).unwrap(); + + if let Some(transports) = cred.transports { + let transports_array = Array::new(); + for transport in transports { + transports_array.push(&transport.into()); + } + js_sys::Reflect::set(&cred_obj, &"transports".into(), &transports_array).unwrap(); + } + + exclude_array.push(&cred_obj); + } + creation_options.set_exclude_credentials(&exclude_array); + } + + // Set authenticator selection + if let Some(auth_sel) = options.authenticator_selection { + let auth_sel_obj = Object::new(); + + if let Some(attachment) = auth_sel.authenticator_attachment { + js_sys::Reflect::set(&auth_sel_obj, &"authenticatorAttachment".into(), &attachment.into()).unwrap(); + } + + if let Some(resident_key) = auth_sel.resident_key { + js_sys::Reflect::set(&auth_sel_obj, &"residentKey".into(), &resident_key.into()).unwrap(); + } + + if let Some(require_resident_key) = auth_sel.require_resident_key { + js_sys::Reflect::set(&auth_sel_obj, &"requireResidentKey".into(), &require_resident_key.into()).unwrap(); + } + + if let Some(user_verification) = auth_sel.user_verification { + js_sys::Reflect::set(&auth_sel_obj, &"userVerification".into(), &user_verification.into()).unwrap(); + } + + creation_options.set_authenticator_selection(&auth_sel_obj); + } + + // Set attestation + if let Some(attestation) = options.attestation { + creation_options.set_attestation(&attestation.into()); + } + + Ok(creation_options) + } + + fn build_request_options( + &self, + options: WebAuthnAuthenticationOptions, + ) -> AuthResult { + let request_options = PublicKeyCredentialRequestOptions::new(); + + // Set challenge + let challenge_bytes = general_purpose::STANDARD + .decode(&options.challenge) + .map_err(|e| AuthError { + message: format!("Failed to decode challenge: {}", e), + code: Some("CHALLENGE_DECODE_ERROR".to_string()), + field: None, + })?; + let challenge_array = Uint8Array::from(&challenge_bytes[..]); + request_options.set_challenge(&challenge_array); + + // Set timeout + if let Some(timeout) = options.timeout { + request_options.set_timeout(timeout); + } + + // Set RP ID + if let Some(rp_id) = options.rp_id { + request_options.set_rp_id(&rp_id); + } + + // Set allow credentials + if let Some(allow_creds) = options.allow_credentials { + let allow_array = Array::new(); + for cred in allow_creds { + let cred_obj = Object::new(); + js_sys::Reflect::set(&cred_obj, &"type".into(), &cred.type_.into()).unwrap(); + + let cred_id_bytes = general_purpose::STANDARD + .decode(&cred.id) + .map_err(|e| AuthError { + message: format!("Failed to decode credential ID: {}", e), + code: Some("CRED_ID_DECODE_ERROR".to_string()), + field: None, + })?; + let cred_id_array = Uint8Array::from(&cred_id_bytes[..]); + js_sys::Reflect::set(&cred_obj, &"id".into(), &cred_id_array).unwrap(); + + if let Some(transports) = cred.transports { + let transports_array = Array::new(); + for transport in transports { + transports_array.push(&transport.into()); + } + js_sys::Reflect::set(&cred_obj, &"transports".into(), &transports_array).unwrap(); + } + + allow_array.push(&cred_obj); + } + request_options.set_allow_credentials(&allow_array); + } + + // Set user verification + if let Some(user_verification) = options.user_verification { + request_options.set_user_verification(&user_verification.into()); + } + + Ok(request_options) + } + + fn parse_registration_result( + &self, + credential: JsValue, + ) -> AuthResult { + let credential: PublicKeyCredential = credential + .dyn_into() + .map_err(|_| AuthError { + message: "Invalid credential type".to_string(), + code: Some("INVALID_CREDENTIAL_TYPE".to_string()), + field: None, + })?; + + let response: AuthenticatorAttestationResponse = credential + .response() + .dyn_into() + .map_err(|_| AuthError { + message: "Invalid response type".to_string(), + code: Some("INVALID_RESPONSE_TYPE".to_string()), + field: None, + })?; + + let id = credential.id(); + let raw_id = general_purpose::STANDARD.encode(Uint8Array::from(credential.raw_id()).to_vec()); + let type_ = credential.type_(); + + let client_data_json = general_purpose::STANDARD.encode( + Uint8Array::from(response.client_data_json()).to_vec() + ); + let attestation_object = general_purpose::STANDARD.encode( + Uint8Array::from(response.attestation_object()).to_vec() + ); + + Ok(WebAuthnRegistrationResult { + id, + raw_id, + type_, + response: AttestationResponse { + client_data_json, + attestation_object, + }, + }) + } + + fn parse_authentication_result( + &self, + credential: JsValue, + ) -> AuthResult { + let credential: PublicKeyCredential = credential + .dyn_into() + .map_err(|_| AuthError { + message: "Invalid credential type".to_string(), + code: Some("INVALID_CREDENTIAL_TYPE".to_string()), + field: None, + })?; + + let response: AuthenticatorAssertionResponse = credential + .response() + .dyn_into() + .map_err(|_| AuthError { + message: "Invalid response type".to_string(), + code: Some("INVALID_RESPONSE_TYPE".to_string()), + field: None, + })?; + + let id = credential.id(); + let raw_id = general_purpose::STANDARD.encode(Uint8Array::from(credential.raw_id()).to_vec()); + let type_ = credential.type_(); + + let client_data_json = general_purpose::STANDARD.encode( + Uint8Array::from(response.client_data_json()).to_vec() + ); + let authenticator_data = general_purpose::STANDARD.encode( + Uint8Array::from(response.authenticator_data()).to_vec() + ); + let signature = general_purpose::STANDARD.encode( + Uint8Array::from(response.signature()).to_vec() + ); + + let user_handle = response.user_handle().map(|handle| { + general_purpose::STANDARD.encode(Uint8Array::from(handle).to_vec()) + }); + + Ok(WebAuthnAuthenticationResult { + id, + raw_id, + type_, + response: AssertionResponse { + client_data_json, + authenticator_data, + signature, + user_handle, + }, + }) + } + + pub fn get_supported_authenticator_types(&self) -> Vec { + let mut types = Vec::new(); + + if self.is_platform_authenticator_available() { + types.push("platform".to_string()); + } + + if self.is_cross_platform_authenticator_available() { + types.push("cross-platform".to_string()); + } + + types + } + + pub fn is_platform_authenticator_available(&self) -> bool { + // Check if platform authenticator (like Touch ID, Face ID, Windows Hello) is available + window() + .and_then(|w| { + let promise = js_sys::Reflect::get(&w.navigator(), &"credentials".into()) + .ok() + .and_then(|creds| { + js_sys::Reflect::get(&creds, &"isUserVerifyingPlatformAuthenticatorAvailable".into()) + .ok() + }); + + promise.and_then(|func| { + if func.is_function() { + // This would normally return a Promise, but for simplicity + // we'll return true if the function exists + Some(true) + } else { + None + } + }) + }) + .unwrap_or(false) + } + + pub fn is_cross_platform_authenticator_available(&self) -> bool { + // Cross-platform authenticators (USB security keys) are generally available + // if WebAuthn is supported + self.is_supported() + } +} \ No newline at end of file diff --git a/control-center-ui/src/components/audit/AuditLogViewer.tsx b/control-center-ui/src/components/audit/AuditLogViewer.tsx new file mode 100644 index 0000000..3672a1b --- /dev/null +++ b/control-center-ui/src/components/audit/AuditLogViewer.tsx @@ -0,0 +1,433 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { + Download, + RefreshCw, + Settings, + AlertCircle, + Activity, + TrendingUp, + Clock, + Users, + Shield +} from 'lucide-react'; + +import { SearchFilters } from './SearchFilters'; +import { VirtualizedLogTable } from './VirtualizedLogTable'; +import { LogDetailModal } from './LogDetailModal'; +import { ExportModal } from './ExportModal'; +import { RealTimeIndicator } from './RealTimeIndicator'; +import { useWebSocket } from '@/hooks/useWebSocket'; +import auditApi from '@/services/api'; +import { + AuditLogEntry, + AuditSearchFilters, + SavedSearch, + AuditDashboardStats, + AuditExportRequest +} from '@/types/audit'; + +const WEBSOCKET_URL = process.env.NODE_ENV === 'production' + ? `wss://${window.location.host}/ws/audit` + : 'ws://localhost:8080/ws/audit'; + +export const AuditLogViewer: React.FC = () => { + const { logId } = useParams<{ logId?: string }>(); + const navigate = useNavigate(); + + // Component state + const [filters, setFilters] = useState({ + dateRange: {}, + users: [], + actions: [], + resources: [], + severity: [], + complianceFrameworks: [], + tags: [] + }); + + const [selectedLogIds, setSelectedLogIds] = useState>(new Set()); + const [selectedLog, setSelectedLog] = useState(null); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [realTimeEnabled, setRealTimeEnabled] = useState(true); + const [newLogCount, setNewLogCount] = useState(0); + const [realtimeQueue, setRealtimeQueue] = useState([]); + + // Fetch logs with infinite query for pagination + const { + data: logsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: isLogsLoading, + refetch: refetchLogs, + error: logsError + } = useInfiniteQuery({ + queryKey: ['auditLogs', filters], + queryFn: ({ pageParam = 0 }) => + auditApi.getLogs(filters, pageParam, 50), + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, + staleTime: 30 * 1000, // 30 seconds + enabled: true, + refetchOnWindowFocus: false + }); + + // Fetch saved searches + const { data: savedSearches = [] } = useQuery({ + queryKey: ['savedSearches'], + queryFn: () => auditApi.getSavedSearches(), + staleTime: 5 * 60 * 1000 + }); + + // Fetch dashboard stats + const { data: dashboardStats } = useQuery({ + queryKey: ['dashboardStats'], + queryFn: () => auditApi.getDashboardStats(), + refetchInterval: 30 * 1000 // Refresh every 30 seconds + }); + + // Flatten logs from all pages + const allLogs = React.useMemo(() => { + if (!logsData) return []; + return [ + ...realtimeQueue, // New logs from WebSocket at the top + ...logsData.pages.flatMap(page => page.logs) + ]; + }, [logsData, realtimeQueue]); + + const totalCount = logsData?.pages[0]?.total || 0; + + // WebSocket for real-time updates + const { isConnected, lastMessage, reconnect, readyState, reconnectAttempts } = useWebSocket({ + url: WEBSOCKET_URL, + shouldReconnect: realTimeEnabled, + onNewAuditLog: (log: AuditLogEntry) => { + if (realTimeEnabled) { + setRealtimeQueue(prev => [log, ...prev.slice(0, 49)]); // Keep last 50 realtime logs + setNewLogCount(prev => prev + 1); + + // Show toast for critical severity logs + if (log.severity === 'critical') { + toast.error(`Critical audit event: ${log.action.description}`, { + position: 'top-right', + autoClose: 10000 + }); + } + } + }, + onComplianceAlert: (alert: any) => { + toast.warning(`Compliance alert: ${alert.message}`, { + position: 'top-right', + autoClose: 8000 + }); + }, + onError: (error) => { + console.error('WebSocket error:', error); + toast.error('Real-time connection error'); + } + }); + + // Load specific log from URL parameter + useEffect(() => { + if (logId) { + auditApi.getLog(logId) + .then(log => { + setSelectedLog(log); + setIsDetailModalOpen(true); + }) + .catch(error => { + toast.error(`Failed to load log: ${error.message}`); + navigate('/audit'); + }); + } + }, [logId, navigate]); + + // Reset new log count when real-time is disabled or filters change + useEffect(() => { + setNewLogCount(0); + setRealtimeQueue([]); + }, [realTimeEnabled, filters]); + + // Handle filter changes + const handleFiltersChange = useCallback((newFilters: AuditSearchFilters) => { + setFilters(newFilters); + setNewLogCount(0); + setRealtimeQueue([]); + }, []); + + // Handle save search + const handleSaveSearch = useCallback(async (name: string, description?: string) => { + try { + await auditApi.createSavedSearch({ + name, + description, + filters, + isPublic: false, + createdBy: 'current-user' // This would come from auth context + }); + toast.success('Search saved successfully'); + } catch (error) { + toast.error('Failed to save search'); + } + }, [filters]); + + // Handle load saved search + const handleLoadSavedSearch = useCallback((search: SavedSearch) => { + setFilters(search.filters); + toast.success(`Loaded search: ${search.name}`); + }, []); + + // Handle row click + const handleRowClick = useCallback((log: AuditLogEntry) => { + setSelectedLog(log); + setIsDetailModalOpen(true); + navigate(`/audit/${log.id}`, { replace: true }); + }, [navigate]); + + // Handle modal close + const handleCloseDetailModal = useCallback(() => { + setIsDetailModalOpen(false); + setSelectedLog(null); + navigate('/audit', { replace: true }); + }, [navigate]); + + // Handle view correlated logs + const handleViewCorrelated = useCallback(async (requestId: string) => { + try { + const correlatedLogs = await auditApi.getCorrelatedLogs(requestId); + // Set filters to show only correlated logs + setFilters({ + ...filters, + requestId + }); + handleCloseDetailModal(); + toast.success(`Found ${correlatedLogs.length} correlated logs`); + } catch (error) { + toast.error('Failed to load correlated logs'); + } + }, [filters, handleCloseDetailModal]); + + // Handle view session logs + const handleViewSession = useCallback(async (sessionId: string) => { + try { + const sessionLogs = await auditApi.getLogsBySession(sessionId); + setFilters({ + ...filters, + sessionId + }); + handleCloseDetailModal(); + toast.success(`Found ${sessionLogs.length} session logs`); + } catch (error) { + toast.error('Failed to load session logs'); + } + }, [filters, handleCloseDetailModal]); + + // Handle export + const handleExport = useCallback(async (request: AuditExportRequest) => { + try { + const blob = await auditApi.exportLogs({ + ...request, + filters + }); + return blob; + } catch (error) { + toast.error('Export failed'); + throw error; + } + }, [filters]); + + // Handle real-time toggle + const handleToggleRealTime = useCallback(() => { + setRealTimeEnabled(prev => !prev); + if (!realTimeEnabled) { + setNewLogCount(0); + } + }, [realTimeEnabled]); + + // Handle refresh + const handleRefresh = useCallback(() => { + refetchLogs(); + setNewLogCount(0); + setRealtimeQueue([]); + }, [refetchLogs]); + + if (logsError) { + return ( +
+ +

Error Loading Logs

+

+ {logsError.message || 'An unexpected error occurred while loading audit logs.'} +

+ +
+ ); + } + + return ( +
+ {/* Dashboard Stats */} + {dashboardStats && ( +
+
+
+
+

Total Events

+

{dashboardStats.totalEvents.toLocaleString()}

+
+ +
+
+
+
+
+

Success Rate

+

{(dashboardStats.successRate * 100).toFixed(1)}%

+
+ +
+
+
+
+
+

Critical Events

+

{dashboardStats.criticalEvents}

+
+ +
+
+
+
+
+

Compliance Score

+

{dashboardStats.complianceScore}%

+
+ +
+
+
+ )} + + {/* Controls Bar */} +
+
+ +
+ +
+ + + + + {selectedLogIds.size > 0 && ( +
+ + {selectedLogIds.size} selected + + +
+ )} +
+
+ + {/* Search Filters */} + + + {/* Results Info */} + {!isLogsLoading && ( +
+
+ + Showing {allLogs.length.toLocaleString()} of {totalCount.toLocaleString()} logs + + {newLogCount > 0 && ( + + ({newLogCount} new in real-time) + + )} +
+ {isConnected && ( +
+
+ Live +
+ )} +
+ )} + + {/* Log Table */} + + + {/* Log Detail Modal */} + + + {/* Export Modal */} + setIsExportModalOpen(false)} + logs={allLogs} + filters={filters} + totalCount={totalCount} + onExport={handleExport} + /> +
+ ); +}; + +export default AuditLogViewer; \ No newline at end of file diff --git a/control-center-ui/src/components/audit/ComplianceReportGenerator.tsx b/control-center-ui/src/components/audit/ComplianceReportGenerator.tsx new file mode 100644 index 0000000..4e3bdd3 --- /dev/null +++ b/control-center-ui/src/components/audit/ComplianceReportGenerator.tsx @@ -0,0 +1,668 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useForm, Controller } from 'react-hook-form'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { format, subDays, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, startOfYear, endOfYear } from 'date-fns'; +import { + FileText, + Download, + Calendar, + Shield, + AlertTriangle, + CheckCircle, + Clock, + Settings, + X, + Play, + RefreshCw, + Eye, + Filter +} from 'lucide-react'; +import { toast } from 'react-toastify'; +import auditApi from '@/services/api'; +import { ComplianceReport } from '@/types/audit'; + +interface ReportGeneratorProps { + isOpen: boolean; + onClose: () => void; +} + +interface FormData { + type: 'soc2' | 'hipaa' | 'pci' | 'gdpr' | 'custom'; + startDate: string; + endDate: string; + template: string; + includeFindings: boolean; + includeRecommendations: boolean; + includeEvidence: boolean; + executiveSummary: boolean; + customTitle: string; + customDescription: string; +} + +const complianceFrameworks = [ + { + value: 'soc2' as const, + label: 'SOC 2 Type II', + description: 'Service Organization Control 2 Type II assessment', + icon: Shield, + color: 'text-blue-600', + requirements: ['Trust Services Criteria', 'Security', 'Availability', 'Confidentiality', 'Processing Integrity', 'Privacy'] + }, + { + value: 'hipaa' as const, + label: 'HIPAA', + description: 'Health Insurance Portability and Accountability Act', + icon: Shield, + color: 'text-green-600', + requirements: ['Administrative Safeguards', 'Physical Safeguards', 'Technical Safeguards', 'Breach Notification'] + }, + { + value: 'pci' as const, + label: 'PCI DSS', + description: 'Payment Card Industry Data Security Standard', + icon: Shield, + color: 'text-purple-600', + requirements: ['Network Security', 'Cardholder Data Protection', 'Vulnerability Management', 'Access Control'] + }, + { + value: 'gdpr' as const, + label: 'GDPR', + description: 'General Data Protection Regulation', + icon: Shield, + color: 'text-indigo-600', + requirements: ['Data Minimization', 'Consent Management', 'Data Subject Rights', 'Breach Notification'] + }, + { + value: 'custom' as const, + label: 'Custom Report', + description: 'Create a custom compliance report', + icon: FileText, + color: 'text-gray-600', + requirements: ['Flexible Requirements', 'Custom Controls', 'Tailored Assessment'] + } +]; + +const predefinedPeriods = [ + { + label: 'Last 7 days', + getValue: () => ({ + start: format(subDays(new Date(), 7), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd') + }) + }, + { + label: 'Last 30 days', + getValue: () => ({ + start: format(subDays(new Date(), 30), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd') + }) + }, + { + label: 'Current Month', + getValue: () => ({ + start: format(startOfMonth(new Date()), 'yyyy-MM-dd'), + end: format(endOfMonth(new Date()), 'yyyy-MM-dd') + }) + }, + { + label: 'Current Quarter', + getValue: () => ({ + start: format(startOfQuarter(new Date()), 'yyyy-MM-dd'), + end: format(endOfQuarter(new Date()), 'yyyy-MM-dd') + }) + }, + { + label: 'Current Year', + getValue: () => ({ + start: format(startOfYear(new Date()), 'yyyy-MM-dd'), + end: format(endOfYear(new Date()), 'yyyy-MM-dd') + }) + } +]; + +export const ComplianceReportGenerator: React.FC = ({ + isOpen, + onClose +}) => { + const queryClient = useQueryClient(); + const [generatingReportId, setGeneratingReportId] = useState(null); + + const { control, watch, setValue, getValues, handleSubmit } = useForm({ + defaultValues: { + type: 'soc2', + startDate: format(subDays(new Date(), 30), 'yyyy-MM-dd'), + endDate: format(new Date(), 'yyyy-MM-dd'), + template: 'standard', + includeFindings: true, + includeRecommendations: true, + includeEvidence: true, + executiveSummary: true, + customTitle: '', + customDescription: '' + } + }); + + const watchedType = watch('type'); + + // Fetch available templates + const { data: templates = [] } = useQuery({ + queryKey: ['complianceTemplates'], + queryFn: () => auditApi.getComplianceTemplates(), + enabled: isOpen + }); + + // Fetch existing reports + const { data: existingReports = [], refetch: refetchReports } = useQuery({ + queryKey: ['complianceReports'], + queryFn: () => auditApi.getComplianceReports(), + enabled: isOpen + }); + + // Generate report mutation + const generateReportMutation = useMutation({ + mutationFn: (data: { type: FormData['type'], period: { start: Date; end: Date }, template?: string }) => + auditApi.generateComplianceReport(data.type, data.period, data.template), + onSuccess: (result) => { + setGeneratingReportId(result.reportId); + toast.success('Report generation started'); + + // Poll for completion + const pollInterval = setInterval(async () => { + try { + const report = await auditApi.getComplianceReport(result.reportId); + if (report) { + clearInterval(pollInterval); + setGeneratingReportId(null); + queryClient.invalidateQueries({ queryKey: ['complianceReports'] }); + toast.success('Report generated successfully'); + } + } catch (error) { + // Report might not be ready yet, continue polling + } + }, 2000); + + // Stop polling after 5 minutes + setTimeout(() => { + clearInterval(pollInterval); + setGeneratingReportId(null); + }, 5 * 60 * 1000); + }, + onError: (error) => { + toast.error('Failed to generate report'); + setGeneratingReportId(null); + } + }); + + const selectedFramework = complianceFrameworks.find(f => f.value === watchedType); + + const handlePeriodSelect = (period: { start: string; end: string }) => { + setValue('startDate', period.start); + setValue('endDate', period.end); + }; + + const onSubmit = (data: FormData) => { + generateReportMutation.mutate({ + type: data.type, + period: { + start: new Date(data.startDate), + end: new Date(data.endDate) + }, + template: data.template + }); + }; + + const handleDownloadReport = async (report: ComplianceReport) => { + try { + const blob = await auditApi.exportLogs({ + format: 'pdf', + filters: { + dateRange: { + start: report.period.start, + end: report.period.end + }, + complianceFrameworks: [report.type] + }, + includeMetadata: true, + includeCompliance: true, + template: `compliance_${report.type}` + }); + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${report.type}_compliance_report_${format(new Date(), 'yyyy-MM-dd')}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success('Report downloaded successfully'); + } catch (error) { + toast.error('Failed to download report'); + } + }; + + if (!isOpen) return null; + + return ( + + + + {/* Header */} +
+
+

+ Compliance Report Generator +

+

+ Generate comprehensive compliance reports for various frameworks +

+
+ +
+ +
+ {/* Configuration Panel */} +
+
+ {/* Framework Selection */} +
+

+ + Compliance Framework +

+ ( +
+ {complianceFrameworks.map((framework) => { + const Icon = framework.icon; + return ( + + ); + })} +
+ )} + /> +
+ + {/* Time Period */} +
+

+ + Reporting Period +

+ + {/* Quick Period Selection */} +
+ {predefinedPeriods.map((period) => ( + + ))} +
+ +
+ ( +
+ + +
+ )} + /> + ( +
+ + +
+ )} + /> +
+
+ + {/* Template Selection */} +
+

+ + Report Template +

+ ( + + )} + /> +
+ + {/* Report Options */} +
+

+ + Report Options +

+
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+ + {/* Custom Fields for Custom Reports */} + {watchedType === 'custom' && ( +
+

Custom Report Details

+
+ ( +
+ + +
+ )} + /> + ( +
+ +