core: init repo and codebase

This commit is contained in:
Jesús Pérez 2025-10-07 10:59:52 +01:00
commit f2be2414e4
440 changed files with 109141 additions and 0 deletions

319
.env.example Normal file
View File

@ -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

112
.gitignore vendored Normal file
View File

@ -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

219
Cargo.toml Normal file
View File

@ -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 <jesus@librecloud.online>"]
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

267
QUICK_START.md Normal file
View File

@ -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 <your-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/`

556
README.md Normal file
View File

@ -0,0 +1,556 @@
<p align="center">
<img src="https://repo.jesusperez.pro/jesus/provisioning/media/branch/main/resources/provisioning_logo.svg" alt="Provisioning Logo" width="300"/>
</p>
<p align="center">
<img src="https://repo.jesusperez.pro/jesus/provisioning/media/branch/main/resources/logo-text.svg" alt="Provisioning" width="500"/>
</p>
---
# 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

0
api-gateway/.gitkeep Normal file
View File

58
api-gateway/Dockerfile Normal file
View File

@ -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"]

View File

@ -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! {
<Router>
<Routes>
<Route path="/login" view=LoginPage/>
<ProtectedRoute path="/dashboard" view=DashboardPage/>
<ProtectedRoute path="/profile" view=ProfilePage/>
</Routes>
</Router>
}
}
```
### Login Page Implementation
```rust
#[component]
fn LoginPage() -> impl IntoView {
view! {
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h1 class="text-center text-3xl font-extrabold text-gray-900">
"Control Center"
</h1>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<LoginForm/>
</div>
</div>
</div>
}
}
```
### Protected Dashboard
```rust
#[component]
fn DashboardPage() -> impl IntoView {
view! {
<AuthGuard>
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold">"Dashboard"</h1>
</div>
<div class="flex items-center">
<LogoutButton/>
</div>
</div>
</div>
</nav>
<main class="py-6">
<SessionTimeoutModal/>
// Dashboard content
</main>
</div>
</AuthGuard>
}
}
```
### User Profile Management
```rust
#[component]
fn ProfilePage() -> impl IntoView {
view! {
<AuthGuard>
<div class="min-h-screen bg-gray-100">
<div class="py-6">
<UserProfileManagement/>
</div>
</div>
</AuthGuard>
}
}
```
## 🔧 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.**

View File

@ -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

335
control-center-ui/README.md Normal file
View File

@ -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 <repository-url>
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.

View File

@ -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.

View File

@ -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"

View File

@ -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"
}
]
}

View File

@ -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(`
<!DOCTYPE html>
<html>
<head>
<title>Control Center - Offline</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f3f4f6;
}
.offline {
text-align: center;
padding: 2rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="offline">
<h1>You're Offline</h1>
<p>Please check your internet connection and try again.</p>
<button onclick="window.location.reload()">Retry</button>
</div>
</body>
</html>
`, {
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');

View File

@ -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<any>}
*/
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<any>}
*/
write(chunk) {
const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk);
return ret;
}
/**
* @returns {Promise<any>}
*/
close() {
const ptr = this.__destroy_into_raw();
const ret = wasm.intounderlyingsink_close(ptr);
return ret;
}
/**
* @param {any} reason
* @returns {Promise<any>}
*/
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<any>}
*/
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;

Binary file not shown.

View File

@ -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;
}

161
control-center-ui/dist/index.html vendored Normal file
View File

@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Control Center UI - Cloud Infrastructure Management" />
<title>Control Center - Infrastructure Management</title>
<!-- Basic CSS only - no external dependencies -->
<script type="module">
import init, * as bindings from '/control-center-ui-d1956c1b430684b9.js';
const wasm = await init({ module_or_path: '/control-center-ui-d1956c1b430684b9_bg.wasm' });
window.wasmBindings = bindings;
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}}));
</script>
<link rel="stylesheet" href="/index-956be635a01ed8a8.css" integrity="sha384-S7xdkdEGUtxPn+mCObjZCSL1WI4bNE5s6w+M8+gkjz1WXy1f3G+SUCNV0j809J9R"/>
<link rel="modulepreload" href="/control-center-ui-d1956c1b430684b9.js" crossorigin="anonymous" integrity="sha384-d01yvqmZTcgY7noLvlVR68CuBMaa6LlcauC5r93wxPittaXbmiEtxmdlJr/LdGGj"><link rel="preload" href="/control-center-ui-d1956c1b430684b9_bg.wasm" crossorigin="anonymous" integrity="sha384-4Xj4BRrwiy/zdIQhOmEMXuOGOuAgUpFrJV492CVOyqghbT0x/okM6W7IoChZ3ScZ" as="fetch" type="application/wasm"></head>
<body>
<div id="leptos" style="min-height: 100vh; background: #fafafa;">
<div style="padding: 20px; color: #999; text-align: center;">
Loading Leptos app...
</div>
</div>
<script>"use strict";
(function () {
const address = '{{__TRUNK_ADDRESS__}}';
const base = '{{__TRUNK_WS_BASE__}}';
let protocol = '';
protocol =
protocol
? protocol
: window.location.protocol === 'https:'
? 'wss'
: 'ws';
const url = protocol + '://' + address + base + '.well-known/trunk/ws';
class Overlay {
constructor() {
// create an overlay
this._overlay = document.createElement("div");
const style = this._overlay.style;
style.height = "100vh";
style.width = "100vw";
style.position = "fixed";
style.top = "0";
style.left = "0";
style.backgroundColor = "rgba(222, 222, 222, 0.5)";
style.fontFamily = "sans-serif";
// not sure that's the right approach
style.zIndex = "1000000";
style.backdropFilter = "blur(1rem)";
const container = document.createElement("div");
// center it
container.style.position = "absolute";
container.style.top = "30%";
container.style.left = "15%";
container.style.maxWidth = "85%";
this._title = document.createElement("div");
this._title.innerText = "Build failure";
this._title.style.paddingBottom = "2rem";
this._title.style.fontSize = "2.5rem";
this._message = document.createElement("div");
this._message.style.whiteSpace = "pre-wrap";
const icon= document.createElement("div");
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#dc3545" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>';
this._title.prepend(icon);
container.append(this._title, this._message);
this._overlay.append(container);
this._inject();
window.setInterval(() => {
this._inject();
}, 250);
}
set reason(reason) {
this._message.textContent = reason;
}
_inject() {
if (!this._overlay.isConnected) {
// prepend it
document.body?.prepend(this._overlay);
}
}
}
class Client {
constructor(url) {
this.url = url;
this.poll_interval = 5000;
this._overlay = null;
}
start() {
const ws = new WebSocket(this.url);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case "reload":
this.reload();
break;
case "buildFailure":
this.buildFailure(msg.data)
break;
}
};
ws.onclose = () => this.onclose();
}
onclose() {
window.setTimeout(
() => {
// when we successfully reconnect, we'll force a
// reload (since we presumably lost connection to
// trunk due to it being killed, so it will have
// rebuilt on restart)
const ws = new WebSocket(this.url);
ws.onopen = () => window.location.reload();
ws.onclose = () => this.onclose();
},
this.poll_interval);
}
reload() {
window.location.reload();
}
buildFailure({reason}) {
// also log the console
console.error("Build failed:", reason);
console.debug("Overlay", this._overlay);
if (!this._overlay) {
this._overlay = new Overlay();
}
this._overlay.reason = reason;
}
}
new Client(url).start();
})()
</script></body>
</html>

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Control Center UI - Cloud Infrastructure Management" />
<title>Control Center - Infrastructure Management</title>
<!-- Basic CSS only - no external dependencies -->
<link data-trunk rel="rust" data-bin="control-center-ui" data-wasm-opt="z" />
<link data-trunk rel="css" href="src/index.css" />
</head>
<body>
<div id="leptos" style="min-height: 100vh; background: #fafafa;">
<div style="padding: 20px; color: #999; text-align: center;">
Loading Leptos app...
</div>
</div>
<script>
console.log("🟢 HTML loaded, DOM ready");
document.addEventListener('DOMContentLoaded', () => {
console.log("🟢 DOM fully loaded");
// Add a test element
const testDiv = document.createElement('div');
testDiv.innerHTML = '🟢 JavaScript is working';
testDiv.style = 'position: fixed; bottom: 0; left: 0; background: green; color: white; padding: 5px; z-index: 1000;';
document.body.appendChild(testDiv);
});
// Listen for WASM events
window.addEventListener('TrunkApplicationStarted', (event) => {
console.log("🟢 WASM application started:", event);
});
</script>
</body>
</html>

View File

@ -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"
}
]
}

View File

@ -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"
]
}

1829
control-center-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

88
control-center-ui/setup.sh Executable file
View File

@ -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! 🦀"

View File

@ -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;
}

View File

@ -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 (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-base-content">
Control Center
</h1>
<p className="text-base-content/60 mt-1">
Audit Log Viewer & Compliance Management
</p>
</div>
<div className="flex items-center space-x-4">
<div className="badge badge-primary">
Cedar Policy Engine
</div>
<div className="badge badge-secondary">
v1.0.0
</div>
</div>
</div>
</header>
<Routes>
<Route path="/" element={<Navigate to="/audit" replace />} />
<Route path="/audit" element={<AuditLogViewer />} />
<Route path="/audit/:logId" element={<AuditLogViewer />} />
<Route path="*" element={<Navigate to="/audit" replace />} />
</Routes>
</div>
</div>
);
}
export default App;

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -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::*;

View File

@ -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<String>,
pub dependencies: Vec<String>,
pub status: TaskStatus,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub output: Option<String>,
pub error: Option<String>,
}
#[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<String>,
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<BatchOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchOperation {
pub id: String,
pub operation_type: String,
pub provider: String,
pub dependencies: Vec<String>,
pub server_configs: Option<Vec<ServerConfig>>,
pub taskservs: Option<Vec<String>>,
}
#[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<Server>,
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<String>,
pub plan: String,
pub zone: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCredentials {
pub provider: String,
pub credentials: HashMap<String, String>,
pub encrypted: bool,
pub last_used: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
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<String, String> {
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<String> = 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<String, String> {
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<String> = 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<String, String> {
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<String> = 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<String, String> {
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<String> = 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<serde_json::Value, String> {
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<serde_json::Value> = 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<WorkflowTask, String> {
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<WorkflowTask> = 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<Vec<WorkflowTask>, 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<Vec<WorkflowTask>> = 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<String, String> {
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<String> = 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<String>) -> Result<String, String> {
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<String> = 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<String>, operation_ids: Option<Vec<String>>) -> Result<serde_json::Value, String> {
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<serde_json::Value> = 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<serde_json::Value, String> {
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<serde_json::Value> = 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<serde_json::Value, String> {
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<serde_json::Value> = 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<Option<WorkflowTask>>, ReadSignal<bool>, Action<(), ()>) {
let (task, set_task) = create_signal(None::<WorkflowTask>);
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<Vec<WorkflowTask>>, ReadSignal<bool>, Action<(), ()>) {
let (tasks, set_tasks) = create_signal(Vec::<WorkflowTask>::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<Option<serde_json::Value>>, ReadSignal<bool>, Action<(), ()>) {
let (health, set_health) = create_signal(None::<serde_json::Value>);
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)
}

View File

@ -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<RequestBuilder, OrchestratorError> {
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<T>(&self, request: RequestBuilder) -> Result<ApiResponse<T>, 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::<ApiResponse<T>>(&response_text)
.map_err(|e| OrchestratorError::Serialization(format!("Failed to parse response: {}", e)))
}
/// Execute a request with JSON body
async fn execute_json_request<T, B>(&self, request: RequestBuilder, body: &B) -> Result<ApiResponse<T>, 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::<ApiResponse<T>>(&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<String, OrchestratorError> {
let request = self.build_request("GET", "/health")?;
let response = self.execute_request::<String>(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<SystemHealthStatus, OrchestratorError> {
let request = self.build_request("GET", "/state/system/health")?;
let response = self.execute_request::<SystemHealthStatus>(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<SystemMetrics, OrchestratorError> {
let request = self.build_request("GET", "/state/system/metrics")?;
let response = self.execute_request::<SystemMetrics>(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<Vec<WorkflowTask>, OrchestratorError> {
let request = self.build_request("GET", "/tasks")?;
let response = self.execute_request::<Vec<WorkflowTask>>(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<WorkflowTask, OrchestratorError> {
let request = self.build_request("GET", &format!("/tasks/{}", task_id))?;
let response = self.execute_request::<WorkflowTask>(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<String, OrchestratorError> {
let request = self.build_request("POST", "/workflows/servers/create")?;
let response = self.execute_json_request::<String, _>(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<String, OrchestratorError> {
let request = self.build_request("POST", "/workflows/taskserv/create")?;
let response = self.execute_json_request::<String, _>(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<String, OrchestratorError> {
let request = self.build_request("POST", "/workflows/cluster/create")?;
let response = self.execute_json_request::<String, _>(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<BatchOperationResult, OrchestratorError> {
let req = self.build_request("POST", "/batch/execute")?;
let response = self.execute_json_request::<BatchOperationResult, _>(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<Vec<WorkflowExecutionState>, OrchestratorError> {
let request = self.build_request("GET", "/batch/operations")?;
let response = self.execute_request::<Vec<WorkflowExecutionState>>(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<WorkflowExecutionState, OrchestratorError> {
let request = self.build_request("GET", &format!("/batch/operations/{}", operation_id))?;
let response = self.execute_request::<WorkflowExecutionState>(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<String, OrchestratorError> {
let request = self.build_request("POST", &format!("/batch/operations/{}/cancel", operation_id))?;
let response = self.execute_request::<String>(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<ProgressInfo, OrchestratorError> {
let request = self.build_request("GET", &format!("/state/workflows/{}/progress", workflow_id))?;
let response = self.execute_request::<ProgressInfo>(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<Vec<StateSnapshot>, OrchestratorError> {
let request = self.build_request("GET", &format!("/state/workflows/{}/snapshots", workflow_id))?;
let response = self.execute_request::<Vec<StateSnapshot>>(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<String, OrchestratorError> {
let req = self.build_request("POST", "/rollback/checkpoints")?;
let response = self.execute_json_request::<String, _>(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<Vec<Checkpoint>, OrchestratorError> {
let request = self.build_request("GET", "/rollback/checkpoints")?;
let response = self.execute_request::<Vec<Checkpoint>>(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<Checkpoint, OrchestratorError> {
let request = self.build_request("GET", &format!("/rollback/checkpoints/{}", checkpoint_id))?;
let response = self.execute_request::<Checkpoint>(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<RollbackResult, OrchestratorError> {
let req = self.build_request("POST", "/rollback/execute")?;
let response = self.execute_json_request::<RollbackResult, _>(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<RollbackStatistics, OrchestratorError> {
let request = self.build_request("GET", "/rollback/statistics")?;
let response = self.execute_request::<RollbackStatistics>(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<String, OrchestratorError> {
let request = self.build_request("POST", &format!("/rollback/restore/{}", checkpoint_id))?;
let response = self.execute_request::<String>(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<StateManagerStatistics, OrchestratorError> {
let request = self.build_request("GET", "/state/statistics")?;
let response = self.execute_request::<StateManagerStatistics>(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()
}
}

View File

@ -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<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
/// 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<String>,
pub dependencies: Vec<String>,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub output: Option<String>,
pub error: Option<String>,
}
/// Server creation workflow request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateServerWorkflow {
pub infra: String,
pub settings: String,
pub servers: Vec<String>,
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<BatchOperation>,
pub parallel_limit: Option<usize>,
pub rollback_enabled: Option<bool>,
}
/// Individual batch operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchOperation {
pub id: String,
pub operation_type: String,
pub provider: String,
pub dependencies: Vec<String>,
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<String>,
pub completed_operations: Vec<String>,
pub failed_operations: Vec<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
/// System health status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemHealthStatus {
pub overall_status: String,
pub checks: Vec<HealthCheck>,
pub last_updated: DateTime<Utc>,
}
/// Individual health check
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCheck {
pub name: String,
pub status: String,
pub message: Option<String>,
pub last_check: DateTime<Utc>,
}
/// 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<DateTime<Utc>>,
pub step_details: Vec<StepDetail>,
}
/// 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<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
/// 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<Utc>,
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<String>,
pub created_at: DateTime<Utc>,
pub workflow_states: Vec<String>,
}
/// Create checkpoint request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCheckpointRequest {
pub name: String,
pub description: Option<String>,
}
/// Rollback request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackRequest {
pub checkpoint_id: Option<String>,
pub operation_ids: Option<Vec<String>>,
}
/// 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,
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,15 @@
use leptos::*;
/// Main application component - simplified for testing
#[component]
pub fn App() -> impl IntoView {
view! {
<div style="padding: 20px; font-family: Arial, sans-serif;">
<h1 style="color: #333;">"🚀 Control Center UI"</h1>
<p style="color: #666;">"Leptos app is working!"</p>
<div style="background: #f0f0f0; padding: 10px; margin: 10px 0; border-radius: 4px;">
"If you can see this, the basic Leptos rendering is functioning correctly."
</div>
</div>
}
}

View File

@ -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<Sha256>;
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<u8> {
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<Vec<u8>> {
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<String> {
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<bool> {
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<String> {
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<bool> {
// 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<String> {
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<Vec<u8>> {
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<Vec<u8>> {
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());
}
}

View File

@ -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<Request> {
// 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<Response> {
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::<HttpInterceptor>()
}
// Enhanced HTTP client with automatic token refresh and error handling
pub struct AuthenticatedHttpClient {
interceptor: HttpInterceptor,
base_url: String,
default_headers: HashMap<String, String>,
}
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<Response> {
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<T: serde::Serialize>(&self, path: &str, body: &T) -> AuthResult<Response> {
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<T: serde::Serialize>(&self, path: &str, body: &T) -> AuthResult<Response> {
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<Response> {
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<T: serde::Serialize>(&self, path: &str, body: &T) -> AuthResult<Response> {
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<Response> {
// 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<F, T>(&self, future: F) -> AuthResult<T>
where
F: std::future::Future<Output = AuthResult<T>>,
{
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<AuthError> {
// 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<bool>,
}
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<dyn Fn(web_sys::Event)>)
};
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<dyn Fn(web_sys::Event)>)
};
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<F>(&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<Vec<QueuedRequest>>,
network_monitor: NetworkStatusMonitor,
}
#[derive(Clone, Debug)]
pub struct QueuedRequest {
pub method: String,
pub url: String,
pub headers: HashMap<String, String>,
pub body: Option<String>,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
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()
}
}

View File

@ -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<Utc>,
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<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthState {
pub user: Option<User>,
pub token: Option<AuthToken>,
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<String>,
pub field: Option<String>,
}
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<T> = Result<T, AuthError>;
#[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<Utc>,
pub last_used: DateTime<Utc>,
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,
}

View File

@ -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<Self> {
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<Vec<u8>> {
// 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<Option<AuthToken>> {
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<EncryptedData> {
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<String> {
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")
}
}

View File

@ -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<String>,
expires_in: i64,
token_type: String,
}
#[derive(Debug, Clone)]
pub struct TokenManager {
storage: SecureTokenStorage,
refresh_timeout: RwSignal<Option<Timeout>>,
api_base_url: String,
}
impl TokenManager {
pub fn new(api_base_url: String) -> AuthResult<Self> {
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<Option<AuthToken>> {
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<AuthToken> {
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<Option<AuthToken>> {
if let Some(current_token) = self.get_current_token()? {
if !self.is_token_valid(&current_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<Option<String>> {
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<crate::auth::AuthState>,
}
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::<AuthContext>()
}
pub fn use_token_manager() -> TokenManager {
let context = use_auth_context();
context.token_manager
}

View File

@ -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<PubKeyCredParam>,
pub timeout: Option<u32>,
pub exclude_credentials: Option<Vec<CredentialDescriptor>>,
pub authenticator_selection: Option<AuthenticatorSelection>,
pub attestation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAuthnAuthenticationOptions {
pub challenge: String,
pub timeout: Option<u32>,
pub rp_id: Option<String>,
pub allow_credentials: Option<Vec<CredentialDescriptor>>,
pub user_verification: Option<String>,
}
#[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<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatorSelection {
pub authenticator_attachment: Option<String>,
pub resident_key: Option<String>,
pub require_resident_key: Option<bool>,
pub user_verification: Option<String>,
}
#[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<String>,
}
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<WebAuthnRegistrationResult> {
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<WebAuthnAuthenticationResult> {
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<PublicKeyCredentialCreationOptions> {
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(&param_obj, &"type".into(), &param.type_.into()).unwrap();
js_sys::Reflect::set(&param_obj, &"alg".into(), &param.alg.into()).unwrap();
cred_params.push(&param_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<PublicKeyCredentialRequestOptions> {
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<WebAuthnRegistrationResult> {
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<WebAuthnAuthenticationResult> {
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<String> {
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<boolean>, 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()
}
}

View File

@ -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<AuditSearchFilters>({
dateRange: {},
users: [],
actions: [],
resources: [],
severity: [],
complianceFrameworks: [],
tags: []
});
const [selectedLogIds, setSelectedLogIds] = useState<Set<string>>(new Set());
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(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<AuditLogEntry[]>([]);
// 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 (
<div className="flex flex-col items-center justify-center h-64">
<AlertCircle className="h-12 w-12 text-error mb-4" />
<h3 className="text-lg font-semibold mb-2">Error Loading Logs</h3>
<p className="text-base-content/60 mb-4 text-center max-w-md">
{logsError.message || 'An unexpected error occurred while loading audit logs.'}
</p>
<button onClick={handleRefresh} className="btn btn-primary">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Dashboard Stats */}
{dashboardStats && (
<div className="grid grid-cols-4 gap-4">
<div className="metric-card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-base-content/60">Total Events</p>
<p className="text-2xl font-bold">{dashboardStats.totalEvents.toLocaleString()}</p>
</div>
<Activity className="h-8 w-8 text-primary" />
</div>
</div>
<div className="metric-card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-base-content/60">Success Rate</p>
<p className="text-2xl font-bold">{(dashboardStats.successRate * 100).toFixed(1)}%</p>
</div>
<TrendingUp className="h-8 w-8 text-success" />
</div>
</div>
<div className="metric-card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-base-content/60">Critical Events</p>
<p className="text-2xl font-bold">{dashboardStats.criticalEvents}</p>
</div>
<AlertCircle className="h-8 w-8 text-error" />
</div>
</div>
<div className="metric-card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-base-content/60">Compliance Score</p>
<p className="text-2xl font-bold">{dashboardStats.complianceScore}%</p>
</div>
<Shield className="h-8 w-8 text-primary" />
</div>
</div>
</div>
)}
{/* Controls Bar */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<RealTimeIndicator
readyState={readyState}
lastMessageTime={lastMessage?.timestamp}
newLogCount={newLogCount}
onToggleRealTime={handleToggleRealTime}
onReconnect={reconnect}
isEnabled={realTimeEnabled}
reconnectAttempts={reconnectAttempts}
/>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleRefresh}
className="btn btn-ghost btn-sm"
disabled={isLogsLoading}
>
<RefreshCw className={`h-4 w-4 ${isLogsLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
<button
onClick={() => setIsExportModalOpen(true)}
className="btn btn-outline btn-sm"
disabled={allLogs.length === 0}
>
<Download className="h-4 w-4 mr-1" />
Export
</button>
{selectedLogIds.size > 0 && (
<div className="flex items-center space-x-2">
<span className="text-sm text-base-content/60">
{selectedLogIds.size} selected
</span>
<button
onClick={() => setSelectedLogIds(new Set())}
className="btn btn-ghost btn-sm"
>
Clear
</button>
</div>
)}
</div>
</div>
{/* Search Filters */}
<SearchFilters
onFiltersChange={handleFiltersChange}
onSaveSearch={handleSaveSearch}
savedSearches={savedSearches}
onLoadSavedSearch={handleLoadSavedSearch}
isLoading={isLogsLoading}
/>
{/* Results Info */}
{!isLogsLoading && (
<div className="flex items-center justify-between text-sm text-base-content/60">
<div className="flex items-center space-x-4">
<span>
Showing {allLogs.length.toLocaleString()} of {totalCount.toLocaleString()} logs
</span>
{newLogCount > 0 && (
<span className="text-primary">
({newLogCount} new in real-time)
</span>
)}
</div>
{isConnected && (
<div className="flex items-center space-x-1 text-success">
<div className="w-2 h-2 bg-success rounded-full animate-pulse"></div>
<span>Live</span>
</div>
)}
</div>
)}
{/* Log Table */}
<VirtualizedLogTable
logs={allLogs}
isLoading={isLogsLoading || isFetchingNextPage}
hasNextPage={hasNextPage || false}
fetchNextPage={fetchNextPage}
onRowClick={handleRowClick}
onViewCorrelated={handleViewCorrelated}
onViewSession={handleViewSession}
selectedLogIds={selectedLogIds}
onSelectionChange={setSelectedLogIds}
/>
{/* Log Detail Modal */}
<LogDetailModal
log={selectedLog}
isOpen={isDetailModalOpen}
onClose={handleCloseDetailModal}
onViewCorrelated={handleViewCorrelated}
onViewSession={handleViewSession}
/>
{/* Export Modal */}
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
logs={allLogs}
filters={filters}
totalCount={totalCount}
onExport={handleExport}
/>
</div>
);
};
export default AuditLogViewer;

View File

@ -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<ReportGeneratorProps> = ({
isOpen,
onClose
}) => {
const queryClient = useQueryClient();
const [generatingReportId, setGeneratingReportId] = useState<string | null>(null);
const { control, watch, setValue, getValues, handleSubmit } = useForm<FormData>({
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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="modal-box w-11/12 max-w-6xl max-h-[90vh] overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-base-content">
Compliance Report Generator
</h2>
<p className="text-base-content/60 mt-1">
Generate comprehensive compliance reports for various frameworks
</p>
</div>
<button onClick={onClose} className="btn btn-ghost btn-sm">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-6 h-full overflow-hidden">
{/* Configuration Panel */}
<div className="col-span-2 space-y-6 overflow-y-auto pr-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Framework Selection */}
<div className="form-section">
<h3 className="form-section-title">
<Shield className="h-4 w-4 inline mr-2" />
Compliance Framework
</h3>
<Controller
name="type"
control={control}
render={({ field }) => (
<div className="grid grid-cols-2 gap-3">
{complianceFrameworks.map((framework) => {
const Icon = framework.icon;
return (
<label
key={framework.value}
className={`block p-4 border rounded-lg cursor-pointer transition-all ${
field.value === framework.value
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
: 'border-base-300 hover:border-base-400'
}`}
>
<input
type="radio"
{...field}
value={framework.value}
className="sr-only"
/>
<div className="flex items-start space-x-3">
<Icon className={`h-5 w-5 ${framework.color} mt-0.5`} />
<div className="flex-1">
<div className="font-medium text-base-content">
{framework.label}
</div>
<div className="text-sm text-base-content/60 mt-1">
{framework.description}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{framework.requirements.slice(0, 2).map((req) => (
<span key={req} className="badge badge-outline badge-xs">
{req}
</span>
))}
{framework.requirements.length > 2 && (
<span className="badge badge-outline badge-xs">
+{framework.requirements.length - 2}
</span>
)}
</div>
</div>
</div>
</label>
);
})}
</div>
)}
/>
</div>
{/* Time Period */}
<div className="form-section">
<h3 className="form-section-title">
<Calendar className="h-4 w-4 inline mr-2" />
Reporting Period
</h3>
{/* Quick Period Selection */}
<div className="flex flex-wrap gap-2 mb-4">
{predefinedPeriods.map((period) => (
<button
key={period.label}
type="button"
onClick={() => handlePeriodSelect(period.getValue())}
className="btn btn-outline btn-sm"
>
{period.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
name="startDate"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Start Date</span>
</label>
<input
{...field}
type="date"
className="input input-bordered w-full"
/>
</div>
)}
/>
<Controller
name="endDate"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">End Date</span>
</label>
<input
{...field}
type="date"
className="input input-bordered w-full"
/>
</div>
)}
/>
</div>
</div>
{/* Template Selection */}
<div className="form-section">
<h3 className="form-section-title">
<FileText className="h-4 w-4 inline mr-2" />
Report Template
</h3>
<Controller
name="template"
control={control}
render={({ field }) => (
<select {...field} className="select select-bordered w-full">
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} - {template.description}
</option>
))}
</select>
)}
/>
</div>
{/* Report Options */}
<div className="form-section">
<h3 className="form-section-title">
<Settings className="h-4 w-4 inline mr-2" />
Report Options
</h3>
<div className="space-y-3">
<Controller
name="includeFindings"
control={control}
render={({ field }) => (
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include detailed findings</span>
</label>
)}
/>
<Controller
name="includeRecommendations"
control={control}
render={({ field }) => (
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include remediation recommendations</span>
</label>
)}
/>
<Controller
name="includeEvidence"
control={control}
render={({ field }) => (
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include supporting evidence</span>
</label>
)}
/>
<Controller
name="executiveSummary"
control={control}
render={({ field }) => (
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include executive summary</span>
</label>
)}
/>
</div>
</div>
{/* Custom Fields for Custom Reports */}
{watchedType === 'custom' && (
<div className="form-section">
<h3 className="form-section-title">Custom Report Details</h3>
<div className="space-y-4">
<Controller
name="customTitle"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Report Title</span>
</label>
<input
{...field}
type="text"
placeholder="Custom Compliance Assessment"
className="input input-bordered w-full"
/>
</div>
)}
/>
<Controller
name="customDescription"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Description</span>
</label>
<textarea
{...field}
placeholder="Describe the scope and objectives of this compliance assessment..."
className="textarea textarea-bordered w-full h-24"
/>
</div>
)}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end space-x-2 pt-4 border-t border-base-300">
<button
type="button"
onClick={onClose}
className="btn btn-ghost"
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={generateReportMutation.isPending}
>
{generateReportMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Generate Report
</>
)}
</button>
</div>
</form>
</div>
{/* Reports Panel */}
<div className="space-y-4 overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Recent Reports</h3>
<button
onClick={() => refetchReports()}
className="btn btn-ghost btn-sm"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
<div className="space-y-3">
{existingReports.length === 0 ? (
<div className="text-center text-base-content/60 py-8">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>No reports generated yet</p>
</div>
) : (
existingReports.map((report) => (
<div
key={report.id}
className="bg-base-200 rounded-lg p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-medium text-base-content">
{report.title}
</div>
<div className="text-sm text-base-content/60 mt-1">
{report.type.toUpperCase()} {format(report.generatedAt, 'MMM dd, yyyy')}
</div>
<div className="text-sm text-base-content/60">
{format(report.period.start, 'MMM dd')} - {format(report.period.end, 'MMM dd, yyyy')}
</div>
</div>
</div>
{/* Report Summary */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center space-x-1">
<CheckCircle className="h-3 w-3 text-success" />
<span>{report.summary.compliantEvents}</span>
<span className="text-base-content/60">compliant</span>
</div>
<div className="flex items-center space-x-1">
<AlertTriangle className="h-3 w-3 text-warning" />
<span>{report.summary.violations}</span>
<span className="text-base-content/60">violations</span>
</div>
<div className="flex items-center space-x-1">
<Activity className="h-3 w-3 text-primary" />
<span>{report.summary.totalEvents}</span>
<span className="text-base-content/60">events</span>
</div>
<div className="flex items-center space-x-1">
<AlertTriangle className="h-3 w-3 text-error" />
<span>{report.summary.criticalFindings}</span>
<span className="text-base-content/60">critical</span>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-2">
<button
onClick={() => {/* View report logic */}}
className="btn btn-ghost btn-xs"
>
<Eye className="h-3 w-3 mr-1" />
View
</button>
<button
onClick={() => handleDownloadReport(report)}
className="btn btn-ghost btn-xs"
>
<Download className="h-3 w-3 mr-1" />
Download
</button>
</div>
</div>
))
)}
</div>
{/* Framework Info */}
{selectedFramework && (
<div className="bg-base-200 rounded-lg p-4 mt-6">
<h4 className="font-medium mb-2">{selectedFramework.label}</h4>
<p className="text-sm text-base-content/70 mb-3">
{selectedFramework.description}
</p>
<div className="space-y-1">
<div className="text-sm font-medium">Key Requirements:</div>
{selectedFramework.requirements.map((req) => (
<div key={req} className="text-xs text-base-content/60 flex items-center">
<CheckCircle className="h-3 w-3 mr-1 text-success" />
{req}
</div>
))}
</div>
</div>
)}
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@ -0,0 +1,676 @@
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useForm, Controller } from 'react-hook-form';
import { format } from 'date-fns';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import {
X,
Download,
FileText,
Code,
File,
Calendar,
Filter,
Settings,
CheckCircle,
AlertCircle
} from 'lucide-react';
import { AuditLogEntry, AuditSearchFilters, AuditExportRequest } from '@/types/audit';
import { toast } from 'react-toastify';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
logs: AuditLogEntry[];
filters: AuditSearchFilters;
totalCount: number;
onExport: (request: AuditExportRequest) => Promise<Blob>;
}
interface FormData {
format: 'csv' | 'json' | 'pdf';
includeMetadata: boolean;
includeCompliance: boolean;
dateFormat: string;
template: string;
fields: string[];
maxRecords: number;
includeFilters: boolean;
}
const formatOptions = [
{
value: 'csv' as const,
label: 'CSV',
description: 'Comma-separated values for spreadsheet applications',
icon: File,
maxRecords: 100000,
supportedFeatures: ['fields', 'dateFormat', 'maxRecords']
},
{
value: 'json' as const,
label: 'JSON',
description: 'JavaScript Object Notation for programmatic use',
icon: Code,
maxRecords: 50000,
supportedFeatures: ['metadata', 'compliance', 'maxRecords']
},
{
value: 'pdf' as const,
label: 'PDF',
description: 'Portable Document Format for reports and documentation',
icon: FileText,
maxRecords: 10000,
supportedFeatures: ['template', 'fields', 'dateFormat', 'maxRecords']
}
];
const availableFields = [
{ value: 'timestamp', label: 'Timestamp', required: true },
{ value: 'user.username', label: 'Username', required: true },
{ value: 'user.email', label: 'User Email' },
{ value: 'user.roles', label: 'User Roles' },
{ value: 'action.type', label: 'Action Type', required: true },
{ value: 'action.resource', label: 'Resource', required: true },
{ value: 'action.resourceId', label: 'Resource ID' },
{ value: 'action.description', label: 'Description' },
{ value: 'result.success', label: 'Success', required: true },
{ value: 'result.decision', label: 'Decision' },
{ value: 'result.reason', label: 'Reason' },
{ value: 'severity', label: 'Severity', required: true },
{ value: 'context.sessionId', label: 'Session ID' },
{ value: 'context.requestId', label: 'Request ID' },
{ value: 'context.ipAddress', label: 'IP Address' },
{ value: 'context.location.country', label: 'Country' },
{ value: 'context.location.city', label: 'City' },
{ value: 'context.mfaEnabled', label: 'MFA Enabled' },
{ value: 'tags', label: 'Tags' },
{ value: 'compliance.soc2Relevant', label: 'SOC2 Relevant' },
{ value: 'compliance.hipaaRelevant', label: 'HIPAA Relevant' },
{ value: 'compliance.pciRelevant', label: 'PCI Relevant' },
{ value: 'compliance.gdprRelevant', label: 'GDPR Relevant' }
];
const dateFormatOptions = [
{ value: 'iso', label: 'ISO 8601 (2023-12-25T10:30:00Z)' },
{ value: 'human', label: 'Human Readable (Dec 25, 2023 10:30 AM)' },
{ value: 'unix', label: 'Unix Timestamp (1703500200)' },
{ value: 'custom', label: 'Custom Format' }
];
const templateOptions = [
{ value: 'standard', label: 'Standard Report' },
{ value: 'compliance', label: 'Compliance Report' },
{ value: 'executive', label: 'Executive Summary' },
{ value: 'incident', label: 'Incident Report' },
{ value: 'audit', label: 'Audit Trail Report' }
];
export const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
logs,
filters,
totalCount,
onExport
}) => {
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const { control, watch, setValue, getValues } = useForm<FormData>({
defaultValues: {
format: 'csv',
includeMetadata: true,
includeCompliance: true,
dateFormat: 'iso',
template: 'standard',
fields: availableFields.filter(f => f.required).map(f => f.value),
maxRecords: 1000,
includeFilters: true
}
});
const watchedFormat = watch('format');
const watchedFields = watch('fields');
const watchedMaxRecords = watch('maxRecords');
const selectedFormatOption = useMemo(
() => formatOptions.find(option => option.value === watchedFormat),
[watchedFormat]
);
const estimatedSize = useMemo(() => {
const recordCount = Math.min(watchedMaxRecords, logs.length);
const fieldsCount = watchedFields.length;
switch (watchedFormat) {
case 'csv':
return Math.round((recordCount * fieldsCount * 20) / 1024); // ~20 bytes per field
case 'json':
return Math.round((recordCount * 500) / 1024); // ~500 bytes per record
case 'pdf':
return Math.round(recordCount / 50 + 100); // ~50 records per page, base 100KB
default:
return 0;
}
}, [watchedFormat, watchedFields.length, watchedMaxRecords, logs.length]);
const handleFieldToggle = (fieldValue: string, isRequired = false) => {
if (isRequired) return; // Don't allow toggling required fields
const currentFields = watchedFields;
const updatedFields = currentFields.includes(fieldValue)
? currentFields.filter(f => f !== fieldValue)
: [...currentFields, fieldValue];
setValue('fields', updatedFields);
};
const handleSelectAllFields = () => {
setValue('fields', availableFields.map(f => f.value));
};
const handleSelectRequiredFields = () => {
setValue('fields', availableFields.filter(f => f.required).map(f => f.value));
};
const convertToCSV = (logs: AuditLogEntry[], fields: string[]): string => {
const headers = fields.map(field => {
const fieldConfig = availableFields.find(f => f.value === field);
return fieldConfig?.label || field;
});
const getValue = (log: AuditLogEntry, field: string): string => {
const keys = field.split('.');
let value: any = log;
for (const key of keys) {
value = value?.[key];
}
if (Array.isArray(value)) {
return value.join(', ');
}
if (value instanceof Date) {
return format(value, 'yyyy-MM-dd HH:mm:ss');
}
return value?.toString() || '';
};
const rows = logs.map(log =>
fields.map(field => {
const value = getValue(log, field);
// Escape CSV values that contain commas or quotes
return value.includes(',') || value.includes('"')
? `"${value.replace(/"/g, '""')}"`
: value;
})
);
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
};
const generatePDF = (logs: AuditLogEntry[], fields: string[]): jsPDF => {
const doc = new jsPDF();
// Add title
doc.setFontSize(20);
doc.text('Audit Log Export', 20, 20);
// Add metadata
doc.setFontSize(10);
doc.text(`Generated: ${format(new Date(), 'PPpp')}`, 20, 30);
doc.text(`Records: ${logs.length}`, 20, 35);
doc.text(`Filters Applied: ${Object.keys(filters).length > 0 ? 'Yes' : 'No'}`, 20, 40);
// Prepare table data
const headers = fields.map(field => {
const fieldConfig = availableFields.find(f => f.value === field);
return fieldConfig?.label || field;
});
const data = logs.map(log => {
return fields.map(field => {
const keys = field.split('.');
let value: any = log;
for (const key of keys) {
value = value?.[key];
}
if (Array.isArray(value)) {
return value.join(', ');
}
if (value instanceof Date) {
return format(value, 'MMM dd, yyyy HH:mm');
}
return value?.toString() || '';
});
});
// Add table
autoTable(doc, {
head: [headers],
body: data,
startY: 50,
styles: { fontSize: 8 },
headStyles: { fillColor: [59, 130, 246] },
columnStyles: {
0: { cellWidth: 30 }, // Timestamp
},
margin: { top: 50 },
didDrawPage: (data) => {
// Add page numbers
doc.setFontSize(8);
doc.text(
`Page ${data.pageNumber}`,
doc.internal.pageSize.width - 30,
doc.internal.pageSize.height - 10
);
}
});
return doc;
};
const handleExport = async () => {
setIsExporting(true);
setExportProgress(0);
try {
const formData = getValues();
const recordsToExport = logs.slice(0, formData.maxRecords);
// Simulate progress
const progressInterval = setInterval(() => {
setExportProgress(prev => Math.min(prev + 10, 90));
}, 100);
let blob: Blob;
let filename: string;
switch (formData.format) {
case 'csv':
const csvContent = convertToCSV(recordsToExport, formData.fields);
blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
filename = `audit-logs-${format(new Date(), 'yyyy-MM-dd')}.csv`;
break;
case 'json':
const jsonContent = JSON.stringify({
exportedAt: new Date().toISOString(),
totalRecords: logs.length,
exportedRecords: recordsToExport.length,
filters: formData.includeFilters ? filters : undefined,
logs: recordsToExport
}, null, 2);
blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
filename = `audit-logs-${format(new Date(), 'yyyy-MM-dd')}.json`;
break;
case 'pdf':
const pdf = generatePDF(recordsToExport, formData.fields);
blob = new Blob([pdf.output('blob')], { type: 'application/pdf' });
filename = `audit-logs-${format(new Date(), 'yyyy-MM-dd')}.pdf`;
break;
default:
throw new Error('Unsupported format');
}
clearInterval(progressInterval);
setExportProgress(100);
// Download file
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success(`Successfully exported ${recordsToExport.length} records as ${formData.format.toUpperCase()}`);
setTimeout(() => {
onClose();
}, 1000);
} catch (error) {
console.error('Export failed:', error);
toast.error('Export failed. Please try again.');
} finally {
setIsExporting(false);
setExportProgress(0);
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="modal-box w-11/12 max-w-4xl"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Export Audit Logs</h2>
<button onClick={onClose} className="btn btn-ghost btn-sm">
<X className="h-4 w-4" />
</button>
</div>
{/* Export Summary */}
<div className="bg-base-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<File className="h-4 w-4 text-primary" />
<span className="font-medium">{logs.length.toLocaleString()} records available</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-base-content/60" />
<span className="text-sm text-base-content/60">
{totalCount > logs.length ? `${totalCount.toLocaleString()} total (filtered)` : 'All records'}
</span>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium">Estimated size: {estimatedSize}KB</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
{/* Format Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Export Format</h3>
<Controller
name="format"
control={control}
render={({ field }) => (
<div className="space-y-3">
{formatOptions.map((option) => (
<label
key={option.value}
className={`block p-4 border rounded-lg cursor-pointer transition-colors ${
field.value === option.value
? 'border-primary bg-primary/5'
: 'border-base-300 hover:border-base-400'
}`}
>
<input
type="radio"
{...field}
value={option.value}
className="sr-only"
/>
<div className="flex items-start space-x-3">
<option.icon className="h-5 w-5 text-primary mt-0.5" />
<div>
<div className="font-medium">{option.label}</div>
<div className="text-sm text-base-content/60 mt-1">
{option.description}
</div>
<div className="text-xs text-base-content/50 mt-1">
Max records: {option.maxRecords.toLocaleString()}
</div>
</div>
</div>
</label>
))}
</div>
)}
/>
</div>
{/* Options */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Export Options</h3>
{/* Max Records */}
<div>
<label className="label">
<span className="label-text">Maximum Records</span>
</label>
<Controller
name="maxRecords"
control={control}
render={({ field }) => (
<input
{...field}
type="number"
min="1"
max={selectedFormatOption?.maxRecords}
className="input input-bordered w-full"
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
)}
/>
<div className="label">
<span className="label-text-alt text-base-content/60">
Max allowed: {selectedFormatOption?.maxRecords.toLocaleString()}
</span>
</div>
</div>
{/* Additional Options */}
{selectedFormatOption?.supportedFeatures.includes('metadata') && (
<div className="form-control">
<Controller
name="includeMetadata"
control={control}
render={({ field }) => (
<label className="label cursor-pointer justify-start space-x-2">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include metadata</span>
</label>
)}
/>
</div>
)}
{selectedFormatOption?.supportedFeatures.includes('compliance') && (
<div className="form-control">
<Controller
name="includeCompliance"
control={control}
render={({ field }) => (
<label className="label cursor-pointer justify-start space-x-2">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include compliance data</span>
</label>
)}
/>
</div>
)}
<div className="form-control">
<Controller
name="includeFilters"
control={control}
render={({ field }) => (
<label className="label cursor-pointer justify-start space-x-2">
<input
type="checkbox"
className="checkbox"
checked={field.value}
onChange={field.onChange}
/>
<span className="label-text">Include applied filters</span>
</label>
)}
/>
</div>
{/* Date Format */}
{selectedFormatOption?.supportedFeatures.includes('dateFormat') && (
<div>
<label className="label">
<span className="label-text">Date Format</span>
</label>
<Controller
name="dateFormat"
control={control}
render={({ field }) => (
<select {...field} className="select select-bordered w-full">
{dateFormatOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
/>
</div>
)}
{/* Template Selection for PDF */}
{selectedFormatOption?.supportedFeatures.includes('template') && (
<div>
<label className="label">
<span className="label-text">Report Template</span>
</label>
<Controller
name="template"
control={control}
render={({ field }) => (
<select {...field} className="select select-bordered w-full">
{templateOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
/>
</div>
)}
</div>
</div>
{/* Field Selection */}
{selectedFormatOption?.supportedFeatures.includes('fields') && (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Fields to Export</h3>
<div className="space-x-2">
<button
type="button"
onClick={handleSelectRequiredFields}
className="btn btn-ghost btn-sm"
>
Required Only
</button>
<button
type="button"
onClick={handleSelectAllFields}
className="btn btn-ghost btn-sm"
>
Select All
</button>
</div>
</div>
<div className="bg-base-200 rounded-lg p-4">
<div className="grid grid-cols-3 gap-3">
{availableFields.map((field) => (
<label
key={field.value}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={watchedFields.includes(field.value)}
onChange={() => handleFieldToggle(field.value, field.required)}
disabled={field.required}
/>
<span className={`text-sm ${field.required ? 'font-medium' : ''}`}>
{field.label}
{field.required && <span className="text-error ml-1">*</span>}
</span>
</label>
))}
</div>
<div className="mt-2 text-xs text-base-content/60">
* Required fields cannot be deselected
</div>
</div>
</div>
)}
{/* Export Progress */}
{isExporting && (
<div className="mt-6 bg-base-200 rounded-lg p-4">
<div className="flex items-center space-x-3">
<div className="loading-spinner"></div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Exporting...</span>
<span className="text-sm">{exportProgress}%</span>
</div>
<progress
className="progress progress-primary w-full"
value={exportProgress}
max="100"
></progress>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="modal-action">
<button
onClick={onClose}
className="btn"
disabled={isExporting}
>
Cancel
</button>
<button
onClick={handleExport}
className="btn btn-primary"
disabled={isExporting || watchedFields.length === 0}
>
{isExporting ? (
'Exporting...'
) : (
<>
<Download className="h-4 w-4 mr-2" />
Export {watchedFormat.toUpperCase()}
</>
)}
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@ -0,0 +1,586 @@
import React, { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import ReactJsonView from 'react-json-view';
import { format } from 'date-fns';
import {
X,
Copy,
ExternalLink,
Shield,
User,
Clock,
MapPin,
AlertTriangle,
CheckCircle,
XCircle,
Eye,
EyeOff,
Download,
Link as LinkIcon,
Code,
FileText,
Activity,
Tag
} from 'lucide-react';
import { AuditLogEntry } from '@/types/audit';
import { toast } from 'react-toastify';
interface LogDetailModalProps {
log: AuditLogEntry | null;
isOpen: boolean;
onClose: () => void;
onViewCorrelated?: (requestId: string) => void;
onViewSession?: (sessionId: string) => void;
}
type TabType = 'overview' | 'context' | 'metadata' | 'compliance' | 'raw';
const SeverityBadge: React.FC<{ severity: AuditLogEntry['severity'] }> = ({ severity }) => {
const config = {
low: { icon: CheckCircle, className: 'severity-low' },
medium: { icon: AlertTriangle, className: 'severity-medium' },
high: { icon: AlertTriangle, className: 'severity-high' },
critical: { icon: XCircle, className: 'severity-critical' }
};
const { icon: Icon, className } = config[severity];
return (
<span className={`severity-indicator ${className}`}>
<Icon className="h-3 w-3 mr-1" />
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</span>
);
};
const StatusBadge: React.FC<{ success: boolean; reason?: string }> = ({ success, reason }) => {
return (
<div className="flex items-center space-x-2">
<span className={`status-indicator ${success ? 'status-success' : 'status-error'}`}>
{success ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<XCircle className="h-3 w-3 mr-1" />
)}
{success ? 'Success' : 'Failed'}
</span>
{reason && (
<span className="text-xs text-base-content/60">
{reason}
</span>
)}
</div>
);
};
const ComplianceBadges: React.FC<{ compliance: AuditLogEntry['compliance'] }> = ({ compliance }) => {
const frameworks = [];
if (compliance.soc2Relevant) frameworks.push({ name: 'SOC2', relevant: true });
if (compliance.hipaaRelevant) frameworks.push({ name: 'HIPAA', relevant: true });
if (compliance.pciRelevant) frameworks.push({ name: 'PCI DSS', relevant: true });
if (compliance.gdprRelevant) frameworks.push({ name: 'GDPR', relevant: true });
return (
<div className="flex flex-wrap gap-2">
{frameworks.map(({ name }) => (
<span key={name} className="compliance-badge compliance-compliant">
{name}
</span>
))}
{frameworks.length === 0 && (
<span className="text-base-content/60 text-sm">No compliance frameworks</span>
)}
</div>
);
};
const CopyButton: React.FC<{ value: string; label: string }> = ({ value, label }) => {
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} copied to clipboard`);
} catch (error) {
toast.error(`Failed to copy ${label}`);
}
}, [value, label]);
return (
<button
onClick={handleCopy}
className="btn btn-ghost btn-xs"
title={`Copy ${label}`}
>
<Copy className="h-3 w-3" />
</button>
);
};
export const LogDetailModal: React.FC<LogDetailModalProps> = ({
log,
isOpen,
onClose,
onViewCorrelated,
onViewSession
}) => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [showSensitiveData, setShowSensitiveData] = useState(false);
if (!log) return null;
const handleExportJson = () => {
const dataStr = JSON.stringify(log, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = `audit-log-${log.id}.json`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
};
const tabs = [
{ id: 'overview', label: 'Overview', icon: Eye },
{ id: 'context', label: 'Context', icon: Activity },
{ id: 'metadata', label: 'Metadata', icon: Tag },
{ id: 'compliance', label: 'Compliance', icon: Shield },
{ id: 'raw', label: 'Raw JSON', icon: Code }
] as const;
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal-backdrop"
onClick={onClose}
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="modal-content w-full max-w-6xl"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-base-300">
<div className="flex items-center space-x-4">
<div>
<h2 className="text-xl font-bold text-base-content">
Audit Log Details
</h2>
<div className="text-sm text-base-content/60 flex items-center space-x-4">
<span className="flex items-center">
<Clock className="h-3 w-3 mr-1" />
{format(log.timestamp, 'PPpp')}
</span>
<span className="flex items-center">
<User className="h-3 w-3 mr-1" />
{log.user.username}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleExportJson}
className="btn btn-ghost btn-sm"
title="Export as JSON"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={() => setShowSensitiveData(!showSensitiveData)}
className="btn btn-ghost btn-sm"
title={showSensitiveData ? 'Hide sensitive data' : 'Show sensitive data'}
>
{showSensitiveData ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
<button
onClick={onClose}
className="btn btn-ghost btn-sm"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-base-300">
<nav className="flex space-x-8 px-6">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-base-content/60 hover:text-base-content/80 hover:border-base-300'
}`}
>
<tab.icon className="h-4 w-4 inline mr-2" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="p-6 max-h-96 overflow-y-auto scrollbar-thin">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Action and Status */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-3">
<h3 className="text-lg font-semibold">Action</h3>
<div className="bg-base-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-base-content/60">Type</span>
<span className="font-medium">{log.action.type.replace(/_/g, ' ').toUpperCase()}</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-base-content/60">Resource</span>
<span className="font-medium">{log.action.resource}</span>
</div>
{log.action.resourceId && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-base-content/60">Resource ID</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-sm">{log.action.resourceId}</span>
<CopyButton value={log.action.resourceId} label="Resource ID" />
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Description</span>
<span className="text-sm max-w-64 text-right">{log.action.description}</span>
</div>
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-semibold">Result</h3>
<div className="bg-base-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-base-content/60">Status</span>
<StatusBadge success={log.result.success} reason={log.result.reason} />
</div>
{log.result.decision && (
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-base-content/60">Decision</span>
<span className={`font-medium ${
log.result.decision === 'Allow' ? 'text-success' : 'text-error'
}`}>
{log.result.decision}
</span>
</div>
)}
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-base-content/60">Severity</span>
<SeverityBadge severity={log.severity} />
</div>
{(log.result.errorCode || log.result.errorMessage) && (
<div className="border-t border-base-300 pt-3">
{log.result.errorCode && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-base-content/60">Error Code</span>
<span className="font-mono text-sm text-error">{log.result.errorCode}</span>
</div>
)}
{log.result.errorMessage && (
<div>
<span className="text-sm text-base-content/60">Error Message</span>
<p className="text-sm text-error mt-1">{log.result.errorMessage}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* User Information */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">User Information</h3>
<div className="bg-base-200 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Username</span>
<span className="font-medium">{log.user.username}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Email</span>
<span className="font-medium">{log.user.email}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">User ID</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-sm">{log.user.id}</span>
<CopyButton value={log.user.id} label="User ID" />
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Roles</span>
<div className="flex flex-wrap gap-1">
{log.user.roles.map((role) => (
<span key={role} className="badge badge-outline badge-sm">
{role}
</span>
))}
</div>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-2">
{onViewCorrelated && (
<button
onClick={() => onViewCorrelated(log.context.requestId)}
className="btn btn-outline btn-sm"
>
<LinkIcon className="h-4 w-4 mr-1" />
View Correlated Logs
</button>
)}
{onViewSession && (
<button
onClick={() => onViewSession(log.context.sessionId)}
className="btn btn-outline btn-sm"
>
<User className="h-4 w-4 mr-1" />
View Session Logs
</button>
)}
</div>
</div>
)}
{activeTab === 'context' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Request Context</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Session & Request</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Session ID</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-xs">{log.context.sessionId}</span>
<CopyButton value={log.context.sessionId} label="Session ID" />
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Request ID</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-xs">{log.context.requestId}</span>
<CopyButton value={log.context.requestId} label="Request ID" />
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">MFA Enabled</span>
<span className={`badge badge-sm ${
log.context.mfaEnabled ? 'badge-success' : 'badge-warning'
}`}>
{log.context.mfaEnabled ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Network & Location</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">IP Address</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-sm">{log.context.ipAddress}</span>
<CopyButton value={log.context.ipAddress} label="IP Address" />
</div>
</div>
{log.context.location && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Country</span>
<span className="font-medium">{log.context.location.country}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">City</span>
<span className="font-medium">{log.context.location.city}</span>
</div>
{log.context.location.coordinates && (
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Coordinates</span>
<span className="font-mono text-xs">
{log.context.location.coordinates[0]}, {log.context.location.coordinates[1]}
</span>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">User Agent</h4>
<div className="code-block">
{showSensitiveData ? log.context.userAgent : '***HIDDEN***'}
</div>
</div>
</div>
)}
{activeTab === 'metadata' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Metadata</h3>
{/* Tags */}
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Tags</h4>
{log.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{log.tags.map((tag) => (
<span key={tag} className="badge badge-primary badge-outline">
<Tag className="h-3 w-3 mr-1" />
{tag}
</span>
))}
</div>
) : (
<span className="text-base-content/60 text-sm">No tags</span>
)}
</div>
{/* Retention Policy */}
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Retention Policy</h4>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Retention Period</span>
<span className="font-medium">{log.retention.retentionPeriod} days</span>
</div>
{log.retention.archiveDate && (
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Archive Date</span>
<span className="font-medium">{format(log.retention.archiveDate, 'PP')}</span>
</div>
)}
{log.retention.deletionDate && (
<div className="flex items-center justify-between">
<span className="text-sm text-base-content/60">Deletion Date</span>
<span className="font-medium">{format(log.retention.deletionDate, 'PP')}</span>
</div>
)}
</div>
</div>
{/* Custom Metadata */}
{Object.keys(log.metadata).length > 0 && (
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Custom Metadata</h4>
<div className="json-viewer">
<ReactJsonView
src={log.metadata}
theme="rjv-default"
displayObjectSize={false}
displayDataTypes={false}
enableClipboard={true}
collapsed={1}
/>
</div>
</div>
)}
</div>
)}
{activeTab === 'compliance' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Compliance Information</h3>
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-3">Relevant Frameworks</h4>
<ComplianceBadges compliance={log.compliance} />
</div>
{/* Compliance Details */}
<div className="grid grid-cols-2 gap-4">
{log.compliance.soc2Relevant && (
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-2 text-success">SOC2 Type II</h4>
<p className="text-sm text-base-content/70">
This log entry is relevant for SOC2 Type II compliance monitoring.
</p>
</div>
)}
{log.compliance.hipaaRelevant && (
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-2 text-success">HIPAA</h4>
<p className="text-sm text-base-content/70">
This log entry contains PHI-related activity for HIPAA compliance.
</p>
</div>
)}
{log.compliance.pciRelevant && (
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-2 text-success">PCI DSS</h4>
<p className="text-sm text-base-content/70">
This log entry is relevant for PCI DSS compliance requirements.
</p>
</div>
)}
{log.compliance.gdprRelevant && (
<div className="bg-base-200 rounded-lg p-4">
<h4 className="font-medium mb-2 text-success">GDPR</h4>
<p className="text-sm text-base-content/70">
This log entry involves personal data processing under GDPR.
</p>
</div>
)}
</div>
</div>
)}
{activeTab === 'raw' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Raw JSON Data</h3>
<CopyButton
value={JSON.stringify(log, null, 2)}
label="Raw JSON"
/>
</div>
<div className="json-viewer">
<ReactJsonView
src={showSensitiveData ? log : { ...log, context: { ...log.context, userAgent: '***HIDDEN***' } }}
theme="rjv-default"
displayObjectSize={true}
displayDataTypes={true}
enableClipboard={true}
collapsed={2}
name="auditLog"
/>
</div>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,145 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Wifi, WifiOff, RefreshCw, AlertCircle } from 'lucide-react';
import { WebSocketReadyState } from '@/hooks/useWebSocket';
interface RealTimeIndicatorProps {
readyState: WebSocketReadyState;
lastMessageTime?: Date;
newLogCount: number;
onToggleRealTime: () => void;
onReconnect: () => void;
isEnabled: boolean;
reconnectAttempts: number;
}
export const RealTimeIndicator: React.FC<RealTimeIndicatorProps> = ({
readyState,
lastMessageTime,
newLogCount,
onToggleRealTime,
onReconnect,
isEnabled,
reconnectAttempts
}) => {
const getStatusInfo = () => {
switch (readyState) {
case WebSocketReadyState.CONNECTING:
return {
status: 'Connecting',
color: 'warning',
icon: RefreshCw,
pulse: true
};
case WebSocketReadyState.OPEN:
return {
status: 'Connected',
color: 'success',
icon: Wifi,
pulse: false
};
case WebSocketReadyState.CLOSING:
return {
status: 'Closing',
color: 'warning',
icon: WifiOff,
pulse: true
};
case WebSocketReadyState.CLOSED:
return {
status: 'Disconnected',
color: 'error',
icon: WifiOff,
pulse: false
};
default:
return {
status: 'Unknown',
color: 'error',
icon: AlertCircle,
pulse: false
};
}
};
const statusInfo = getStatusInfo();
const StatusIcon = statusInfo.icon;
const isConnected = readyState === WebSocketReadyState.OPEN;
return (
<div className="flex items-center space-x-4 bg-base-100 border border-base-300 rounded-lg px-4 py-2">
{/* Connection Status */}
<div className="flex items-center space-x-2">
<div className="relative">
<StatusIcon
className={`h-4 w-4 text-${statusInfo.color} ${
statusInfo.pulse ? 'animate-spin' : ''
}`}
/>
{statusInfo.color === 'success' && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-success rounded-full animate-pulse-slow"></div>
)}
</div>
<span className={`text-sm font-medium text-${statusInfo.color}`}>
{statusInfo.status}
</span>
</div>
{/* Last Message Time */}
{lastMessageTime && isConnected && (
<div className="text-xs text-base-content/60">
Last: {lastMessageTime.toLocaleTimeString()}
</div>
)}
{/* Reconnect Attempts */}
{reconnectAttempts > 0 && (
<div className="text-xs text-warning">
Attempt {reconnectAttempts}
</div>
)}
{/* New Log Count */}
<AnimatePresence>
{newLogCount > 0 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className="flex items-center space-x-2"
>
<span className="badge badge-primary badge-sm">
{newLogCount} new
</span>
</motion.div>
)}
</AnimatePresence>
{/* Controls */}
<div className="flex items-center space-x-2">
<div className="form-control">
<label className="label cursor-pointer space-x-2">
<span className="label-text text-xs">Real-time</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-sm"
checked={isEnabled}
onChange={onToggleRealTime}
/>
</label>
</div>
{!isConnected && (
<button
onClick={onReconnect}
className="btn btn-ghost btn-sm text-primary"
disabled={readyState === WebSocketReadyState.CONNECTING}
>
<RefreshCw className="h-3 w-3" />
Retry
</button>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,732 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';
import { format } from 'date-fns';
import {
Search,
Filter,
Calendar,
User,
Shield,
Database,
AlertTriangle,
X,
RotateCcw,
Bookmark,
Clock
} from 'lucide-react';
import {
AuditSearchFilters,
AuditActionType,
AuditSeverity,
SavedSearch
} from '@/types/audit';
interface SearchFiltersProps {
onFiltersChange: (filters: AuditSearchFilters) => void;
onSaveSearch: (name: string, description?: string) => void;
savedSearches: SavedSearch[];
onLoadSavedSearch: (search: SavedSearch) => void;
isLoading?: boolean;
}
const actionTypeOptions: Array<{ value: AuditActionType; label: string }> = [
{ value: 'authentication', label: 'Authentication' },
{ value: 'authorization', label: 'Authorization' },
{ value: 'policy_evaluation', label: 'Policy Evaluation' },
{ value: 'policy_creation', label: 'Policy Creation' },
{ value: 'policy_update', label: 'Policy Update' },
{ value: 'policy_deletion', label: 'Policy Deletion' },
{ value: 'user_creation', label: 'User Creation' },
{ value: 'user_update', label: 'User Update' },
{ value: 'user_deletion', label: 'User Deletion' },
{ value: 'role_assignment', label: 'Role Assignment' },
{ value: 'role_revocation', label: 'Role Revocation' },
{ value: 'data_access', label: 'Data Access' },
{ value: 'data_modification', label: 'Data Modification' },
{ value: 'data_deletion', label: 'Data Deletion' },
{ value: 'system_configuration', label: 'System Configuration' },
{ value: 'backup_creation', label: 'Backup Creation' },
{ value: 'backup_restoration', label: 'Backup Restoration' },
{ value: 'security_incident', label: 'Security Incident' },
{ value: 'compliance_check', label: 'Compliance Check' },
{ value: 'anomaly_detection', label: 'Anomaly Detection' },
{ value: 'session_start', label: 'Session Start' },
{ value: 'session_end', label: 'Session End' },
{ value: 'mfa_challenge', label: 'MFA Challenge' },
{ value: 'password_change', label: 'Password Change' },
{ value: 'api_access', label: 'API Access' }
];
const severityOptions: Array<{ value: AuditSeverity; label: string }> = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' }
];
const complianceFrameworkOptions = [
{ value: 'soc2', label: 'SOC2' },
{ value: 'hipaa', label: 'HIPAA' },
{ value: 'pci', label: 'PCI DSS' },
{ value: 'gdpr', label: 'GDPR' },
{ value: 'iso27001', label: 'ISO 27001' },
{ value: 'nist', label: 'NIST' }
];
const customSelectStyles = {
control: (provided: any) => ({
...provided,
minHeight: '2.5rem',
borderColor: 'rgb(209 213 219)',
'&:hover': {
borderColor: 'rgb(59 130 246)'
}
}),
multiValue: (provided: any) => ({
...provided,
backgroundColor: 'rgb(59 130 246)',
color: 'white'
}),
multiValueLabel: (provided: any) => ({
...provided,
color: 'white'
}),
multiValueRemove: (provided: any) => ({
...provided,
color: 'white',
'&:hover': {
backgroundColor: 'rgb(37 99 235)',
color: 'white'
}
})
};
interface FormData {
searchTerm: string;
startDate: string;
endDate: string;
users: string[];
actions: AuditActionType[];
resources: string[];
severity: AuditSeverity[];
success: 'all' | 'success' | 'failure';
complianceFrameworks: string[];
tags: string[];
requestId: string;
sessionId: string;
ipAddress: string;
}
export const SearchFilters: React.FC<SearchFiltersProps> = ({
onFiltersChange,
onSaveSearch,
savedSearches,
onLoadSavedSearch,
isLoading
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [saveSearchName, setSaveSearchName] = useState('');
const [saveSearchDescription, setSaveSearchDescription] = useState('');
const { control, watch, setValue, reset, getValues } = useForm<FormData>({
defaultValues: {
searchTerm: '',
startDate: '',
endDate: '',
users: [],
actions: [],
resources: [],
severity: [],
success: 'all',
complianceFrameworks: [],
tags: [],
requestId: '',
sessionId: '',
ipAddress: ''
}
});
const watchedValues = watch();
// Debounced filter change handler
const debouncedOnFiltersChange = useCallback(
debounce((filters: AuditSearchFilters) => {
onFiltersChange(filters);
}, 300),
[onFiltersChange]
);
// Convert form data to search filters
const convertToFilters = useCallback((formData: FormData): AuditSearchFilters => {
return {
searchTerm: formData.searchTerm || undefined,
dateRange: {
start: formData.startDate ? new Date(formData.startDate) : undefined,
end: formData.endDate ? new Date(formData.endDate) : undefined
},
users: formData.users,
actions: formData.actions,
resources: formData.resources,
severity: formData.severity,
success: formData.success === 'all' ? undefined : formData.success === 'success',
complianceFrameworks: formData.complianceFrameworks,
tags: formData.tags,
requestId: formData.requestId || undefined,
sessionId: formData.sessionId || undefined,
ipAddress: formData.ipAddress || undefined
};
}, []);
// Watch for changes and update filters
useEffect(() => {
const filters = convertToFilters(watchedValues);
debouncedOnFiltersChange(filters);
}, [watchedValues, convertToFilters, debouncedOnFiltersChange]);
// Handle clear filters
const handleClearFilters = () => {
reset();
};
// Handle save search
const handleSaveSearch = () => {
if (saveSearchName.trim()) {
onSaveSearch(saveSearchName.trim(), saveSearchDescription.trim() || undefined);
setSaveSearchName('');
setSaveSearchDescription('');
setShowSaveModal(false);
}
};
// Handle load saved search
const handleLoadSavedSearch = (search: SavedSearch) => {
const filters = search.filters;
setValue('searchTerm', filters.searchTerm || '');
setValue('startDate', filters.dateRange.start ? format(filters.dateRange.start, 'yyyy-MM-dd') : '');
setValue('endDate', filters.dateRange.end ? format(filters.dateRange.end, 'yyyy-MM-dd') : '');
setValue('users', filters.users || []);
setValue('actions', filters.actions || []);
setValue('resources', filters.resources || []);
setValue('severity', filters.severity || []);
setValue('success',
filters.success === undefined ? 'all' :
filters.success ? 'success' : 'failure'
);
setValue('complianceFrameworks', filters.complianceFrameworks || []);
setValue('tags', filters.tags || []);
setValue('requestId', filters.requestId || '');
setValue('sessionId', filters.sessionId || '');
setValue('ipAddress', filters.ipAddress || '');
onLoadSavedSearch(search);
};
// Check if any filters are active
const hasActiveFilters = Object.values(watchedValues).some(value => {
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'string') return value !== '';
if (value === 'all') return false;
return value !== undefined && value !== null;
});
return (
<div className="bg-base-100 border border-base-300 rounded-lg shadow-sm">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-base-300">
<div className="flex items-center space-x-2">
<Search className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold text-base-content">Search Filters</h3>
{hasActiveFilters && (
<span className="badge badge-primary badge-sm">Active</span>
)}
</div>
<div className="flex items-center space-x-2">
{savedSearches.length > 0 && (
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-sm">
<Bookmark className="h-4 w-4" />
Saved
</label>
<div className="dropdown-content z-50 menu p-2 shadow bg-base-100 rounded-box w-80 border border-base-300">
<div className="text-sm font-medium p-2 border-b border-base-300">
Saved Searches
</div>
<div className="max-h-60 overflow-y-auto">
{savedSearches.map((search) => (
<div
key={search.id}
className="p-2 hover:bg-base-200 cursor-pointer rounded"
onClick={() => handleLoadSavedSearch(search)}
>
<div className="font-medium text-sm">{search.name}</div>
{search.description && (
<div className="text-xs text-base-content/60 mt-1">
{search.description}
</div>
)}
<div className="flex items-center justify-between mt-1">
<div className="text-xs text-base-content/50">
{search.useCount} uses
</div>
{search.lastUsed && (
<div className="text-xs text-base-content/50">
<Clock className="h-3 w-3 inline mr-1" />
{format(search.lastUsed, 'MMM dd')}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
<button
onClick={() => setShowSaveModal(true)}
className="btn btn-ghost btn-sm"
disabled={!hasActiveFilters}
>
<Bookmark className="h-4 w-4" />
Save
</button>
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="btn btn-ghost btn-sm text-error"
>
<RotateCcw className="h-4 w-4" />
Clear
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="btn btn-ghost btn-sm"
>
<Filter className="h-4 w-4" />
{isExpanded ? 'Less' : 'More'}
</button>
</div>
</div>
{/* Basic Search */}
<div className="p-4 space-y-4">
<Controller
name="searchTerm"
control={control}
render={({ field }) => (
<div className="relative">
<input
{...field}
type="text"
placeholder="Search logs by user, action, resource, or any text..."
className="input input-bordered w-full pl-10"
disabled={isLoading}
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-base-content/50" />
</div>
)}
/>
{/* Quick Date Range */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
setValue('startDate', format(oneHourAgo, 'yyyy-MM-dd\'T\'HH:mm'));
setValue('endDate', format(now, 'yyyy-MM-dd\'T\'HH:mm'));
}}
className="btn btn-outline btn-sm"
>
Last Hour
</button>
<button
onClick={() => {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
setValue('startDate', format(oneDayAgo, 'yyyy-MM-dd'));
setValue('endDate', format(now, 'yyyy-MM-dd'));
}}
className="btn btn-outline btn-sm"
>
Last 24h
</button>
<button
onClick={() => {
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
setValue('startDate', format(oneWeekAgo, 'yyyy-MM-dd'));
setValue('endDate', format(now, 'yyyy-MM-dd'));
}}
className="btn btn-outline btn-sm"
>
Last 7d
</button>
<button
onClick={() => {
const now = new Date();
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
setValue('startDate', format(oneMonthAgo, 'yyyy-MM-dd'));
setValue('endDate', format(now, 'yyyy-MM-dd'));
}}
className="btn btn-outline btn-sm"
>
Last 30d
</button>
</div>
</div>
{/* Advanced Filters */}
{isExpanded && (
<div className="border-t border-base-300 p-4 space-y-6">
{/* Date Range */}
<div className="form-section">
<h4 className="form-section-title">
<Calendar className="h-4 w-4 inline mr-2" />
Date Range
</h4>
<div className="grid grid-cols-2 gap-4">
<Controller
name="startDate"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Start Date</span>
</label>
<input
{...field}
type="datetime-local"
className="input input-bordered w-full"
disabled={isLoading}
/>
</div>
)}
/>
<Controller
name="endDate"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">End Date</span>
</label>
<input
{...field}
type="datetime-local"
className="input input-bordered w-full"
disabled={isLoading}
/>
</div>
)}
/>
</div>
</div>
{/* Actions and Users */}
<div className="grid grid-cols-2 gap-6">
<div className="form-section">
<h4 className="form-section-title">
<Shield className="h-4 w-4 inline mr-2" />
Actions
</h4>
<Controller
name="actions"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={actionTypeOptions}
placeholder="Select actions..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
/>
)}
/>
</div>
<div className="form-section">
<h4 className="form-section-title">
<User className="h-4 w-4 inline mr-2" />
Users
</h4>
<Controller
name="users"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={[]} // This would be populated from API
placeholder="Type to search users..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
isSearchable
value={field.value.map(user => ({ value: user, label: user }))}
onChange={(selected) => field.onChange(selected?.map(s => s.value) || [])}
onInputChange={(inputValue) => {
// Here you would typically trigger a search for users
}}
/>
)}
/>
</div>
</div>
{/* Resources and Severity */}
<div className="grid grid-cols-2 gap-6">
<div className="form-section">
<h4 className="form-section-title">
<Database className="h-4 w-4 inline mr-2" />
Resources
</h4>
<Controller
name="resources"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={[]} // This would be populated from API
placeholder="Type to search resources..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
isSearchable
value={field.value.map(resource => ({ value: resource, label: resource }))}
onChange={(selected) => field.onChange(selected?.map(s => s.value) || [])}
/>
)}
/>
</div>
<div className="form-section">
<h4 className="form-section-title">
<AlertTriangle className="h-4 w-4 inline mr-2" />
Severity
</h4>
<Controller
name="severity"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={severityOptions}
placeholder="Select severity levels..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
/>
)}
/>
</div>
</div>
{/* Success Status and Compliance */}
<div className="grid grid-cols-2 gap-6">
<div className="form-section">
<h4 className="form-section-title">Result Status</h4>
<Controller
name="success"
control={control}
render={({ field }) => (
<select
{...field}
className="select select-bordered w-full"
disabled={isLoading}
>
<option value="all">All Results</option>
<option value="success">Successful Only</option>
<option value="failure">Failed Only</option>
</select>
)}
/>
</div>
<div className="form-section">
<h4 className="form-section-title">Compliance Frameworks</h4>
<Controller
name="complianceFrameworks"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={complianceFrameworkOptions}
placeholder="Select frameworks..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
/>
)}
/>
</div>
</div>
{/* Correlation IDs */}
<div className="form-section">
<h4 className="form-section-title">Correlation & Tracing</h4>
<div className="grid grid-cols-3 gap-4">
<Controller
name="requestId"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Request ID</span>
</label>
<input
{...field}
type="text"
placeholder="req_..."
className="input input-bordered w-full"
disabled={isLoading}
/>
</div>
)}
/>
<Controller
name="sessionId"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">Session ID</span>
</label>
<input
{...field}
type="text"
placeholder="sess_..."
className="input input-bordered w-full"
disabled={isLoading}
/>
</div>
)}
/>
<Controller
name="ipAddress"
control={control}
render={({ field }) => (
<div>
<label className="label">
<span className="label-text">IP Address</span>
</label>
<input
{...field}
type="text"
placeholder="192.168.1.1"
className="input input-bordered w-full"
disabled={isLoading}
/>
</div>
)}
/>
</div>
</div>
{/* Tags */}
<div className="form-section">
<h4 className="form-section-title">Tags</h4>
<Controller
name="tags"
control={control}
render={({ field }) => (
<Select
{...field}
isMulti
options={[]} // This would be populated from API
placeholder="Type to add tags..."
className="react-select-container"
classNamePrefix="react-select"
styles={customSelectStyles}
isDisabled={isLoading}
isCreatable
value={field.value.map(tag => ({ value: tag, label: tag }))}
onChange={(selected) => field.onChange(selected?.map(s => s.value) || [])}
/>
)}
/>
</div>
</div>
)}
{/* Save Search Modal */}
{showSaveModal && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Save Search</h3>
<div className="py-4 space-y-4">
<div>
<label className="label">
<span className="label-text">Search Name</span>
</label>
<input
type="text"
placeholder="Enter search name..."
className="input input-bordered w-full"
value={saveSearchName}
onChange={(e) => setSaveSearchName(e.target.value)}
/>
</div>
<div>
<label className="label">
<span className="label-text">Description (Optional)</span>
</label>
<textarea
placeholder="Describe what this search is for..."
className="textarea textarea-bordered w-full"
value={saveSearchDescription}
onChange={(e) => setSaveSearchDescription(e.target.value)}
/>
</div>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => setShowSaveModal(false)}
>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleSaveSearch}
disabled={!saveSearchName.trim()}
>
Save Search
</button>
</div>
</div>
</div>
)}
</div>
);
};
// Debounce utility
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export default SearchFilters;

View File

@ -0,0 +1,519 @@
import React, { useMemo, useCallback, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
SortingState,
ColumnDef,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import {
ChevronUp,
ChevronDown,
ExternalLink,
Shield,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Eye,
Link as LinkIcon,
Clock,
User,
Database,
MapPin
} from 'lucide-react';
import { AuditLogEntry, AuditSeverity } from '@/types/audit';
interface VirtualizedLogTableProps {
logs: AuditLogEntry[];
isLoading: boolean;
hasNextPage: boolean;
fetchNextPage: () => void;
onRowClick: (log: AuditLogEntry) => void;
onViewCorrelated?: (requestId: string) => void;
onViewSession?: (sessionId: string) => void;
selectedLogIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
}
const SeverityBadge: React.FC<{ severity: AuditSeverity }> = ({ severity }) => {
const className = `severity-indicator severity-${severity}`;
const icon = {
low: CheckCircle,
medium: AlertTriangle,
high: AlertTriangle,
critical: XCircle
}[severity];
const Icon = icon;
return (
<span className={className}>
<Icon className="h-3 w-3 mr-1" />
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</span>
);
};
const StatusBadge: React.FC<{ success: boolean }> = ({ success }) => {
return (
<span className={`status-indicator ${success ? 'status-success' : 'status-error'}`}>
{success ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<XCircle className="h-3 w-3 mr-1" />
)}
{success ? 'Success' : 'Failed'}
</span>
);
};
const ActionCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
const actionTypeMap = {
authentication: 'Auth',
authorization: 'Authz',
policy_evaluation: 'Policy',
policy_creation: 'Create Policy',
policy_update: 'Update Policy',
policy_deletion: 'Delete Policy',
user_creation: 'Create User',
user_update: 'Update User',
user_deletion: 'Delete User',
role_assignment: 'Assign Role',
role_revocation: 'Revoke Role',
data_access: 'Data Access',
data_modification: 'Data Modify',
data_deletion: 'Data Delete',
system_configuration: 'Config',
backup_creation: 'Backup',
backup_restoration: 'Restore',
security_incident: 'Security',
compliance_check: 'Compliance',
anomaly_detection: 'Anomaly',
session_start: 'Login',
session_end: 'Logout',
mfa_challenge: 'MFA',
password_change: 'Password',
api_access: 'API'
};
return (
<div className="flex flex-col">
<div className="font-medium text-sm">
{actionTypeMap[log.action.type] || log.action.type}
</div>
<div className="text-xs text-base-content/60 truncate max-w-32">
{log.action.resource}
</div>
</div>
);
};
const UserCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
return (
<div className="flex flex-col">
<div className="font-medium text-sm">{log.user.username}</div>
<div className="text-xs text-base-content/60">
{log.user.roles.join(', ')}
</div>
</div>
);
};
const ContextCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
return (
<div className="flex flex-col">
<div className="text-xs text-base-content/60">{log.context.ipAddress}</div>
{log.context.location && (
<div className="text-xs text-base-content/50 flex items-center">
<MapPin className="h-3 w-3 mr-1" />
{log.context.location.city}, {log.context.location.country}
</div>
)}
</div>
);
};
const ActionsMenu: React.FC<{
log: AuditLogEntry;
onViewDetails: () => void;
onViewCorrelated?: (requestId: string) => void;
onViewSession?: (sessionId: string) => void;
}> = ({ log, onViewDetails, onViewCorrelated, onViewSession }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="dropdown dropdown-end">
<label
tabIndex={0}
className="btn btn-ghost btn-xs"
onClick={() => setIsOpen(!isOpen)}
>
<MoreHorizontal className="h-4 w-4" />
</label>
{isOpen && (
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
<li>
<button onClick={onViewDetails} className="text-sm">
<Eye className="h-4 w-4" />
View Details
</button>
</li>
{onViewCorrelated && (
<li>
<button
onClick={() => onViewCorrelated(log.context.requestId)}
className="text-sm"
>
<LinkIcon className="h-4 w-4" />
View Correlated
</button>
</li>
)}
{onViewSession && (
<li>
<button
onClick={() => onViewSession(log.context.sessionId)}
className="text-sm"
>
<User className="h-4 w-4" />
View Session
</button>
</li>
)}
<div className="divider my-1"></div>
<li>
<button
onClick={() => navigator.clipboard.writeText(log.id)}
className="text-sm"
>
Copy Log ID
</button>
</li>
<li>
<button
onClick={() => navigator.clipboard.writeText(log.context.requestId)}
className="text-sm"
>
Copy Request ID
</button>
</li>
</ul>
)}
</div>
);
};
export const VirtualizedLogTable: React.FC<VirtualizedLogTableProps> = ({
logs,
isLoading,
hasNextPage,
fetchNextPage,
onRowClick,
onViewCorrelated,
onViewSession,
selectedLogIds = new Set(),
onSelectionChange
}) => {
const [sorting, setSorting] = useState<SortingState>([]);
const columns = useMemo<ColumnDef<AuditLogEntry>[]>(() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={selectedLogIds.has(row.original.id)}
onChange={(e) => {
if (onSelectionChange) {
const newSelection = new Set(selectedLogIds);
if (e.target.checked) {
newSelection.add(row.original.id);
} else {
newSelection.delete(row.original.id);
}
onSelectionChange(newSelection);
}
}}
/>
),
size: 50,
},
{
accessorKey: 'timestamp',
header: 'Time',
cell: ({ getValue }) => {
const timestamp = getValue() as Date;
return (
<div className="flex flex-col">
<div className="text-sm font-medium">
{format(timestamp, 'MMM dd, HH:mm:ss')}
</div>
<div className="text-xs text-base-content/60">
{format(timestamp, 'yyyy')}
</div>
</div>
);
},
size: 120,
},
{
id: 'user',
header: 'User',
cell: ({ row }) => <UserCell log={row.original} />,
size: 150,
},
{
id: 'action',
header: 'Action',
cell: ({ row }) => <ActionCell log={row.original} />,
size: 180,
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <StatusBadge success={row.original.result.success} />,
size: 100,
},
{
id: 'severity',
header: 'Severity',
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
size: 120,
},
{
id: 'context',
header: 'Context',
cell: ({ row }) => <ContextCell log={row.original} />,
size: 140,
},
{
id: 'compliance',
header: 'Compliance',
cell: ({ row }) => {
const compliance = row.original.compliance;
const frameworks = [];
if (compliance.soc2Relevant) frameworks.push('SOC2');
if (compliance.hipaaRelevant) frameworks.push('HIPAA');
if (compliance.pciRelevant) frameworks.push('PCI');
if (compliance.gdprRelevant) frameworks.push('GDPR');
return (
<div className="flex flex-wrap gap-1">
{frameworks.slice(0, 2).map((framework) => (
<span key={framework} className="badge badge-outline badge-xs">
{framework}
</span>
))}
{frameworks.length > 2 && (
<span className="badge badge-outline badge-xs">
+{frameworks.length - 2}
</span>
)}
</div>
);
},
size: 120,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsMenu
log={row.original}
onViewDetails={() => onRowClick(row.original)}
onViewCorrelated={onViewCorrelated}
onViewSession={onViewSession}
/>
),
size: 60,
},
], [selectedLogIds, onSelectionChange, onRowClick, onViewCorrelated, onViewSession]);
const table = useReactTable({
data: logs,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: process.env.NODE_ENV === 'development',
});
const { rows } = table.getRowModel();
// Create a parent ref for the virtualizer
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? rows.length + 1 : rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 10,
});
// Load more data when scrolling near the end
const virtualItems = rowVirtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
React.useEffect(() => {
if (
lastItem &&
lastItem.index >= rows.length - 1 &&
hasNextPage &&
!isLoading
) {
fetchNextPage();
}
}, [lastItem, hasNextPage, fetchNextPage, isLoading, rows.length]);
const handleRowClick = useCallback((log: AuditLogEntry, event: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox, dropdown, or buttons
const target = event.target as HTMLElement;
if (target.closest('input') || target.closest('.dropdown') || target.closest('button')) {
return;
}
onRowClick(log);
}, [onRowClick]);
if (logs.length === 0 && !isLoading) {
return (
<div className="flex flex-col items-center justify-center h-64 text-base-content/60">
<Database className="h-12 w-12 mb-4" />
<h3 className="text-lg font-semibold mb-2">No audit logs found</h3>
<p className="text-sm">Try adjusting your search filters or date range.</p>
</div>
);
}
return (
<div className="bg-base-100 border border-base-300 rounded-lg overflow-hidden">
{/* Table Header */}
<div className="border-b border-base-300">
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-base-200 text-sm font-medium text-base-content">
<div className="col-span-1">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
</div>
{table.getFlatHeaders().slice(1).map((header, index) => (
<div
key={header.id}
className={`${
index === 0 ? 'col-span-2' :
index === 1 ? 'col-span-2' :
index === 2 ? 'col-span-2' :
index === 6 ? 'col-span-2' :
'col-span-1'
} cursor-pointer flex items-center space-x-1`}
onClick={header.column.getToggleSortingHandler()}
>
<span>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
{header.column.getIsSorted() === 'asc' ? (
<ChevronUp className="h-3 w-3" />
) : header.column.getIsSorted() === 'desc' ? (
<ChevronDown className="h-3 w-3" />
) : (
<div className="h-3 w-3" />
)}
</div>
)}
</div>
))}
</div>
</div>
{/* Virtualized Table Body */}
<div
ref={parentRef}
className="h-96 overflow-auto scrollbar-thin"
style={{ contain: 'strict' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const isLoaderRow = virtualRow.index > rows.length - 1;
const row = rows[virtualRow.index];
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
<div className="flex items-center justify-center h-full">
<div className="loading-spinner"></div>
<span className="ml-2 text-base-content/60">Loading more logs...</span>
</div>
) : null
) : (
<div
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-base-300 hover:bg-base-50 cursor-pointer transition-colors"
onClick={(e) => handleRowClick(row.original, e)}
>
{row.getVisibleCells().map((cell, cellIndex) => (
<div
key={cell.id}
className={`${
cellIndex === 1 ? 'col-span-2' :
cellIndex === 2 ? 'col-span-2' :
cellIndex === 3 ? 'col-span-2' :
cellIndex === 7 ? 'col-span-2' :
'col-span-1'
} flex items-center`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Footer with selection info */}
{selectedLogIds.size > 0 && (
<div className="border-t border-base-300 px-4 py-2 bg-base-200 text-sm text-base-content/60">
{selectedLogIds.size} log{selectedLogIds.size === 1 ? '' : 's'} selected
</div>
)}
</div>
);
};
export default VirtualizedLogTable;

View File

@ -0,0 +1,19 @@
use leptos::*;
use leptos_router::*;
#[component]
pub fn ProtectedRoute<T>(
path: &'static str,
view: T,
children: Option<Children>,
) -> impl IntoView
where
T: Fn() -> leptos::View + 'static,
{
// For now, just render the view directly - in a real app, check auth state
view! {
<Route path=path view=view>
{children.map(|child| child()).unwrap_or_else(|| ().into_view().into())}
</Route>
}
}

View File

@ -0,0 +1,10 @@
use leptos::*;
#[component]
pub fn ubiometricauth() -> impl IntoView {
view! {
<div class="biometric_auth-placeholder">
<p>"biometric auth placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,10 @@
use leptos::*;
#[component]
pub fn udevicetrust() -> impl IntoView {
view! {
<div class="device_trust-placeholder">
<p>"device trust placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,51 @@
use leptos::*;
#[component]
pub fn LoginForm() -> impl IntoView {
let (email, set_email) = create_signal("".to_string());
let (password, set_password) = create_signal("".to_string());
let handle_submit = move |_| {
// Placeholder login logic
web_sys::console::log_1(&"Login attempted".into());
};
view! {
<form on:submit=handle_submit class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
"Email"
</label>
<input
type="email"
id="email"
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
on:input=move |ev| {
set_email.set(event_target_value(&ev));
}
prop:value=email
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
"Password"
</label>
<input
type="password"
id="password"
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
on:input=move |ev| {
set_password.set(event_target_value(&ev));
}
prop:value=password
/>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md text-sm font-medium hover:bg-blue-700"
>
"Sign In"
</button>
</form>
}
}

View File

@ -0,0 +1,17 @@
use leptos::*;
#[component]
pub fn LogoutButton() -> impl IntoView {
let handle_logout = move |_| {
web_sys::console::log_1(&"Logout clicked".into());
};
view! {
<button
on:click=handle_logout
class="logout-button text-red-600 hover:text-red-800"
>
"Logout"
</button>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn MFASetup() -> impl IntoView {
view! {
<div class="mfa-setup">
<h2>"Multi-Factor Authentication Setup"</h2>
<p>"MFA setup placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,21 @@
pub mod login_form;
pub mod mfa_setup;
pub mod password_reset;
pub mod auth_guard;
pub mod session_timeout;
pub mod sso_buttons;
pub mod device_trust;
pub mod biometric_auth;
pub mod logout_button;
pub mod user_profile;
pub use login_form::*;
pub use mfa_setup::*;
pub use password_reset::*;
pub use auth_guard::*;
pub use session_timeout::*;
pub use sso_buttons::*;
pub use device_trust::*;
pub use biometric_auth::*;
pub use logout_button::*;
pub use user_profile::*;

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn PasswordResetForm() -> impl IntoView {
view! {
<div class="password-reset">
<h2>"Password Reset"</h2>
<p>"Password reset placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,10 @@
use leptos::*;
#[component]
pub fn SessionTimeoutModal() -> impl IntoView {
view! {
<div class="session-timeout-modal hidden">
<p>"Session timeout modal placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,10 @@
use leptos::*;
#[component]
pub fn ussobuttons() -> impl IntoView {
view! {
<div class="sso_buttons-placeholder">
<p>"sso buttons placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn UserProfileManagement() -> impl IntoView {
view! {
<div class="user-profile-management">
<h2>"User Profile Management"</h2>
<p>"User profile management placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,170 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, Duration};
use std::collections::HashMap;
use std::rc::Rc;
// Filtering and Date Range Components
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DateRange {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DateRangePreset {
LastHour,
Last6Hours,
Last24Hours,
Last7Days,
Last30Days,
ThisWeek,
ThisMonth,
LastMonth,
Custom,
}
impl DateRangePreset {
pub fn to_date_range(&self) -> DateRange {
let now = Utc::now();
match self {
DateRangePreset::LastHour => DateRange {
start: now - Duration::hours(1),
end: now,
},
DateRangePreset::Last6Hours => DateRange {
start: now - Duration::hours(6),
end: now,
},
DateRangePreset::Last24Hours => DateRange {
start: now - Duration::days(1),
end: now,
},
DateRangePreset::Last7Days => DateRange {
start: now - Duration::days(7),
end: now,
},
DateRangePreset::Last30Days => DateRange {
start: now - Duration::days(30),
end: now,
},
DateRangePreset::ThisWeek => DateRange {
start: now - Duration::days(7),
end: now,
},
DateRangePreset::ThisMonth => DateRange {
start: now - Duration::days(30),
end: now,
},
DateRangePreset::LastMonth => DateRange {
start: now - Duration::days(60),
end: now - Duration::days(30),
},
DateRangePreset::Custom => DateRange {
start: now - Duration::days(1),
end: now,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MetricFilter {
pub metric_types: Vec<String>,
pub severity_levels: Vec<String>,
pub sources: Vec<String>,
pub users: Vec<String>,
pub date_range: Option<DateRange>,
pub custom_filters: HashMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct FilterOptions {
pub metric_types: Vec<String>,
pub severity_levels: Vec<String>,
pub sources: Vec<String>,
pub users: Vec<String>,
}
// Simplified placeholder components
#[component]
pub fn MetricFilterPanel(
_filter: ReadSignal<MetricFilter>,
on_filter_change: Rc<dyn Fn(MetricFilter) + 'static>,
#[prop(optional)] _available_options: Option<FilterOptions>,
) -> impl IntoView {
let (is_expanded, set_is_expanded) = create_signal(false);
let filter_handler = on_filter_change.clone();
view! {
<div class="metric-filter-panel">
<div class="filter-header">
<button
class="filter-toggle"
on:click=move |_| set_is_expanded.update(|exp| *exp = !*exp)
>
<span>"Filters"</span>
</button>
</div>
<Show when=move || is_expanded.get()>
<div class="filter-content">
<p>"Filter components placeholder"</p>
<button
class="btn-secondary"
on:click={
let handler = filter_handler.clone();
move |_| {
handler(MetricFilter::default());
}
}
>
"Reset All"
</button>
</div>
</Show>
</div>
}
}
#[component]
pub fn DateRangePicker(
_selected: impl Fn() -> Option<DateRange> + 'static,
_on_change: Rc<dyn Fn(Option<DateRange>) + 'static>,
) -> impl IntoView {
view! {
<div class="date-range-picker">
<p>"Date range picker placeholder"</p>
</div>
}
}
// Other utility components as placeholders
#[component]
pub fn LoadingSpinner(#[prop(optional)] size: Option<String>) -> impl IntoView {
let size_class = size.unwrap_or_else(|| "medium".to_string());
view! {
<div class=format!("loading-spinner {}", size_class)>
<div class="spinner"></div>
</div>
}
}
#[component]
pub fn ErrorMessage(message: String) -> impl IntoView {
view! {
<div class="error-message">
<p>{message}</p>
</div>
}
}
#[component]
pub fn SuccessMessage(message: String) -> impl IntoView {
view! {
<div class="success-message">
<p>{message}</p>
</div>
}
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,466 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use web_sys::{DragEvent, HtmlElement, MouseEvent, TouchEvent};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct GridPosition {
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct GridSize {
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridLayout {
pub columns: i32,
pub row_height: i32,
pub margin: (i32, i32),
pub container_padding: (i32, i32),
pub breakpoints: HashMap<String, BreakpointConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BreakpointConfig {
pub columns: i32,
pub margin: (i32, i32),
pub container_padding: (i32, i32),
}
impl Default for GridLayout {
fn default() -> Self {
let mut breakpoints = HashMap::new();
breakpoints.insert("lg".to_string(), BreakpointConfig {
columns: 12,
margin: (10, 10),
container_padding: (10, 10),
});
breakpoints.insert("md".to_string(), BreakpointConfig {
columns: 10,
margin: (8, 8),
container_padding: (8, 8),
});
breakpoints.insert("sm".to_string(), BreakpointConfig {
columns: 6,
margin: (5, 5),
container_padding: (5, 5),
});
breakpoints.insert("xs".to_string(), BreakpointConfig {
columns: 4,
margin: (3, 3),
container_padding: (3, 3),
});
Self {
columns: 12,
row_height: 30,
margin: (10, 10),
container_padding: (10, 10),
breakpoints,
}
}
}
#[component]
pub fn DashboardGrid(
layout: ReadSignal<GridLayout>,
is_editing: ReadSignal<bool>,
is_mobile: ReadSignal<bool>,
on_layout_change: Box<dyn Fn(GridLayout) + 'static>,
children: Children,
) -> impl IntoView {
let container_ref = create_node_ref::<html::Div>();
let (drag_state, set_drag_state) = create_signal(Option::<DragState>::None);
let (container_width, set_container_width) = create_signal(1200i32);
// Responsive breakpoint detection
let current_breakpoint = create_memo(move |_| {
let width = container_width.get();
if width >= 1200 {
"lg"
} else if width >= 996 {
"md"
} else if width >= 768 {
"sm"
} else {
"xs"
}
});
// Update layout based on breakpoint
create_effect(move |_| {
let breakpoint = current_breakpoint.get();
let current_layout = layout.get();
if let Some(bp_config) = current_layout.breakpoints.get(breakpoint) {
let mut new_layout = current_layout;
new_layout.columns = bp_config.columns;
new_layout.margin = bp_config.margin;
new_layout.container_padding = bp_config.container_padding;
on_layout_change(new_layout);
}
});
// Resize observer for responsive behavior
create_effect(move |_| {
if let Some(container) = container_ref.get() {
let container_clone = container.clone();
let set_width = set_container_width;
let closure = Closure::wrap(Box::new(move |entries: js_sys::Array| {
if let Some(entry) = entries.get(0).dyn_into::<web_sys::ResizeObserverEntry>().ok() {
let content_rect = entry.content_rect();
set_width.set(content_rect.width() as i32);
}
}) as Box<dyn FnMut(js_sys::Array)>);
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref()).unwrap();
observer.observe(&container_clone);
closure.forget();
}
});
let grid_style = create_memo(move |_| {
let layout = layout.get();
let (pad_x, pad_y) = layout.container_padding;
format!(
"padding: {}px {}px; min-height: 100vh; position: relative; background: var(--bg-primary);",
pad_y, pad_x
)
});
// Drag and drop handlers
let on_drag_over = move |event: DragEvent| {
event.prevent_default();
event.data_transfer().unwrap().set_drop_effect("move");
};
let on_drop = move |event: DragEvent| {
event.prevent_default();
if let Some(data_transfer) = event.data_transfer() {
if let Ok(widget_data) = data_transfer.get_data("application/json") {
if let Ok(drop_data) = serde_json::from_str::<DropData>(&widget_data) {
// Calculate grid position from mouse coordinates
let rect = container_ref.get().unwrap().get_bounding_client_rect();
let x = event.client_x() as f64 - rect.left();
let y = event.client_y() as f64 - rect.top();
let grid_pos = pixel_to_grid_position(x, y, &layout.get(), container_width.get());
// Emit drop event with calculated position
web_sys::console::log_2(
&"Widget dropped at position:".into(),
&format!("x: {}, y: {}", grid_pos.x, grid_pos.y).into()
);
}
}
}
set_drag_state.set(None);
};
view! {
<div
node_ref=container_ref
class=move || format!(
"dashboard-grid {} {}",
if is_editing.get() { "editing" } else { "" },
if is_mobile.get() { "mobile" } else { "desktop" }
)
style=move || grid_style.get()
on:dragover=on_drag_over
on:drop=on_drop
>
<div class="grid-background">
<GridBackground
layout=layout
container_width=container_width
show_grid=is_editing
/>
</div>
<div class="grid-items">
{children()}
</div>
// Drop indicator
<Show when=move || drag_state.get().is_some()>
<div class="drop-indicator">
// Visual indicator for where item will be dropped
</div>
</Show>
</div>
}
}
#[component]
pub fn GridItem(
id: String,
position: GridPosition,
size: GridSize,
draggable: ReadSignal<bool>,
#[prop(optional)] on_drag_start: Option<Box<dyn Fn(DragEvent) + 'static>>,
#[prop(optional)] on_resize: Option<Box<dyn Fn(GridSize) + 'static>>,
#[prop(optional)] on_remove: Option<Box<dyn Fn() + 'static>>,
children: Children,
) -> impl IntoView {
let item_ref = create_node_ref::<html::Div>();
let (is_dragging, set_is_dragging) = create_signal(false);
let (is_resizing, set_is_resizing) = create_signal(false);
let (current_position, set_current_position) = create_signal(position);
let (current_size, set_current_size) = create_signal(size);
// Calculate item style based on grid position and size
let item_style = create_memo(move |_| {
let pos = current_position.get();
let size = current_size.get();
// This would be calculated based on the grid layout
// For now, using a simple calculation
let x = pos.x * 100; // Column width in pixels
let y = pos.y * 40; // Row height in pixels
let width = size.width * 100 - 10; // Account for margins
let height = size.height * 40 - 10;
format!(
"position: absolute; left: {}px; top: {}px; width: {}px; height: {}px; z-index: {};",
x, y, width, height,
if is_dragging.get() { 1000 } else { 1 }
)
});
let drag_start_handler = move |event: DragEvent| {
set_is_dragging.set(true);
// Set drag data
let drag_data = DropData {
widget_id: id.clone(),
widget_type: "existing".to_string(),
original_position: current_position.get(),
original_size: current_size.get(),
};
if let Ok(data_json) = serde_json::to_string(&drag_data) {
event.data_transfer().unwrap()
.set_data("application/json", &data_json).unwrap();
}
// Call custom handler if provided
if let Some(handler) = &on_drag_start {
handler(event);
}
};
let drag_end_handler = move |_event: DragEvent| {
set_is_dragging.set(false);
};
// Resize handlers
let start_resize = move |event: MouseEvent, direction: ResizeDirection| {
event.prevent_default();
set_is_resizing.set(true);
let start_x = event.client_x();
let start_y = event.client_y();
let start_size = current_size.get();
let document = web_sys::window().unwrap().document().unwrap();
let mouse_move_closure = Closure::wrap(Box::new(move |event: MouseEvent| {
let delta_x = event.client_x() - start_x;
let delta_y = event.client_y() - start_y;
let mut new_size = start_size;
match direction {
ResizeDirection::SE => {
new_size.width = (start_size.width as f64 + delta_x as f64 / 100.0) as i32;
new_size.height = (start_size.height as f64 + delta_y as f64 / 40.0) as i32;
},
ResizeDirection::E => {
new_size.width = (start_size.width as f64 + delta_x as f64 / 100.0) as i32;
},
ResizeDirection::S => {
new_size.height = (start_size.height as f64 + delta_y as f64 / 40.0) as i32;
},
}
// Constrain to minimum size
new_size.width = new_size.width.max(1);
new_size.height = new_size.height.max(1);
set_current_size.set(new_size);
}) as Box<dyn FnMut(MouseEvent)>);
let mouse_up_closure = Closure::wrap(Box::new(move |_event: MouseEvent| {
set_is_resizing.set(false);
if let Some(handler) = &on_resize {
handler(current_size.get());
}
}) as Box<dyn FnMut(MouseEvent)>);
document.add_event_listener_with_callback("mousemove", mouse_move_closure.as_ref().unchecked_ref()).unwrap();
document.add_event_listener_with_callback("mouseup", mouse_up_closure.as_ref().unchecked_ref()).unwrap();
mouse_move_closure.forget();
mouse_up_closure.forget();
};
view! {
<div
node_ref=item_ref
class=move || format!(
"grid-item {} {} {}",
if draggable.get() { "draggable" } else { "" },
if is_dragging.get() { "dragging" } else { "" },
if is_resizing.get() { "resizing" } else { "" }
)
style=move || item_style.get()
draggable=move || draggable.get()
on:dragstart=drag_start_handler
on:dragend=drag_end_handler
>
// Widget controls (visible in editing mode)
<Show when=draggable>
<div class="widget-controls">
<div class="drag-handle">
<i class="bi-arrows-move"></i>
</div>
<Show when=move || on_remove.is_some()>
<button
class="control-btn remove-btn"
on:click=move |_| {
if let Some(handler) = &on_remove {
handler();
}
}
>
<i class="bi-x"></i>
</button>
</Show>
</div>
</Show>
// Widget content
<div class="widget-content">
{children()}
</div>
// Resize handles (visible when draggable)
<Show when=draggable>
<div class="resize-handles">
<div
class="resize-handle resize-e"
on:mousedown=move |e| start_resize(e, ResizeDirection::E)
></div>
<div
class="resize-handle resize-s"
on:mousedown=move |e| start_resize(e, ResizeDirection::S)
></div>
<div
class="resize-handle resize-se"
on:mousedown=move |e| start_resize(e, ResizeDirection::SE)
></div>
</div>
</Show>
</div>
}
}
#[component]
pub fn GridBackground(
layout: ReadSignal<GridLayout>,
container_width: ReadSignal<i32>,
show_grid: ReadSignal<bool>,
) -> impl IntoView {
let grid_lines_style = create_memo(move |_| {
if !show_grid.get() {
return "display: none;".to_string();
}
let layout = layout.get();
let width = container_width.get();
let column_width = width / layout.columns;
let row_height = layout.row_height;
format!(
"background-image:
linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px);
background-size: {}px {}px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;",
column_width, row_height
)
});
view! {
<div
class="grid-background"
style=move || grid_lines_style.get()
></div>
}
}
// Helper types and functions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DropData {
pub widget_id: String,
pub widget_type: String,
pub original_position: GridPosition,
pub original_size: GridSize,
}
#[derive(Debug, Clone)]
pub struct DragState {
pub widget_id: String,
pub start_position: GridPosition,
pub current_position: GridPosition,
}
#[derive(Debug, Clone, Copy)]
pub enum ResizeDirection {
E, // East
S, // South
SE, // Southeast
}
pub fn pixel_to_grid_position(x: f64, y: f64, layout: &GridLayout, container_width: i32) -> GridPosition {
let column_width = container_width as f64 / layout.columns as f64;
let row_height = layout.row_height as f64;
let grid_x = (x / column_width).floor() as i32;
let grid_y = (y / row_height).floor() as i32;
GridPosition {
x: grid_x.max(0).min(layout.columns - 1),
y: grid_y.max(0),
}
}
pub fn grid_to_pixel_position(position: GridPosition, layout: &GridLayout, container_width: i32) -> (f64, f64) {
let column_width = container_width as f64 / layout.columns as f64;
let row_height = layout.row_height as f64;
let x = position.x as f64 * column_width;
let y = position.y as f64 * row_height;
(x, y)
}

View File

@ -0,0 +1,67 @@
use leptos::*;
use crate::store::{use_app_state, use_theme};
#[component]
pub fn Header() -> impl IntoView {
let app_state = use_app_state();
let (theme, _set_theme) = use_theme();
let app_state_theme = app_state.clone();
let toggle_theme = move |_| {
app_state_theme.toggle_theme();
};
let toggle_sidebar = move |_| {
app_state.toggle_sidebar();
};
view! {
<header class="navbar bg-base-100 shadow-sm border-b border-base-300 px-6">
// Left side
<div class="navbar-start">
<button class="btn btn-ghost btn-square lg:hidden" on:click=toggle_sidebar>
""
</button>
<div class="breadcrumbs text-sm">
<ul>
<li><a>"Home"</a></li>
<li>"Dashboard"</li>
</ul>
</div>
</div>
// Right side
<div class="navbar-end gap-2">
// Theme toggle
<button class="btn btn-ghost btn-square" on:click=toggle_theme>
{move || match theme.get() {
crate::store::Theme::Light => "🌙",
crate::store::Theme::Dark => "☀️",
crate::store::Theme::Auto => "🌍",
}}
</button>
// Notifications
<div class="dropdown dropdown-end">
<button class="btn btn-ghost btn-square">
"🔔"
</button>
</div>
// User menu
<div class="dropdown dropdown-end">
<button class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
"👤"
</div>
</button>
<ul class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
<li><a>"Profile"</a></li>
<li><a>"Settings"</a></li>
<li><a>"Logout"</a></li>
</ul>
</div>
</div>
</header>
}
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,794 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use web_sys::{window, MediaQueryListEvent};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use std::rc::Rc;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ScreenSize {
Mobile, // < 768px
Tablet, // 768px - 1024px
Desktop, // 1024px - 1440px
Large, // > 1440px
}
impl ScreenSize {
pub fn from_width(width: f64) -> Self {
if width < 768.0 {
ScreenSize::Mobile
} else if width < 1024.0 {
ScreenSize::Tablet
} else if width < 1440.0 {
ScreenSize::Desktop
} else {
ScreenSize::Large
}
}
pub fn breakpoint(&self) -> &'static str {
match self {
ScreenSize::Mobile => "mobile",
ScreenSize::Tablet => "tablet",
ScreenSize::Desktop => "desktop",
ScreenSize::Large => "large",
}
}
pub fn grid_columns(&self) -> i32 {
match self {
ScreenSize::Mobile => 4,
ScreenSize::Tablet => 8,
ScreenSize::Desktop => 12,
ScreenSize::Large => 16,
}
}
pub fn sidebar_behavior(&self) -> SidebarBehavior {
match self {
ScreenSize::Mobile => SidebarBehavior::Overlay,
ScreenSize::Tablet => SidebarBehavior::Collapsible,
ScreenSize::Desktop => SidebarBehavior::Fixed,
ScreenSize::Large => SidebarBehavior::Fixed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SidebarBehavior {
Fixed, // Always visible
Collapsible, // Can be collapsed
Overlay, // Overlays content
Hidden, // Not shown
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponsiveConfig {
pub mobile_layout: MobileLayoutConfig,
pub tablet_layout: TabletLayoutConfig,
pub desktop_layout: DesktopLayoutConfig,
pub touch_enabled: bool,
pub swipe_gestures: bool,
pub auto_hide_controls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileLayoutConfig {
pub stack_widgets: bool,
pub hide_sidebar: bool,
pub collapse_navigation: bool,
pub single_column: bool,
pub touch_friendly_controls: bool,
pub swipe_navigation: bool,
pub bottom_navigation: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabletLayoutConfig {
pub collapsible_sidebar: bool,
pub compact_widgets: bool,
pub dual_pane: bool,
pub adaptive_grid: bool,
pub touch_optimized: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopLayoutConfig {
pub multi_column: bool,
pub persistent_sidebar: bool,
pub dense_layout: bool,
pub hover_interactions: bool,
pub keyboard_shortcuts: bool,
}
impl Default for ResponsiveConfig {
fn default() -> Self {
Self {
mobile_layout: MobileLayoutConfig::default(),
tablet_layout: TabletLayoutConfig::default(),
desktop_layout: DesktopLayoutConfig::default(),
touch_enabled: true,
swipe_gestures: true,
auto_hide_controls: true,
}
}
}
impl Default for MobileLayoutConfig {
fn default() -> Self {
Self {
stack_widgets: true,
hide_sidebar: true,
collapse_navigation: true,
single_column: true,
touch_friendly_controls: true,
swipe_navigation: true,
bottom_navigation: true,
}
}
}
impl Default for TabletLayoutConfig {
fn default() -> Self {
Self {
collapsible_sidebar: true,
compact_widgets: true,
dual_pane: false,
adaptive_grid: true,
touch_optimized: true,
}
}
}
impl Default for DesktopLayoutConfig {
fn default() -> Self {
Self {
multi_column: true,
persistent_sidebar: true,
dense_layout: false,
hover_interactions: true,
keyboard_shortcuts: true,
}
}
}
#[component]
pub fn Layout(children: Children) -> impl IntoView {
// Legacy layout for existing components
view! {
<div class="min-h-screen bg-base-100 text-base-content">
<div class="drawer lg:drawer-open">
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<main class="flex-1 p-6 transition-all duration-300">
<div class="max-w-7xl mx-auto">
{children()}
</div>
</main>
</div>
</div>
</div>
}
}
#[component]
pub fn ResponsiveLayout(
#[prop(optional)] config: Option<ResponsiveConfig>,
children: Children,
) -> impl IntoView {
let config = Rc::new(config.unwrap_or_default());
let config_for_layout = config.clone();
let config_for_header1 = config.clone();
let config_for_header2 = config.clone();
let config_for_header3 = config.clone();
let config_for_bottom_nav = config.clone();
let config_for_touch = config.clone();
let config_for_swipe = config.clone();
let (screen_size, set_screen_size) = create_signal(ScreenSize::Desktop);
let (is_sidebar_open, set_is_sidebar_open) = create_signal(true);
let (is_touch_device, set_is_touch_device) = create_signal(false);
// Detect screen size changes
create_effect(move |_| {
let window = window().unwrap();
// Initial screen size detection
let width = window.inner_width().unwrap().as_f64().unwrap();
set_screen_size.set(ScreenSize::from_width(width));
// Touch device detection
let navigator = window.navigator();
let has_touch = js_sys::Reflect::has(&navigator, &"maxTouchPoints".into()).unwrap_or(false) &&
js_sys::Reflect::get(&navigator, &"maxTouchPoints".into())
.unwrap()
.as_f64()
.unwrap_or(0.0) > 0.0;
set_is_touch_device.set(has_touch);
// Setup media query listeners
setup_media_queries(set_screen_size);
});
// Update sidebar state based on screen size
create_effect(move |_| {
let size = screen_size.get();
match size.sidebar_behavior() {
SidebarBehavior::Hidden => set_is_sidebar_open.set(false),
SidebarBehavior::Overlay => set_is_sidebar_open.set(false),
SidebarBehavior::Fixed => set_is_sidebar_open.set(true),
SidebarBehavior::Collapsible => {
// Keep current state
}
}
});
let layout_class = create_memo(move |_| {
let size = screen_size.get();
format!(
"responsive-layout {} {} {} {}",
size.breakpoint(),
if is_sidebar_open.get() { "sidebar-open" } else { "sidebar-closed" },
if is_touch_device.get() { "touch-device" } else { "no-touch" },
if config_for_layout.touch_enabled { "touch-enabled" } else { "" }
)
});
view! {
<div class=layout_class>
<ResponsiveHeader
screen_size=screen_size
is_sidebar_open=is_sidebar_open
on_sidebar_toggle=move |_| set_is_sidebar_open.update(|open| *open = !*open)
config=(*config_for_header1).clone()
/>
<div class="layout-body">
<Show when=move || screen_size.get().sidebar_behavior() != SidebarBehavior::Hidden>
<ResponsiveSidebar
screen_size=screen_size
is_open=is_sidebar_open
behavior=move || screen_size.get().sidebar_behavior()
on_close=move |_| set_is_sidebar_open.set(false)
config=(*config_for_header2).clone()
/>
</Show>
<main class="main-content">
<ResponsiveGrid
screen_size=screen_size
config=(*config_for_header3).clone()
>
{children()}
</ResponsiveGrid>
</main>
</div>
<Show when={let config_clone = config_for_bottom_nav.clone(); move || matches!(screen_size.get(), ScreenSize::Mobile) && config_clone.mobile_layout.bottom_navigation}>
<MobileBottomNavigation />
</Show>
<Show when={let config_clone = config_for_touch.clone(); move || config_clone.touch_enabled && is_touch_device.get()}>
<TouchGestureHandler
_screen_size=screen_size
on_swipe_right={let config_clone = config_for_swipe.clone(); move |_| {
if config_clone.swipe_gestures {
set_is_sidebar_open.set(true);
}
}}
/>
</Show>
</div>
}
}
#[component]
pub fn ResponsiveHeader(
screen_size: ReadSignal<ScreenSize>,
is_sidebar_open: ReadSignal<bool>,
on_sidebar_toggle: impl Fn(web_sys::MouseEvent) + 'static,
config: ResponsiveConfig,
) -> impl IntoView {
let on_sidebar_toggle = Rc::new(on_sidebar_toggle);
let header_class = create_memo(move |_| {
let size = screen_size.get();
format!("responsive-header {}", size.breakpoint())
});
view! {
<header class=header_class>
<div class="header-content">
<Show when=move || !matches!(screen_size.get().sidebar_behavior(), SidebarBehavior::Fixed)>
<button
class="sidebar-toggle btn-icon"
on:click={let toggle = on_sidebar_toggle.clone(); move |e| toggle(e)}
>
<i class=move || {
if is_sidebar_open.get() {
"bi-x"
} else {
"bi-list"
}
}></i>
</button>
</Show>
<div class="header-title">
<h1>"Control Center"</h1>
<Show when=move || !matches!(screen_size.get(), ScreenSize::Mobile)>
<span class="subtitle">"Dashboard"</span>
</Show>
</div>
<div class="header-actions">
<MobileHeaderActions screen_size=screen_size />
<DesktopHeaderActions screen_size=screen_size />
</div>
</div>
</header>
}
}
#[component]
pub fn MobileHeaderActions(screen_size: ReadSignal<ScreenSize>) -> impl IntoView {
view! {
<Show when=move || matches!(screen_size.get(), ScreenSize::Mobile)>
<div class="mobile-header-actions">
<button class="btn-icon" title="Search">
<i class="bi-search"></i>
</button>
<button class="btn-icon" title="Notifications">
<i class="bi-bell"></i>
</button>
<button class="btn-icon" title="Menu">
<i class="bi-three-dots-vertical"></i>
</button>
</div>
</Show>
}
}
#[component]
pub fn DesktopHeaderActions(screen_size: ReadSignal<ScreenSize>) -> impl IntoView {
view! {
<Show when=move || !matches!(screen_size.get(), ScreenSize::Mobile)>
<div class="desktop-header-actions">
<div class="search-box">
<input type="text" placeholder="Search..." class="search-input" />
<i class="bi-search search-icon"></i>
</div>
<button class="btn-icon" title="Notifications">
<i class="bi-bell"></i>
<span class="notification-badge">3</span>
</button>
<button class="btn-icon" title="Theme">
<i class="bi-moon"></i>
</button>
<div class="user-menu">
<button class="user-avatar">
<i class="bi-person-circle"></i>
</button>
</div>
</div>
</Show>
}
}
#[component]
pub fn ResponsiveSidebar(
screen_size: ReadSignal<ScreenSize>,
is_open: ReadSignal<bool>,
behavior: impl Fn() -> SidebarBehavior + 'static,
on_close: impl Fn(web_sys::MouseEvent) + 'static,
config: ResponsiveConfig,
) -> impl IntoView {
let on_close = Rc::new(on_close);
let on_close_1 = on_close.clone();
let on_close_2 = on_close.clone();
let behavior = Rc::new(behavior);
let behavior_for_memo = behavior.clone();
let sidebar_class = create_memo(move |_| {
let size = screen_size.get();
let behavior_class = match behavior_for_memo() {
SidebarBehavior::Fixed => "sidebar-fixed",
SidebarBehavior::Collapsible => "sidebar-collapsible",
SidebarBehavior::Overlay => "sidebar-overlay",
SidebarBehavior::Hidden => "sidebar-hidden",
};
format!(
"responsive-sidebar {} {} {}",
size.breakpoint(),
behavior_class,
if is_open.get() { "sidebar-open" } else { "sidebar-closed" }
)
});
view! {
<aside class=sidebar_class>
<Show when={let behavior_fn = behavior.clone(); move || matches!(behavior_fn(), SidebarBehavior::Overlay)}>
<div
class="sidebar-backdrop"
on:click={let close_fn = on_close_1.clone(); move |e| close_fn(e)}
></div>
</Show>
<div class="sidebar-content">
<div class="sidebar-header">
<div class="sidebar-brand">
<i class="bi-shield-check brand-icon"></i>
<Show when=move || !matches!(screen_size.get(), ScreenSize::Mobile) || is_open.get()>
<span class="brand-text">"Control Center"</span>
</Show>
</div>
<Show when={let behavior_fn = behavior.clone(); move || matches!(behavior_fn(), SidebarBehavior::Overlay)}>
<button
class="sidebar-close btn-icon"
on:click={let close_fn = on_close_2.clone(); move |e| close_fn(e)}
>
<i class="bi-x"></i>
</button>
</Show>
</div>
<nav class="sidebar-nav">
<SidebarNavigation
_screen_size=screen_size
is_collapsed={let behavior_fn3 = behavior.clone(); Rc::new(move || !is_open.get() && matches!(behavior_fn3(), SidebarBehavior::Collapsible))}
/>
</nav>
<div class="sidebar-footer">
<Show when=move || !matches!(screen_size.get(), ScreenSize::Mobile)>
<div class="sidebar-user">
<div class="user-avatar">
<i class="bi-person-circle"></i>
</div>
<Show when=move || is_open.get()>
<div class="user-info">
<span class="user-name">"Admin User"</span>
<span class="user-role">"Administrator"</span>
</div>
</Show>
</div>
</Show>
</div>
</div>
</aside>
}
}
#[component]
pub fn SidebarNavigation(
_screen_size: ReadSignal<ScreenSize>,
is_collapsed: Rc<dyn Fn() -> bool>,
) -> impl IntoView {
let nav_items = vec![
NavItem {
id: "dashboard".to_string(),
label: "Dashboard".to_string(),
icon: "bi-speedometer2".to_string(),
href: "/".to_string(),
active: true,
badge: None,
},
NavItem {
id: "analytics".to_string(),
label: "Analytics".to_string(),
icon: "bi-graph-up".to_string(),
href: "/analytics".to_string(),
active: false,
badge: None,
},
NavItem {
id: "security".to_string(),
label: "Security".to_string(),
icon: "bi-shield-check".to_string(),
href: "/security".to_string(),
active: false,
badge: Some("3".to_string()),
},
NavItem {
id: "users".to_string(),
label: "Users".to_string(),
icon: "bi-people".to_string(),
href: "/users".to_string(),
active: false,
badge: None,
},
NavItem {
id: "settings".to_string(),
label: "Settings".to_string(),
icon: "bi-gear".to_string(),
href: "/settings".to_string(),
active: false,
badge: None,
},
];
view! {
<ul class="nav-list">
<For
each=move || nav_items.clone()
key=|item| item.id.clone()
children=move |item| {
let badge = item.badge.clone();
let badge_clone = badge.clone();
let is_collapsed_fn = is_collapsed.clone();
let is_collapsed_fn2 = is_collapsed.clone();
let is_collapsed_fn3 = is_collapsed.clone();
view! {
<li class="nav-item">
<a
href=item.href.clone()
class=move || format!(
"nav-link {} {}",
if item.active { "active" } else { "" },
if is_collapsed_fn() { "collapsed" } else { "" }
)
>
<i class=item.icon.clone()></i>
<Show when=move || !is_collapsed_fn2()>
<span class="nav-text">{item.label.clone()}</span>
</Show>
<Show when=move || badge_clone.is_some() && !is_collapsed_fn3()>
<span class="nav-badge">
{badge.clone().unwrap_or_default()}
</span>
</Show>
</a>
</li>
}
}
/>
</ul>
}
}
#[component]
pub fn ResponsiveGrid(
screen_size: ReadSignal<ScreenSize>,
config: ResponsiveConfig,
children: Children,
) -> impl IntoView {
let grid_class = create_memo(move |_| {
let size = screen_size.get();
let columns = size.grid_columns();
format!(
"responsive-grid {} grid-cols-{}",
size.breakpoint(),
columns
)
});
let grid_style = create_memo(move |_| {
let size = screen_size.get();
match size {
ScreenSize::Mobile if config.mobile_layout.single_column => {
"display: flex; flex-direction: column; gap: 1rem;".to_string()
}
_ => {
let columns = size.grid_columns();
format!(
"display: grid; grid-template-columns: repeat({}, 1fr); gap: 1rem;",
columns
)
}
}
});
view! {
<div
class=grid_class
style=grid_style
>
{children()}
</div>
}
}
#[component]
pub fn MobileBottomNavigation() -> impl IntoView {
let nav_items = vec![
("Dashboard", "bi-speedometer2", "/", true),
("Analytics", "bi-graph-up", "/analytics", false),
("Security", "bi-shield-check", "/security", false),
("Settings", "bi-gear", "/settings", false),
];
view! {
<nav class="mobile-bottom-nav">
<div class="bottom-nav-content">
<For
each=move || nav_items.clone()
key=|item| item.0.to_string()
children=move |(label, icon, href, active)| {
view! {
<a
href=href
class=format!("bottom-nav-item {}", if active { "active" } else { "" })
>
<i class=icon></i>
<span class="bottom-nav-label">{label}</span>
</a>
}
}
/>
</div>
</nav>
}
}
#[component]
pub fn TouchGestureHandler(
_screen_size: ReadSignal<ScreenSize>,
on_swipe_right: impl Fn(TouchGesture) + 'static,
) -> impl IntoView {
let handle_touch_start = move |_event: web_sys::TouchEvent| {
// Simplified touch handler - placeholder implementation
};
let handle_touch_end = move |_event: web_sys::TouchEvent| {
// Simplified touch handler - trigger right swipe gesture
let gesture = TouchGesture {
start_x: 0.0,
start_y: 0.0,
end_x: 100.0,
end_y: 0.0,
distance: 100.0,
direction: SwipeDirection::Right,
};
on_swipe_right(gesture);
};
view! {
<div
class="touch-gesture-handler"
on:touchstart=handle_touch_start
on:touchend=handle_touch_end
style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1;"
></div>
}
}
// Supporting types and utilities
#[derive(Debug, Clone)]
pub struct NavItem {
pub id: String,
pub label: String,
pub icon: String,
pub href: String,
pub active: bool,
pub badge: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TouchGesture {
pub start_x: f64,
pub start_y: f64,
pub end_x: f64,
pub end_y: f64,
pub distance: f64,
pub direction: SwipeDirection,
}
#[derive(Debug, Clone)]
pub enum SwipeDirection {
Up,
Down,
Left,
Right,
}
fn setup_media_queries(set_screen_size: WriteSignal<ScreenSize>) {
let window = window().unwrap();
// Setup media query listeners for different breakpoints
let queries = vec![
("(max-width: 767px)", ScreenSize::Mobile),
("(min-width: 768px) and (max-width: 1023px)", ScreenSize::Tablet),
("(min-width: 1024px) and (max-width: 1439px)", ScreenSize::Desktop),
("(min-width: 1440px)", ScreenSize::Large),
];
for (query, size) in queries {
if let Ok(Some(media_query)) = window.match_media(query) {
if media_query.matches() {
set_screen_size.set(size);
}
let size_clone = size;
let set_screen_size_clone = set_screen_size;
let callback = Closure::wrap(Box::new(move |event: MediaQueryListEvent| {
if event.matches() {
set_screen_size_clone.set(size_clone);
}
}) as Box<dyn FnMut(MediaQueryListEvent)>);
media_query.set_onchange(Some(callback.as_ref().unchecked_ref()));
callback.forget();
}
}
}
// Hook for accessing responsive context
pub fn use_responsive() -> (ReadSignal<ScreenSize>, ReadSignal<bool>, ReadSignal<bool>) {
let (screen_size, _) = create_signal(ScreenSize::Desktop);
let (is_mobile, _) = create_signal(false);
let (is_touch_device, _) = create_signal(false);
// These would be updated by the ResponsiveLayout component
(screen_size, is_mobile, is_touch_device)
}
// Utility components for responsive behavior
#[component]
pub fn ShowOnMobile(children: Children) -> impl IntoView {
let (screen_size, _, _) = use_responsive();
if matches!(screen_size.get_untracked(), ScreenSize::Mobile) {
children().into_view()
} else {
().into_view()
}
}
#[component]
pub fn ShowOnTablet(children: Children) -> impl IntoView {
let (screen_size, _, _) = use_responsive();
if matches!(screen_size.get_untracked(), ScreenSize::Tablet) {
children().into_view()
} else {
().into_view()
}
}
#[component]
pub fn ShowOnDesktop(children: Children) -> impl IntoView {
let (screen_size, _, _) = use_responsive();
if matches!(screen_size.get_untracked(), ScreenSize::Desktop | ScreenSize::Large) {
children().into_view()
} else {
().into_view()
}
}
#[component]
pub fn HideOnMobile(children: Children) -> impl IntoView {
let (screen_size, _, _) = use_responsive();
if !matches!(screen_size.get_untracked(), ScreenSize::Mobile) {
children().into_view()
} else {
().into_view()
}
}
#[component]
pub fn ResponsiveText(
mobile: &'static str,
tablet: Option<&'static str>,
desktop: &'static str,
) -> impl IntoView {
let (screen_size, _, _) = use_responsive();
let text = create_memo(move |_| {
match screen_size.get() {
ScreenSize::Mobile => mobile,
ScreenSize::Tablet => tablet.unwrap_or(desktop),
ScreenSize::Desktop | ScreenSize::Large => desktop,
}
});
view! {
<span>{text}</span>
}
}

View File

@ -0,0 +1,21 @@
use leptos::*;
#[component]
pub fn LoadingSpinner(#[prop(optional)] size: Option<&'static str>) -> impl IntoView {
let size_class = size.unwrap_or("loading-md");
view! {
<div class=format!("loading loading-spinner {}", size_class)></div>
}
}
#[component]
pub fn LoadingPage() -> impl IntoView {
view! {
<div class="min-h-screen-75 flex items-center justify-center">
<div class="text-center">
<LoadingSpinner size="loading-lg" />
<p class="mt-4 text-base-content/70">"Loading..."</p>
</div>
</div>
}
}

View File

@ -0,0 +1,25 @@
pub mod auth;
pub mod layout;
pub mod sidebar;
pub mod header;
pub mod loading;
pub mod toast;
pub mod modal;
pub mod forms;
pub mod tables;
pub mod charts;
pub mod icons;
pub mod common;
pub use auth::*;
pub use layout::*;
pub use sidebar::*;
pub use header::*;
pub use loading::*;
pub use toast::*;
pub use modal::*;
pub use forms::*;
pub use tables::*;
pub use charts::*;
pub use icons::*;
pub use common::*;

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Modal() -> impl IntoView {
view! { <div></div> }
}

View File

@ -0,0 +1,504 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, Duration};
use uuid::Uuid;
use std::collections::VecDeque;
use gloo_timers::callback::Timeout;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum NotificationLevel {
Info,
Success,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationData {
pub id: String,
pub title: Option<String>,
pub message: String,
pub level: NotificationLevel,
pub timestamp: DateTime<Utc>,
pub duration: Option<u32>, // seconds, None = persistent
pub action: Option<NotificationAction>,
pub dismissible: bool,
pub progress: Option<f32>, // 0.0 to 1.0 for progress notifications
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationAction {
pub label: String,
pub action_type: String,
pub data: Option<serde_json::Value>,
}
impl NotificationData {
pub fn new(message: &str, level: NotificationLevel) -> Self {
Self {
id: Uuid::new_v4().to_string(),
title: None,
message: message.to_string(),
level,
timestamp: Utc::now(),
duration: Some(match level {
NotificationLevel::Error | NotificationLevel::Critical => 10,
NotificationLevel::Warning => 7,
NotificationLevel::Success => 5,
NotificationLevel::Info => 4,
}),
action: None,
dismissible: true,
progress: None,
}
}
pub fn with_title(mut self, title: &str) -> Self {
self.title = Some(title.to_string());
self
}
pub fn with_action(mut self, label: &str, action_type: &str) -> Self {
self.action = Some(NotificationAction {
label: label.to_string(),
action_type: action_type.to_string(),
data: None,
});
self
}
pub fn persistent(mut self) -> Self {
self.duration = None;
self
}
pub fn with_progress(mut self, progress: f32) -> Self {
self.progress = Some(progress.clamp(0.0, 1.0));
self
}
pub fn non_dismissible(mut self) -> Self {
self.dismissible = false;
self
}
}
#[component]
pub fn NotificationToast(
notification: NotificationData,
#[prop(optional)] on_dismiss: Option<Box<dyn Fn(String) + 'static>>,
#[prop(optional)] on_action: Option<Box<dyn Fn(NotificationAction) + 'static>>,
) -> impl IntoView {
let (visible, set_visible) = create_signal(true);
let (progress, set_progress) = create_signal(notification.progress.unwrap_or(0.0));
let (is_hovered, set_is_hovered) = create_signal(false);
let notification_id = notification.id.clone();
let dismiss_handler = on_dismiss.clone();
// Auto-dismiss timer
if let Some(duration) = notification.duration {
let dismiss_timer = Duration::seconds(duration as i64);
let notification_id_clone = notification_id.clone();
let set_visible_clone = set_visible;
spawn_local(async move {
if !is_hovered.get() {
gloo_timers::future::TimeoutFuture::new(duration * 1000).await;
set_visible_clone.set(false);
}
});
}
// Progress animation for progress notifications
if notification.progress.is_some() {
create_effect(move |_| {
if let Some(prog) = notification.progress {
set_progress.set(prog);
}
});
}
let toast_class = move || {
format!(
"toast-notification {} {} {}",
match notification.level {
NotificationLevel::Info => "toast-info",
NotificationLevel::Success => "toast-success",
NotificationLevel::Warning => "toast-warning",
NotificationLevel::Error => "toast-error",
NotificationLevel::Critical => "toast-critical",
},
if visible.get() { "toast-visible" } else { "toast-hidden" },
if is_hovered.get() { "toast-hovered" } else { "" }
)
};
let icon_class = move || match notification.level {
NotificationLevel::Info => "bi-info-circle",
NotificationLevel::Success => "bi-check-circle",
NotificationLevel::Warning => "bi-exclamation-triangle",
NotificationLevel::Error => "bi-x-circle",
NotificationLevel::Critical => "bi-exclamation-octagon",
};
let dismiss_notification = move |_| {
set_visible.set(false);
if let Some(handler) = &on_dismiss {
handler(notification_id.clone());
}
};
let handle_action = move |action: NotificationAction| {
if let Some(handler) = &on_action {
handler(action);
}
};
view! {
<div
class=toast_class
on:mouseenter=move |_| set_is_hovered.set(true)
on:mouseleave=move |_| set_is_hovered.set(false)
>
<div class="toast-content">
<div class="toast-icon">
<i class=icon_class></i>
</div>
<div class="toast-body">
<Show when=move || notification.title.is_some()>
<div class="toast-title">
{notification.title.clone().unwrap_or_default()}
</div>
</Show>
<div class="toast-message">
{notification.message.clone()}
</div>
<Show when=move || notification.progress.is_some()>
<div class="toast-progress">
<div class="progress-bar">
<div
class="progress-fill"
style=move || format!("width: {}%", progress.get() * 100.0)
></div>
</div>
<span class="progress-text">
{move || format!("{:.0}%", progress.get() * 100.0)}
</span>
</div>
</Show>
<div class="toast-timestamp">
{notification.timestamp.format("%H:%M:%S").to_string()}
</div>
</div>
<div class="toast-actions">
<Show when=move || notification.action.is_some()>
<button
class="toast-action-btn"
on:click=move |_| {
if let Some(action) = &notification.action {
handle_action(action.clone());
}
}
>
{notification.action.as_ref().map(|a| a.label.clone()).unwrap_or_default()}
</button>
</Show>
<Show when=move || notification.dismissible>
<button
class="toast-dismiss-btn"
on:click=dismiss_notification
title="Dismiss"
>
<i class="bi-x"></i>
</button>
</Show>
</div>
</div>
// Auto-dismiss progress indicator
<Show when=move || notification.duration.is_some() && !is_hovered.get()>
<div class="toast-timer">
<div class="timer-progress"></div>
</div>
</Show>
</div>
}
}
#[component]
pub fn NotificationContainer(
notifications: ReadSignal<Vec<NotificationData>>,
#[prop(optional)] position: NotificationPosition,
#[prop(optional)] max_visible: Option<usize>,
#[prop(optional)] on_dismiss: Option<Box<dyn Fn(String) + 'static>>,
#[prop(optional)] on_action: Option<Box<dyn Fn(NotificationAction) + 'static>>,
) -> impl IntoView {
let max_visible = max_visible.unwrap_or(5);
let container_class = move || {
format!(
"notification-container {}",
match position {
NotificationPosition::TopRight => "position-top-right",
NotificationPosition::TopLeft => "position-top-left",
NotificationPosition::BottomRight => "position-bottom-right",
NotificationPosition::BottomLeft => "position-bottom-left",
NotificationPosition::TopCenter => "position-top-center",
NotificationPosition::BottomCenter => "position-bottom-center",
}
)
};
let visible_notifications = create_memo(move |_| {
let mut notifications = notifications.get();
// Sort by timestamp (newest first for top positions, oldest first for bottom positions)
match position {
NotificationPosition::TopRight | NotificationPosition::TopLeft | NotificationPosition::TopCenter => {
notifications.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
}
NotificationPosition::BottomRight | NotificationPosition::BottomLeft | NotificationPosition::BottomCenter => {
notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
}
}
notifications.into_iter().take(max_visible).collect::<Vec<_>>()
});
view! {
<div class=container_class>
<For
each=move || visible_notifications.get()
key=|notification| notification.id.clone()
children=move |notification| {
view! {
<NotificationToast
notification=notification
on_dismiss=on_dismiss.clone()
on_action=on_action.clone()
/>
}
}
/>
</div>
}
}
#[derive(Debug, Clone, Copy)]
pub enum NotificationPosition {
TopRight,
TopLeft,
BottomRight,
BottomLeft,
TopCenter,
BottomCenter,
}
impl Default for NotificationPosition {
fn default() -> Self {
NotificationPosition::TopRight
}
}
// Notification manager for global state
#[derive(Debug, Clone)]
pub struct NotificationManager {
notifications: RwSignal<VecDeque<NotificationData>>,
max_notifications: usize,
}
impl NotificationManager {
pub fn new(max_notifications: usize) -> Self {
Self {
notifications: create_rw_signal(VecDeque::new()),
max_notifications,
}
}
pub fn show(&self, notification: NotificationData) {
self.notifications.update(|notifications| {
notifications.push_front(notification);
// Limit the number of notifications
while notifications.len() > self.max_notifications {
notifications.pop_back();
}
});
}
pub fn show_info(&self, message: &str) {
self.show(NotificationData::new(message, NotificationLevel::Info));
}
pub fn show_success(&self, message: &str) {
self.show(NotificationData::new(message, NotificationLevel::Success));
}
pub fn show_warning(&self, message: &str) {
self.show(NotificationData::new(message, NotificationLevel::Warning));
}
pub fn show_error(&self, message: &str) {
self.show(NotificationData::new(message, NotificationLevel::Error));
}
pub fn show_critical(&self, message: &str) {
self.show(NotificationData::new(message, NotificationLevel::Critical).persistent());
}
pub fn show_progress(&self, message: &str, progress: f32) {
let notification = NotificationData::new(message, NotificationLevel::Info)
.with_progress(progress)
.non_dismissible();
self.show(notification);
}
pub fn update_progress(&self, notification_id: &str, progress: f32) {
self.notifications.update(|notifications| {
if let Some(notification) = notifications.iter_mut().find(|n| n.id == notification_id) {
notification.progress = Some(progress.clamp(0.0, 1.0));
}
});
}
pub fn dismiss(&self, notification_id: &str) {
self.notifications.update(|notifications| {
notifications.retain(|n| n.id != notification_id);
});
}
pub fn dismiss_all(&self) {
self.notifications.update(|notifications| {
notifications.clear();
});
}
pub fn dismiss_by_level(&self, level: NotificationLevel) {
self.notifications.update(|notifications| {
notifications.retain(|n| n.level != level);
});
}
pub fn get_notifications(&self) -> ReadSignal<Vec<NotificationData>> {
create_memo(move |_| {
self.notifications.get().iter().cloned().collect()
}).into()
}
pub fn get_count(&self) -> ReadSignal<usize> {
create_memo(move |_| {
self.notifications.get().len()
}).into()
}
pub fn get_count_by_level(&self, level: NotificationLevel) -> ReadSignal<usize> {
create_memo(move |_| {
self.notifications.get().iter().filter(|n| n.level == level).count()
}).into()
}
}
// Global notification context
#[derive(Clone)]
pub struct NotificationContext {
manager: NotificationManager,
}
impl NotificationContext {
pub fn new() -> Self {
Self {
manager: NotificationManager::new(50),
}
}
pub fn provide() {
provide_context(Self::new());
}
pub fn use_notifications() -> NotificationManager {
use_context::<NotificationContext>()
.expect("NotificationContext not provided")
.manager
}
}
// Notification hook for easy usage
pub fn use_notifications() -> NotificationManager {
NotificationContext::use_notifications()
}
// Predefined notification templates
pub mod templates {
use super::*;
pub fn save_success() -> NotificationData {
NotificationData::new("Changes saved successfully", NotificationLevel::Success)
.with_title("Saved")
}
pub fn save_error(error: &str) -> NotificationData {
NotificationData::new(&format!("Failed to save: {}", error), NotificationLevel::Error)
.with_title("Save Error")
.with_action("Retry", "retry_save")
}
pub fn network_error() -> NotificationData {
NotificationData::new("Network connection error. Please check your internet connection.", NotificationLevel::Error)
.with_title("Connection Error")
.with_action("Retry", "retry_connection")
}
pub fn unauthorized() -> NotificationData {
NotificationData::new("Your session has expired. Please log in again.", NotificationLevel::Warning)
.with_title("Session Expired")
.with_action("Login", "redirect_login")
.persistent()
}
pub fn websocket_connected() -> NotificationData {
NotificationData::new("Real-time connection established", NotificationLevel::Success)
.with_title("Connected")
}
pub fn websocket_disconnected() -> NotificationData {
NotificationData::new("Real-time connection lost. Attempting to reconnect...", NotificationLevel::Warning)
.with_title("Disconnected")
.persistent()
}
pub fn export_started(format: &str) -> NotificationData {
NotificationData::new(&format!("Starting {} export...", format), NotificationLevel::Info)
.with_title("Export Started")
.with_progress(0.0)
}
pub fn export_completed(format: &str) -> NotificationData {
NotificationData::new(&format!("{} export completed successfully", format), NotificationLevel::Success)
.with_title("Export Complete")
.with_action("Download", "download_export")
}
pub fn widget_added(widget_type: &str) -> NotificationData {
NotificationData::new(&format!("{} widget added to dashboard", widget_type), NotificationLevel::Info)
}
pub fn widget_removed(widget_type: &str) -> NotificationData {
NotificationData::new(&format!("{} widget removed from dashboard", widget_type), NotificationLevel::Info)
}
pub fn dashboard_saved() -> NotificationData {
NotificationData::new("Dashboard layout saved", NotificationLevel::Success)
}
pub fn theme_changed(theme: &str) -> NotificationData {
NotificationData::new(&format!("Theme changed to {}", theme), NotificationLevel::Info)
}
}

View File

@ -0,0 +1,28 @@
pub mod policy_editor;
pub mod monaco_integration;
pub mod template_library;
pub mod testing_sandbox;
pub mod dry_run_evaluator;
pub mod impact_analyzer;
pub mod version_manager;
pub mod diff_viewer;
pub mod approval_workflow;
pub mod metrics_dashboard;
pub mod violation_debugger;
pub mod role_mining;
pub mod what_if_simulator;
// Re-export main components
pub use policy_editor::PolicyEditor;
pub use monaco_integration::MonacoEditor;
pub use template_library::TemplateLibrary;
pub use testing_sandbox::TestingSandbox;
pub use dry_run_evaluator::DryRunEvaluator;
pub use impact_analyzer::ImpactAnalyzer;
pub use version_manager::VersionManager;
pub use diff_viewer::DiffViewer;
pub use approval_workflow::ApprovalWorkflow;
pub use metrics_dashboard::MetricsDashboard;
pub use violation_debugger::ViolationDebugger;
pub use role_mining::RoleMining;
pub use what_if_simulator::WhatIfSimulator;

View File

@ -0,0 +1,474 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use web_sys::js_sys;
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyTemplate {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub content: String,
pub variables: Vec<PolicyVariable>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyVariable {
pub name: String,
pub description: String,
pub var_type: String,
pub default_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEvaluation {
pub policy_id: String,
pub test_cases: Vec<TestCase>,
pub results: Vec<EvaluationResult>,
pub impact_analysis: ImpactAnalysis,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestCase {
pub id: String,
pub name: String,
pub principal: String,
pub action: String,
pub resource: String,
pub context: serde_json::Value,
pub expected_result: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationResult {
pub test_case_id: String,
pub decision: String,
pub reasons: Vec<String>,
pub passed: bool,
pub execution_time_ms: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImpactAnalysis {
pub affected_users: u32,
pub affected_resources: u32,
pub risk_level: String,
pub recommendations: Vec<String>,
}
#[component]
pub fn PolicyEditor() -> impl IntoView {
let (current_policy, set_current_policy) = create_signal(String::new());
let (selected_template, set_selected_template) = create_signal(None::<PolicyTemplate>);
let (test_results, set_test_results) = create_signal(None::<PolicyEvaluation>);
let (is_testing, set_is_testing) = create_signal(false);
let (show_templates, set_show_templates) = create_signal(false);
let (show_diff_viewer, set_show_diff_viewer) = create_signal(false);
let (policy_versions, set_policy_versions) = create_signal(Vec::<PolicyVersion>::new());
let templates = create_signal(vec![
PolicyTemplate {
id: "require-mfa".to_string(),
name: "Require Multi-Factor Authentication".to_string(),
description: "Enforce MFA for sensitive operations".to_string(),
category: "Security".to_string(),
content: r#"permit (
principal is User,
action in [Action::"create_server", Action::"delete_server"],
resource
) when {
principal.mfa_enabled == true &&
context.mfa_verified == true
};"#.to_string(),
variables: vec![],
},
PolicyTemplate {
id: "production-approval".to_string(),
name: "Production Environment Approval".to_string(),
description: "Require approval for production changes".to_string(),
category: "Governance".to_string(),
content: r#"permit (
principal is User,
action,
resource is Infrastructure
) when {
resource.environment != "production" ||
(resource.environment == "production" &&
context.approval_count >= {{min_approvals}})
};"#.to_string(),
variables: vec![
PolicyVariable {
name: "min_approvals".to_string(),
description: "Minimum number of approvals required".to_string(),
var_type: "number".to_string(),
default_value: Some("2".to_string()),
},
],
},
PolicyTemplate {
id: "time-based-access".to_string(),
name: "Time-Based Access Control".to_string(),
description: "Restrict access to business hours".to_string(),
category: "Access Control".to_string(),
content: r#"permit (
principal is User,
action,
resource
) when {
context.current_time >= time("{{start_time}}") &&
context.current_time <= time("{{end_time}}") &&
context.day_of_week in {{allowed_days}}
};"#.to_string(),
variables: vec![
PolicyVariable {
name: "start_time".to_string(),
description: "Start time (HH:MM format)".to_string(),
var_type: "time".to_string(),
default_value: Some("09:00".to_string()),
},
PolicyVariable {
name: "end_time".to_string(),
description: "End time (HH:MM format)".to_string(),
var_type: "time".to_string(),
default_value: Some("17:00".to_string()),
},
PolicyVariable {
name: "allowed_days".to_string(),
description: "Allowed days of week".to_string(),
var_type: "array".to_string(),
default_value: Some(r#"["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]"#.to_string()),
},
],
},
]).0;
// Initialize Monaco Editor
create_effect(move |_| {
let editor_element = web_sys::window()
.unwrap()
.document()
.unwrap()
.get_element_by_id("monaco-editor");
if let Some(element) = editor_element {
// Initialize Monaco with Cedar syntax highlighting
init_monaco_editor(&element, current_policy.get());
}
});
let run_policy_test = move || {
set_is_testing.set(true);
// Simulate policy evaluation
spawn_local(async move {
// In a real implementation, this would call the Cedar engine
let mock_results = PolicyEvaluation {
policy_id: "test-policy".to_string(),
test_cases: vec![
TestCase {
id: "test-1".to_string(),
name: "Admin with MFA".to_string(),
principal: "User::\"admin\"".to_string(),
action: "Action::\"create_server\"".to_string(),
resource: "Resource::\"production\"".to_string(),
context: serde_json::json!({
"mfa_enabled": true,
"mfa_verified": true
}),
expected_result: "Allow".to_string(),
},
],
results: vec![
EvaluationResult {
test_case_id: "test-1".to_string(),
decision: "Allow".to_string(),
reasons: vec!["MFA requirements satisfied".to_string()],
passed: true,
execution_time_ms: 15,
},
],
impact_analysis: ImpactAnalysis {
affected_users: 25,
affected_resources: 100,
risk_level: "Medium".to_string(),
recommendations: vec![
"Consider adding time-based restrictions".to_string(),
"Monitor for policy violations".to_string(),
],
},
};
set_test_results.set(Some(mock_results));
set_is_testing.set(false);
});
};
view! {
<div class="policy-editor h-full flex flex-col">
<div class="flex-none border-b border-base-300 p-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">"Cedar Policy Editor"</h2>
<div class="flex gap-2">
<button
class="btn btn-outline btn-sm"
on:click=move |_| set_show_templates.set(!show_templates.get())
>
"Templates"
</button>
<button
class="btn btn-outline btn-sm"
on:click=move |_| set_show_diff_viewer.set(!show_diff_viewer.get())
>
"Version History"
</button>
<button
class="btn btn-primary btn-sm"
on:click=move |_| run_policy_test()
disabled=move || is_testing.get()
>
{move || if is_testing.get() { "Testing..." } else { "Test Policy" }}
</button>
</div>
</div>
</div>
<div class="flex-1 flex">
// Left Panel - Editor and Templates
<div class="flex-1 flex flex-col">
// Template Library
{move || if show_templates.get() {
view! {
<div class="h-48 border-b border-base-300 p-4">
<h3 class="font-semibold mb-3">"Policy Templates"</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{templates.get().into_iter().map(|template| {
let template_clone = template.clone();
view! {
<div class="card bg-base-200 p-3 cursor-pointer hover:bg-base-300"
on:click=move |_| {
set_current_policy.set(template_clone.content.clone());
set_selected_template.set(Some(template_clone.clone()));
set_show_templates.set(false);
}
>
<h4 class="font-medium text-sm">{&template.name}</h4>
<p class="text-xs text-base-content/70 mt-1">{&template.description}</p>
<div class="badge badge-outline badge-xs mt-2">{&template.category}</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
</div>
}.into()
} else {
view! { <div></div> }.into()
}}
// Monaco Editor
<div class="flex-1 relative">
<div id="monaco-editor" class="w-full h-full"></div>
</div>
// Syntax and Validation Errors
<div class="flex-none h-32 border-t border-base-300 bg-base-100 p-4">
<h3 class="font-semibold mb-2">"Syntax Check"</h3>
<div class="text-sm text-success">
"✓ Policy syntax is valid"
</div>
</div>
</div>
// Right Panel - Testing and Results
<div class="w-1/3 border-l border-base-300 flex flex-col">
// Test Cases
<div class="flex-none p-4 border-b border-base-300">
<h3 class="font-semibold mb-3">"Test Scenarios"</h3>
<TestCaseBuilder />
</div>
// Results
<div class="flex-1 p-4">
{move || {
if let Some(results) = test_results.get() {
view! {
<div>
<h3 class="font-semibold mb-3">"Test Results"</h3>
{results.results.into_iter().map(|result| {
view! {
<div class="mb-3 p-3 rounded border border-base-300">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">{result.test_case_id}</span>
<div class={format!("badge {}",
if result.passed { "badge-success" } else { "badge-error" }
)}>
{result.decision}
</div>
</div>
<div class="text-sm text-base-content/70">
{result.reasons.join(", ")}
</div>
<div class="text-xs text-base-content/50 mt-1">
{format!("{}ms", result.execution_time_ms)}
</div>
</div>
}
}).collect::<Vec<_>>()}
// Impact Analysis
<div class="mt-4 p-3 bg-base-200 rounded">
<h4 class="font-medium mb-2">"Impact Analysis"</h4>
<div class="text-sm space-y-1">
<div>"Affected Users: " {results.impact_analysis.affected_users}</div>
<div>"Affected Resources: " {results.impact_analysis.affected_resources}</div>
<div>"Risk Level: "
<span class={format!("badge badge-{}",
match results.impact_analysis.risk_level.as_str() {
"Low" => "success",
"Medium" => "warning",
"High" => "error",
_ => "neutral"
}
)}>
{&results.impact_analysis.risk_level}
</span>
</div>
</div>
</div>
</div>
}.into()
} else {
view! {
<div class="text-center text-base-content/50 mt-8">
"Run tests to see results"
</div>
}.into()
}
}}
</div>
</div>
</div>
// Version Diff Viewer Modal
{move || if show_diff_viewer.get() {
view! {
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-base-100 rounded-lg w-4/5 h-4/5 flex flex-col">
<div class="flex-none p-4 border-b border-base-300 flex justify-between items-center">
<h3 class="text-lg font-semibold">"Policy Version History"</h3>
<button
class="btn btn-ghost btn-sm"
on:click=move |_| set_show_diff_viewer.set(false)
>
""
</button>
</div>
<div class="flex-1 p-4">
<PolicyDiffViewer />
</div>
</div>
</div>
}.into()
} else {
view! { <div></div> }.into()
}}
</div>
}
}
#[component]
fn TestCaseBuilder() -> impl IntoView {
let (principal, set_principal) = create_signal("User::\"alice\"".to_string());
let (action, set_action) = create_signal("Action::\"read\"".to_string());
let (resource, set_resource) = create_signal("Resource::\"document\"".to_string());
view! {
<div class="space-y-3">
<div>
<label class="label label-text text-xs">"Principal"</label>
<input
type="text"
class="input input-bordered input-sm w-full"
prop:value=move || principal.get()
on:input=move |ev| set_principal.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label label-text text-xs">"Action"</label>
<input
type="text"
class="input input-bordered input-sm w-full"
prop:value=move || action.get()
on:input=move |ev| set_action.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label label-text text-xs">"Resource"</label>
<input
type="text"
class="input input-bordered input-sm w-full"
prop:value=move || resource.get()
on:input=move |ev| set_resource.set(event_target_value(&ev))
/>
</div>
<button class="btn btn-outline btn-sm w-full">"Add Test Case"</button>
</div>
}
}
#[component]
fn PolicyDiffViewer() -> impl IntoView {
view! {
<div class="h-full flex">
<div class="w-1/4 border-r border-base-300 pr-4">
<h4 class="font-medium mb-3">"Versions"</h4>
<div class="space-y-2">
<div class="p-2 bg-primary text-primary-content rounded text-sm">
"v1.2.0 (current)"
<div class="text-xs opacity-70">"2024-01-15"</div>
</div>
<div class="p-2 bg-base-200 rounded text-sm cursor-pointer hover:bg-base-300">
"v1.1.0"
<div class="text-xs opacity-70">"2024-01-10"</div>
</div>
<div class="p-2 bg-base-200 rounded text-sm cursor-pointer hover:bg-base-300">
"v1.0.0"
<div class="text-xs opacity-70">"2024-01-01"</div>
</div>
</div>
</div>
<div class="flex-1 pl-4">
<h4 class="font-medium mb-3">"Changes in v1.2.0"</h4>
<div class="font-mono text-sm bg-base-200 p-4 rounded">
<div class="text-success">"+ Added MFA requirement for sensitive actions"</div>
<div class="text-error">"- Removed time-based restrictions"</div>
<div class="text-info">"~ Modified approval threshold from 1 to 2"</div>
</div>
</div>
</div>
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyVersion {
pub id: String,
pub version: String,
pub created_at: String,
pub author: String,
pub changes: String,
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = ["window", "monaco", "editor"])]
fn create(element: &web_sys::Element, options: &js_sys::Object) -> js_sys::Object;
}
fn init_monaco_editor(element: &web_sys::Element, initial_value: String) {
let options = js_sys::Object::new();
js_sys::Reflect::set(&options, &"value".into(), &initial_value.into()).unwrap();
js_sys::Reflect::set(&options, &"language".into(), &"cedar".into()).unwrap();
js_sys::Reflect::set(&options, &"theme".into(), &"vs-dark".into()).unwrap();
create(element, &options);
}

View File

@ -0,0 +1,66 @@
use leptos::*;
use leptos_router::*;
use crate::store::use_sidebar_collapsed;
#[component]
pub fn Sidebar() -> impl IntoView {
let (sidebar_collapsed, set_sidebar_collapsed) = use_sidebar_collapsed();
let nav_items = vec![
("Dashboard", "/dashboard", "📊"),
("Servers", "/servers", "🖥️"),
("Clusters", "/clusters", "☸️"),
("TaskServs", "/taskservs", "⚙️"),
("Workflows", "/workflows", "🔄"),
("Settings", "/settings", "⚙️"),
];
view! {
<aside class=move || format!(
"sidebar bg-base-200 text-base-content transition-all duration-300 {}",
if sidebar_collapsed.get() { "sidebar-collapsed" } else { "sidebar-expanded" }
)>
// Logo and toggle
<div class="p-4 border-b border-base-300">
<div class="flex items-center justify-between">
<Show when=move || !sidebar_collapsed.get()>
<h1 class="text-xl font-bold text-primary">"Control Center"</h1>
</Show>
<button
class="btn btn-ghost btn-sm btn-square"
on:click=move |_| set_sidebar_collapsed.set(!sidebar_collapsed.get())
>
{move || if sidebar_collapsed.get() { "" } else { "" }}
</button>
</div>
</div>
// Navigation
<nav class="flex-1 p-2">
<ul class="menu">
{nav_items.into_iter().map(|(label, href, icon)| {
view! {
<li>
<A href=href class="nav-item">
<span class="nav-item-icon">{icon}</span>
<Show when=move || !sidebar_collapsed.get()>
<span class="nav-item-text">{label}</span>
</Show>
</A>
</li>
}
}).collect::<Vec<_>>()}
</ul>
</nav>
// Footer
<div class="p-4 border-t border-base-300">
<Show when=move || !sidebar_collapsed.get()>
<div class="text-xs text-base-content/60">
"Control Center UI v1.0.0"
</div>
</Show>
</div>
</aside>
}
}

View File

@ -0,0 +1,6 @@
use leptos::*;
#[component]
pub fn Placeholder() -> impl IntoView {
view! { <div>"Placeholder"</div> }
}

View File

@ -0,0 +1,651 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use web_sys::{window, Document, Element};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Theme {
Light,
Dark,
Auto, // System preference
Custom(String),
}
impl Theme {
pub fn as_string(&self) -> String {
match self {
Theme::Light => "light".to_string(),
Theme::Dark => "dark".to_string(),
Theme::Auto => "auto".to_string(),
Theme::Custom(name) => name.clone(),
}
}
pub fn from_string(s: &str) -> Self {
match s {
"light" => Theme::Light,
"dark" => Theme::Dark,
"auto" => Theme::Auto,
custom => Theme::Custom(custom.to_string()),
}
}
pub fn display_name(&self) -> &str {
match self {
Theme::Light => "Light",
Theme::Dark => "Dark",
Theme::Auto => "Auto",
Theme::Custom(name) => name,
}
}
pub fn icon(&self) -> &str {
match self {
Theme::Light => "bi-sun",
Theme::Dark => "bi-moon",
Theme::Auto => "bi-circle-half",
Theme::Custom(_) => "bi-palette",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
pub current_theme: Theme,
pub available_themes: Vec<Theme>,
pub custom_properties: HashMap<String, String>,
pub auto_switch_enabled: bool,
pub system_preference_override: Option<Theme>,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
current_theme: Theme::Auto,
available_themes: vec![
Theme::Light,
Theme::Dark,
Theme::Auto,
],
custom_properties: HashMap::new(),
auto_switch_enabled: true,
system_preference_override: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ThemeContext {
pub config: RwSignal<ThemeConfig>,
pub resolved_theme: ReadSignal<Theme>,
pub is_dark_mode: ReadSignal<bool>,
}
impl ThemeContext {
pub fn new() -> Self {
// Load theme from localStorage or use default
let stored_config = LocalStorage::get::<ThemeConfig>("dashboard_theme_config")
.unwrap_or_default();
let config = create_rw_signal(stored_config);
// Create system theme preference detector
let system_theme = create_signal_from_system_preference();
// Resolve the actual theme to use
let resolved_theme = create_memo(move |_| {
let current_config = config.get();
match current_config.current_theme {
Theme::Auto => {
if let Some(override_theme) = current_config.system_preference_override {
override_theme
} else {
system_theme.get()
}
}
theme => theme,
}
});
let is_dark_mode = create_memo(move |_| {
matches!(resolved_theme.get(), Theme::Dark | Theme::Custom(ref name) if name.contains("dark"))
});
// Save config to localStorage when it changes
create_effect(move |_| {
let current_config = config.get();
let _ = LocalStorage::set("dashboard_theme_config", &current_config);
});
// Apply theme to document
create_effect(move |_| {
apply_theme_to_document(&resolved_theme.get(), &config.get().custom_properties);
});
Self {
config,
resolved_theme: resolved_theme.into(),
is_dark_mode: is_dark_mode.into(),
}
}
pub fn set_theme(&self, theme: Theme) {
self.config.update(|config| {
config.current_theme = theme;
});
}
pub fn toggle_theme(&self) {
let current = self.resolved_theme.get();
let new_theme = match current {
Theme::Light => Theme::Dark,
Theme::Dark => Theme::Light,
Theme::Auto => {
// Toggle based on current resolved theme
if self.is_dark_mode.get() {
Theme::Light
} else {
Theme::Dark
}
}
Theme::Custom(_) => Theme::Light, // Default fallback
};
self.set_theme(new_theme);
}
pub fn add_custom_property(&self, property: String, value: String) {
self.config.update(|config| {
config.custom_properties.insert(property, value);
});
}
pub fn remove_custom_property(&self, property: &str) {
self.config.update(|config| {
config.custom_properties.remove(property);
});
}
pub fn reset_to_defaults(&self) {
self.config.set(ThemeConfig::default());
}
pub fn get_css_variables(&self) -> HashMap<String, String> {
let theme = self.resolved_theme.get();
let config = self.config.get();
let mut variables = get_theme_css_variables(&theme);
// Add custom properties
for (key, value) in config.custom_properties {
variables.insert(key, value);
}
variables
}
}
#[component]
pub fn ThemeProvider(
#[prop(optional)] theme: Option<ReadSignal<String>>,
children: Children,
) -> impl IntoView {
// Create theme context
let theme_context = ThemeContext::new();
// If a specific theme is provided via prop, use it
if let Some(theme_signal) = theme {
create_effect(move |_| {
let theme_str = theme_signal.get();
if !theme_str.is_empty() {
theme_context.set_theme(Theme::from_string(&theme_str));
}
});
}
// Provide context to children
provide_context(theme_context.clone());
// Apply theme classes to body
create_effect(move |_| {
if let Some(body) = document().body() {
let theme = theme_context.resolved_theme.get();
let theme_class = format!("theme-{}", theme.as_string());
// Remove existing theme classes
let class_list = body.class_list();
for i in 0..class_list.length() {
if let Some(class_name) = class_list.item(i) {
if class_name.starts_with("theme-") {
let _ = class_list.remove_1(&class_name);
}
}
}
// Add current theme class
let _ = class_list.add_1(&theme_class);
// Add dark mode class for convenience
if theme_context.is_dark_mode.get() {
let _ = class_list.add_1("dark-mode");
} else {
let _ = class_list.remove_1("dark-mode");
}
}
});
view! {
<div class="theme-provider">
{children()}
</div>
}
}
#[component]
pub fn ThemeToggleButton(
#[prop(optional)] size: Option<ButtonSize>,
#[prop(optional)] variant: Option<ButtonVariant>,
#[prop(optional)] show_label: Option<bool>,
) -> impl IntoView {
let theme_context = use_theme_context();
let size = size.unwrap_or(ButtonSize::Medium);
let variant = variant.unwrap_or(ButtonVariant::Ghost);
let show_label = show_label.unwrap_or(false);
let button_class = move || {
format!(
"theme-toggle-btn btn-{} btn-{}",
size.as_str(),
variant.as_str()
)
};
let current_theme = theme_context.resolved_theme;
let is_dark = theme_context.is_dark_mode;
let toggle_theme = move |_| {
theme_context.toggle_theme();
};
view! {
<button
class=button_class
on:click=toggle_theme
title=move || format!("Switch to {} theme", if is_dark.get() { "light" } else { "dark" })
>
<i class=move || current_theme.get().icon()></i>
<Show when=move || show_label>
<span class="btn-label">
{move || current_theme.get().display_name()}
</span>
</Show>
</button>
}
}
#[component]
pub fn ThemeSelector(
#[prop(optional)] compact: Option<bool>,
) -> impl IntoView {
let theme_context = use_theme_context();
let compact = compact.unwrap_or(false);
let (show_dropdown, set_show_dropdown) = create_signal(false);
let config = theme_context.config;
let current_theme = theme_context.resolved_theme;
let select_theme = move |theme: Theme| {
theme_context.set_theme(theme);
set_show_dropdown.set(false);
};
let toggle_dropdown = move |_| {
set_show_dropdown.update(|show| *show = !*show);
};
view! {
<div class="theme-selector" class:compact=compact>
<button
class="theme-selector-trigger"
on:click=toggle_dropdown
>
<i class=move || current_theme.get().icon()></i>
<Show when=move || !compact>
<span class="theme-name">
{move || current_theme.get().display_name()}
</span>
</Show>
<i class="bi-chevron-down dropdown-icon"></i>
</button>
<Show when=move || show_dropdown.get()>
<div class="theme-dropdown">
<For
each=move || config.get().available_themes
key=|theme| theme.as_string()
children=move |theme| {
let is_current = create_memo(move |_| {
config.get().current_theme == theme
});
view! {
<button
class=move || format!("theme-option {}", if is_current.get() { "active" } else { "" })
on:click=move |_| select_theme(theme.clone())
>
<i class=theme.icon()></i>
<span class="theme-label">{theme.display_name()}</span>
<Show when=move || is_current.get()>
<i class="bi-check theme-check"></i>
</Show>
</button>
}
}
/>
<div class="theme-dropdown-divider"></div>
<button class="theme-option" on:click=move |_| {
// Open theme customization
set_show_dropdown.set(false);
}>
<i class="bi-palette"></i>
<span class="theme-label">"Customize"</span>
</button>
</div>
</Show>
</div>
}
}
#[component]
pub fn ThemeCustomizer() -> impl IntoView {
let theme_context = use_theme_context();
let (is_open, set_is_open) = create_signal(false);
let config = theme_context.config;
let css_variables = create_memo(move |_| theme_context.get_css_variables());
// Color customization
let (custom_primary, set_custom_primary) = create_signal("#007bff".to_string());
let (custom_secondary, set_custom_secondary) = create_signal("#6c757d".to_string());
let (custom_background, set_custom_background) = create_signal("#ffffff".to_string());
let apply_custom_colors = move |_| {
theme_context.add_custom_property("--color-primary".to_string(), custom_primary.get());
theme_context.add_custom_property("--color-secondary".to_string(), custom_secondary.get());
theme_context.add_custom_property("--color-background".to_string(), custom_background.get());
};
let reset_customizations = move |_| {
theme_context.config.update(|config| {
config.custom_properties.clear();
});
};
view! {
<div class="theme-customizer">
<button
class="customizer-trigger"
on:click=move |_| set_is_open.update(|open| *open = !*open)
>
<i class="bi-palette"></i>
"Customize Theme"
</button>
<Show when=move || is_open.get()>
<div class="customizer-panel">
<div class="customizer-header">
<h3>"Theme Customization"</h3>
<button
class="close-btn"
on:click=move |_| set_is_open.set(false)
>
<i class="bi-x"></i>
</button>
</div>
<div class="customizer-content">
<div class="color-section">
<h4>"Colors"</h4>
<div class="color-input-group">
<label>"Primary Color"</label>
<input
type="color"
prop:value=custom_primary
on:input=move |ev| set_custom_primary.set(event_target_value(&ev))
/>
</div>
<div class="color-input-group">
<label>"Secondary Color"</label>
<input
type="color"
prop:value=custom_secondary
on:input=move |ev| set_custom_secondary.set(event_target_value(&ev))
/>
</div>
<div class="color-input-group">
<label>"Background Color"</label>
<input
type="color"
prop:value=custom_background
on:input=move |ev| set_custom_background.set(event_target_value(&ev))
/>
</div>
</div>
<div class="preview-section">
<h4>"Preview"</h4>
<div class="theme-preview">
<div class="preview-card">
<div class="preview-header" style=move || format!("background-color: {}", custom_primary.get())>
"Header"
</div>
<div class="preview-content" style=move || format!("background-color: {}", custom_background.get())>
"Content area with custom colors"
</div>
</div>
</div>
</div>
<div class="customizer-actions">
<button
class="btn btn-primary"
on:click=apply_custom_colors
>
"Apply Changes"
</button>
<button
class="btn btn-secondary"
on:click=reset_customizations
>
"Reset to Default"
</button>
</div>
<div class="css-variables-section">
<h4>"CSS Variables"</h4>
<div class="variables-list">
<For
each=move || css_variables.get().into_iter().collect::<Vec<_>>()
key=|item| item.0.clone()
children=move |(var_name, var_value)| {
view! {
<div class="variable-item">
<code class="variable-name">{var_name}</code>
<code class="variable-value">{var_value}</code>
</div>
}
}
/>
</div>
</div>
</div>
</div>
</Show>
</div>
}
}
// Supporting types and utilities
#[derive(Debug, Clone, Copy)]
pub enum ButtonSize {
Small,
Medium,
Large,
}
impl ButtonSize {
pub fn as_str(&self) -> &'static str {
match self {
ButtonSize::Small => "sm",
ButtonSize::Medium => "md",
ButtonSize::Large => "lg",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ButtonVariant {
Primary,
Secondary,
Ghost,
Outline,
}
impl ButtonVariant {
pub fn as_str(&self) -> &'static str {
match self {
ButtonVariant::Primary => "primary",
ButtonVariant::Secondary => "secondary",
ButtonVariant::Ghost => "ghost",
ButtonVariant::Outline => "outline",
}
}
}
// Utility functions
pub fn use_theme_context() -> ThemeContext {
use_context::<ThemeContext>()
.expect("ThemeContext must be provided by ThemeProvider")
}
fn create_signal_from_system_preference() -> ReadSignal<Theme> {
let (system_theme, set_system_theme) = create_signal(get_system_theme_preference());
// Listen for system theme changes
let window = window().unwrap();
let media_query = window
.match_media("(prefers-color-scheme: dark)")
.unwrap()
.unwrap();
let callback = Closure::wrap(Box::new(move |_: web_sys::MediaQueryListEvent| {
set_system_theme.set(get_system_theme_preference());
}) as Box<dyn FnMut(web_sys::MediaQueryListEvent)>);
media_query.set_onchange(Some(callback.as_ref().unchecked_ref()));
callback.forget();
system_theme
}
fn get_system_theme_preference() -> Theme {
if let Some(window) = window() {
if let Ok(Some(media_query)) = window.match_media("(prefers-color-scheme: dark)") {
if media_query.matches() {
return Theme::Dark;
}
}
}
Theme::Light
}
fn document() -> Document {
window().unwrap().document().unwrap()
}
fn apply_theme_to_document(theme: &Theme, custom_properties: &HashMap<String, String>) {
if let Some(document_element) = document().document_element() {
let style = document_element.style();
// Apply base theme variables
let theme_vars = get_theme_css_variables(theme);
for (property, value) in theme_vars {
let _ = style.set_property(&property, &value);
}
// Apply custom properties
for (property, value) in custom_properties {
let _ = style.set_property(property, value);
}
}
}
fn get_theme_css_variables(theme: &Theme) -> HashMap<String, String> {
let mut vars = HashMap::new();
match theme {
Theme::Light => {
vars.insert("--color-primary".to_string(), "#007bff".to_string());
vars.insert("--color-secondary".to_string(), "#6c757d".to_string());
vars.insert("--color-success".to_string(), "#28a745".to_string());
vars.insert("--color-warning".to_string(), "#ffc107".to_string());
vars.insert("--color-danger".to_string(), "#dc3545".to_string());
vars.insert("--color-info".to_string(), "#17a2b8".to_string());
vars.insert("--bg-primary".to_string(), "#ffffff".to_string());
vars.insert("--bg-secondary".to_string(), "#f8f9fa".to_string());
vars.insert("--bg-tertiary".to_string(), "#e9ecef".to_string());
vars.insert("--text-primary".to_string(), "#212529".to_string());
vars.insert("--text-secondary".to_string(), "#6c757d".to_string());
vars.insert("--text-muted".to_string(), "#6c757d".to_string());
vars.insert("--border-color".to_string(), "#dee2e6".to_string());
vars.insert("--border-color-light".to_string(), "#f1f3f4".to_string());
vars.insert("--shadow-sm".to_string(), "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)".to_string());
vars.insert("--shadow".to_string(), "0 0.5rem 1rem rgba(0, 0, 0, 0.15)".to_string());
vars.insert("--shadow-lg".to_string(), "0 1rem 3rem rgba(0, 0, 0, 0.175)".to_string());
}
Theme::Dark => {
vars.insert("--color-primary".to_string(), "#0d6efd".to_string());
vars.insert("--color-secondary".to_string(), "#6c757d".to_string());
vars.insert("--color-success".to_string(), "#198754".to_string());
vars.insert("--color-warning".to_string(), "#ffc107".to_string());
vars.insert("--color-danger".to_string(), "#dc3545".to_string());
vars.insert("--color-info".to_string(), "#0dcaf0".to_string());
vars.insert("--bg-primary".to_string(), "#1a1a1a".to_string());
vars.insert("--bg-secondary".to_string(), "#2d2d2d".to_string());
vars.insert("--bg-tertiary".to_string(), "#404040".to_string());
vars.insert("--text-primary".to_string(), "#ffffff".to_string());
vars.insert("--text-secondary".to_string(), "#adb5bd".to_string());
vars.insert("--text-muted".to_string(), "#6c757d".to_string());
vars.insert("--border-color".to_string(), "#404040".to_string());
vars.insert("--border-color-light".to_string(), "#2d2d2d".to_string());
vars.insert("--shadow-sm".to_string(), "0 0.125rem 0.25rem rgba(0, 0, 0, 0.3)".to_string());
vars.insert("--shadow".to_string(), "0 0.5rem 1rem rgba(0, 0, 0, 0.4)".to_string());
vars.insert("--shadow-lg".to_string(), "0 1rem 3rem rgba(0, 0, 0, 0.5)".to_string());
}
Theme::Auto => {
// Auto theme will be resolved to Light or Dark
vars.extend(get_theme_css_variables(&get_system_theme_preference()));
}
Theme::Custom(_) => {
// Custom themes would be loaded from configuration
vars.extend(get_theme_css_variables(&Theme::Light)); // Fallback
}
}
vars
}

View File

@ -0,0 +1,10 @@
use leptos::*;
#[component]
pub fn ToastContainer() -> impl IntoView {
view! {
<div class="toast toast-top toast-end">
// Toast notifications will be rendered here
</div>
}
}

View File

@ -0,0 +1,883 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use web_sys::{IntersectionObserver, IntersectionObserverEntry, Element};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use crate::components::charts::{ChartConfig, ChartData};
use crate::types::{SystemStatus, UserRole};
// Activity Feed Component with Infinite Scroll and Virtualization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityEvent {
pub id: String,
pub event_type: ActivityEventType,
pub title: String,
pub description: String,
pub user: Option<String>,
pub user_role: Option<UserRole>,
pub timestamp: DateTime<Utc>,
pub metadata: HashMap<String, serde_json::Value>,
pub severity: ActivitySeverity,
pub source: String,
pub related_resource: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ActivityEventType {
UserLogin,
UserLogout,
UserRegistration,
PasswordChange,
RoleChange,
PolicyViolation,
SystemAlert,
WorkflowStarted,
WorkflowCompleted,
WorkflowFailed,
ResourceCreated,
ResourceUpdated,
ResourceDeleted,
ConfigurationChange,
SecurityEvent,
AuditEvent,
PerformanceAlert,
HealthCheck,
BackupCompleted,
MaintenanceStarted,
MaintenanceCompleted,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ActivitySeverity {
Info,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityFeedConfig {
pub title: String,
pub show_filters: bool,
pub show_search: bool,
pub items_per_page: usize,
pub virtual_scroll: bool,
pub auto_refresh: bool,
pub refresh_interval: u32, // seconds
pub event_types: Vec<ActivityEventType>,
pub severity_filter: Vec<ActivitySeverity>,
pub user_filter: Option<String>,
pub date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
}
impl Default for ActivityFeedConfig {
fn default() -> Self {
Self {
title: "Activity Feed".to_string(),
show_filters: true,
show_search: true,
items_per_page: 50,
virtual_scroll: true,
auto_refresh: true,
refresh_interval: 30,
event_types: vec![], // Empty = all types
severity_filter: vec![], // Empty = all severities
user_filter: None,
date_range: None,
}
}
}
#[component]
pub fn ActivityFeedWidget(
config: ActivityFeedConfig,
data: ReadSignal<Vec<ActivityEvent>>,
#[prop(optional)] on_load_more: Option<Box<dyn Fn() + 'static>>,
#[prop(optional)] on_refresh: Option<Box<dyn Fn() + 'static>>,
) -> impl IntoView {
let (filtered_data, set_filtered_data) = create_signal(Vec::<ActivityEvent>::new());
let (is_loading, set_is_loading) = create_signal(false);
let (search_query, set_search_query) = create_signal(String::new());
let (selected_event_types, set_selected_event_types) = create_signal(config.event_types.clone());
let (selected_severities, set_selected_severities) = create_signal(config.severity_filter.clone());
let (visible_items, set_visible_items) = create_signal(config.items_per_page);
let container_ref = create_node_ref::<html::Div>();
let scroll_sentinel_ref = create_node_ref::<html::Div>();
// Filter data based on current filters
create_effect(move |_| {
let data = data.get();
let query = search_query.get().to_lowercase();
let event_types = selected_event_types.get();
let severities = selected_severities.get();
let filtered = data.into_iter()
.filter(|event| {
// Search filter
if !query.is_empty() {
let matches_search = event.title.to_lowercase().contains(&query) ||
event.description.to_lowercase().contains(&query) ||
event.user.as_ref().map_or(false, |u| u.to_lowercase().contains(&query));
if !matches_search {
return false;
}
}
// Event type filter
if !event_types.is_empty() && !event_types.contains(&event.event_type) {
return false;
}
// Severity filter
if !severities.is_empty() && !severities.contains(&event.severity) {
return false;
}
true
})
.collect::<Vec<_>>();
set_filtered_data.set(filtered);
});
// Infinite scroll setup
create_effect(move |_| {
if let Some(sentinel) = scroll_sentinel_ref.get() {
let observer_callback = Closure::wrap(Box::new(move |entries: js_sys::Array| {
let entry = entries.get(0).unchecked_into::<IntersectionObserverEntry>();
if entry.is_intersecting() {
set_visible_items.update(|items| *items += config.items_per_page);
if let Some(handler) = &on_load_more {
handler();
}
}
}) as Box<dyn FnMut(js_sys::Array)>);
let observer = IntersectionObserver::new(observer_callback.as_ref().unchecked_ref()).unwrap();
observer.observe(&sentinel);
observer_callback.forget();
}
});
// Auto-refresh setup
if config.auto_refresh {
create_effect(move |_| {
let refresh_handler = on_refresh.clone();
spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(config.refresh_interval * 1000).await;
if let Some(handler) = &refresh_handler {
handler();
}
}
});
});
}
let refresh_feed = move |_| {
set_is_loading.set(true);
if let Some(handler) = &on_refresh {
handler();
}
// Reset loading state after a delay (would normally be handled by data update)
spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(500).await;
set_is_loading.set(false);
});
};
let visible_events = create_memo(move |_| {
let data = filtered_data.get();
let max_items = visible_items.get();
data.into_iter().take(max_items).collect::<Vec<_>>()
});
view! {
<div class="activity-feed-widget">
<div class="widget-header">
<h3 class="widget-title">{config.title.clone()}</h3>
<div class="widget-controls">
<button
class="btn-icon refresh-btn"
on:click=refresh_feed
disabled=is_loading
title="Refresh"
>
<i class=move || if is_loading.get() { "bi-arrow-clockwise rotating" } else { "bi-arrow-clockwise" }></i>
</button>
</div>
</div>
<Show when=move || config.show_search || config.show_filters>
<div class="activity-filters">
<Show when=move || config.show_search>
<div class="search-box">
<input
type="text"
placeholder="Search activities..."
class="search-input"
prop:value=search_query
on:input=move |ev| set_search_query.set(event_target_value(&ev))
/>
<i class="bi-search search-icon"></i>
</div>
</Show>
<Show when=move || config.show_filters>
<div class="filter-controls">
<ActivityTypeFilter
selected=selected_event_types
on_change=set_selected_event_types
/>
<SeverityFilter
selected=selected_severities
on_change=set_selected_severities
/>
</div>
</Show>
</div>
</Show>
<div class="activity-list" node_ref=container_ref>
<VirtualizedList
items=visible_events
item_height=80
container_height=400
render_item=move |event: ActivityEvent| {
view! {
<ActivityEventItem event=event />
}
}
/>
<Show when=move || filtered_data.get().len() > visible_items.get()>
<div node_ref=scroll_sentinel_ref class="scroll-sentinel">
<div class="loading-more">
<div class="spinner-sm"></div>
<span>"Loading more activities..."</span>
</div>
</div>
</Show>
<Show when=move || filtered_data.get().is_empty()>
<div class="empty-state">
<i class="bi-journal-x"></i>
<h4>"No activities found"</h4>
<p>"Try adjusting your filters or search query"</p>
</div>
</Show>
</div>
</div>
}
}
#[component]
pub fn ActivityEventItem(event: ActivityEvent) -> impl IntoView {
let event_icon = move || match event.event_type {
ActivityEventType::UserLogin => "bi-box-arrow-in-right",
ActivityEventType::UserLogout => "bi-box-arrow-right",
ActivityEventType::UserRegistration => "bi-person-plus",
ActivityEventType::PasswordChange => "bi-key",
ActivityEventType::RoleChange => "bi-shield-check",
ActivityEventType::PolicyViolation => "bi-exclamation-triangle",
ActivityEventType::SystemAlert => "bi-bell",
ActivityEventType::WorkflowStarted => "bi-play-circle",
ActivityEventType::WorkflowCompleted => "bi-check-circle",
ActivityEventType::WorkflowFailed => "bi-x-circle",
ActivityEventType::ResourceCreated => "bi-plus-circle",
ActivityEventType::ResourceUpdated => "bi-pencil-square",
ActivityEventType::ResourceDeleted => "bi-trash",
ActivityEventType::ConfigurationChange => "bi-gear",
ActivityEventType::SecurityEvent => "bi-shield-exclamation",
ActivityEventType::AuditEvent => "bi-eye",
ActivityEventType::PerformanceAlert => "bi-speedometer2",
ActivityEventType::HealthCheck => "bi-heart-pulse",
ActivityEventType::BackupCompleted => "bi-archive",
ActivityEventType::MaintenanceStarted => "bi-tools",
ActivityEventType::MaintenanceCompleted => "bi-check2-all",
ActivityEventType::Custom(_) => "bi-info-circle",
};
let severity_class = move || match event.severity {
ActivitySeverity::Info => "severity-info",
ActivitySeverity::Low => "severity-low",
ActivitySeverity::Medium => "severity-medium",
ActivitySeverity::High => "severity-high",
ActivitySeverity::Critical => "severity-critical",
};
let time_ago = move || {
let now = Utc::now();
let diff = now.signed_duration_since(event.timestamp);
if diff.num_seconds() < 60 {
"just now".to_string()
} else if diff.num_minutes() < 60 {
format!("{}m ago", diff.num_minutes())
} else if diff.num_hours() < 24 {
format!("{}h ago", diff.num_hours())
} else {
format!("{}d ago", diff.num_days())
}
};
view! {
<div class=format!("activity-item {}", severity_class())>
<div class="activity-icon">
<i class=event_icon></i>
</div>
<div class="activity-content">
<div class="activity-header">
<h4 class="activity-title">{event.title.clone()}</h4>
<span class="activity-time">{time_ago()}</span>
</div>
<p class="activity-description">{event.description.clone()}</p>
<div class="activity-meta">
<Show when=move || event.user.is_some()>
<span class="activity-user">
<i class="bi-person"></i>
{event.user.clone().unwrap_or_default()}
</span>
</Show>
<span class="activity-source">
<i class="bi-diagram-3"></i>
{event.source.clone()}
</span>
<Show when=move || event.related_resource.is_some()>
<span class="activity-resource">
<i class="bi-link"></i>
{event.related_resource.clone().unwrap_or_default()}
</span>
</Show>
</div>
</div>
<div class="activity-severity">
<span class=format!("severity-badge {}", severity_class())>
{format!("{:?}", event.severity)}
</span>
</div>
</div>
}
}
#[component]
pub fn VirtualizedList<T, F>(
items: ReadSignal<Vec<T>>,
item_height: u32,
container_height: u32,
render_item: F,
) -> impl IntoView
where
T: Clone + 'static,
F: Fn(T) -> View + Clone + 'static,
{
let (scroll_top, set_scroll_top) = create_signal(0u32);
let container_ref = create_node_ref::<html::Div>();
let visible_range = create_memo(move |_| {
let start_index = (scroll_top.get() / item_height) as usize;
let visible_count = (container_height / item_height) as usize + 2; // Buffer items
let items_count = items.get().len();
let end_index = (start_index + visible_count).min(items_count);
(start_index, end_index)
});
let visible_items = create_memo(move |_| {
let (start, end) = visible_range.get();
let all_items = items.get();
all_items.into_iter().skip(start).take(end - start).collect::<Vec<_>>()
});
let total_height = create_memo(move |_| {
items.get().len() as u32 * item_height
});
let offset_y = create_memo(move |_| {
let (start, _) = visible_range.get();
start as u32 * item_height
});
let on_scroll = move |_| {
if let Some(container) = container_ref.get() {
set_scroll_top.set(container.scroll_top() as u32);
}
};
view! {
<div
node_ref=container_ref
class="virtualized-list"
style=format!("height: {}px; overflow-y: auto;", container_height)
on:scroll=on_scroll
>
<div
class="virtual-spacer-top"
style=format!("height: {}px;", offset_y.get())
></div>
<div class="virtual-items">
<For
each=move || visible_items.get()
key=|item| std::ptr::addr_of!(*item) as usize
children=move |item| {
let render_fn = render_item.clone();
view! {
<div
class="virtual-item"
style=format!("height: {}px;", item_height)
>
{render_fn(item)}
</div>
}
}
/>
</div>
<div
class="virtual-spacer-bottom"
style=format!("height: {}px;", total_height.get().saturating_sub(offset_y.get() + visible_items.get().len() as u32 * item_height))
></div>
</div>
}
}
#[component]
pub fn ActivityTypeFilter(
selected: ReadSignal<Vec<ActivityEventType>>,
on_change: WriteSignal<Vec<ActivityEventType>>,
) -> impl IntoView {
let available_types = vec![
(ActivityEventType::UserLogin, "User Login"),
(ActivityEventType::UserLogout, "User Logout"),
(ActivityEventType::PolicyViolation, "Policy Violation"),
(ActivityEventType::SystemAlert, "System Alert"),
(ActivityEventType::WorkflowStarted, "Workflow Started"),
(ActivityEventType::WorkflowCompleted, "Workflow Completed"),
(ActivityEventType::WorkflowFailed, "Workflow Failed"),
(ActivityEventType::SecurityEvent, "Security Event"),
(ActivityEventType::ConfigurationChange, "Configuration Change"),
];
view! {
<div class="filter-group">
<label class="filter-label">"Event Types"</label>
<div class="checkbox-group">
<For
each=move || available_types.clone()
key=|item| format!("{:?}", item.0)
children=move |(event_type, label)| {
let is_selected = create_memo(move |_| {
selected.get().contains(&event_type)
});
let toggle_selection = move |_| {
on_change.update(|types| {
if let Some(pos) = types.iter().position(|t| *t == event_type) {
types.remove(pos);
} else {
types.push(event_type.clone());
}
});
};
view! {
<label class="checkbox-item">
<input
type="checkbox"
checked=is_selected
on:change=toggle_selection
/>
<span class="checkbox-label">{label}</span>
</label>
}
}
/>
</div>
</div>
}
}
#[component]
pub fn SeverityFilter(
selected: ReadSignal<Vec<ActivitySeverity>>,
on_change: WriteSignal<Vec<ActivitySeverity>>,
) -> impl IntoView {
let available_severities = vec![
(ActivitySeverity::Info, "Info"),
(ActivitySeverity::Low, "Low"),
(ActivitySeverity::Medium, "Medium"),
(ActivitySeverity::High, "High"),
(ActivitySeverity::Critical, "Critical"),
];
view! {
<div class="filter-group">
<label class="filter-label">"Severity"</label>
<div class="checkbox-group">
<For
each=move || available_severities.clone()
key=|item| format!("{:?}", item.0)
children=move |(severity, label)| {
let is_selected = create_memo(move |_| {
selected.get().contains(&severity)
});
let toggle_selection = move |_| {
on_change.update(|severities| {
if let Some(pos) = severities.iter().position(|s| *s == severity) {
severities.remove(pos);
} else {
severities.push(severity.clone());
}
});
};
view! {
<label class="checkbox-item">
<input
type="checkbox"
checked=is_selected
on:change=toggle_selection
/>
<span class=format!("checkbox-label severity-{:?}", severity).to_lowercase()>
{label}
</span>
</label>
}
}
/>
</div>
</div>
}
}
// System Health Widget with Traffic Light Status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemHealthConfig {
pub title: String,
pub show_details: bool,
pub auto_refresh: bool,
pub refresh_interval: u32,
pub thresholds: HealthThresholds,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthThresholds {
pub cpu_warning: f64,
pub cpu_critical: f64,
pub memory_warning: f64,
pub memory_critical: f64,
pub disk_warning: f64,
pub disk_critical: f64,
}
impl Default for HealthThresholds {
fn default() -> Self {
Self {
cpu_warning: 70.0,
cpu_critical: 90.0,
memory_warning: 80.0,
memory_critical: 95.0,
disk_warning: 85.0,
disk_critical: 95.0,
}
}
}
impl Default for SystemHealthConfig {
fn default() -> Self {
Self {
title: "System Health".to_string(),
show_details: true,
auto_refresh: true,
refresh_interval: 15,
thresholds: HealthThresholds::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemHealthData {
pub overall_status: SystemStatus,
pub cpu_usage: f64,
pub memory_usage: f64,
pub disk_usage: f64,
pub network_status: SystemStatus,
pub services: Vec<ServiceHealth>,
pub uptime: String,
pub last_updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceHealth {
pub name: String,
pub status: SystemStatus,
pub response_time: Option<u32>, // milliseconds
pub last_check: DateTime<Utc>,
pub error_message: Option<String>,
}
#[component]
pub fn SystemHealthWidget(
config: SystemHealthConfig,
data: ReadSignal<SystemHealthData>,
) -> impl IntoView {
let overall_status_class = create_memo(move |_| {
match data.get().overall_status {
SystemStatus::Healthy => "status-healthy",
SystemStatus::Warning => "status-warning",
SystemStatus::Critical => "status-critical",
SystemStatus::Unknown => "status-unknown",
}
});
view! {
<div class="system-health-widget">
<div class="widget-header">
<h3 class="widget-title">{config.title.clone()}</h3>
<div class="overall-status">
<div class=format!("status-indicator {}", overall_status_class.get())>
<div class="status-light"></div>
<span class="status-text">
{move || format!("{:?}", data.get().overall_status)}
</span>
</div>
</div>
</div>
<div class="health-overview">
<div class="metric-grid">
<HealthMetric
label="CPU"
value=move || data.get().cpu_usage
unit="%"
thresholds=(config.thresholds.cpu_warning, config.thresholds.cpu_critical)
icon="bi-cpu"
/>
<HealthMetric
label="Memory"
value=move || data.get().memory_usage
unit="%"
thresholds=(config.thresholds.memory_warning, config.thresholds.memory_critical)
icon="bi-memory"
/>
<HealthMetric
label="Disk"
value=move || data.get().disk_usage
unit="%"
thresholds=(config.thresholds.disk_warning, config.thresholds.disk_critical)
icon="bi-hdd"
/>
<HealthMetric
label="Network"
value=move || match data.get().network_status {
SystemStatus::Healthy => 100.0,
SystemStatus::Warning => 50.0,
SystemStatus::Critical => 0.0,
SystemStatus::Unknown => 0.0,
}
unit=""
thresholds=(50.0, 90.0)
icon="bi-wifi"
format_value=move |val| {
match val as u8 {
100 => "Online".to_string(),
50 => "Degraded".to_string(),
0 => "Offline".to_string(),
_ => "Unknown".to_string(),
}
}
/>
</div>
</div>
<Show when=move || config.show_details>
<div class="health-details">
<div class="uptime-info">
<i class="bi-clock"></i>
<span>"Uptime: " {move || data.get().uptime}</span>
</div>
<div class="services-health">
<h4>"Services"</h4>
<div class="services-list">
<For
each=move || data.get().services
key=|service| service.name.clone()
children=move |service| {
view! {
<ServiceHealthItem service=service />
}
}
/>
</div>
</div>
</div>
</Show>
<div class="widget-footer">
<small class="text-muted">
"Last updated: "
{move || data.get().last_updated.format("%H:%M:%S").to_string()}
</small>
</div>
</div>
}
}
#[component]
pub fn HealthMetric(
label: &'static str,
value: impl Fn() -> f64 + 'static,
unit: &'static str,
thresholds: (f64, f64), // (warning, critical)
icon: &'static str,
#[prop(optional)] format_value: Option<Box<dyn Fn(f64) -> String + 'static>>,
) -> impl IntoView {
let status_class = create_memo(move |_| {
let val = value();
let (warning, critical) = thresholds;
if val >= critical {
"metric-critical"
} else if val >= warning {
"metric-warning"
} else {
"metric-healthy"
}
});
let formatted_value = move || {
let val = value();
if let Some(formatter) = &format_value {
formatter(val)
} else {
format!("{:.1}{}", val, unit)
}
};
view! {
<div class=format!("health-metric {}", status_class.get())>
<div class="metric-icon">
<i class=icon></i>
</div>
<div class="metric-content">
<div class="metric-value">{formatted_value}</div>
<div class="metric-label">{label}</div>
</div>
<div class="metric-status">
<div class="status-dot"></div>
</div>
</div>
}
}
#[component]
pub fn ServiceHealthItem(service: ServiceHealth) -> impl IntoView {
let status_class = move || match service.status {
SystemStatus::Healthy => "service-healthy",
SystemStatus::Warning => "service-warning",
SystemStatus::Critical => "service-critical",
SystemStatus::Unknown => "service-unknown",
};
let status_icon = move || match service.status {
SystemStatus::Healthy => "bi-check-circle-fill",
SystemStatus::Warning => "bi-exclamation-triangle-fill",
SystemStatus::Critical => "bi-x-circle-fill",
SystemStatus::Unknown => "bi-question-circle-fill",
};
view! {
<div class=format!("service-item {}", status_class())>
<div class="service-status">
<i class=status_icon></i>
</div>
<div class="service-info">
<span class="service-name">{service.name.clone()}</span>
<Show when=move || service.response_time.is_some()>
<span class="service-response-time">
{service.response_time.map(|rt| format!("{}ms", rt)).unwrap_or_default()}
</span>
</Show>
</div>
<Show when=move || service.error_message.is_some()>
<div class="service-error">
<i class="bi-exclamation-triangle"></i>
<span>{service.error_message.clone().unwrap_or_default()}</span>
</div>
</Show>
</div>
}
}
// Additional widget types
#[component]
pub fn MetricWidget(
config: MetricConfig,
data: ReadSignal<Option<MetricData>>,
) -> impl IntoView {
view! {
<div class="metric-widget">
<div class="metric-header">
<h3 class="metric-title">{config.title}</h3>
</div>
<div class="metric-body">
// Implementation for metric display
{move || {
if let Some(data) = data.get() {
format!("{:.2} {}", data.value, config.unit)
} else {
"Loading...".to_string()
}
}}
</div>
</div>
}
}
// Supporting types and configurations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricConfig {
pub title: String,
pub unit: String,
pub format: String,
pub thresholds: Option<(f64, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricData {
pub value: f64,
pub timestamp: DateTime<Utc>,
pub trend: Option<f64>, // percentage change
}
impl Default for MetricConfig {
fn default() -> Self {
Self {
title: "Metric".to_string(),
unit: "".to_string(),
format: "{:.2}".to_string(),
thresholds: None,
}
}
}

View File

View File

@ -0,0 +1,218 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { AuditLogEntry, WebSocketMessage } from '@/types/audit';
export enum WebSocketReadyState {
CONNECTING = 0,
OPEN = 1,
CLOSING = 2,
CLOSED = 3,
}
interface UseWebSocketOptions {
url: string;
onMessage?: (message: WebSocketMessage) => void;
onNewAuditLog?: (log: AuditLogEntry) => void;
onComplianceAlert?: (alert: any) => void;
onSystemStatus?: (status: any) => void;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
shouldReconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
protocols?: string | string[];
}
interface UseWebSocketReturn {
readyState: WebSocketReadyState;
lastMessage: WebSocketMessage | null;
lastJsonMessage: any;
sendMessage: (message: string) => void;
sendJsonMessage: (message: object) => void;
connectionStatus: 'Connecting' | 'Open' | 'Closing' | 'Closed';
isConnected: boolean;
reconnect: () => void;
close: () => void;
reconnectAttempts: number;
}
export const useWebSocket = (options: UseWebSocketOptions): UseWebSocketReturn => {
const {
url,
onMessage,
onNewAuditLog,
onComplianceAlert,
onSystemStatus,
onOpen,
onClose,
onError,
shouldReconnect = true,
reconnectInterval = 3000,
maxReconnectAttempts = 10,
protocols
} = options;
const [readyState, setReadyState] = useState<WebSocketReadyState>(WebSocketReadyState.CONNECTING);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [lastJsonMessage, setLastJsonMessage] = useState<any>(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldReconnectRef = useRef(shouldReconnect);
const urlRef = useRef(url);
// Update refs when props change
useEffect(() => {
shouldReconnectRef.current = shouldReconnect;
}, [shouldReconnect]);
useEffect(() => {
urlRef.current = url;
}, [url]);
const connect = useCallback(() => {
try {
const ws = new WebSocket(url, protocols);
websocketRef.current = ws;
ws.onopen = (event) => {
setReadyState(WebSocketReadyState.OPEN);
setReconnectAttempts(0);
onOpen?.(event);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WebSocketMessage;
setLastMessage(data);
setLastJsonMessage(data.data);
// Route messages to specific handlers
switch (data.type) {
case 'new_audit_log':
onNewAuditLog?.(data.data as AuditLogEntry);
break;
case 'compliance_alert':
onComplianceAlert?.(data.data);
break;
case 'system_status':
onSystemStatus?.(data.data);
break;
case 'heartbeat':
// Handle heartbeat silently
break;
default:
console.warn('Unknown WebSocket message type:', data.type);
}
onMessage?.(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = (event) => {
setReadyState(WebSocketReadyState.CLOSED);
websocketRef.current = null;
onClose?.(event);
// Attempt to reconnect if enabled
if (shouldReconnectRef.current && reconnectAttempts < maxReconnectAttempts) {
setReconnectAttempts(prev => prev + 1);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
}
};
ws.onerror = (event) => {
setReadyState(WebSocketReadyState.CLOSED);
onError?.(event);
};
// Set initial connecting state
setReadyState(WebSocketReadyState.CONNECTING);
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
setReadyState(WebSocketReadyState.CLOSED);
}
}, [url, protocols, onOpen, onClose, onError, onMessage, onNewAuditLog, onComplianceAlert, onSystemStatus, reconnectInterval, maxReconnectAttempts, reconnectAttempts]);
const sendMessage = useCallback((message: string) => {
if (websocketRef.current?.readyState === WebSocketReadyState.OPEN) {
websocketRef.current.send(message);
} else {
console.warn('WebSocket is not connected. Message not sent:', message);
}
}, []);
const sendJsonMessage = useCallback((message: object) => {
sendMessage(JSON.stringify(message));
}, [sendMessage]);
const reconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
setReconnectAttempts(0);
connect();
}, [connect]);
const close = useCallback(() => {
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
}, []);
// Initial connection and cleanup
useEffect(() => {
connect();
return () => {
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
};
}, [connect]);
const connectionStatus = (() => {
switch (readyState) {
case WebSocketReadyState.CONNECTING:
return 'Connecting';
case WebSocketReadyState.OPEN:
return 'Open';
case WebSocketReadyState.CLOSING:
return 'Closing';
case WebSocketReadyState.CLOSED:
return 'Closed';
default:
return 'Closed';
}
})();
return {
readyState,
lastMessage,
lastJsonMessage,
sendMessage,
sendJsonMessage,
connectionStatus,
isConnected: readyState === WebSocketReadyState.OPEN,
reconnect,
close,
reconnectAttempts,
};
};

View File

@ -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;
}

View File

@ -0,0 +1,19 @@
pub mod components;
pub mod auth;
pub mod utils;
pub mod types;
pub mod pages;
pub mod store;
pub mod app;
pub use app::App;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn hydrate() {
console_error_panic_hook::set_once();
tracing_wasm::set_as_global_default();
leptos::mount_to_body(App);
}

View File

@ -0,0 +1,101 @@
use leptos::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use console_error_panic_hook;
use tracing_wasm;
mod app;
mod components;
mod pages;
mod store;
mod utils;
mod api;
use app::App;
// Main entry point for the Leptos CSR application
fn main() {
// Initialize panic hook for better error messages in development
console_error_panic_hook::set_once();
// Initialize tracing for logging
tracing_wasm::set_as_global_default();
// Log application startup
tracing::info!("Starting Control Center UI");
// Also log to browser console
web_sys::console::log_1(&"🚀 Control Center UI WASM loaded and main() called".into());
// Try mounting to body first to test basic functionality
leptos::mount_to_body(|| {
view! {
<div style="position: fixed; top: 0; left: 0; z-index: 9999; background: red; color: white; padding: 10px;">
"🚀 LEPTOS IS WORKING!"
</div>
<App/>
}
});
web_sys::console::log_1(&"✅ Leptos app mounted to body".into());
}
// Export functions that can be called from JavaScript
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
#[wasm_bindgen(js_namespace = localStorage)]
fn getItem(key: &str) -> Option<String>;
#[wasm_bindgen(js_namespace = localStorage)]
fn setItem(key: &str, value: &str);
}
// Helper macro for console logging
#[allow(unused_macros)]
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
#[allow(unused_imports)]
pub(crate) use console_log;
// Utility functions for localStorage interaction
pub fn get_local_storage_item(key: &str) -> Option<String> {
getItem(key)
}
pub fn set_local_storage_item(key: &str, value: &str) {
setItem(key, value);
}
// Theme utilities
pub fn get_saved_theme() -> String {
get_local_storage_item("theme").unwrap_or_else(|| "light".to_string())
}
pub fn save_theme(theme: &str) {
set_local_storage_item("theme", theme);
}
// Performance monitoring
#[wasm_bindgen]
pub fn mark_performance(name: &str) {
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = ["window", "performance"], js_name = mark)]
fn performance_mark(name: &str);
}
performance_mark(name);
}
// Initialize application
#[wasm_bindgen(start)]
pub fn start() {
// This function is called when the WASM module is instantiated
tracing::info!("WASM module initialized");
mark_performance("wasm-initialized");
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import App from './App.tsx';
import './index.css';
import 'react-toastify/dist/ReactToastify.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (error?.status >= 400 && error?.status < 500) {
return false;
}
return failureCount < 3;
},
},
mutations: {
retry: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn ClustersPage() -> impl IntoView {
view! {
<div class="clusters-page">
<h1>"Clusters"</h1>
<p>"Cluster management placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn Dashboard() -> impl IntoView {
view! {
<div class="dashboard-page">
<h1>"Dashboard"</h1>
<p>"Dashboard content placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,662 @@
use leptos::*;
use crate::api::orchestrator::*;
#[component]
pub fn InfrastructurePage() -> impl IntoView {
let (active_tab, set_active_tab) = create_signal("servers".to_string());
let (tasks, tasks_loading, refresh_tasks) = use_tasks_list();
let (system_health, health_loading, refresh_health) = use_system_health();
let (show_create_modal, set_show_create_modal) = create_signal(false);
let (show_batch_modal, set_show_batch_modal) = create_signal(false);
view! {
<div class="infrastructure-page h-full flex flex-col">
// Header
<div class="flex-none border-b border-base-300 p-4">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold">"Infrastructure Management"</h1>
<div class="flex gap-2">
<button
class="btn btn-outline btn-sm"
on:click=move |_| set_show_batch_modal.set(true)
>
"Batch Operations"
</button>
<button
class="btn btn-outline btn-sm"
on:click=move |_| refresh_health.dispatch(())
disabled=move || health_loading.get()
>
{move || if health_loading.get() { "Refreshing..." } else { "Refresh Status" }}
</button>
<button
class="btn btn-primary btn-sm"
on:click=move |_| set_show_create_modal.set(true)
>
"Create Workflow"
</button>
</div>
</div>
// Tab Navigation
<div class="tabs tabs-boxed mt-4">
<a
class={move || if active_tab.get() == "servers" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("servers".to_string())
>
"Servers"
</a>
<a
class={move || if active_tab.get() == "workflows" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("workflows".to_string())
>
"Workflows"
</a>
<a
class={move || if active_tab.get() == "providers" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("providers".to_string())
>
"Providers"
</a>
<a
class={move || if active_tab.get() == "health" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("health".to_string())
>
"System Health"
</a>
</div>
</div>
// Content Area
<div class="flex-1 overflow-hidden">
{move || match active_tab.get().as_str() {
"servers" => view! { <ServersTab /> }.into(),
"workflows" => view! { <WorkflowsTab tasks=tasks tasks_loading=tasks_loading refresh_tasks=refresh_tasks /> }.into(),
"providers" => view! { <ProvidersTab /> }.into(),
"health" => view! { <SystemHealthTab system_health=system_health health_loading=health_loading /> }.into(),
_ => view! { <div>"Unknown tab"</div> }.into(),
}}
</div>
// Create Workflow Modal
{move || if show_create_modal.get() {
view! {
<CreateWorkflowModal
show=show_create_modal
set_show=set_show_create_modal
/>
}.into()
} else {
view! { <div></div> }.into()
}}
// Batch Operations Modal
{move || if show_batch_modal.get() {
view! {
<BatchWorkflowModal
show=show_batch_modal
set_show=set_show_batch_modal
/>
}.into()
} else {
view! { <div></div> }.into()
}}
</div>
}
}
#[component]
fn ServersTab() -> impl IntoView {
// Mock server data
let servers = vec![
Server {
id: "s1".to_string(),
name: "web-01".to_string(),
status: "running".to_string(),
ip_address: Some("192.168.1.10".to_string()),
plan: "1xCPU-2GB".to_string(),
zone: "de-fra1".to_string(),
},
Server {
id: "s2".to_string(),
name: "web-02".to_string(),
status: "stopped".to_string(),
ip_address: Some("192.168.1.11".to_string()),
plan: "1xCPU-2GB".to_string(),
zone: "us-nyc1".to_string(),
},
];
view! {
<div class="p-4 h-full flex flex-col">
<div class="flex-none mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold">"Server Infrastructure"</h2>
<div class="flex gap-2">
<input
type="text"
placeholder="Search servers..."
class="input input-bordered input-sm"
/>
<select class="select select-bordered select-sm">
<option>"All Providers"</option>
<option>"UpCloud"</option>
<option>"AWS"</option>
<option>"Local"</option>
</select>
</div>
</div>
<div class="flex-1 overflow-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>"Name"</th>
<th>"Status"</th>
<th>"IP Address"</th>
<th>"Plan"</th>
<th>"Zone"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
{servers.into_iter().map(|server| {
view! {
<tr>
<td class="font-medium">{&server.name}</td>
<td>
<div class={format!("badge {}",
match server.status.as_str() {
"running" => "badge-success",
"stopped" => "badge-error",
"starting" => "badge-warning",
_ => "badge-ghost",
}
)}>
{&server.status}
</div>
</td>
<td class="font-mono text-sm">
{server.ip_address.unwrap_or_else(|| "-".to_string())}
</td>
<td>{&server.plan}</td>
<td>{&server.zone}</td>
<td>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs">"SSH"</button>
<button class="btn btn-ghost btn-xs">"Restart"</button>
<button class="btn btn-ghost btn-xs text-error">"Delete"</button>
</div>
</td>
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}
#[component]
fn WorkflowsTab(
tasks: ReadSignal<Vec<WorkflowTask>>,
tasks_loading: ReadSignal<bool>,
refresh_tasks: Action<(), ()>,
) -> impl IntoView {
view! {
<div class="p-4 h-full flex flex-col">
<div class="flex-none mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold">"Workflow Execution"</h2>
<button
class="btn btn-outline btn-sm"
on:click=move |_| refresh_tasks.dispatch(())
disabled=move || tasks_loading.get()
>
{move || if tasks_loading.get() { "Refreshing..." } else { "Refresh" }}
</button>
</div>
<div class="flex-1 overflow-auto">
{move || {
if tasks_loading.get() {
view! {
<div class="flex items-center justify-center h-32">
<span class="loading loading-spinner loading-md"></span>
<span class="ml-2">"Loading workflows..."</span>
</div>
}.into()
} else {
let task_list = tasks.get();
if task_list.is_empty() {
view! {
<div class="text-center text-base-content/50 mt-8">
"No workflows found"
</div>
}.into()
} else {
view! {
<table class="table table-zebra w-full">
<thead>
<tr>
<th>"ID"</th>
<th>"Name"</th>
<th>"Status"</th>
<th>"Created"</th>
<th>"Duration"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
{task_list.into_iter().map(|task| {
let task_id = task.id.clone();
view! {
<tr>
<td class="font-mono text-sm">{&task.id[..8]}"..."</td>
<td class="font-medium">{&task.name}</td>
<td>
<div class={format!("badge {}",
match task.status {
TaskStatus::Pending => "badge-warning",
TaskStatus::Running => "badge-info",
TaskStatus::Completed => "badge-success",
TaskStatus::Failed => "badge-error",
TaskStatus::Cancelled => "badge-ghost",
}
)}>
{format!("{:?}", task.status)}
</div>
</td>
<td class="text-sm text-base-content/70">
{&task.created_at}
</td>
<td class="text-sm">
{if let (Some(started), Some(completed)) = (&task.started_at, &task.completed_at) {
"2m 15s".to_string() // Calculate duration
} else if task.started_at.is_some() {
"Running...".to_string()
} else {
"-".to_string()
}}
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs">"View"</button>
{if matches!(task.status, TaskStatus::Running | TaskStatus::Pending) {
view! {
<button class="btn btn-ghost btn-xs text-error">"Cancel"</button>
}.into()
} else {
view! { <div></div> }.into()
}}
</div>
</td>
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
}.into()
}
}
}}
</div>
</div>
}
}
#[component]
fn ProvidersTab() -> impl IntoView {
let providers = vec![
("UpCloud", "Connected", ""),
("AWS", "Connected", ""),
("Local", "Available", ""),
];
view! {
<div class="p-4">
<h2 class="text-xl font-semibold mb-4">"Provider Credentials"</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.into_iter().map(|(name, status, icon)| {
view! {
<div class="card bg-base-200 p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-lg">{name}</h3>
<p class="text-sm text-base-content/70">{status}</p>
</div>
<div class="text-2xl">{icon}</div>
</div>
<div class="mt-3 flex gap-2">
<button class="btn btn-outline btn-sm">"Configure"</button>
<button class="btn btn-ghost btn-sm">"Test"</button>
</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
<div class="mt-6">
<button class="btn btn-primary btn-sm">"Add Provider"</button>
</div>
</div>
}
}
#[component]
fn SystemHealthTab(
system_health: ReadSignal<Option<serde_json::Value>>,
health_loading: ReadSignal<bool>,
) -> impl IntoView {
view! {
<div class="p-4">
<h2 class="text-xl font-semibold mb-4">"System Health Overview"</h2>
{move || {
if health_loading.get() {
view! {
<div class="flex items-center justify-center h-32">
<span class="loading loading-spinner loading-md"></span>
<span class="ml-2">"Loading health data..."</span>
</div>
}.into()
} else if let Some(health_data) = system_health.get() {
view! {
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"Orchestrator"</h3>
<div class="badge badge-success mt-2">"Healthy"</div>
<p class="text-sm text-base-content/70 mt-2">"All systems operational"</p>
</div>
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"Database"</h3>
<div class="badge badge-success mt-2">"Connected"</div>
<p class="text-sm text-base-content/70 mt-2">"SurrealDB online"</p>
</div>
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"KMS"</h3>
<div class="badge badge-warning mt-2">"Limited"</div>
<p class="text-sm text-base-content/70 mt-2">"Local mode only"</p>
</div>
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"Policy Engine"</h3>
<div class="badge badge-success mt-2">"Active"</div>
<p class="text-sm text-base-content/70 mt-2">"Cedar engine operational"</p>
</div>
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"Providers"</h3>
<div class="badge badge-success mt-2">"2/3 Connected"</div>
<p class="text-sm text-base-content/70 mt-2">"UpCloud, AWS ready"</p>
</div>
<div class="card bg-base-200 p-4">
<h3 class="font-semibold">"Storage"</h3>
<div class="badge badge-success mt-2">"Available"</div>
<p class="text-sm text-base-content/70 mt-2">"85% capacity remaining"</p>
</div>
</div>
}.into()
} else {
view! {
<div class="text-center text-base-content/50 mt-8">
"Failed to load health data"
</div>
}.into()
}
}}
</div>
}
}
#[component]
fn CreateWorkflowModal(
show: ReadSignal<bool>,
set_show: WriteSignal<bool>,
) -> impl IntoView {
let (workflow_type, set_workflow_type) = create_signal("server".to_string());
let (infra_name, set_infra_name) = create_signal("default".to_string());
let (check_mode, set_check_mode) = create_signal(true);
let create_workflow = move || {
spawn_local(async move {
let client = get_orchestrator_client();
match workflow_type.get().as_str() {
"server" => {
let workflow = CreateServerWorkflow {
infra: infra_name.get(),
settings: "default".to_string(),
servers: vec!["web-server".to_string()],
check_mode: check_mode.get(),
wait: false,
};
match client.create_server_workflow(workflow).await {
Ok(task_id) => {
logging::log!("Server workflow created: {}", task_id);
set_show.set(false);
}
Err(err) => {
logging::error!("Failed to create server workflow: {}", err);
}
}
}
"taskserv" => {
let workflow = TaskservWorkflow {
infra: infra_name.get(),
settings: "default".to_string(),
taskserv: "kubernetes".to_string(),
operation: "create".to_string(),
check_mode: check_mode.get(),
wait: false,
};
match client.create_taskserv_workflow(workflow).await {
Ok(task_id) => {
logging::log!("Taskserv workflow created: {}", task_id);
set_show.set(false);
}
Err(err) => {
logging::error!("Failed to create taskserv workflow: {}", err);
}
}
}
_ => {}
}
});
};
view! {
<div class={move || if show.get() { "modal modal-open" } else { "modal" }}>
<div class="modal-box">
<h3 class="font-bold text-lg">"Create Workflow"</h3>
<div class="py-4 space-y-4">
<div>
<label class="label">"Workflow Type"</label>
<select
class="select select-bordered w-full"
on:change=move |ev| set_workflow_type.set(event_target_value(&ev))
>
<option value="server">"Server Creation"</option>
<option value="taskserv">"Task Service"</option>
<option value="cluster">"Cluster"</option>
</select>
</div>
<div>
<label class="label">"Infrastructure"</label>
<input
type="text"
class="input input-bordered w-full"
prop:value=move || infra_name.get()
on:input=move |ev| set_infra_name.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
prop:checked=move || check_mode.get()
on:change=move |_| set_check_mode.set(!check_mode.get())
/>
<span class="label-text">"Check mode (dry run)"</span>
</label>
</div>
</div>
<div class="modal-action">
<button
class="btn"
on:click=move |_| set_show.set(false)
>
"Cancel"
</button>
<button
class="btn btn-primary"
on:click=move |_| create_workflow()
>
"Create"
</button>
</div>
</div>
</div>
}
}
#[component]
fn BatchWorkflowModal(
show: ReadSignal<bool>,
set_show: WriteSignal<bool>,
) -> impl IntoView {
let (batch_name, set_batch_name) = create_signal("multi_cloud_deployment".to_string());
let create_batch_workflow = move || {
spawn_local(async move {
let client = get_orchestrator_client();
let batch_workflow = BatchWorkflowRequest {
workflow: BatchWorkflow {
name: batch_name.get(),
version: "1.0.0".to_string(),
storage_backend: "surrealdb".to_string(),
parallel_limit: 5,
rollback_enabled: true,
operations: vec![
BatchOperation {
id: "upcloud_servers".to_string(),
operation_type: "server_batch".to_string(),
provider: "upcloud".to_string(),
dependencies: vec![],
server_configs: Some(vec![
ServerConfig {
name: "web-01".to_string(),
plan: "1xCPU-2GB".to_string(),
zone: "de-fra1".to_string(),
},
ServerConfig {
name: "web-02".to_string(),
plan: "1xCPU-2GB".to_string(),
zone: "us-nyc1".to_string(),
},
]),
taskservs: None,
},
BatchOperation {
id: "aws_taskservs".to_string(),
operation_type: "taskserv_batch".to_string(),
provider: "aws".to_string(),
dependencies: vec!["upcloud_servers".to_string()],
server_configs: None,
taskservs: Some(vec![
"kubernetes".to_string(),
"cilium".to_string(),
"containerd".to_string(),
]),
},
],
},
};
match client.submit_batch_workflow(batch_workflow).await {
Ok(batch_id) => {
logging::log!("Batch workflow created: {}", batch_id);
set_show.set(false);
}
Err(err) => {
logging::error!("Failed to create batch workflow: {}", err);
}
}
});
};
view! {
<div class={move || if show.get() { "modal modal-open" } else { "modal" }}>
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">"Create Batch Workflow"</h3>
<div class="py-4 space-y-4">
<div>
<label class="label">"Batch Name"</label>
<input
type="text"
class="input input-bordered w-full"
prop:value=move || batch_name.get()
on:input=move |ev| set_batch_name.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label">"Operations"</label>
<div class="bg-base-200 p-4 rounded">
<div class="text-sm space-y-2">
<div>"1. Create UpCloud servers (web-01, web-02)"</div>
<div>"2. Deploy Kubernetes + Cilium + containerd on AWS"</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">"Parallel Limit"</label>
<input
type="number"
class="input input-bordered w-full"
value="5"
min="1"
max="20"
/>
</div>
<div class="pt-8">
<label class="label cursor-pointer">
<input type="checkbox" class="checkbox" checked />
<span class="label-text">"Enable Rollback"</span>
</label>
</div>
</div>
</div>
<div class="modal-action">
<button
class="btn"
on:click=move |_| set_show.set(false)
>
"Cancel"
</button>
<button
class="btn btn-primary"
on:click=move |_| create_batch_workflow()
>
"Create Batch"
</button>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,15 @@
pub mod dashboard;
pub mod servers;
pub mod clusters;
pub mod taskservs;
pub mod workflows;
pub mod settings;
pub mod not_found;
pub use dashboard::*;
pub use servers::*;
pub use clusters::*;
pub use taskservs::*;
pub use workflows::*;
pub use settings::*;
pub use not_found::*;

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn NotFound() -> impl IntoView {
view! {
<div class="not-found-page">
<h1>"404 - Page Not Found"</h1>
<p>"The page you are looking for does not exist."</p>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn ServersPage() -> impl IntoView {
view! {
<div class="servers-page">
<h1>"Servers"</h1>
<p>"Servers management placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn SettingsPage() -> impl IntoView {
view! {
<div class="settings-page">
<h1>"Settings"</h1>
<p>"Application settings placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn TaskservsPage() -> impl IntoView {
view! {
<div class="taskservs-page">
<h1>"Task Services"</h1>
<p>"Task services management placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,763 @@
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub email: String,
pub name: String,
pub status: UserStatus,
pub roles: Vec<String>,
pub groups: Vec<String>,
pub created_at: String,
pub last_login: Option<String>,
pub mfa_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum UserStatus {
Active,
Inactive,
Suspended,
PendingVerification,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Role {
pub id: String,
pub name: String,
pub description: String,
pub permissions: Vec<String>,
pub parent_role: Option<String>,
pub children: Vec<String>,
pub level: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: String,
pub name: String,
pub description: String,
pub members: Vec<String>,
pub parent_group: Option<String>,
pub children: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Permission {
pub id: String,
pub name: String,
pub description: String,
pub resource: String,
pub action: String,
pub category: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessReviewCampaign {
pub id: String,
pub name: String,
pub description: String,
pub schedule: String,
pub reviewers: Vec<String>,
pub status: String,
pub due_date: String,
}
#[component]
pub fn UsersPage() -> impl IntoView {
let (active_tab, set_active_tab) = create_signal("users".to_string());
let (users, set_users) = create_signal(Vec::<User>::new());
let (roles, set_roles) = create_signal(Vec::<Role>::new());
let (groups, set_groups) = create_signal(Vec::<Group>::new());
let (permissions, set_permissions) = create_signal(Vec::<Permission>::new());
let (selected_user, set_selected_user) = create_signal(None::<User>);
let (show_user_modal, set_show_user_modal) = create_signal(false);
let (show_role_hierarchy, set_show_role_hierarchy) = create_signal(false);
let (show_bulk_operations, set_show_bulk_operations) = create_signal(false);
// Load initial data
create_effect(move |_| {
spawn_local(async move {
// Mock data - in real app, fetch from API
let mock_users = vec![
User {
id: "u1".to_string(),
email: "admin@example.com".to_string(),
name: "System Admin".to_string(),
status: UserStatus::Active,
roles: vec!["admin".to_string()],
groups: vec!["admins".to_string()],
created_at: "2024-01-01".to_string(),
last_login: Some("2024-01-15".to_string()),
mfa_enabled: true,
},
User {
id: "u2".to_string(),
email: "user@example.com".to_string(),
name: "Regular User".to_string(),
status: UserStatus::Active,
roles: vec!["user".to_string()],
groups: vec!["users".to_string()],
created_at: "2024-01-10".to_string(),
last_login: Some("2024-01-14".to_string()),
mfa_enabled: false,
},
];
let mock_roles = vec![
Role {
id: "admin".to_string(),
name: "Administrator".to_string(),
description: "Full system access".to_string(),
permissions: vec!["*".to_string()],
parent_role: None,
children: vec!["operator".to_string()],
level: 0,
},
Role {
id: "operator".to_string(),
name: "Operator".to_string(),
description: "Infrastructure management".to_string(),
permissions: vec!["infra:*".to_string()],
parent_role: Some("admin".to_string()),
children: vec!["user".to_string()],
level: 1,
},
Role {
id: "user".to_string(),
name: "User".to_string(),
description: "Basic access".to_string(),
permissions: vec!["read:*".to_string()],
parent_role: Some("operator".to_string()),
children: vec![],
level: 2,
},
];
set_users.set(mock_users);
set_roles.set(mock_roles);
});
});
view! {
<div class="users-page h-full flex flex-col">
// Header
<div class="flex-none border-b border-base-300 p-4">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold">"User Management"</h1>
<div class="flex gap-2">
<button
class="btn btn-outline btn-sm"
on:click=move |_| set_show_bulk_operations.set(true)
>
"Bulk Operations"
</button>
<button
class="btn btn-outline btn-sm"
on:click=move |_| set_show_role_hierarchy.set(true)
>
"Role Hierarchy"
</button>
<button
class="btn btn-primary btn-sm"
on:click=move |_| {
set_selected_user.set(None);
set_show_user_modal.set(true);
}
>
"Add User"
</button>
</div>
</div>
// Tab Navigation
<div class="tabs tabs-boxed mt-4">
<a
class={move || if active_tab.get() == "users" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("users".to_string())
>
"Users"
</a>
<a
class={move || if active_tab.get() == "roles" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("roles".to_string())
>
"Roles"
</a>
<a
class={move || if active_tab.get() == "groups" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("groups".to_string())
>
"Groups"
</a>
<a
class={move || if active_tab.get() == "permissions" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("permissions".to_string())
>
"Permissions"
</a>
<a
class={move || if active_tab.get() == "reviews" { "tab tab-active" } else { "tab" }}
on:click=move |_| set_active_tab.set("reviews".to_string())
>
"Access Reviews"
</a>
</div>
</div>
// Content Area
<div class="flex-1 overflow-hidden">
{move || match active_tab.get().as_str() {
"users" => view! { <UsersTab users=users set_selected_user=set_selected_user set_show_user_modal=set_show_user_modal /> }.into(),
"roles" => view! { <RolesTab roles=roles /> }.into(),
"groups" => view! { <GroupsTab groups=groups /> }.into(),
"permissions" => view! { <PermissionsTab permissions=permissions /> }.into(),
"reviews" => view! { <AccessReviewsTab /> }.into(),
_ => view! { <div>"Unknown tab"</div> }.into(),
}}
</div>
// User Modal
{move || if show_user_modal.get() {
view! {
<UserModal
user=selected_user
show=show_user_modal
set_show=set_show_user_modal
roles=roles.get()
groups=groups.get()
/>
}.into()
} else {
view! { <div></div> }.into()
}}
// Role Hierarchy Modal
{move || if show_role_hierarchy.get() {
view! {
<RoleHierarchyModal
roles=roles.get()
show=show_role_hierarchy
set_show=set_show_role_hierarchy
/>
}.into()
} else {
view! { <div></div> }.into()
}}
// Bulk Operations Modal
{move || if show_bulk_operations.get() {
view! {
<BulkOperationsModal
show=show_bulk_operations
set_show=set_show_bulk_operations
/>
}.into()
} else {
view! { <div></div> }.into()
}}
</div>
}
}
#[component]
fn UsersTab(
users: ReadSignal<Vec<User>>,
set_selected_user: WriteSignal<Option<User>>,
set_show_user_modal: WriteSignal<bool>,
) -> impl IntoView {
let (search_term, set_search_term) = create_signal(String::new());
let (status_filter, set_status_filter) = create_signal("all".to_string());
let filtered_users = create_memo(move |_| {
let search = search_term.get().to_lowercase();
let status = status_filter.get();
users.get().into_iter().filter(|user| {
let matches_search = search.is_empty() ||
user.name.to_lowercase().contains(&search) ||
user.email.to_lowercase().contains(&search);
let matches_status = status == "all" ||
format!("{:?}", user.status).to_lowercase() == status;
matches_search && matches_status
}).collect::<Vec<_>>()
});
view! {
<div class="p-4 h-full flex flex-col">
// Search and Filters
<div class="flex-none mb-4 flex gap-4 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Search users..."
class="input input-bordered w-full"
prop:value=move || search_term.get()
on:input=move |ev| set_search_term.set(event_target_value(&ev))
/>
</div>
<select
class="select select-bordered"
on:change=move |ev| set_status_filter.set(event_target_value(&ev))
>
<option value="all">"All Status"</option>
<option value="active">"Active"</option>
<option value="inactive">"Inactive"</option>
<option value="suspended">"Suspended"</option>
<option value="pendingverification">"Pending"</option>
</select>
</div>
// Users Table
<div class="flex-1 overflow-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>"Name"</th>
<th>"Email"</th>
<th>"Status"</th>
<th>"Roles"</th>
<th>"MFA"</th>
<th>"Last Login"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
{move || {
filtered_users.get().into_iter().map(|user| {
let user_clone = user.clone();
let user_clone2 = user.clone();
view! {
<tr>
<td class="font-medium">{&user.name}</td>
<td>{&user.email}</td>
<td>
<div class={format!("badge {}",
match user.status {
UserStatus::Active => "badge-success",
UserStatus::Inactive => "badge-warning",
UserStatus::Suspended => "badge-error",
UserStatus::PendingVerification => "badge-info",
}
)}>
{format!("{:?}", user.status)}
</div>
</td>
<td>
<div class="flex gap-1 flex-wrap">
{user.roles.into_iter().map(|role| {
view! {
<div class="badge badge-outline badge-sm">{role}</div>
}
}).collect::<Vec<_>>()}
</div>
</td>
<td>
{if user.mfa_enabled {
view! { <div class="badge badge-success">"Enabled"</div> }
} else {
view! { <div class="badge badge-warning">"Disabled"</div> }
}}
</td>
<td class="text-sm text-base-content/70">
{user.last_login.unwrap_or_else(|| "Never".to_string())}
</td>
<td>
<div class="flex gap-2">
<button
class="btn btn-ghost btn-xs"
on:click=move |_| {
set_selected_user.set(Some(user_clone.clone()));
set_show_user_modal.set(true);
}
>
"Edit"
</button>
<button class="btn btn-ghost btn-xs text-error">
"Delete"
</button>
</div>
</td>
</tr>
}
}).collect::<Vec<_>>()
}}
</tbody>
</table>
</div>
</div>
}
}
#[component]
fn RolesTab(roles: ReadSignal<Vec<Role>>) -> impl IntoView {
view! {
<div class="p-4 h-full">
<div class="mb-4">
<button class="btn btn-primary btn-sm">"Add Role"</button>
</div>
<div class="grid gap-4">
{move || {
roles.get().into_iter().map(|role| {
view! {
<div class="card bg-base-200 p-4">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold">{&role.name}</h3>
<p class="text-sm text-base-content/70 mt-1">{&role.description}</p>
<div class="mt-2">
<span class="text-xs font-medium">"Permissions: "</span>
<div class="flex gap-1 flex-wrap mt-1">
{role.permissions.into_iter().map(|perm| {
view! {
<div class="badge badge-outline badge-xs">{perm}</div>
}
}).collect::<Vec<_>>()}
</div>
</div>
{role.parent_role.as_ref().map(|parent| {
view! {
<div class="mt-2 text-xs">
"Inherits from: "
<span class="badge badge-ghost badge-xs">{parent}</span>
</div>
}
})}
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs">"Edit"</button>
<button class="btn btn-ghost btn-xs text-error">"Delete"</button>
</div>
</div>
</div>
}
}).collect::<Vec<_>>()
}}
</div>
</div>
}
}
#[component]
fn GroupsTab(groups: ReadSignal<Vec<Group>>) -> impl IntoView {
view! {
<div class="p-4">
<div class="text-center text-base-content/50 mt-8">
"Group management coming soon..."
</div>
</div>
}
}
#[component]
fn PermissionsTab(permissions: ReadSignal<Vec<Permission>>) -> impl IntoView {
view! {
<div class="p-4">
<PermissionMatrix />
</div>
}
}
#[component]
fn AccessReviewsTab() -> impl IntoView {
view! {
<div class="p-4">
<div class="mb-4">
<button class="btn btn-primary btn-sm">"Create Campaign"</button>
</div>
<div class="text-center text-base-content/50 mt-8">
"Access review campaigns coming soon..."
</div>
</div>
}
}
#[component]
fn UserModal(
user: ReadSignal<Option<User>>,
show: ReadSignal<bool>,
set_show: WriteSignal<bool>,
roles: Vec<Role>,
groups: Vec<Group>,
) -> impl IntoView {
let (name, set_name) = create_signal(String::new());
let (email, set_email) = create_signal(String::new());
let (selected_roles, set_selected_roles) = create_signal(Vec::<String>::new());
create_effect(move |_| {
if let Some(u) = user.get() {
set_name.set(u.name);
set_email.set(u.email);
set_selected_roles.set(u.roles);
} else {
set_name.set(String::new());
set_email.set(String::new());
set_selected_roles.set(Vec::new());
}
});
view! {
<div class={move || if show.get() { "modal modal-open" } else { "modal" }}>
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">
{move || if user.get().is_some() { "Edit User" } else { "Add User" }}
</h3>
<div class="py-4 space-y-4">
<div>
<label class="label">"Name"</label>
<input
type="text"
class="input input-bordered w-full"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label">"Email"</label>
<input
type="email"
class="input input-bordered w-full"
prop:value=move || email.get()
on:input=move |ev| set_email.set(event_target_value(&ev))
/>
</div>
<div>
<label class="label">"Roles"</label>
<div class="space-y-2">
{roles.into_iter().map(|role| {
let role_id = role.id.clone();
view! {
<label class="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
class="checkbox"
prop:checked=move || selected_roles.get().contains(&role_id)
on:change=move |_| {
let mut current = selected_roles.get();
if current.contains(&role_id) {
current.retain(|r| r != &role_id);
} else {
current.push(role_id.clone());
}
set_selected_roles.set(current);
}
/>
<span class="label-text">{&role.name}</span>
<span class="text-xs text-base-content/50">{"("}{&role.description}{")"}</span>
</label>
}
}).collect::<Vec<_>>()}
</div>
</div>
</div>
<div class="modal-action">
<button
class="btn"
on:click=move |_| set_show.set(false)
>
"Cancel"
</button>
<button class="btn btn-primary">"Save"</button>
</div>
</div>
</div>
}
}
#[component]
fn RoleHierarchyModal(
roles: Vec<Role>,
show: ReadSignal<bool>,
set_show: WriteSignal<bool>,
) -> impl IntoView {
view! {
<div class={move || if show.get() { "modal modal-open" } else { "modal" }}>
<div class="modal-box w-11/12 max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">"Role Hierarchy"</h3>
<button
class="btn btn-ghost btn-sm"
on:click=move |_| set_show.set(false)
>
""
</button>
</div>
<div class="py-4">
<RoleHierarchyViewer roles=roles />
</div>
</div>
</div>
}
}
#[component]
fn RoleHierarchyViewer(roles: Vec<Role>) -> impl IntoView {
// Sort roles by level for hierarchy display
let mut sorted_roles = roles;
sorted_roles.sort_by_key(|r| r.level);
view! {
<div class="space-y-4">
{sorted_roles.into_iter().map(|role| {
let indent = role.level * 24; // 24px per level
view! {
<div class="flex items-center" style={format!("margin-left: {}px", indent)}>
<div class="w-4 h-4 border-2 border-primary rounded mr-3"></div>
<div class="flex-1">
<div class="font-medium">{&role.name}</div>
<div class="text-sm text-base-content/70">{&role.description}</div>
<div class="flex gap-1 mt-1">
{role.permissions.into_iter().take(3).map(|perm| {
view! {
<div class="badge badge-outline badge-xs">{perm}</div>
}
}).collect::<Vec<_>>()}
{if role.permissions.len() > 3 {
view! {
<div class="badge badge-ghost badge-xs">
{"+"}{role.permissions.len() - 3}{" more"}
</div>
}.into()
} else {
view! { <div></div> }.into()
}}
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs">"Edit"</button>
</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
}
#[component]
fn PermissionMatrix() -> impl IntoView {
let resources = vec!["Users", "Roles", "Infrastructure", "Workflows", "Policies"];
let actions = vec!["Read", "Write", "Delete", "Execute", "Admin"];
let roles = vec!["Admin", "Operator", "User"];
view! {
<div class="overflow-auto">
<table class="table table-zebra table-compact w-full">
<thead>
<tr>
<th>"Resource"</th>
<th>"Action"</th>
{roles.iter().map(|role| {
view! { <th>{role}</th> }
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody>
{resources.into_iter().flat_map(|resource| {
actions.iter().map({
let resource = resource.clone();
move |action| {
view! {
<tr>
<td>{&resource}</td>
<td>{action}</td>
{roles.iter().map(|role| {
view! {
<td>
<input type="checkbox" class="checkbox checkbox-xs" />
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}
}).collect::<Vec<_>>()
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
}
#[component]
fn BulkOperationsModal(
show: ReadSignal<bool>,
set_show: WriteSignal<bool>,
) -> impl IntoView {
let (operation_type, set_operation_type) = create_signal("import".to_string());
view! {
<div class={move || if show.get() { "modal modal-open" } else { "modal" }}>
<div class="modal-box">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">"Bulk Operations"</h3>
<button
class="btn btn-ghost btn-sm"
on:click=move |_| set_show.set(false)
>
""
</button>
</div>
<div class="py-4 space-y-4">
<div>
<label class="label">"Operation Type"</label>
<select
class="select select-bordered w-full"
on:change=move |ev| set_operation_type.set(event_target_value(&ev))
>
<option value="import">"Import Users"</option>
<option value="export">"Export Users"</option>
<option value="bulk_update">"Bulk Update"</option>
<option value="bulk_delete">"Bulk Delete"</option>
</select>
</div>
{move || match operation_type.get().as_str() {
"import" => view! {
<div>
<label class="label">"CSV File"</label>
<input type="file" class="file-input file-input-bordered w-full" accept=".csv" />
<div class="text-sm text-base-content/70 mt-2">
"Expected format: name,email,roles (comma-separated)"
</div>
</div>
}.into(),
"export" => view! {
<div>
<label class="label cursor-pointer">
<input type="checkbox" class="checkbox" />
<span class="label-text">"Include sensitive data"</span>
</label>
<label class="label cursor-pointer">
<input type="checkbox" class="checkbox" />
<span class="label-text">"Include inactive users"</span>
</label>
</div>
}.into(),
_ => view! { <div>"Operation settings..."</div> }.into(),
}}
</div>
<div class="modal-action">
<button
class="btn"
on:click=move |_| set_show.set(false)
>
"Cancel"
</button>
<button class="btn btn-primary">"Execute"</button>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,11 @@
use leptos::*;
#[component]
pub fn WorkflowsPage() -> impl IntoView {
view! {
<div class="workflows-page">
<h1>"Workflows"</h1>
<p>"Workflow management placeholder"</p>
</div>
}
}

View File

@ -0,0 +1,324 @@
import {
AuditLogEntry,
AuditSearchFilters,
AuditExportRequest,
SavedSearch,
ComplianceReport,
RemediationTask,
Attestation,
AuditDashboardStats,
AuditVisualizationData
} from '@/types/audit';
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8080';
class ApiError extends Error {
constructor(
message: string,
public status: number,
public statusText: string,
public response?: any
) {
super(message);
this.name = 'ApiError';
}
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include',
...options,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
errorData
);
}
return response.json();
}
export const auditApi = {
// Audit Log Operations
async getLogs(filters: Partial<AuditSearchFilters>, page = 0, limit = 50): Promise<{
logs: AuditLogEntry[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...Object.entries(filters).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
acc[key] = value.join(',');
} else if (value instanceof Date) {
acc[key] = value.toISOString();
} else {
acc[key] = value.toString();
}
}
return acc;
}, {} as Record<string, string>)
});
return apiRequest(`/audit/logs?${params.toString()}`);
},
async getLog(id: string): Promise<AuditLogEntry> {
return apiRequest(`/audit/logs/${id}`);
},
async searchLogs(query: string, filters: Partial<AuditSearchFilters>): Promise<{
logs: AuditLogEntry[];
total: number;
suggestions: string[];
}> {
return apiRequest('/audit/search', {
method: 'POST',
body: JSON.stringify({ query, filters }),
});
},
// Real-time streaming
createWebSocket(onMessage: (message: any) => void): WebSocket {
const wsUrl = API_BASE_URL.replace('http', 'ws') + '/audit/stream';
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
onMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
return ws;
},
// Export Operations
async exportLogs(request: AuditExportRequest): Promise<Blob> {
const response = await fetch(`${API_BASE_URL}/audit/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(request),
});
if (!response.ok) {
throw new ApiError(
'Export failed',
response.status,
response.statusText
);
}
return response.blob();
},
// Saved Searches
async getSavedSearches(): Promise<SavedSearch[]> {
return apiRequest('/audit/saved-searches');
},
async createSavedSearch(search: Omit<SavedSearch, 'id' | 'createdAt' | 'lastUsed' | 'useCount'>): Promise<SavedSearch> {
return apiRequest('/audit/saved-searches', {
method: 'POST',
body: JSON.stringify(search),
});
},
async updateSavedSearch(id: string, search: Partial<SavedSearch>): Promise<SavedSearch> {
return apiRequest(`/audit/saved-searches/${id}`, {
method: 'PUT',
body: JSON.stringify(search),
});
},
async deleteSavedSearch(id: string): Promise<void> {
return apiRequest(`/audit/saved-searches/${id}`, {
method: 'DELETE',
});
},
// Dashboard and Statistics
async getDashboardStats(period?: 'hour' | 'day' | 'week' | 'month'): Promise<AuditDashboardStats> {
const params = period ? `?period=${period}` : '';
return apiRequest(`/audit/dashboard/stats${params}`);
},
async getVisualizationData(filters: Partial<AuditSearchFilters>): Promise<AuditVisualizationData> {
return apiRequest('/audit/dashboard/visualization', {
method: 'POST',
body: JSON.stringify(filters),
});
},
// Compliance Operations
async getComplianceReports(): Promise<ComplianceReport[]> {
return apiRequest('/compliance/reports');
},
async getComplianceReport(id: string): Promise<ComplianceReport> {
return apiRequest(`/compliance/reports/${id}`);
},
async generateComplianceReport(
type: 'soc2' | 'hipaa' | 'pci' | 'gdpr' | 'custom',
period: { start: Date; end: Date },
template?: string
): Promise<{ reportId: string; status: 'generating' | 'completed' | 'failed' }> {
return apiRequest('/compliance/reports/generate', {
method: 'POST',
body: JSON.stringify({
type,
period: {
start: period.start.toISOString(),
end: period.end.toISOString(),
},
template,
}),
});
},
async getComplianceTemplates(): Promise<Array<{ id: string; name: string; type: string; description: string }>> {
return apiRequest('/compliance/templates');
},
// Remediation Operations
async getRemediationTasks(filters?: {
status?: string;
priority?: string;
assignee?: string;
}): Promise<RemediationTask[]> {
const params = filters ? '?' + new URLSearchParams(filters).toString() : '';
return apiRequest(`/remediation/tasks${params}`);
},
async getRemediationTask(id: string): Promise<RemediationTask> {
return apiRequest(`/remediation/tasks/${id}`);
},
async createRemediationTask(task: Omit<RemediationTask, 'id' | 'createdAt'>): Promise<RemediationTask> {
return apiRequest('/remediation/tasks', {
method: 'POST',
body: JSON.stringify(task),
});
},
async updateRemediationTask(id: string, updates: Partial<RemediationTask>): Promise<RemediationTask> {
return apiRequest(`/remediation/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
},
// Attestation Operations
async getAttestations(): Promise<Attestation[]> {
return apiRequest('/attestations');
},
async getAttestation(id: string): Promise<Attestation> {
return apiRequest(`/attestations/${id}`);
},
async createAttestation(attestation: Omit<Attestation, 'id' | 'createdAt'>): Promise<Attestation> {
return apiRequest('/attestations', {
method: 'POST',
body: JSON.stringify(attestation),
});
},
async signAttestation(id: string, signature: string): Promise<Attestation> {
return apiRequest(`/attestations/${id}/sign`, {
method: 'POST',
body: JSON.stringify({ signature }),
});
},
// Log Correlation
async getCorrelatedLogs(requestId: string): Promise<AuditLogEntry[]> {
return apiRequest(`/audit/correlation/request/${requestId}`);
},
async getLogsBySession(sessionId: string): Promise<AuditLogEntry[]> {
return apiRequest(`/audit/correlation/session/${sessionId}`);
},
async getLogTrail(logId: string, depth = 5): Promise<{
upstream: AuditLogEntry[];
downstream: AuditLogEntry[];
}> {
return apiRequest(`/audit/correlation/trail/${logId}?depth=${depth}`);
},
// Log Retention and Archival
async getRetentionPolicies(): Promise<Array<{
id: string;
name: string;
description: string;
retentionDays: number;
archiveDays: number;
filters: AuditSearchFilters;
enabled: boolean;
}>> {
return apiRequest('/audit/retention/policies');
},
async updateRetentionPolicy(id: string, policy: any): Promise<void> {
return apiRequest(`/audit/retention/policies/${id}`, {
method: 'PUT',
body: JSON.stringify(policy),
});
},
async getArchivedLogs(filters: Partial<AuditSearchFilters>): Promise<{
logs: AuditLogEntry[];
total: number;
}> {
const params = new URLSearchParams(
Object.entries(filters).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = Array.isArray(value) ? value.join(',') : value.toString();
}
return acc;
}, {} as Record<string, string>)
);
return apiRequest(`/audit/archived?${params.toString()}`);
},
// Health and Status
async getHealthStatus(): Promise<{
status: 'healthy' | 'unhealthy' | 'degraded';
version: string;
uptime: number;
database: { connected: boolean; latency: number };
websocket: { connected: boolean; clients: number };
storage: { available: number; used: number };
}> {
return apiRequest('/health');
},
};
export default auditApi;
export { ApiError };

View File

@ -0,0 +1,867 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use gloo_storage::{LocalStorage, Storage};
use crate::components::grid::{GridLayout, GridPosition, GridSize};
use crate::components::charts::ChartConfig;
use crate::components::widgets::{ActivityFeedConfig, SystemHealthConfig, MetricConfig};
use crate::types::UserRole;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardConfig {
pub id: String,
pub name: String,
pub description: Option<String>,
pub user_id: Option<String>,
pub user_role: UserRole,
pub layout: GridLayout,
pub widgets: HashMap<String, WidgetConfig>,
pub theme_config: ThemeSettings,
pub filter_presets: Vec<FilterPreset>,
pub export_settings: ExportSettings,
pub auto_refresh_settings: AutoRefreshSettings,
pub notification_settings: NotificationSettings,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_default: bool,
pub is_template: bool,
pub template_category: Option<String>,
pub version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetConfig {
pub id: String,
pub widget_type: WidgetType,
pub position: GridPosition,
pub size: GridSize,
pub title: String,
pub data_source: String,
pub refresh_interval: u32,
pub visible: bool,
pub config_data: WidgetConfigData,
pub permissions: WidgetPermissions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WidgetType {
Chart,
Metric,
SystemHealth,
ActivityFeed,
Table,
Map,
Custom { component_name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "widget_type")]
pub enum WidgetConfigData {
Chart(ChartConfig),
Metric(MetricConfig),
SystemHealth(SystemHealthConfig),
ActivityFeed(ActivityFeedConfig),
Table(TableConfig),
Map(MapConfig),
Custom(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetPermissions {
pub can_edit: bool,
pub can_move: bool,
pub can_resize: bool,
pub can_remove: bool,
pub required_roles: Vec<UserRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeSettings {
pub theme_name: String,
pub custom_colors: HashMap<String, String>,
pub font_size: FontSize,
pub density: UiDensity,
pub animation_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterPreset {
pub id: String,
pub name: String,
pub filters: HashMap<String, serde_json::Value>,
pub date_range: Option<DateRangePreset>,
pub is_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateRangePreset {
pub name: String,
pub range_type: DateRangeType,
pub custom_start: Option<DateTime<Utc>>,
pub custom_end: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DateRangeType {
Last15Minutes,
LastHour,
Last6Hours,
Last24Hours,
Last7Days,
Last30Days,
ThisMonth,
LastMonth,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportSettings {
pub default_format: ExportFormat,
pub include_metadata: bool,
pub compress_data: bool,
pub auto_timestamp: bool,
pub custom_filename_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoRefreshSettings {
pub enabled: bool,
pub global_interval: u32, // seconds
pub widget_overrides: HashMap<String, u32>,
pub pause_when_hidden: bool,
pub pause_on_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettings {
pub enabled: bool,
pub position: NotificationPosition,
pub auto_dismiss: bool,
pub dismiss_timeout: u32,
pub max_visible: u32,
pub sound_enabled: bool,
pub level_filters: Vec<NotificationLevel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FontSize {
Small,
Medium,
Large,
ExtraLarge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum UiDensity {
Compact,
Normal,
Comfortable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExportFormat {
Png,
Pdf,
Csv,
Excel,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NotificationPosition {
TopRight,
TopLeft,
BottomRight,
BottomLeft,
TopCenter,
BottomCenter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NotificationLevel {
Info,
Success,
Warning,
Error,
Critical,
}
// Additional widget config types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableConfig {
pub columns: Vec<TableColumn>,
pub row_height: u32,
pub pagination: PaginationConfig,
pub sorting: SortingConfig,
pub filtering: TableFilterConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableColumn {
pub id: String,
pub label: String,
pub field: String,
pub width: Option<u32>,
pub sortable: bool,
pub filterable: bool,
pub formatter: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginationConfig {
pub enabled: bool,
pub page_size: u32,
pub show_size_selector: bool,
pub available_sizes: Vec<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortingConfig {
pub enabled: bool,
pub multi_column: bool,
pub default_sort: Option<(String, SortDirection)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableFilterConfig {
pub enabled: bool,
pub show_search: bool,
pub column_filters: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapConfig {
pub center: (f64, f64), // lat, lng
pub zoom: u32,
pub map_style: String,
pub show_controls: bool,
pub markers: Vec<MapMarker>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapMarker {
pub id: String,
pub position: (f64, f64),
pub label: String,
pub color: String,
pub icon: Option<String>,
}
// Dashboard configuration service
#[derive(Debug, Clone)]
pub struct DashboardConfigService {
current_config: RwSignal<DashboardConfig>,
saved_configs: RwSignal<Vec<DashboardConfig>>,
templates: RwSignal<Vec<DashboardTemplate>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardTemplate {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub icon: String,
pub config: DashboardConfig,
pub preview_image: Option<String>,
pub tags: Vec<String>,
pub required_roles: Vec<UserRole>,
pub created_by: String,
pub is_official: bool,
}
impl DashboardConfigService {
pub fn new() -> Self {
// Load saved configurations from localStorage
let saved_configs = LocalStorage::get::<Vec<DashboardConfig>>("dashboard_configs")
.unwrap_or_default();
// Load current configuration
let current_config = if let Some(config) = saved_configs.first() {
config.clone()
} else {
DashboardConfig::default_for_role(UserRole::Admin)
};
// Load templates
let templates = Self::load_default_templates();
Self {
current_config: create_rw_signal(current_config),
saved_configs: create_rw_signal(saved_configs),
templates: create_rw_signal(templates),
}
}
pub fn get_current_config(&self) -> ReadSignal<DashboardConfig> {
self.current_config.into()
}
pub fn update_config<F>(&self, f: F)
where
F: FnOnce(&mut DashboardConfig) + 'static,
{
self.current_config.update(|config| {
f(config);
config.updated_at = Utc::now();
config.version += 1;
});
// Auto-save to localStorage
self.save_current_config();
}
pub fn save_current_config(&self) {
let config = self.current_config.get();
self.save_config(config);
}
pub fn save_config(&self, mut config: DashboardConfig) {
config.updated_at = Utc::now();
self.saved_configs.update(|configs| {
// Update existing or add new
if let Some(existing) = configs.iter_mut().find(|c| c.id == config.id) {
*existing = config.clone();
} else {
configs.push(config.clone());
}
// Limit number of saved configs
configs.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
configs.truncate(10);
// Save to localStorage
let _ = LocalStorage::set("dashboard_configs", configs);
});
}
pub fn load_config(&self, config_id: &str) -> Option<DashboardConfig> {
self.saved_configs.get().into_iter().find(|c| c.id == config_id)
}
pub fn set_current_config(&self, config: DashboardConfig) {
self.current_config.set(config);
self.save_current_config();
}
pub fn delete_config(&self, config_id: &str) {
self.saved_configs.update(|configs| {
configs.retain(|c| c.id != config_id);
let _ = LocalStorage::set("dashboard_configs", configs);
});
}
pub fn get_saved_configs(&self) -> ReadSignal<Vec<DashboardConfig>> {
self.saved_configs.into()
}
pub fn create_config_from_template(&self, template: &DashboardTemplate) -> DashboardConfig {
let mut config = template.config.clone();
config.id = Uuid::new_v4().to_string();
config.name = format!("{} - Copy", template.name);
config.created_at = Utc::now();
config.updated_at = Utc::now();
config.version = 1;
config.is_template = false;
config.is_default = false;
config
}
pub fn get_templates(&self) -> ReadSignal<Vec<DashboardTemplate>> {
self.templates.into()
}
pub fn get_templates_for_role(&self, role: UserRole) -> Vec<DashboardTemplate> {
self.templates.get()
.into_iter()
.filter(|template| {
template.required_roles.is_empty() ||
template.required_roles.contains(&role)
})
.collect()
}
// Widget management
pub fn add_widget(&self, widget: WidgetConfig) {
self.update_config(|config| {
config.widgets.insert(widget.id.clone(), widget);
});
}
pub fn remove_widget(&self, widget_id: &str) {
self.update_config(|config| {
config.widgets.remove(widget_id);
});
}
pub fn update_widget<F>(&self, widget_id: &str, f: F)
where
F: FnOnce(&mut WidgetConfig) + 'static,
{
self.update_config(|config| {
if let Some(widget) = config.widgets.get_mut(widget_id) {
f(widget);
}
});
}
pub fn move_widget(&self, widget_id: &str, position: GridPosition) {
self.update_widget(widget_id, |widget| {
widget.position = position;
});
}
pub fn resize_widget(&self, widget_id: &str, size: GridSize) {
self.update_widget(widget_id, |widget| {
widget.size = size;
});
}
// Layout management
pub fn update_layout(&self, layout: GridLayout) {
self.update_config(|config| {
config.layout = layout;
});
}
// Theme management
pub fn update_theme(&self, theme_settings: ThemeSettings) {
self.update_config(|config| {
config.theme_config = theme_settings;
});
}
// Filter presets
pub fn add_filter_preset(&self, preset: FilterPreset) {
self.update_config(|config| {
config.filter_presets.push(preset);
});
}
pub fn remove_filter_preset(&self, preset_id: &str) {
self.update_config(|config| {
config.filter_presets.retain(|p| p.id != preset_id);
});
}
pub fn apply_filter_preset(&self, preset_id: &str) -> Option<FilterPreset> {
let config = self.current_config.get();
config.filter_presets.into_iter().find(|p| p.id == preset_id)
}
// Export functionality
pub fn export_config(&self) -> String {
let config = self.current_config.get();
serde_json::to_string_pretty(&config).unwrap_or_default()
}
pub fn import_config(&self, config_json: &str) -> Result<(), String> {
let config: DashboardConfig = serde_json::from_str(config_json)
.map_err(|e| format!("Invalid configuration format: {}", e))?;
self.set_current_config(config);
Ok(())
}
// Reset functionality
pub fn reset_to_default(&self, role: UserRole) {
let default_config = DashboardConfig::default_for_role(role);
self.set_current_config(default_config);
}
fn load_default_templates() -> Vec<DashboardTemplate> {
vec![
DashboardTemplate {
id: "admin_dashboard".to_string(),
name: "Administrator Dashboard".to_string(),
description: "Complete overview with system metrics, security monitoring, and activity feeds".to_string(),
category: "Admin".to_string(),
icon: "bi-shield-check".to_string(),
config: DashboardConfig::admin_template(),
preview_image: Some("admin_dashboard_preview.png".to_string()),
tags: vec!["admin".to_string(), "complete".to_string(), "monitoring".to_string()],
required_roles: vec![UserRole::Admin],
created_by: "System".to_string(),
is_official: true,
},
DashboardTemplate {
id: "security_dashboard".to_string(),
name: "Security Dashboard".to_string(),
description: "Focus on security metrics, threat detection, and compliance monitoring".to_string(),
category: "Security".to_string(),
icon: "bi-shield-exclamation".to_string(),
config: DashboardConfig::security_template(),
preview_image: Some("security_dashboard_preview.png".to_string()),
tags: vec!["security".to_string(), "compliance".to_string(), "threats".to_string()],
required_roles: vec![UserRole::Admin, UserRole::User],
created_by: "System".to_string(),
is_official: true,
},
DashboardTemplate {
id: "operational_dashboard".to_string(),
name: "Operations Dashboard".to_string(),
description: "System health, performance metrics, and operational insights".to_string(),
category: "Operations".to_string(),
icon: "bi-gear".to_string(),
config: DashboardConfig::operations_template(),
preview_image: Some("operations_dashboard_preview.png".to_string()),
tags: vec!["operations".to_string(), "performance".to_string(), "health".to_string()],
required_roles: vec![UserRole::Admin, UserRole::User],
created_by: "System".to_string(),
is_official: true,
},
DashboardTemplate {
id: "minimal_dashboard".to_string(),
name: "Minimal Dashboard".to_string(),
description: "Simple layout with essential metrics only".to_string(),
category: "Basic".to_string(),
icon: "bi-layout-text-sidebar".to_string(),
config: DashboardConfig::minimal_template(),
preview_image: Some("minimal_dashboard_preview.png".to_string()),
tags: vec!["minimal".to_string(), "simple".to_string(), "basic".to_string()],
required_roles: vec![], // Available to all roles
created_by: "System".to_string(),
is_official: true,
},
]
}
}
// Implementation of default configurations for different contexts
impl DashboardConfig {
pub fn default_for_role(role: UserRole) -> Self {
match role {
UserRole::Admin => Self::admin_template(),
UserRole::User => Self::user_template(),
UserRole::ReadOnly => Self::readonly_template(),
}
}
pub fn admin_template() -> Self {
let mut config = Self::base_config("Admin Dashboard");
// Add comprehensive widget set for admin
config.widgets.insert("system_health".to_string(), WidgetConfig {
id: "system_health".to_string(),
widget_type: WidgetType::SystemHealth,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 6, height: 3 },
title: "System Health".to_string(),
data_source: "system_metrics".to_string(),
refresh_interval: 15,
visible: true,
config_data: WidgetConfigData::SystemHealth(SystemHealthConfig::default()),
permissions: WidgetPermissions::admin(),
});
config.widgets.insert("workflow_metrics".to_string(), WidgetConfig {
id: "workflow_metrics".to_string(),
widget_type: WidgetType::Chart,
position: GridPosition { x: 6, y: 0 },
size: GridSize { width: 6, height: 3 },
title: "Workflow Metrics".to_string(),
data_source: "workflow_data".to_string(),
refresh_interval: 30,
visible: true,
config_data: WidgetConfigData::Chart(ChartConfig::default()),
permissions: WidgetPermissions::admin(),
});
config.widgets.insert("activity_feed".to_string(), WidgetConfig {
id: "activity_feed".to_string(),
widget_type: WidgetType::ActivityFeed,
position: GridPosition { x: 0, y: 3 },
size: GridSize { width: 8, height: 4 },
title: "Activity Feed".to_string(),
data_source: "activity_events".to_string(),
refresh_interval: 30,
visible: true,
config_data: WidgetConfigData::ActivityFeed(ActivityFeedConfig::default()),
permissions: WidgetPermissions::admin(),
});
config.widgets.insert("security_metrics".to_string(), WidgetConfig {
id: "security_metrics".to_string(),
widget_type: WidgetType::Chart,
position: GridPosition { x: 8, y: 3 },
size: GridSize { width: 4, height: 4 },
title: "Security Events".to_string(),
data_source: "security_data".to_string(),
refresh_interval: 60,
visible: true,
config_data: WidgetConfigData::Chart(ChartConfig::default()),
permissions: WidgetPermissions::admin(),
});
config
}
pub fn security_template() -> Self {
let mut config = Self::base_config("Security Dashboard");
// Security-focused widgets
config.widgets.insert("security_overview".to_string(), WidgetConfig {
id: "security_overview".to_string(),
widget_type: WidgetType::Chart,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 6, height: 3 },
title: "Security Overview".to_string(),
data_source: "security_metrics".to_string(),
refresh_interval: 30,
visible: true,
config_data: WidgetConfigData::Chart(ChartConfig::default()),
permissions: WidgetPermissions::user(),
});
config.widgets.insert("threat_feed".to_string(), WidgetConfig {
id: "threat_feed".to_string(),
widget_type: WidgetType::ActivityFeed,
position: GridPosition { x: 6, y: 0 },
size: GridSize { width: 6, height: 5 },
title: "Security Events".to_string(),
data_source: "security_events".to_string(),
refresh_interval: 15,
visible: true,
config_data: WidgetConfigData::ActivityFeed(ActivityFeedConfig::default()),
permissions: WidgetPermissions::user(),
});
config
}
pub fn operations_template() -> Self {
let mut config = Self::base_config("Operations Dashboard");
// Operations-focused widgets
config.widgets.insert("system_resources".to_string(), WidgetConfig {
id: "system_resources".to_string(),
widget_type: WidgetType::Chart,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 8, height: 3 },
title: "System Resources".to_string(),
data_source: "system_metrics".to_string(),
refresh_interval: 15,
visible: true,
config_data: WidgetConfigData::Chart(ChartConfig::default()),
permissions: WidgetPermissions::user(),
});
config.widgets.insert("system_health".to_string(), WidgetConfig {
id: "system_health".to_string(),
widget_type: WidgetType::SystemHealth,
position: GridPosition { x: 8, y: 0 },
size: GridSize { width: 4, height: 3 },
title: "Health Status".to_string(),
data_source: "health_data".to_string(),
refresh_interval: 15,
visible: true,
config_data: WidgetConfigData::SystemHealth(SystemHealthConfig::default()),
permissions: WidgetPermissions::user(),
});
config
}
pub fn user_template() -> Self {
let mut config = Self::base_config("User Dashboard");
// User-focused widgets
config.widgets.insert("my_activities".to_string(), WidgetConfig {
id: "my_activities".to_string(),
widget_type: WidgetType::ActivityFeed,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 8, height: 4 },
title: "My Activities".to_string(),
data_source: "user_activities".to_string(),
refresh_interval: 60,
visible: true,
config_data: WidgetConfigData::ActivityFeed(ActivityFeedConfig::default()),
permissions: WidgetPermissions::user(),
});
config.widgets.insert("quick_metrics".to_string(), WidgetConfig {
id: "quick_metrics".to_string(),
widget_type: WidgetType::Metric,
position: GridPosition { x: 8, y: 0 },
size: GridSize { width: 4, height: 2 },
title: "Quick Stats".to_string(),
data_source: "user_metrics".to_string(),
refresh_interval: 120,
visible: true,
config_data: WidgetConfigData::Metric(MetricConfig::default()),
permissions: WidgetPermissions::user(),
});
config
}
pub fn readonly_template() -> Self {
let mut config = Self::base_config("Readonly Dashboard");
// Readonly widgets with view-only permissions
config.widgets.insert("status_overview".to_string(), WidgetConfig {
id: "status_overview".to_string(),
widget_type: WidgetType::SystemHealth,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 6, height: 3 },
title: "System Status".to_string(),
data_source: "system_status".to_string(),
refresh_interval: 60,
visible: true,
config_data: WidgetConfigData::SystemHealth(SystemHealthConfig::default()),
permissions: WidgetPermissions::readonly(),
});
config.widgets.insert("basic_metrics".to_string(), WidgetConfig {
id: "basic_metrics".to_string(),
widget_type: WidgetType::Metric,
position: GridPosition { x: 6, y: 0 },
size: GridSize { width: 6, height: 3 },
title: "Key Metrics".to_string(),
data_source: "basic_metrics".to_string(),
refresh_interval: 120,
visible: true,
config_data: WidgetConfigData::Metric(MetricConfig::default()),
permissions: WidgetPermissions::readonly(),
});
config
}
pub fn minimal_template() -> Self {
let mut config = Self::base_config("Minimal Dashboard");
// Single widget for minimal interface
config.widgets.insert("essential_metrics".to_string(), WidgetConfig {
id: "essential_metrics".to_string(),
widget_type: WidgetType::Metric,
position: GridPosition { x: 0, y: 0 },
size: GridSize { width: 12, height: 2 },
title: "Essential Metrics".to_string(),
data_source: "essential_data".to_string(),
refresh_interval: 60,
visible: true,
config_data: WidgetConfigData::Metric(MetricConfig::default()),
permissions: WidgetPermissions::user(),
});
config
}
fn base_config(name: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
description: None,
user_id: None,
user_role: UserRole::User,
layout: GridLayout::default(),
widgets: HashMap::new(),
theme_config: ThemeSettings::default(),
filter_presets: vec![],
export_settings: ExportSettings::default(),
auto_refresh_settings: AutoRefreshSettings::default(),
notification_settings: NotificationSettings::default(),
created_at: Utc::now(),
updated_at: Utc::now(),
is_default: true,
is_template: false,
template_category: None,
version: 1,
}
}
}
// Default implementations
impl Default for ThemeSettings {
fn default() -> Self {
Self {
theme_name: "auto".to_string(),
custom_colors: HashMap::new(),
font_size: FontSize::Medium,
density: UiDensity::Normal,
animation_enabled: true,
}
}
}
impl Default for ExportSettings {
fn default() -> Self {
Self {
default_format: ExportFormat::Png,
include_metadata: true,
compress_data: false,
auto_timestamp: true,
custom_filename_template: None,
}
}
}
impl Default for AutoRefreshSettings {
fn default() -> Self {
Self {
enabled: true,
global_interval: 30,
widget_overrides: HashMap::new(),
pause_when_hidden: true,
pause_on_error: true,
}
}
}
impl Default for NotificationSettings {
fn default() -> Self {
Self {
enabled: true,
position: NotificationPosition::TopRight,
auto_dismiss: true,
dismiss_timeout: 5,
max_visible: 5,
sound_enabled: false,
level_filters: vec![], // Empty = show all levels
}
}
}
impl WidgetPermissions {
pub fn admin() -> Self {
Self {
can_edit: true,
can_move: true,
can_resize: true,
can_remove: true,
required_roles: vec![UserRole::Admin],
}
}
pub fn user() -> Self {
Self {
can_edit: true,
can_move: true,
can_resize: true,
can_remove: true,
required_roles: vec![UserRole::Admin, UserRole::User],
}
}
pub fn readonly() -> Self {
Self {
can_edit: false,
can_move: false,
can_resize: false,
can_remove: false,
required_roles: vec![], // All roles can view
}
}
}

View File

@ -0,0 +1,733 @@
use leptos::*;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{HtmlCanvasElement, HtmlAnchorElement, Blob, Url, window, document};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use crate::services::dashboard_config::DashboardConfig;
use crate::components::charts::ChartData;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExportFormat {
Png,
Jpeg,
Pdf,
Svg,
Csv,
Excel,
Json,
}
impl ExportFormat {
pub fn extension(&self) -> &'static str {
match self {
ExportFormat::Png => "png",
ExportFormat::Jpeg => "jpeg",
ExportFormat::Pdf => "pdf",
ExportFormat::Svg => "svg",
ExportFormat::Csv => "csv",
ExportFormat::Excel => "xlsx",
ExportFormat::Json => "json",
}
}
pub fn mime_type(&self) -> &'static str {
match self {
ExportFormat::Png => "image/png",
ExportFormat::Jpeg => "image/jpeg",
ExportFormat::Pdf => "application/pdf",
ExportFormat::Svg => "image/svg+xml",
ExportFormat::Csv => "text/csv",
ExportFormat::Excel => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ExportFormat::Json => "application/json",
}
}
pub fn display_name(&self) -> &'static str {
match self {
ExportFormat::Png => "PNG Image",
ExportFormat::Jpeg => "JPEG Image",
ExportFormat::Pdf => "PDF Document",
ExportFormat::Svg => "SVG Vector",
ExportFormat::Csv => "CSV Data",
ExportFormat::Excel => "Excel Spreadsheet",
ExportFormat::Json => "JSON Data",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportOptions {
pub format: ExportFormat,
pub filename: Option<String>,
pub include_metadata: bool,
pub compress: bool,
pub quality: Option<f64>, // For image formats (0.0-1.0)
pub width: Option<u32>,
pub height: Option<u32>,
pub background_color: Option<String>,
pub scale: Option<f64>,
}
impl Default for ExportOptions {
fn default() -> Self {
Self {
format: ExportFormat::Png,
filename: None,
include_metadata: true,
compress: false,
quality: Some(0.95),
width: None,
height: None,
background_color: Some("#ffffff".to_string()),
scale: Some(2.0), // High DPI
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportMetadata {
pub exported_at: DateTime<Utc>,
pub dashboard_name: String,
pub user_id: Option<String>,
pub widget_count: usize,
pub export_format: ExportFormat,
pub version: String,
}
#[derive(Debug, Clone)]
pub struct ExportService {
export_history: RwSignal<Vec<ExportRecord>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportRecord {
pub id: String,
pub filename: String,
pub format: ExportFormat,
pub size_bytes: usize,
pub exported_at: DateTime<Utc>,
pub success: bool,
pub error_message: Option<String>,
}
impl ExportService {
pub fn new() -> Self {
Self {
export_history: create_rw_signal(Vec::new()),
}
}
pub async fn export_dashboard(
&self,
config: &DashboardConfig,
format: ExportFormat,
) -> Result<(), String> {
let options = ExportOptions {
format: format.clone(),
filename: Some(format!("{}_dashboard", config.name.replace(" ", "_").to_lowercase())),
..Default::default()
};
self.export_dashboard_with_options(config, options).await
}
pub async fn export_dashboard_with_options(
&self,
config: &DashboardConfig,
options: ExportOptions,
) -> Result<(), String> {
let start_time = Utc::now();
let export_id = uuid::Uuid::new_v4().to_string();
match options.format {
ExportFormat::Json => {
self.export_dashboard_json(config, &options, &export_id).await
},
ExportFormat::Csv => {
self.export_dashboard_csv(config, &options, &export_id).await
},
ExportFormat::Png | ExportFormat::Jpeg => {
self.export_dashboard_image(config, &options, &export_id).await
},
ExportFormat::Pdf => {
self.export_dashboard_pdf(config, &options, &export_id).await
},
ExportFormat::Svg => {
self.export_dashboard_svg(config, &options, &export_id).await
},
ExportFormat::Excel => {
self.export_dashboard_excel(config, &options, &export_id).await
},
}
}
pub async fn export_chart(
&self,
canvas: &HtmlCanvasElement,
chart_title: &str,
format: ExportFormat,
) -> Result<(), String> {
let options = ExportOptions {
format: format.clone(),
filename: Some(format!("{}_chart", chart_title.replace(" ", "_").to_lowercase())),
..Default::default()
};
self.export_chart_with_options(canvas, chart_title, options).await
}
pub async fn export_chart_with_options(
&self,
canvas: &HtmlCanvasElement,
chart_title: &str,
options: ExportOptions,
) -> Result<(), String> {
match options.format {
ExportFormat::Png | ExportFormat::Jpeg => {
self.export_canvas_as_image(canvas, &options).await
},
ExportFormat::Pdf => {
self.export_canvas_as_pdf(canvas, chart_title, &options).await
},
ExportFormat::Svg => {
Err("SVG export not supported for canvas charts".to_string())
},
_ => {
Err(format!("Format {:?} not supported for chart export", options.format))
}
}
}
async fn export_dashboard_json(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
let mut export_data = serde_json::to_value(config)
.map_err(|e| format!("Failed to serialize dashboard config: {}", e))?;
if options.include_metadata {
let metadata = ExportMetadata {
exported_at: Utc::now(),
dashboard_name: config.name.clone(),
user_id: config.user_id.clone(),
widget_count: config.widgets.len(),
export_format: options.format.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
export_data["export_metadata"] = serde_json::to_value(metadata)
.map_err(|e| format!("Failed to serialize metadata: {}", e))?;
}
let json_string = if options.compress {
serde_json::to_string(&export_data)
} else {
serde_json::to_string_pretty(&export_data)
}.map_err(|e| format!("Failed to serialize export data: {}", e))?;
let filename = generate_filename(options, "dashboard");
self.download_text_file(&json_string, &filename, options.format.mime_type())?;
self.record_export(export_id, &filename, options.format.clone(), json_string.len(), true, None);
Ok(())
}
async fn export_dashboard_csv(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
let mut csv_content = String::new();
csv_content.push_str("Widget ID,Widget Type,Title,Position X,Position Y,Width,Height,Data Source,Refresh Interval\n");
for (id, widget) in &config.widgets {
csv_content.push_str(&format!(
"{},{:?},{},{},{},{},{},{},{}\n",
id,
widget.widget_type,
escape_csv_field(&widget.title),
widget.position.x,
widget.position.y,
widget.size.width,
widget.size.height,
escape_csv_field(&widget.data_source),
widget.refresh_interval
));
}
let filename = generate_filename(options, "dashboard");
self.download_text_file(&csv_content, &filename, options.format.mime_type())?;
self.record_export(export_id, &filename, options.format.clone(), csv_content.len(), true, None);
Ok(())
}
async fn export_dashboard_image(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
// Create a composite image of the dashboard
let canvas = self.create_dashboard_canvas(config, options)?;
self.export_canvas_as_image(&canvas, options).await?;
let filename = generate_filename(options, "dashboard");
self.record_export(export_id, &filename, options.format.clone(), 0, true, None);
Ok(())
}
async fn export_dashboard_pdf(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
// Create PDF using jsPDF or similar library
// This is a placeholder implementation
let pdf_content = format!("Dashboard PDF Export\n\nDashboard: {}\nWidgets: {}\nExported: {}",
config.name,
config.widgets.len(),
Utc::now().format("%Y-%m-%d %H:%M:%S"));
let filename = generate_filename(options, "dashboard");
self.download_text_file(&pdf_content, &filename, options.format.mime_type())?;
self.record_export(export_id, &filename, options.format.clone(), pdf_content.len(), true, None);
Ok(())
}
async fn export_dashboard_svg(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
// Generate SVG representation of the dashboard
let svg_content = self.generate_dashboard_svg(config, options)?;
let filename = generate_filename(options, "dashboard");
self.download_text_file(&svg_content, &filename, options.format.mime_type())?;
self.record_export(export_id, &filename, options.format.clone(), svg_content.len(), true, None);
Ok(())
}
async fn export_dashboard_excel(
&self,
config: &DashboardConfig,
options: &ExportOptions,
export_id: &str,
) -> Result<(), String> {
// This would require a WASM-compatible Excel library
// For now, export as CSV with Excel-friendly formatting
self.export_dashboard_csv(config, options, export_id).await
}
async fn export_canvas_as_image(
&self,
canvas: &HtmlCanvasElement,
options: &ExportOptions,
) -> Result<(), String> {
let format_str = match options.format {
ExportFormat::Png => "image/png",
ExportFormat::Jpeg => "image/jpeg",
_ => return Err("Unsupported image format".to_string()),
};
let quality = options.quality.unwrap_or(0.95);
let data_url = canvas.to_data_url_with_type_and_encoder_options(format_str, &JsValue::from_f64(quality))
.map_err(|_| "Failed to generate image data")?;
let filename = generate_filename(options, "chart");
self.download_data_url(&data_url, &filename)?;
Ok(())
}
async fn export_canvas_as_pdf(
&self,
canvas: &HtmlCanvasElement,
title: &str,
options: &ExportOptions,
) -> Result<(), String> {
// Convert canvas to image first, then embed in PDF
let data_url = canvas.to_data_url()
.map_err(|_| "Failed to generate image data")?;
// This would require jsPDF or similar library
// For now, just download as PNG
let filename = generate_filename(options, title);
self.download_data_url(&data_url, &filename.replace(".pdf", ".png"))?;
Ok(())
}
fn create_dashboard_canvas(
&self,
config: &DashboardConfig,
options: &ExportOptions,
) -> Result<HtmlCanvasElement, String> {
let document = document().ok_or("No document available")?;
let canvas = document
.create_element("canvas")
.map_err(|_| "Failed to create canvas")?
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| "Failed to cast to canvas")?;
let width = options.width.unwrap_or(1920);
let height = options.height.unwrap_or(1080);
canvas.set_width(width);
canvas.set_height(height);
let context = canvas
.get_context("2d")
.map_err(|_| "Failed to get canvas context")?
.ok_or("No canvas context")?
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.map_err(|_| "Failed to cast to 2d context")?;
// Fill background
if let Some(bg_color) = &options.background_color {
context.set_fill_style(&JsValue::from_str(bg_color));
context.fill_rect(0.0, 0.0, width as f64, height as f64);
}
// Render dashboard content
self.render_dashboard_to_canvas(&context, config, width, height)?;
Ok(canvas)
}
fn render_dashboard_to_canvas(
&self,
context: &web_sys::CanvasRenderingContext2d,
config: &DashboardConfig,
width: u32,
height: u32,
) -> Result<(), String> {
// Calculate grid dimensions
let columns = config.layout.columns as f64;
let column_width = width as f64 / columns;
let row_height = config.layout.row_height as f64;
// Draw grid background
context.set_stroke_style(&JsValue::from_str("#e0e0e0"));
context.set_line_width(1.0);
// Vertical lines
for i in 0..=config.layout.columns {
let x = i as f64 * column_width;
context.begin_path();
context.move_to(x, 0.0);
context.line_to(x, height as f64);
context.stroke();
}
// Horizontal lines
let rows = (height as f64 / row_height).ceil() as i32;
for i in 0..=rows {
let y = i as f64 * row_height;
context.begin_path();
context.move_to(0.0, y);
context.line_to(width as f64, y);
context.stroke();
}
// Render widgets
context.set_fill_style(&JsValue::from_str("#333333"));
context.set_font("14px Arial");
for (id, widget) in &config.widgets {
let x = widget.position.x as f64 * column_width;
let y = widget.position.y as f64 * row_height;
let w = widget.size.width as f64 * column_width;
let h = widget.size.height as f64 * row_height;
// Draw widget border
context.set_stroke_style(&JsValue::from_str("#007bff"));
context.set_line_width(2.0);
context.stroke_rect(x, y, w, h);
// Draw widget title
context.set_fill_style(&JsValue::from_str("#333333"));
let _ = context.fill_text(&widget.title, x + 10.0, y + 25.0);
// Draw widget type
context.set_fill_style(&JsValue::from_str("#666666"));
let _ = context.fill_text(&format!("Type: {:?}", widget.widget_type), x + 10.0, y + 45.0);
}
Ok(())
}
fn generate_dashboard_svg(
&self,
config: &DashboardConfig,
options: &ExportOptions,
) -> Result<String, String> {
let width = options.width.unwrap_or(1920);
let height = options.height.unwrap_or(1080);
let mut svg = format!(
r#"<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg">"#,
width, height
);
// Background
if let Some(bg_color) = &options.background_color {
svg.push_str(&format!(
r#"<rect width="100%" height="100%" fill="{}"/>"#,
bg_color
));
}
// Grid
let columns = config.layout.columns as f64;
let column_width = width as f64 / columns;
let row_height = config.layout.row_height as f64;
// Render widgets as SVG rectangles
for (_, widget) in &config.widgets {
let x = widget.position.x as f64 * column_width;
let y = widget.position.y as f64 * row_height;
let w = widget.size.width as f64 * column_width;
let h = widget.size.height as f64 * row_height;
svg.push_str(&format!(
r#"<rect x="{}" y="{}" width="{}" height="{}" fill="none" stroke="#007bff" stroke-width="2"/>"#,
x, y, w, h
));
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-family="Arial" font-size="14" fill="#333">{}</text>"#,
x + 10.0, y + 25.0, escape_xml(&widget.title)
));
}
svg.push_str("</svg>");
Ok(svg)
}
fn download_text_file(&self, content: &str, filename: &str, mime_type: &str) -> Result<(), String> {
let window = window().ok_or("No window available")?;
let document = window.document().ok_or("No document available")?;
// Create blob
let array = js_sys::Array::new();
array.push(&JsValue::from_str(content));
let blob_parts = js_sys::Object::new();
js_sys::Reflect::set(&blob_parts, &"type".into(), &mime_type.into()).unwrap();
let blob = Blob::new_with_str_sequence_and_options(&array, &blob_parts)
.map_err(|_| "Failed to create blob")?;
// Create download link
let url = Url::create_object_url_with_blob(&blob)
.map_err(|_| "Failed to create object URL")?;
let anchor = document
.create_element("a")
.map_err(|_| "Failed to create anchor element")?
.dyn_into::<HtmlAnchorElement>()
.map_err(|_| "Failed to cast to anchor")?;
anchor.set_href(&url);
anchor.set_download(filename);
anchor.click();
// Cleanup
Url::revoke_object_url(&url).unwrap();
Ok(())
}
fn download_data_url(&self, data_url: &str, filename: &str) -> Result<(), String> {
let window = window().ok_or("No window available")?;
let document = window.document().ok_or("No document available")?;
let anchor = document
.create_element("a")
.map_err(|_| "Failed to create anchor element")?
.dyn_into::<HtmlAnchorElement>()
.map_err(|_| "Failed to cast to anchor")?;
anchor.set_href(data_url);
anchor.set_download(filename);
anchor.click();
Ok(())
}
fn record_export(
&self,
id: &str,
filename: &str,
format: ExportFormat,
size_bytes: usize,
success: bool,
error_message: Option<String>,
) {
let record = ExportRecord {
id: id.to_string(),
filename: filename.to_string(),
format,
size_bytes,
exported_at: Utc::now(),
success,
error_message,
};
self.export_history.update(|history| {
history.push(record);
// Keep only last 100 exports
if history.len() > 100 {
history.remove(0);
}
});
}
pub fn get_export_history(&self) -> ReadSignal<Vec<ExportRecord>> {
self.export_history.into()
}
pub fn clear_export_history(&self) {
self.export_history.set(Vec::new());
}
}
// Utility functions
fn generate_filename(options: &ExportOptions, base_name: &str) -> String {
if let Some(filename) = &options.filename {
if filename.contains('.') {
filename.clone()
} else {
format!("{}.{}", filename, options.format.extension())
}
} else {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
format!("{}_{}.{}", base_name, timestamp, options.format.extension())
}
}
fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace("\"", "\"\""))
} else {
field.to_string()
}
}
fn escape_xml(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
// Export widget component for easy integration
#[component]
pub fn ExportWidget(
#[prop(optional)] on_export: Option<Box<dyn Fn(ExportFormat) + 'static>>,
#[prop(optional)] compact: Option<bool>,
) -> impl IntoView {
let (show_menu, set_show_menu) = create_signal(false);
let compact = compact.unwrap_or(false);
let export_formats = vec![
ExportFormat::Png,
ExportFormat::Pdf,
ExportFormat::Csv,
ExportFormat::Json,
];
let handle_export = move |format: ExportFormat| {
if let Some(handler) = &on_export {
handler(format);
}
set_show_menu.set(false);
};
view! {
<div class="export-widget">
<button
class="export-trigger btn-icon"
on:click=move |_| set_show_menu.update(|show| *show = !*show)
title="Export"
>
<i class="bi-download"></i>
<Show when=move || !compact>
<span class="btn-text">"Export"</span>
</Show>
</button>
<Show when=move || show_menu.get()>
<div class="export-menu">
<div class="export-menu-header">
<h4>"Export Options"</h4>
<button
class="btn-icon close-btn"
on:click=move |_| set_show_menu.set(false)
>
<i class="bi-x"></i>
</button>
</div>
<div class="export-options">
<For
each=move || export_formats.clone()
key=|format| format!("{:?}", format)
children=move |format| {
let format_clone = format.clone();
view! {
<button
class="export-option"
on:click=move |_| handle_export(format_clone.clone())
>
<i class=format_icon(&format)></i>
<div class="option-details">
<span class="option-name">{format.display_name()}</span>
<small class="option-desc">{format_description(&format)}</small>
</div>
</button>
}
}
/>
</div>
</div>
</Show>
</div>
}
}
fn format_icon(format: &ExportFormat) -> &'static str {
match format {
ExportFormat::Png | ExportFormat::Jpeg => "bi-image",
ExportFormat::Pdf => "bi-file-earmark-pdf",
ExportFormat::Svg => "bi-vector-pen",
ExportFormat::Csv => "bi-filetype-csv",
ExportFormat::Excel => "bi-file-earmark-spreadsheet",
ExportFormat::Json => "bi-file-earmark-code",
}
}
fn format_description(format: &ExportFormat) -> &'static str {
match format {
ExportFormat::Png => "High-quality raster image",
ExportFormat::Jpeg => "Compressed raster image",
ExportFormat::Pdf => "Portable document format",
ExportFormat::Svg => "Scalable vector graphics",
ExportFormat::Csv => "Comma-separated values",
ExportFormat::Excel => "Microsoft Excel format",
ExportFormat::Json => "Structured data format",
}
}

Some files were not shown because too many files have changed in this diff Show More