core: init repo and codebase
This commit is contained in:
commit
f2be2414e4
319
.env.example
Normal file
319
.env.example
Normal 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
112
.gitignore
vendored
Normal 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
219
Cargo.toml
Normal 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
267
QUICK_START.md
Normal 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
556
README.md
Normal 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
0
api-gateway/.gitkeep
Normal file
58
api-gateway/Dockerfile
Normal file
58
api-gateway/Dockerfile
Normal 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"]
|
||||||
346
control-center-ui/AUTH_SYSTEM.md
Normal file
346
control-center-ui/AUTH_SYSTEM.md
Normal 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.**
|
||||||
176
control-center-ui/Cargo.toml
Normal file
176
control-center-ui/Cargo.toml
Normal 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
335
control-center-ui/README.md
Normal 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.
|
||||||
29
control-center-ui/REFERENCE.md
Normal file
29
control-center-ui/REFERENCE.md
Normal 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.
|
||||||
46
control-center-ui/Trunk.toml
Normal file
46
control-center-ui/Trunk.toml
Normal 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"
|
||||||
131
control-center-ui/assets/manifest.json
Normal file
131
control-center-ui/assets/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
353
control-center-ui/assets/sw.js
Normal file
353
control-center-ui/assets/sw.js
Normal 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');
|
||||||
798
control-center-ui/dist/control-center-ui-d1956c1b430684b9.js
vendored
Normal file
798
control-center-ui/dist/control-center-ui-d1956c1b430684b9.js
vendored
Normal 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;
|
||||||
BIN
control-center-ui/dist/control-center-ui-d1956c1b430684b9_bg.wasm
vendored
Normal file
BIN
control-center-ui/dist/control-center-ui-d1956c1b430684b9_bg.wasm
vendored
Normal file
Binary file not shown.
44
control-center-ui/dist/index-956be635a01ed8a8.css
vendored
Normal file
44
control-center-ui/dist/index-956be635a01ed8a8.css
vendored
Normal 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
161
control-center-ui/dist/index.html
vendored
Normal 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>
|
||||||
38
control-center-ui/index.html
Normal file
38
control-center-ui/index.html
Normal 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>
|
||||||
131
control-center-ui/manifest.json
Normal file
131
control-center-ui/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
control-center-ui/package.json
Normal file
35
control-center-ui/package.json
Normal 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
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
88
control-center-ui/setup.sh
Executable 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! 🦀"
|
||||||
41
control-center-ui/src/App.css
Normal file
41
control-center-ui/src/App.css
Normal 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;
|
||||||
|
}
|
||||||
58
control-center-ui/src/App.tsx
Normal file
58
control-center-ui/src/App.tsx
Normal 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;
|
||||||
6
control-center-ui/src/api/auth.rs
Normal file
6
control-center-ui/src/api/auth.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/client.rs
Normal file
6
control-center-ui/src/api/client.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/clusters.rs
Normal file
6
control-center-ui/src/api/clusters.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/dashboard.rs
Normal file
6
control-center-ui/src/api/dashboard.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
15
control-center-ui/src/api/mod.rs
Normal file
15
control-center-ui/src/api/mod.rs
Normal 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::*;
|
||||||
503
control-center-ui/src/api/orchestrator.rs
Normal file
503
control-center-ui/src/api/orchestrator.rs
Normal 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)
|
||||||
|
}
|
||||||
408
control-center-ui/src/api/orchestrator_client.rs
Normal file
408
control-center-ui/src/api/orchestrator_client.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
220
control-center-ui/src/api/orchestrator_types.rs
Normal file
220
control-center-ui/src/api/orchestrator_types.rs
Normal 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,
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/servers.rs
Normal file
6
control-center-ui/src/api/servers.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/types.rs
Normal file
6
control-center-ui/src/api/types.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
6
control-center-ui/src/api/workflows.rs
Normal file
6
control-center-ui/src/api/workflows.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
15
control-center-ui/src/app.rs
Normal file
15
control-center-ui/src/app.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
305
control-center-ui/src/auth/crypto.rs
Normal file
305
control-center-ui/src/auth/crypto.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
430
control-center-ui/src/auth/http_interceptor.rs
Normal file
430
control-center-ui/src/auth/http_interceptor.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
128
control-center-ui/src/auth/mod.rs
Normal file
128
control-center-ui/src/auth/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
196
control-center-ui/src/auth/storage.rs
Normal file
196
control-center-ui/src/auth/storage.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
220
control-center-ui/src/auth/token_manager.rs
Normal file
220
control-center-ui/src/auth/token_manager.rs
Normal 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(¤t_token) {
|
||||||
|
let new_token = self.refresh_token_now(current_token).await?;
|
||||||
|
return Ok(Some(new_token));
|
||||||
|
}
|
||||||
|
return Ok(Some(current_token));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_auth_header(&self) -> AuthResult<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
|
||||||
|
}
|
||||||
525
control-center-ui/src/auth/webauthn.rs
Normal file
525
control-center-ui/src/auth/webauthn.rs
Normal 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(¶m_obj, &"type".into(), ¶m.type_.into()).unwrap();
|
||||||
|
js_sys::Reflect::set(¶m_obj, &"alg".into(), ¶m.alg.into()).unwrap();
|
||||||
|
cred_params.push(¶m_obj);
|
||||||
|
}
|
||||||
|
creation_options.set_pub_key_cred_params(&cred_params);
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
if let Some(timeout) = options.timeout {
|
||||||
|
creation_options.set_timeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set exclude credentials
|
||||||
|
if let Some(exclude_creds) = options.exclude_credentials {
|
||||||
|
let exclude_array = Array::new();
|
||||||
|
for cred in exclude_creds {
|
||||||
|
let cred_obj = Object::new();
|
||||||
|
js_sys::Reflect::set(&cred_obj, &"type".into(), &cred.type_.into()).unwrap();
|
||||||
|
|
||||||
|
let cred_id_bytes = general_purpose::STANDARD
|
||||||
|
.decode(&cred.id)
|
||||||
|
.map_err(|e| AuthError {
|
||||||
|
message: format!("Failed to decode credential ID: {}", e),
|
||||||
|
code: Some("CRED_ID_DECODE_ERROR".to_string()),
|
||||||
|
field: None,
|
||||||
|
})?;
|
||||||
|
let cred_id_array = Uint8Array::from(&cred_id_bytes[..]);
|
||||||
|
js_sys::Reflect::set(&cred_obj, &"id".into(), &cred_id_array).unwrap();
|
||||||
|
|
||||||
|
if let Some(transports) = cred.transports {
|
||||||
|
let transports_array = Array::new();
|
||||||
|
for transport in transports {
|
||||||
|
transports_array.push(&transport.into());
|
||||||
|
}
|
||||||
|
js_sys::Reflect::set(&cred_obj, &"transports".into(), &transports_array).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
exclude_array.push(&cred_obj);
|
||||||
|
}
|
||||||
|
creation_options.set_exclude_credentials(&exclude_array);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authenticator selection
|
||||||
|
if let Some(auth_sel) = options.authenticator_selection {
|
||||||
|
let auth_sel_obj = Object::new();
|
||||||
|
|
||||||
|
if let Some(attachment) = auth_sel.authenticator_attachment {
|
||||||
|
js_sys::Reflect::set(&auth_sel_obj, &"authenticatorAttachment".into(), &attachment.into()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(resident_key) = auth_sel.resident_key {
|
||||||
|
js_sys::Reflect::set(&auth_sel_obj, &"residentKey".into(), &resident_key.into()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(require_resident_key) = auth_sel.require_resident_key {
|
||||||
|
js_sys::Reflect::set(&auth_sel_obj, &"requireResidentKey".into(), &require_resident_key.into()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(user_verification) = auth_sel.user_verification {
|
||||||
|
js_sys::Reflect::set(&auth_sel_obj, &"userVerification".into(), &user_verification.into()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
creation_options.set_authenticator_selection(&auth_sel_obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set attestation
|
||||||
|
if let Some(attestation) = options.attestation {
|
||||||
|
creation_options.set_attestation(&attestation.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(creation_options)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request_options(
|
||||||
|
&self,
|
||||||
|
options: WebAuthnAuthenticationOptions,
|
||||||
|
) -> AuthResult<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
433
control-center-ui/src/components/audit/AuditLogViewer.tsx
Normal file
433
control-center-ui/src/components/audit/AuditLogViewer.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
676
control-center-ui/src/components/audit/ExportModal.tsx
Normal file
676
control-center-ui/src/components/audit/ExportModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
586
control-center-ui/src/components/audit/LogDetailModal.tsx
Normal file
586
control-center-ui/src/components/audit/LogDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
145
control-center-ui/src/components/audit/RealTimeIndicator.tsx
Normal file
145
control-center-ui/src/components/audit/RealTimeIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
732
control-center-ui/src/components/audit/SearchFilters.tsx
Normal file
732
control-center-ui/src/components/audit/SearchFilters.tsx
Normal 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;
|
||||||
519
control-center-ui/src/components/audit/VirtualizedLogTable.tsx
Normal file
519
control-center-ui/src/components/audit/VirtualizedLogTable.tsx
Normal 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;
|
||||||
0
control-center-ui/src/components/audit/mod.rs
Normal file
0
control-center-ui/src/components/audit/mod.rs
Normal file
19
control-center-ui/src/components/auth/auth_guard.rs
Normal file
19
control-center-ui/src/components/auth/auth_guard.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
10
control-center-ui/src/components/auth/biometric_auth.rs
Normal file
10
control-center-ui/src/components/auth/biometric_auth.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
10
control-center-ui/src/components/auth/device_trust.rs
Normal file
10
control-center-ui/src/components/auth/device_trust.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
51
control-center-ui/src/components/auth/login_form.rs
Normal file
51
control-center-ui/src/components/auth/login_form.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
17
control-center-ui/src/components/auth/logout_button.rs
Normal file
17
control-center-ui/src/components/auth/logout_button.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/components/auth/mfa_setup.rs
Normal file
11
control-center-ui/src/components/auth/mfa_setup.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
21
control-center-ui/src/components/auth/mod.rs
Normal file
21
control-center-ui/src/components/auth/mod.rs
Normal 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::*;
|
||||||
11
control-center-ui/src/components/auth/password_reset.rs
Normal file
11
control-center-ui/src/components/auth/password_reset.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
10
control-center-ui/src/components/auth/session_timeout.rs
Normal file
10
control-center-ui/src/components/auth/session_timeout.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
10
control-center-ui/src/components/auth/sso_buttons.rs
Normal file
10
control-center-ui/src/components/auth/sso_buttons.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/components/auth/user_profile.rs
Normal file
11
control-center-ui/src/components/auth/user_profile.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
control-center-ui/src/components/charts.rs
Normal file
6
control-center-ui/src/components/charts.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
170
control-center-ui/src/components/common.rs
Normal file
170
control-center-ui/src/components/common.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
control-center-ui/src/components/forms.rs
Normal file
6
control-center-ui/src/components/forms.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
466
control-center-ui/src/components/grid.rs
Normal file
466
control-center-ui/src/components/grid.rs
Normal 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)
|
||||||
|
}
|
||||||
67
control-center-ui/src/components/header.rs
Normal file
67
control-center-ui/src/components/header.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
control-center-ui/src/components/icons.rs
Normal file
6
control-center-ui/src/components/icons.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
794
control-center-ui/src/components/layout.rs
Normal file
794
control-center-ui/src/components/layout.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
21
control-center-ui/src/components/loading.rs
Normal file
21
control-center-ui/src/components/loading.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
25
control-center-ui/src/components/mod.rs
Normal file
25
control-center-ui/src/components/mod.rs
Normal 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::*;
|
||||||
6
control-center-ui/src/components/modal.rs
Normal file
6
control-center-ui/src/components/modal.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Modal() -> impl IntoView {
|
||||||
|
view! { <div></div> }
|
||||||
|
}
|
||||||
504
control-center-ui/src/components/notifications.rs
Normal file
504
control-center-ui/src/components/notifications.rs
Normal 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) = ¬ification.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
control-center-ui/src/components/policies/mod.rs
Normal file
28
control-center-ui/src/components/policies/mod.rs
Normal 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;
|
||||||
474
control-center-ui/src/components/policies/policy_editor.rs
Normal file
474
control-center-ui/src/components/policies/policy_editor.rs
Normal 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);
|
||||||
|
}
|
||||||
66
control-center-ui/src/components/sidebar.rs
Normal file
66
control-center-ui/src/components/sidebar.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
control-center-ui/src/components/tables.rs
Normal file
6
control-center-ui/src/components/tables.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Placeholder() -> impl IntoView {
|
||||||
|
view! { <div>"Placeholder"</div> }
|
||||||
|
}
|
||||||
651
control-center-ui/src/components/theme.rs
Normal file
651
control-center-ui/src/components/theme.rs
Normal 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", ¤t_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
|
||||||
|
}
|
||||||
10
control-center-ui/src/components/toast.rs
Normal file
10
control-center-ui/src/components/toast.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
883
control-center-ui/src/components/widgets.rs
Normal file
883
control-center-ui/src/components/widgets.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
control-center-ui/src/hooks/mod.rs
Normal file
0
control-center-ui/src/hooks/mod.rs
Normal file
218
control-center-ui/src/hooks/useWebSocket.ts
Normal file
218
control-center-ui/src/hooks/useWebSocket.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
44
control-center-ui/src/index.css
Normal file
44
control-center-ui/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
19
control-center-ui/src/lib.rs
Normal file
19
control-center-ui/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
101
control-center-ui/src/main.rs
Normal file
101
control-center-ui/src/main.rs
Normal 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");
|
||||||
|
}
|
||||||
49
control-center-ui/src/main.tsx
Normal file
49
control-center-ui/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
0
control-center-ui/src/mod.rs
Normal file
0
control-center-ui/src/mod.rs
Normal file
11
control-center-ui/src/pages/clusters.rs
Normal file
11
control-center-ui/src/pages/clusters.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/pages/dashboard.rs
Normal file
11
control-center-ui/src/pages/dashboard.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
662
control-center-ui/src/pages/infrastructure.rs
Normal file
662
control-center-ui/src/pages/infrastructure.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
15
control-center-ui/src/pages/mod.rs
Normal file
15
control-center-ui/src/pages/mod.rs
Normal 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::*;
|
||||||
11
control-center-ui/src/pages/not_found.rs
Normal file
11
control-center-ui/src/pages/not_found.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/pages/servers.rs
Normal file
11
control-center-ui/src/pages/servers.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/pages/settings.rs
Normal file
11
control-center-ui/src/pages/settings.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/pages/taskservs.rs
Normal file
11
control-center-ui/src/pages/taskservs.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
763
control-center-ui/src/pages/users.rs
Normal file
763
control-center-ui/src/pages/users.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
control-center-ui/src/pages/workflows.rs
Normal file
11
control-center-ui/src/pages/workflows.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
324
control-center-ui/src/services/api.ts
Normal file
324
control-center-ui/src/services/api.ts
Normal 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 };
|
||||||
867
control-center-ui/src/services/dashboard_config.rs
Normal file
867
control-center-ui/src/services/dashboard_config.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
733
control-center-ui/src/services/export.rs
Normal file
733
control-center-ui/src/services/export.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
Loading…
x
Reference in New Issue
Block a user