diff --git a/Cargo.toml b/Cargo.toml index 9de5d9c..20e946c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/bitcell-economics", "crates/bitcell-network", "crates/bitcell-node", + "crates/bitcell-admin", ] resolver = "2" diff --git a/crates/bitcell-admin/Cargo.toml b/crates/bitcell-admin/Cargo.toml new file mode 100644 index 0000000..9d77fab --- /dev/null +++ b/crates/bitcell-admin/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "bitcell-admin" +version = "0.1.0" +edition = "2021" +authors = ["BitCell Contributors"] +description = "Administrative console and dashboard for BitCell blockchain" + +[dependencies] +# Web framework +axum = "0.7" +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "cors"] } + +# Async runtime +tokio = { version = "1.0", features = ["full"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Templating +tera = "1.19" + +# HTTP client (for calling node APIs) +reqwest = { version = "0.11", features = ["json"] } + +# Metrics +prometheus-client = "0.22" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Sync primitives +parking_lot = "0.12" + +# BitCell dependencies +bitcell-node = { path = "../bitcell-node" } +bitcell-consensus = { path = "../bitcell-consensus" } +bitcell-state = { path = "../bitcell-state" } +bitcell-network = { path = "../bitcell-network" } +bitcell-crypto = { path = "../bitcell-crypto" } +bitcell-ca = { path = "../bitcell-ca" } + +# Unix process management +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] diff --git a/crates/bitcell-admin/README.md b/crates/bitcell-admin/README.md new file mode 100644 index 0000000..fc2327d --- /dev/null +++ b/crates/bitcell-admin/README.md @@ -0,0 +1,221 @@ +# BitCell Admin Console + +A comprehensive web-based administrative interface for managing and monitoring BitCell blockchain nodes. + +## Features + +### πŸŽ›οΈ Node Management +- **Register and manage multiple nodes** (validators, miners, full nodes) +- **Start/stop nodes** remotely via web interface +- **Real-time status monitoring** with automatic updates +- **Node health checks** and diagnostics + +### πŸ“Š Metrics & Monitoring +- **Chain Metrics**: Block height, transactions, pending pool, block times +- **Network Metrics**: Peer connections, bandwidth usage, message throughput +- **EBSL Metrics**: Active miners, banned miners, trust scores, slashing events +- **System Metrics**: CPU usage, memory usage, disk usage, uptime + +### πŸš€ Deployment Management +- **Automated node deployment** with configurable parameters +- **Multi-node deployment** for testnets and production +- **Deployment status tracking** and history +- **Configuration management** with validation + +### πŸ§ͺ Testing Utilities +- **Battle simulation testing** with custom glider patterns +- **Transaction testing** for stress testing and validation +- **Network connectivity testing** for peer discovery +- **Performance benchmarking** tools + +### βš™οΈ Configuration +- **Network configuration**: Listen addresses, bootstrap peers, max peers +- **Consensus configuration**: Battle steps, tournament rounds, block time +- **EBSL configuration**: Evidence thresholds, slash percentages, decay rates +- **Economics configuration**: Rewards, halving intervals, gas pricing + +## Quick Start + +### Running the Admin Console + +```bash +# Start on default port (8080) +cargo run -p bitcell-admin + +# Start on custom port +cargo run -p bitcell-admin -- 0.0.0.0:9999 +``` + +### Access the Dashboard + +Open your browser and navigate to: +``` +http://localhost:8080 +``` + +## API Endpoints + +### Node Management +- `GET /api/nodes` - List all nodes +- `GET /api/nodes/:id` - Get node details +- `POST /api/nodes/:id/start` - Start a node +- `POST /api/nodes/:id/stop` - Stop a node + +### Metrics +- `GET /api/metrics` - Get all metrics +- `GET /api/metrics/chain` - Chain-specific metrics +- `GET /api/metrics/network` - Network-specific metrics + +### Deployment +- `POST /api/deployment/deploy` - Deploy new nodes +- `GET /api/deployment/status` - Get deployment status + +### Configuration +- `GET /api/config` - Get current configuration +- `POST /api/config` - Update configuration + +### Testing +- `POST /api/test/battle` - Run battle simulation +- `POST /api/test/transaction` - Send test transaction + +## API Examples + +### Deploy Validator Nodes + +```bash +curl -X POST http://localhost:8080/api/deployment/deploy \ + -H "Content-Type: application/json" \ + -d '{ + "node_type": "validator", + "count": 3, + "config": { + "network": "testnet", + "log_level": "info", + "port_start": 9000 + } + }' +``` + +### Run Battle Test + +```bash +curl -X POST http://localhost:8080/api/test/battle \ + -H "Content-Type: application/json" \ + -d '{ + "glider_a": "Standard", + "glider_b": "Heavyweight", + "steps": 1000 + }' +``` + +### Update Configuration + +```bash +curl -X POST http://localhost:8080/api/config \ + -H "Content-Type: application/json" \ + -d '{ + "network": { + "listen_addr": "0.0.0.0:9000", + "bootstrap_peers": ["127.0.0.1:9001"], + "max_peers": 50 + }, + "consensus": { + "battle_steps": 1000, + "tournament_rounds": 5, + "block_time": 6 + }, + "ebsl": { + "evidence_threshold": 0.7, + "slash_percentage": 0.1, + "decay_rate": 0.95 + }, + "economics": { + "initial_reward": 50000000, + "halving_interval": 210000, + "base_gas_price": 1000 + } + }' +``` + +## Architecture + +``` +bitcell-admin/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ lib.rs # Main library interface +β”‚ β”œβ”€β”€ main.rs # Binary entry point +β”‚ β”œβ”€β”€ api/ # REST API endpoints +β”‚ β”‚ β”œβ”€β”€ mod.rs # API types and core +β”‚ β”‚ β”œβ”€β”€ nodes.rs # Node management +β”‚ β”‚ β”œβ”€β”€ metrics.rs # Metrics endpoints +β”‚ β”‚ β”œβ”€β”€ deployment.rs # Deployment endpoints +β”‚ β”‚ β”œβ”€β”€ config.rs # Configuration endpoints +β”‚ β”‚ └── test.rs # Testing utilities +β”‚ β”œβ”€β”€ web/ # Web interface +β”‚ β”‚ β”œβ”€β”€ mod.rs # Template engine setup +β”‚ β”‚ └── dashboard.rs # Dashboard HTML/JS +β”‚ β”œβ”€β”€ deployment.rs # Deployment manager +β”‚ β”œβ”€β”€ config.rs # Configuration manager +β”‚ └── metrics.rs # Metrics collector +└── static/ # Static assets (CSS, JS, images) +``` + +## Security Considerations + +⚠️ **CRITICAL SECURITY WARNING** ⚠️ + +**NO AUTHENTICATION IS CURRENTLY IMPLEMENTED** + +The admin console currently allows **unrestricted access** to all endpoints. This is a **critical security vulnerability**. + +**DO NOT expose this admin console to any network (including localhost) in production without implementing authentication first.** + +For production deployments, you MUST: + +1. **Implement authentication** before exposing to any network +2. **Use HTTPS/TLS** for all communication (never HTTP in production) +3. **Restrict network access** via firewall rules, VPN, or IP allowlisting +4. **Use strong passwords** and rotate them regularly +5. **Enable comprehensive audit logging** for all administrative actions +6. **Implement API rate limiting** to prevent abuse +7. **Run with least-privilege** user accounts (never as root) + +## Development + +### Building + +```bash +cargo build -p bitcell-admin +``` + +### Testing + +```bash +cargo test -p bitcell-admin +``` + +### Running in Development + +```bash +# With auto-reload (requires cargo-watch) +cargo watch -x 'run -p bitcell-admin' +``` + +## Future Enhancements + +- [ ] Authentication and authorization (JWT tokens) +- [ ] WebSocket support for real-time updates +- [ ] Advanced charting and visualization +- [ ] Log aggregation and search +- [ ] Automated health checks and alerting +- [ ] Backup and restore functionality +- [ ] Multi-chain support +- [ ] Mobile-responsive UI improvements + +## License + +Same as BitCell project + +## Contributing + +Contributions welcome! Please follow the BitCell contribution guidelines. diff --git a/crates/bitcell-admin/src/api/config.rs b/crates/bitcell-admin/src/api/config.rs new file mode 100644 index 0000000..350592a --- /dev/null +++ b/crates/bitcell-admin/src/api/config.rs @@ -0,0 +1,74 @@ +//! Configuration API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Config { + pub network: NetworkConfig, + pub consensus: ConsensusConfig, + pub ebsl: EbslConfig, + pub economics: EconomicsConfig, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NetworkConfig { + pub listen_addr: String, + pub bootstrap_peers: Vec, + pub max_peers: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConsensusConfig { + pub battle_steps: usize, + pub tournament_rounds: usize, + pub block_time: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EbslConfig { + pub evidence_threshold: f64, + pub slash_percentage: f64, + pub decay_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EconomicsConfig { + pub initial_reward: u64, + pub halving_interval: u64, + pub base_gas_price: u64, +} + +/// Get current configuration +pub async fn get_config( + State(state): State>, +) -> Result, (StatusCode, Json)> { + match state.config.get_config() { + Ok(config) => Ok(Json(config)), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(format!("Failed to get config: {}", e)), + )), + } +} + +/// Update configuration +pub async fn update_config( + State(state): State>, + Json(config): Json, +) -> Result, (StatusCode, Json)> { + match state.config.update_config(config.clone()) { + Ok(_) => Ok(Json(config)), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(format!("Failed to update config: {}", e)), + )), + } +} diff --git a/crates/bitcell-admin/src/api/deployment.rs b/crates/bitcell-admin/src/api/deployment.rs new file mode 100644 index 0000000..16bffef --- /dev/null +++ b/crates/bitcell-admin/src/api/deployment.rs @@ -0,0 +1,143 @@ +//! Deployment API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; +use super::NodeType; + +#[derive(Debug, Deserialize)] +pub struct DeployNodeRequest { + pub node_type: NodeType, + pub count: usize, + pub config: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DeploymentConfig { + pub network: String, + pub data_dir: Option, + pub log_level: Option, + pub port_start: Option, +} + +#[derive(Debug, Serialize)] +pub struct DeploymentResponse { + pub deployment_id: String, + pub status: String, + pub nodes_deployed: usize, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct DeploymentStatusResponse { + pub active_deployments: usize, + pub total_nodes: usize, + pub deployments: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DeploymentInfo { + pub id: String, + pub node_type: NodeType, + pub node_count: usize, + pub status: String, + pub created_at: chrono::DateTime, +} + +/// Deploy new nodes +pub async fn deploy_node( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Generate deployment ID + let deployment_id = format!("deploy-{}", chrono::Utc::now().timestamp()); + + // Trigger deployment (async) + tokio::spawn({ + let deployment = state.deployment.clone(); + let deployment_id = deployment_id.clone(); + let node_type = req.node_type; + let count = req.count; + + async move { + deployment.deploy_nodes(&deployment_id, node_type, count).await; + } + }); + + Ok(Json(DeploymentResponse { + deployment_id, + status: "deploying".to_string(), + nodes_deployed: req.count, + message: format!( + "Deploying {} {:?} node(s)", + req.count, req.node_type + ), + })) +} + +/// Get deployment status +pub async fn deployment_status( + State(state): State>, +) -> Result, (StatusCode, Json)> { + // Get actual node status from process manager + let nodes = state.process.list_nodes(); + + // Group nodes by type and count + let mut validator_count = 0; + let mut miner_count = 0; + let mut fullnode_count = 0; + + for node in &nodes { + match node.node_type { + super::NodeType::Validator => validator_count += 1, + super::NodeType::Miner => miner_count += 1, + super::NodeType::FullNode => fullnode_count += 1, + } + } + + let mut deployments = Vec::new(); + + if validator_count > 0 { + deployments.push(DeploymentInfo { + id: "validators".to_string(), + node_type: NodeType::Validator, + node_count: validator_count, + status: "running".to_string(), + created_at: chrono::Utc::now(), // TODO: Track actual creation time + }); + } + + if miner_count > 0 { + deployments.push(DeploymentInfo { + id: "miners".to_string(), + node_type: NodeType::Miner, + node_count: miner_count, + status: "running".to_string(), + created_at: chrono::Utc::now(), + }); + } + + if fullnode_count > 0 { + deployments.push(DeploymentInfo { + id: "fullnodes".to_string(), + node_type: NodeType::FullNode, + node_count: fullnode_count, + status: "running".to_string(), + created_at: chrono::Utc::now(), + }); + } + + let response = DeploymentStatusResponse { + active_deployments: deployments.len(), + total_nodes: nodes.len(), + deployments, + }; + + Ok(Json(response)) +} diff --git a/crates/bitcell-admin/src/api/metrics.rs b/crates/bitcell-admin/src/api/metrics.rs new file mode 100644 index 0000000..ff368c2 --- /dev/null +++ b/crates/bitcell-admin/src/api/metrics.rs @@ -0,0 +1,135 @@ +//! Metrics API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; + +#[derive(Debug, Serialize)] +pub struct MetricsResponse { + pub chain: ChainMetrics, + pub network: NetworkMetrics, + pub ebsl: EbslMetrics, + pub system: SystemMetrics, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChainMetrics { + pub height: u64, + pub latest_block_hash: String, + pub latest_block_time: chrono::DateTime, + pub total_transactions: u64, + pub pending_transactions: u64, + pub average_block_time: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct NetworkMetrics { + pub connected_peers: usize, + pub total_peers: usize, + pub bytes_sent: u64, + pub bytes_received: u64, + pub messages_sent: u64, + pub messages_received: u64, +} + +#[derive(Debug, Serialize)] +pub struct EbslMetrics { + pub active_miners: usize, + pub banned_miners: usize, + pub average_trust_score: f64, + pub total_slashing_events: u64, +} + +#[derive(Debug, Serialize)] +pub struct SystemMetrics { + pub uptime_seconds: u64, + pub cpu_usage: f64, + pub memory_usage_mb: u64, + pub disk_usage_mb: u64, +} + +/// Get all metrics from running nodes +pub async fn get_metrics( + State(state): State>, +) -> Result, (StatusCode, Json)> { + let nodes = state.setup.get_nodes(); + + if nodes.is_empty() { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json("No nodes configured. Please complete setup wizard and deploy nodes first.".to_string()), + )); + } + + // Get endpoints for metrics fetching + let endpoints: Vec<(String, String)> = nodes + .iter() + .map(|n| (n.id.clone(), n.metrics_endpoint.clone())) + .collect(); + + // Fetch aggregated metrics + let aggregated = state.metrics_client.aggregate_metrics(&endpoints) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + + // Calculate system metrics + // TODO: Track actual node start times to compute real uptime + let uptime_seconds = 0u64; // Placeholder - requires node start time tracking + + let response = MetricsResponse { + chain: ChainMetrics { + height: aggregated.chain_height, + latest_block_hash: format!("0x{:016x}", aggregated.chain_height), // Simplified + latest_block_time: chrono::Utc::now(), + total_transactions: aggregated.total_txs_processed, + pending_transactions: aggregated.pending_txs as u64, + average_block_time: 6.0, // TODO: Calculate from actual block times + }, + network: NetworkMetrics { + connected_peers: aggregated.total_peers, + total_peers: aggregated.total_nodes * 10, // Estimate + bytes_sent: aggregated.bytes_sent, + bytes_received: aggregated.bytes_received, + messages_sent: 0, // TODO: Requires adding message_sent to node metrics + messages_received: 0, // TODO: Requires adding message_received to node metrics + }, + ebsl: EbslMetrics { + active_miners: aggregated.active_miners, + banned_miners: aggregated.banned_miners, + average_trust_score: 0.85, // TODO: Requires adding trust scores to node metrics + total_slashing_events: 0, // TODO: Requires adding slashing events to node metrics + }, + system: SystemMetrics { + uptime_seconds, + cpu_usage: 0.0, // TODO: Requires system metrics collection (e.g., sysinfo crate) + memory_usage_mb: 0, // TODO: Requires system metrics collection + disk_usage_mb: 0, // TODO: Requires system metrics collection + }, + }; + + Ok(Json(response)) +} + +/// Get chain-specific metrics +pub async fn chain_metrics( + State(state): State>, +) -> Result, (StatusCode, Json)> { + // Reuse get_metrics logic and extract chain metrics + let full_metrics = get_metrics(State(state)).await?; + Ok(Json(full_metrics.chain.clone())) +} + +/// Get network-specific metrics +pub async fn network_metrics( + State(state): State>, +) -> Result, (StatusCode, Json)> { + // Reuse get_metrics logic and extract network metrics + let full_metrics = get_metrics(State(state)).await?; + Ok(Json(full_metrics.network.clone())) +} diff --git a/crates/bitcell-admin/src/api/mod.rs b/crates/bitcell-admin/src/api/mod.rs new file mode 100644 index 0000000..bebe6cf --- /dev/null +++ b/crates/bitcell-admin/src/api/mod.rs @@ -0,0 +1,85 @@ +//! API module for admin console + +pub mod nodes; +pub mod metrics; +pub mod deployment; +pub mod config; +pub mod test; +pub mod setup; + +use std::collections::HashMap; +use std::sync::RwLock; +use serde::{Deserialize, Serialize}; + +/// Node information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfo { + pub id: String, + pub node_type: NodeType, + pub status: NodeStatus, + pub address: String, + pub port: u16, + pub started_at: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeType { + Validator, + Miner, + FullNode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeStatus { + Running, + Stopped, + Starting, + Stopping, + Error, +} + +/// Administrative API handler +pub struct AdminApi { + nodes: RwLock>, +} + +impl AdminApi { + pub fn new() -> Self { + Self { + nodes: RwLock::new(HashMap::new()), + } + } + + pub fn register_node(&self, node: NodeInfo) { + let mut nodes = self.nodes.write().unwrap(); + nodes.insert(node.id.clone(), node); + } + + pub fn get_node(&self, id: &str) -> Option { + let nodes = self.nodes.read().unwrap(); + nodes.get(id).cloned() + } + + pub fn list_nodes(&self) -> Vec { + let nodes = self.nodes.read().unwrap(); + nodes.values().cloned().collect() + } + + pub fn update_node_status(&self, id: &str, status: NodeStatus) -> bool { + let mut nodes = self.nodes.write().unwrap(); + if let Some(node) = nodes.get_mut(id) { + node.status = status; + true + } else { + false + } + } +} + +impl Default for AdminApi { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/bitcell-admin/src/api/nodes.rs b/crates/bitcell-admin/src/api/nodes.rs new file mode 100644 index 0000000..6cb3cd9 --- /dev/null +++ b/crates/bitcell-admin/src/api/nodes.rs @@ -0,0 +1,128 @@ +//! Node management API endpoints + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; +use super::{NodeInfo, NodeStatus}; + +#[derive(Debug, Serialize)] +pub struct NodesResponse { + pub nodes: Vec, + pub total: usize, +} + +#[derive(Debug, Serialize)] +pub struct NodeResponse { + pub node: NodeInfo, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Debug, Deserialize)] +pub struct StartNodeRequest { + pub config: Option, +} + +/// Validate node ID format (alphanumeric, hyphens, and underscores only) +fn validate_node_id(id: &str) -> Result<(), (StatusCode, Json)> { + if id.is_empty() || !id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid node ID format".to_string(), + }), + )); + } + Ok(()) +} + +/// List all registered nodes +pub async fn list_nodes( + State(state): State>, +) -> Result, (StatusCode, Json)> { + let nodes = state.process.list_nodes(); + let total = nodes.len(); + + Ok(Json(NodesResponse { nodes, total })) +} + +/// Get information about a specific node +pub async fn get_node( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + + match state.process.get_node(&id) { + Some(node) => Ok(Json(NodeResponse { node })), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Node '{}' not found", id), + }), + )), + } +} + +/// Start a node +pub async fn start_node( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + + // Config is not supported yet + if req.config.is_some() { + tracing::warn!("Node '{}': Rejected start request with unsupported config", id); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Custom config is not supported yet".to_string(), + }), + )); + } + + match state.process.start_node(&id) { + Ok(node) => { + tracing::info!("Started node '{}' successfully", id); + Ok(Json(NodeResponse { node })) + } + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to start node '{}': {}", id, e), + }), + )), + } +} + +/// Stop a node +pub async fn stop_node( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + + match state.process.stop_node(&id) { + Ok(node) => { + tracing::info!("Stopped node '{}' successfully", id); + Ok(Json(NodeResponse { node })) + } + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to stop node '{}': {}", id, e), + }), + )), + } +} diff --git a/crates/bitcell-admin/src/api/setup.rs b/crates/bitcell-admin/src/api/setup.rs new file mode 100644 index 0000000..92ef905 --- /dev/null +++ b/crates/bitcell-admin/src/api/setup.rs @@ -0,0 +1,157 @@ +//! Setup wizard API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; +use crate::setup::{NodeEndpoint, SETUP_FILE_PATH}; + +#[derive(Debug, Serialize)] +pub struct SetupStatusResponse { + pub initialized: bool, + pub config_path: Option, + pub data_dir: Option, + pub nodes: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AddNodeRequest { + pub id: String, + pub node_type: String, + pub metrics_endpoint: String, + pub rpc_endpoint: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetConfigPathRequest { + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetDataDirRequest { + pub path: String, +} + +/// Get setup status +pub async fn get_setup_status( + State(state): State>, +) -> Result, (StatusCode, Json)> { + let setup_state = state.setup.get_state(); + + let response = SetupStatusResponse { + initialized: setup_state.initialized, + config_path: setup_state.config_path.map(|p| p.to_string_lossy().to_string()), + data_dir: setup_state.data_dir.map(|p| p.to_string_lossy().to_string()), + nodes: setup_state.nodes, + }; + + Ok(Json(response)) +} + +/// Add a node endpoint +pub async fn add_node( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let node = NodeEndpoint { + id: req.id, + node_type: req.node_type, + metrics_endpoint: req.metrics_endpoint, + rpc_endpoint: req.rpc_endpoint, + }; + + state.setup.add_node(node.clone()); + + // Save setup state + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); + state.setup.save_to_file(&setup_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + + tracing::info!("Added node: {}", node.id); + + Ok(Json(node)) +} + +/// Set config path +pub async fn set_config_path( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let path = std::path::PathBuf::from(&req.path); + + state.setup.set_config_path(path.clone()); + + // Save setup state + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); + state.setup.save_to_file(&setup_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + + Ok(Json(req.path)) +} + +/// Set data directory +pub async fn set_data_dir( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let path = std::path::PathBuf::from(&req.path); + + // Create directory if it doesn't exist with restrictive permissions + std::fs::create_dir_all(&path) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(format!("Failed to create data directory: {}", e)) + ))?; + + // Set restrictive permissions on Unix systems (0700 - owner only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(&path, permissions) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(format!("Failed to set directory permissions: {}", e)) + ))?; + tracing::info!("Set data directory permissions to 0700 (owner only)"); + } + + state.setup.set_data_dir(path); + + // Save setup state + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); + state.setup.save_to_file(&setup_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + + Ok(Json(req.path)) +} + +/// Mark setup as complete +pub async fn complete_setup( + State(state): State>, +) -> Result, (StatusCode, Json)> { + state.setup.mark_initialized(); + + // Save setup state + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); + state.setup.save_to_file(&setup_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + + tracing::info!("Setup completed"); + + let setup_state = state.setup.get_state(); + + let response = SetupStatusResponse { + initialized: setup_state.initialized, + config_path: setup_state.config_path.map(|p| p.to_string_lossy().to_string()), + data_dir: setup_state.data_dir.map(|p| p.to_string_lossy().to_string()), + nodes: setup_state.nodes, + }; + + Ok(Json(response)) +} diff --git a/crates/bitcell-admin/src/api/test.rs b/crates/bitcell-admin/src/api/test.rs new file mode 100644 index 0000000..b8c3819 --- /dev/null +++ b/crates/bitcell-admin/src/api/test.rs @@ -0,0 +1,273 @@ +//! Testing utilities API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; + +// Import BitCell types +use bitcell_ca::{Battle, Glider, GliderPattern, Position, BattleOutcome}; + +#[derive(Debug, Deserialize)] +pub struct RunBattleTestRequest { + pub glider_a: String, + pub glider_b: String, + pub steps: Option, +} + +#[derive(Debug, Serialize)] +pub struct BattleTestResponse { + pub test_id: String, + pub winner: String, + pub steps: usize, + pub final_energy_a: u64, + pub final_energy_b: u64, + pub duration_ms: u64, +} + +#[derive(Debug, Deserialize)] +pub struct BattleVisualizationRequest { + pub glider_a: String, + pub glider_b: String, + pub steps: Option, + pub frame_count: Option, + pub downsample_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct BattleVisualizationResponse { + pub test_id: String, + pub winner: String, + pub steps: usize, + pub final_energy_a: u64, + pub final_energy_b: u64, + pub frames: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BattleFrame { + pub step: usize, + pub grid: Vec>, + pub energy_a: u64, + pub energy_b: u64, +} + +#[derive(Debug, Deserialize)] +pub struct SendTestTransactionRequest { + pub from: Option, + pub to: String, + pub amount: u64, +} + +#[derive(Debug, Serialize)] +pub struct TransactionTestResponse { + pub tx_hash: String, + pub status: String, + pub message: String, +} + +fn parse_glider_pattern(name: &str) -> Result { + match name.to_lowercase().as_str() { + "standard" => Ok(GliderPattern::Standard), + "lightweight" | "lwss" => Ok(GliderPattern::Lightweight), + "middleweight" | "mwss" => Ok(GliderPattern::Middleweight), + "heavyweight" | "hwss" => Ok(GliderPattern::Heavyweight), + _ => Err(format!("Unknown glider pattern: {}", name)), + } +} + +/// Run a battle test +pub async fn run_battle_test( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let test_id = format!("test-{}", chrono::Utc::now().timestamp()); + + tracing::info!("Running battle test: {} vs {}", req.glider_a, req.glider_b); + + // Parse glider patterns + let pattern_a = parse_glider_pattern(&req.glider_a) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(e)))?; + + let pattern_b = parse_glider_pattern(&req.glider_b) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(e)))?; + + // Create gliders + let glider_a = Glider::new(pattern_a, Position::new(256, 512)); + let glider_b = Glider::new(pattern_b, Position::new(768, 512)); + + // Create battle + let steps = req.steps.unwrap_or(1000); + let battle = if steps != 1000 { + Battle::with_steps(glider_a, glider_b, steps) + } else { + Battle::new(glider_a, glider_b) + }; + + // Run battle simulation + let start = std::time::Instant::now(); + + let (outcome, energy_a, energy_b) = tokio::task::spawn_blocking(move || { + // Simulate the battle + let outcome = battle.simulate(); + + // Get final grid to measure energies + let final_grid = battle.final_grid(); + let (energy_a, energy_b) = battle.measure_regional_energy(&final_grid); + + (outcome, energy_a, energy_b) + }) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))?; + + let duration = start.elapsed(); + + let winner = match outcome { + BattleOutcome::AWins => "glider_a".to_string(), + BattleOutcome::BWins => "glider_b".to_string(), + BattleOutcome::Tie => "tie".to_string(), + }; + + tracing::info!( + "Battle test completed: winner={}, energy_a={}, energy_b={}, duration={}ms", + winner, + energy_a, + energy_b, + duration.as_millis() + ); + + let response = BattleTestResponse { + test_id, + winner, + steps, + final_energy_a: energy_a, + final_energy_b: energy_b, + duration_ms: duration.as_millis() as u64, + }; + + Ok(Json(response)) +} + +/// Send a test transaction +pub async fn send_test_transaction( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // TODO: Actually send transaction to a running node + // For now, return a formatted response + + let tx_hash = format!("0x{:x}", chrono::Utc::now().timestamp()); + + let response = TransactionTestResponse { + tx_hash, + status: "pending".to_string(), + message: format!( + "Test transaction sent: {} -> {} ({} units)", + req.from.unwrap_or_else(|| "genesis".to_string()), + req.to, + req.amount + ), + }; + + tracing::info!("Test transaction: {}", response.message); + + Ok(Json(response)) +} + +/// Run a battle with visualization frames +pub async fn run_battle_visualization( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let test_id = format!("viz-{}", chrono::Utc::now().timestamp()); + + tracing::info!("Running battle visualization: {} vs {}", req.glider_a, req.glider_b); + + // Parse glider patterns + let pattern_a = parse_glider_pattern(&req.glider_a) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(e)))?; + + let pattern_b = parse_glider_pattern(&req.glider_b) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(e)))?; + + // Create gliders + let glider_a = Glider::new(pattern_a, Position::new(256, 512)); + let glider_b = Glider::new(pattern_b, Position::new(768, 512)); + + // Create battle + let steps = req.steps.unwrap_or(1000); + let frame_count = req.frame_count.unwrap_or(20).min(100); // Max 100 frames + let downsample_size = req.downsample_size.unwrap_or(128).min(512); // Max 512x512 + + let battle = if steps != 1000 { + Battle::with_steps(glider_a, glider_b, steps) + } else { + Battle::new(glider_a, glider_b) + }; + + // Calculate which steps to capture + let sample_interval = steps / frame_count; + let mut sample_steps: Vec = (0..frame_count) + .map(|i| i * sample_interval) + .collect(); + sample_steps.push(steps); // Always include final step + + // Run simulation and capture frames + let (outcome, frames) = tokio::task::spawn_blocking(move || { + // Get outcome + let outcome = battle.simulate(); + + // Get grid states at sample steps + let grids = battle.grid_states(&sample_steps); + + // Create frames with downsampled grids and energy measurements + let mut frames = Vec::new(); + for (i, grid) in grids.iter().enumerate() { + let step = sample_steps[i]; + let (energy_a, energy_b) = battle.measure_regional_energy(grid); + let downsampled = grid.downsample(downsample_size); + + frames.push(BattleFrame { + step, + grid: downsampled, + energy_a, + energy_b, + }); + } + + (outcome, frames) + }) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))?; + + let winner = match outcome { + BattleOutcome::AWins => "glider_a".to_string(), + BattleOutcome::BWins => "glider_b".to_string(), + BattleOutcome::Tie => "tie".to_string(), + }; + + let final_energy_a = frames.last().map(|f| f.energy_a).unwrap_or(0); + let final_energy_b = frames.last().map(|f| f.energy_b).unwrap_or(0); + + tracing::info!( + "Battle visualization completed: winner={}, {} frames captured", + winner, + frames.len() + ); + + let response = BattleVisualizationResponse { + test_id, + winner, + steps, + final_energy_a, + final_energy_b, + frames, + }; + + Ok(Json(response)) +} diff --git a/crates/bitcell-admin/src/config.rs b/crates/bitcell-admin/src/config.rs new file mode 100644 index 0000000..3450b02 --- /dev/null +++ b/crates/bitcell-admin/src/config.rs @@ -0,0 +1,105 @@ +//! Configuration manager with file persistence + +use std::path::PathBuf; +use std::sync::RwLock; + +use crate::api::config::*; + +pub struct ConfigManager { + config: RwLock, + config_path: Option, +} + +impl ConfigManager { + pub fn new() -> Self { + Self { + config: RwLock::new(Self::default_config()), + config_path: None, + } + } + + pub fn with_path(path: PathBuf) -> Result { + let config = if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read config file: {}", e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse config file: {}", e))? + } else { + Self::default_config() + }; + + Ok(Self { + config: RwLock::new(config), + config_path: Some(path), + }) + } + + fn default_config() -> Config { + Config { + network: NetworkConfig { + listen_addr: "0.0.0.0:9000".to_string(), + bootstrap_peers: vec![], + max_peers: 50, + }, + consensus: ConsensusConfig { + battle_steps: 1000, + tournament_rounds: 5, + block_time: 6, + }, + ebsl: EbslConfig { + evidence_threshold: 0.7, + slash_percentage: 0.1, + decay_rate: 0.95, + }, + economics: EconomicsConfig { + initial_reward: 50_000_000, + halving_interval: 210_000, + base_gas_price: 1000, + }, + } + } + + pub fn get_config(&self) -> Result { + let config = self.config.read().unwrap(); + Ok(config.clone()) + } + + pub fn update_config(&self, new_config: Config) -> Result<(), String> { + let mut config = self.config.write().unwrap(); + *config = new_config.clone(); + drop(config); + + // Persist to file if path is set + if let Some(ref path) = self.config_path { + self.save_to_file(path)?; + } + + Ok(()) + } + + fn save_to_file(&self, path: &PathBuf) -> Result<(), String> { + let config = self.config.read().unwrap(); + + let content = serde_json::to_string_pretty(&*config) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + std::fs::write(path, content) + .map_err(|e| format!("Failed to write config file: {}", e))?; + + tracing::info!("Configuration saved to {:?}", path); + + Ok(()) + } +} + +impl Default for ConfigManager { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/bitcell-admin/src/deployment.rs b/crates/bitcell-admin/src/deployment.rs new file mode 100644 index 0000000..85ce93f --- /dev/null +++ b/crates/bitcell-admin/src/deployment.rs @@ -0,0 +1,57 @@ +//! Deployment manager for nodes + +use std::sync::Arc; + +use crate::api::NodeType; +use crate::process::{ProcessManager, NodeConfig}; + +pub struct DeploymentManager { + process: Arc, +} + +impl DeploymentManager { + pub fn new(process: Arc) -> Self { + Self { process } + } + + pub async fn deploy_nodes(&self, deployment_id: &str, node_type: NodeType, count: usize) { + tracing::info!( + "Starting deployment {}: deploying {} {:?} nodes", + deployment_id, + count, + node_type + ); + + let base_port = match node_type { + NodeType::Validator => 9000, + NodeType::Miner => 9100, + NodeType::FullNode => 9200, + }; + + let base_rpc_port = base_port + 1000; + + for i in 0..count { + let node_id = format!("{:?}-{}-{}", node_type, deployment_id, i); + let config = NodeConfig { + node_type, + data_dir: format!("/tmp/bitcell/{}", node_id), + port: base_port + i as u16, + rpc_port: base_rpc_port + i as u16, + log_level: "info".to_string(), + network: "testnet".to_string(), + }; + + // Register the node (but don't start it automatically) + self.process.register_node(node_id.clone(), config); + + tracing::info!("Registered node '{}' in deployment {}", node_id, deployment_id); + } + + tracing::info!( + "Deployment {} completed: registered {} {:?} nodes", + deployment_id, + count, + node_type + ); + } +} diff --git a/crates/bitcell-admin/src/lib.rs b/crates/bitcell-admin/src/lib.rs new file mode 100644 index 0000000..9fb0ccd --- /dev/null +++ b/crates/bitcell-admin/src/lib.rs @@ -0,0 +1,166 @@ +//! BitCell Administrative Console +//! +//! Provides a web-based administrative interface for: +//! - Node deployment and management +//! - System monitoring and metrics +//! - Configuration management +//! - Testing utilities +//! - Log aggregation and viewing + +pub mod api; +pub mod web; +pub mod deployment; +pub mod config; +pub mod metrics; +pub mod process; +pub mod metrics_client; +pub mod setup; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; +use tower_http::services::ServeDir; +use tower_http::cors::CorsLayer; + +pub use api::AdminApi; +pub use deployment::DeploymentManager; +pub use config::ConfigManager; +pub use process::ProcessManager; +pub use setup::SETUP_FILE_PATH; + +/// Administrative console server +pub struct AdminConsole { + addr: SocketAddr, + api: Arc, + deployment: Arc, + config: Arc, + process: Arc, + metrics_client: Arc, + setup: Arc, +} + +impl AdminConsole { + /// Create a new admin console + pub fn new(addr: SocketAddr) -> Self { + let process = Arc::new(ProcessManager::new()); + let deployment = Arc::new(DeploymentManager::new(process.clone())); + let setup = Arc::new(setup::SetupManager::new()); + + // Try to load setup state from default location + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); + if let Err(e) = setup.load_from_file(&setup_path) { + tracing::warn!("Failed to load setup state: {}", e); + } + + Self { + addr, + api: Arc::new(AdminApi::new()), + deployment, + config: Arc::new(ConfigManager::new()), + process, + metrics_client: Arc::new(metrics_client::MetricsClient::new()), + setup, + } + } + + /// Get the process manager + pub fn process_manager(&self) -> Arc { + self.process.clone() + } + + /// Get the setup manager + pub fn setup_manager(&self) -> Arc { + self.setup.clone() + } + + /// Build the application router + fn build_router(&self) -> Router { + Router::new() + // Dashboard + .route("/", get(web::dashboard::index)) + .route("/dashboard", get(web::dashboard::index)) + + // API endpoints + .route("/api/nodes", get(api::nodes::list_nodes)) + .route("/api/nodes/:id", get(api::nodes::get_node)) + .route("/api/nodes/:id/start", post(api::nodes::start_node)) + .route("/api/nodes/:id/stop", post(api::nodes::stop_node)) + + .route("/api/metrics", get(api::metrics::get_metrics)) + .route("/api/metrics/chain", get(api::metrics::chain_metrics)) + .route("/api/metrics/network", get(api::metrics::network_metrics)) + + .route("/api/deployment/deploy", post(api::deployment::deploy_node)) + .route("/api/deployment/status", get(api::deployment::deployment_status)) + + .route("/api/config", get(api::config::get_config)) + .route("/api/config", post(api::config::update_config)) + + .route("/api/test/battle", post(api::test::run_battle_test)) + .route("/api/test/battle/visualize", post(api::test::run_battle_visualization)) + .route("/api/test/transaction", post(api::test::send_test_transaction)) + + .route("/api/setup/status", get(api::setup::get_setup_status)) + .route("/api/setup/node", post(api::setup::add_node)) + .route("/api/setup/config-path", post(api::setup::set_config_path)) + .route("/api/setup/data-dir", post(api::setup::set_data_dir)) + .route("/api/setup/complete", post(api::setup::complete_setup)) + + // Static files + .nest_service("/static", ServeDir::new("static")) + + // CORS - WARNING: Permissive CORS allows requests from any origin. + // This is only suitable for local development. For production, + // configure specific allowed origins to prevent CSRF attacks. + .layer(CorsLayer::permissive()) + + // State + .with_state(Arc::new(AppState { + api: self.api.clone(), + deployment: self.deployment.clone(), + config: self.config.clone(), + process: self.process.clone(), + metrics_client: self.metrics_client.clone(), + setup: self.setup.clone(), + })) + } + + /// Start the admin console server + pub async fn serve(self) -> Result<(), Box> { + tracing::info!("Starting BitCell Admin Console on {}", self.addr); + + let app = self.build_router(); + + let listener = tokio::net::TcpListener::bind(self.addr).await?; + axum::serve(listener, app).await?; + + Ok(()) + } +} + +/// Shared application state +#[derive(Clone)] +pub struct AppState { + pub api: Arc, + pub deployment: Arc, + pub config: Arc, + pub process: Arc, + pub metrics_client: Arc, + pub setup: Arc, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_admin_console_creation() { + let addr = "127.0.0.1:8080".parse().unwrap(); + let console = AdminConsole::new(addr); + assert_eq!(console.addr, addr); + } +} diff --git a/crates/bitcell-admin/src/main.rs b/crates/bitcell-admin/src/main.rs new file mode 100644 index 0000000..4a277e2 --- /dev/null +++ b/crates/bitcell-admin/src/main.rs @@ -0,0 +1,33 @@ +//! BitCell Admin Console - Main Entry Point + +use bitcell_admin::AdminConsole; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "bitcell_admin=info,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + tracing::info!("πŸš€ Starting BitCell Admin Console"); + + // Parse command line arguments + let addr = std::env::args() + .nth(1) + .unwrap_or_else(|| "127.0.0.1:8080".to_string()) + .parse()?; + + let console = AdminConsole::new(addr); + + tracing::info!("Admin console ready"); + tracing::info!("Dashboard available at http://{}", addr); + + console.serve().await?; + + Ok(()) +} diff --git a/crates/bitcell-admin/src/metrics.rs b/crates/bitcell-admin/src/metrics.rs new file mode 100644 index 0000000..6aa7704 --- /dev/null +++ b/crates/bitcell-admin/src/metrics.rs @@ -0,0 +1,27 @@ +//! Metrics integration + +use prometheus_client::registry::Registry; + +pub struct MetricsCollector { + registry: Registry, +} + +impl MetricsCollector { + pub fn new() -> Self { + Self { + registry: Registry::default(), + } + } + + pub fn registry(&self) -> &Registry { + &self.registry + } + + // TODO: Add actual metrics collection from node +} + +impl Default for MetricsCollector { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/bitcell-admin/src/metrics_client.rs b/crates/bitcell-admin/src/metrics_client.rs new file mode 100644 index 0000000..6ea3f75 --- /dev/null +++ b/crates/bitcell-admin/src/metrics_client.rs @@ -0,0 +1,172 @@ +//! Metrics client for fetching real data from running nodes + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeMetrics { + pub node_id: String, + pub endpoint: String, + pub chain_height: u64, + pub sync_progress: u64, + pub peer_count: usize, + pub bytes_sent: u64, + pub bytes_received: u64, + pub pending_txs: usize, + pub total_txs_processed: u64, + pub proofs_generated: u64, + pub proofs_verified: u64, + pub active_miners: usize, + pub banned_miners: usize, + pub last_updated: chrono::DateTime, +} + +#[derive(Clone)] +pub struct MetricsClient { + client: reqwest::Client, +} + +impl MetricsClient { + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client for metrics"), + } + } + + /// Fetch metrics from a node's Prometheus endpoint + pub async fn fetch_node_metrics(&self, node_id: &str, endpoint: &str) -> Result { + let url = format!("{}/metrics", endpoint); + + let response = self.client + .get(&url) + .send() + .await + .map_err(|e| format!("Failed to connect to node {}: {}", node_id, e))?; + + if !response.status().is_success() { + return Err(format!("Node {} returned status: {}", node_id, response.status())); + } + + let text = response.text().await + .map_err(|e| format!("Failed to read response from node {}: {}", node_id, e))?; + + self.parse_prometheus_metrics(node_id, endpoint, &text) + } + + /// Parse Prometheus metrics format + /// NOTE: This is a basic parser that only handles simple "metric_name value" format. + /// It does NOT support metric labels (e.g., metric{label="value"}). + /// For production use, consider using a proper Prometheus parsing library. + fn parse_prometheus_metrics(&self, node_id: &str, endpoint: &str, text: &str) -> Result { + let mut metrics = HashMap::new(); + + for line in text.lines() { + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let key = parts[0]; + if let Ok(value) = parts[1].parse::() { + metrics.insert(key.to_string(), value); + } + } + } + + Ok(NodeMetrics { + node_id: node_id.to_string(), + endpoint: endpoint.to_string(), + chain_height: metrics.get("bitcell_chain_height").copied().unwrap_or(0.0) as u64, + sync_progress: metrics.get("bitcell_sync_progress").copied().unwrap_or(0.0) as u64, + peer_count: metrics.get("bitcell_peer_count").copied().unwrap_or(0.0) as usize, + bytes_sent: metrics.get("bitcell_bytes_sent_total").copied().unwrap_or(0.0) as u64, + bytes_received: metrics.get("bitcell_bytes_received_total").copied().unwrap_or(0.0) as u64, + pending_txs: metrics.get("bitcell_pending_txs").copied().unwrap_or(0.0) as usize, + total_txs_processed: metrics.get("bitcell_txs_processed_total").copied().unwrap_or(0.0) as u64, + proofs_generated: metrics.get("bitcell_proofs_generated_total").copied().unwrap_or(0.0) as u64, + proofs_verified: metrics.get("bitcell_proofs_verified_total").copied().unwrap_or(0.0) as u64, + active_miners: metrics.get("bitcell_active_miners").copied().unwrap_or(0.0) as usize, + banned_miners: metrics.get("bitcell_banned_miners").copied().unwrap_or(0.0) as usize, + last_updated: chrono::Utc::now(), + }) + } + + /// Aggregate metrics from multiple nodes + pub async fn aggregate_metrics(&self, endpoints: &[(String, String)]) -> Result { + if endpoints.is_empty() { + return Err("No nodes configured. Please deploy nodes first.".to_string()); + } + + let mut node_metrics = Vec::new(); + let mut errors = Vec::new(); + + for (node_id, endpoint) in endpoints { + match self.fetch_node_metrics(node_id, endpoint).await { + Ok(metrics) => node_metrics.push(metrics), + Err(e) => { + errors.push(format!("{}: {}", node_id, e)); + tracing::warn!("Failed to fetch metrics from {}: {}", node_id, e); + } + } + } + + if node_metrics.is_empty() { + return Err(format!( + "Failed to fetch metrics from any node. Errors: {}", + errors.join("; ") + )); + } + + // Aggregate across all responding nodes + let chain_height = node_metrics.iter().map(|m| m.chain_height).max().unwrap_or(0); + let total_peer_count: usize = node_metrics.iter().map(|m| m.peer_count).sum(); + let total_bytes_sent: u64 = node_metrics.iter().map(|m| m.bytes_sent).sum(); + let total_bytes_received: u64 = node_metrics.iter().map(|m| m.bytes_received).sum(); + let total_pending_txs: usize = node_metrics.iter().map(|m| m.pending_txs).sum(); + let total_txs_processed: u64 = node_metrics.iter().map(|m| m.total_txs_processed).sum(); + let total_active_miners: usize = node_metrics.iter().map(|m| m.active_miners).max().unwrap_or(0); + let total_banned_miners: usize = node_metrics.iter().map(|m| m.banned_miners).max().unwrap_or(0); + + Ok(AggregatedMetrics { + chain_height, + total_nodes: node_metrics.len(), + online_nodes: node_metrics.len(), + total_peers: total_peer_count, + bytes_sent: total_bytes_sent, + bytes_received: total_bytes_received, + pending_txs: total_pending_txs, + total_txs_processed, + active_miners: total_active_miners, + banned_miners: total_banned_miners, + node_metrics, + errors, + }) + } +} + +impl Default for MetricsClient { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Serialize)] +pub struct AggregatedMetrics { + pub chain_height: u64, + pub total_nodes: usize, + pub online_nodes: usize, + pub total_peers: usize, + pub bytes_sent: u64, + pub bytes_received: u64, + pub pending_txs: usize, + pub total_txs_processed: u64, + pub active_miners: usize, + pub banned_miners: usize, + pub node_metrics: Vec, + pub errors: Vec, +} diff --git a/crates/bitcell-admin/src/process.rs b/crates/bitcell-admin/src/process.rs new file mode 100644 index 0000000..0b46234 --- /dev/null +++ b/crates/bitcell-admin/src/process.rs @@ -0,0 +1,239 @@ +//! Process manager for spawning and managing node processes + +use std::collections::HashMap; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; + +use crate::api::{NodeInfo, NodeType, NodeStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeConfig { + pub node_type: NodeType, + pub data_dir: String, + pub port: u16, + pub rpc_port: u16, + pub log_level: String, + pub network: String, +} + +struct ManagedNode { + info: NodeInfo, + config: NodeConfig, + process: Option, +} + +pub struct ProcessManager { + nodes: Arc>>, +} + +impl ProcessManager { + pub fn new() -> Self { + Self { + nodes: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a new node (without starting it) + pub fn register_node(&self, id: String, config: NodeConfig) -> NodeInfo { + let info = NodeInfo { + id: id.clone(), + node_type: config.node_type, + status: NodeStatus::Stopped, + address: "127.0.0.1".to_string(), + port: config.port, + started_at: None, + }; + + let managed = ManagedNode { + info: info.clone(), + config, + process: None, + }; + + let mut nodes = self.nodes.write(); + nodes.insert(id, managed); + + info + } + + /// Start a node process + pub fn start_node(&self, id: &str) -> Result { + let mut nodes = self.nodes.write(); + let node = nodes.get_mut(id) + .ok_or_else(|| format!("Node '{}' not found", id))?; + + if node.process.is_some() { + return Err("Node is already running".to_string()); + } + + // Build command to start node + // NOTE: Uses 'cargo run' which is suitable for development only. + // For production deployments, use a compiled binary path instead. + let mut cmd = Command::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("bitcell-node") + .arg("--") + .arg(match node.config.node_type { + NodeType::Validator => "validator", + NodeType::Miner => "miner", + NodeType::FullNode => "full-node", + }) + .arg("--port") + .arg(node.config.port.to_string()) + .arg("--rpc-port") + .arg(node.config.rpc_port.to_string()) + .arg("--data-dir") + .arg(&node.config.data_dir) + .env("RUST_LOG", &node.config.log_level) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + tracing::info!("Starting node '{}' with command: {:?}", id, cmd); + + // Spawn the process + let child = cmd.spawn() + .map_err(|e| { + tracing::error!("Failed to spawn process for node '{}': {:?}", id, e); + "Failed to start node process".to_string() + })?; + + node.process = Some(child); + node.info.status = NodeStatus::Running; + node.info.started_at = Some(chrono::Utc::now()); + + tracing::info!("Node '{}' started successfully", id); + + Ok(node.info.clone()) + } + + /// Stop a node process + pub fn stop_node(&self, id: &str) -> Result { + let mut nodes = self.nodes.write(); + let node = nodes.get_mut(id) + .ok_or_else(|| format!("Node '{}' not found", id))?; + + if let Some(mut process) = node.process.take() { + tracing::info!("Stopping node '{}'", id); + + // Try graceful shutdown first + #[cfg(unix)] + { + let pid = process.id(); + // SAFETY: We call libc::kill to send SIGTERM to the child process. + // The PID is obtained from process.id(), which should be valid for a running child. + // However, the process may have already exited, or permissions may be insufficient. + // We check the return value for errors. + let res = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if res != 0 { + let errno = std::io::Error::last_os_error(); + tracing::warn!( + "Failed to send SIGTERM to process {} for node '{}': {}", + pid, + id, + errno + ); + } + + // Wait up to 5 seconds for graceful shutdown + let timeout = std::time::Duration::from_secs(5); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + match process.try_wait() { + Ok(Some(_)) => break, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)), + Err(e) => { + tracing::error!("Error waiting for process: {}", e); + break; + } + } + } + } + + // Force kill if still running + if let Err(e) = process.kill() { + tracing::warn!("Failed to kill process for node '{}': {}", id, e); + } + + let _ = process.wait(); + + node.info.status = NodeStatus::Stopped; + node.info.started_at = None; + + tracing::info!("Node '{}' stopped", id); + + Ok(node.info.clone()) + } else { + Err("Node is not running".to_string()) + } + } + + /// Get node information + pub fn get_node(&self, id: &str) -> Option { + let nodes = self.nodes.read(); + nodes.get(id).map(|n| n.info.clone()) + } + + /// List all nodes + pub fn list_nodes(&self) -> Vec { + let nodes = self.nodes.read(); + nodes.values().map(|n| n.info.clone()).collect() + } + + /// Check if node process is still alive + pub fn check_node_health(&self, id: &str) -> bool { + let mut nodes = self.nodes.write(); + if let Some(node) = nodes.get_mut(id) { + if let Some(ref mut process) = node.process { + match process.try_wait() { + Ok(Some(_)) => { + // Process has exited + node.process = None; + node.info.status = NodeStatus::Error; + node.info.started_at = None; + false + } + Ok(None) => { + // Still running + true + } + Err(_) => { + node.info.status = NodeStatus::Error; + false + } + } + } else { + false + } + } else { + false + } + } + + /// Cleanup all node processes on shutdown + pub fn shutdown(&self) { + let mut nodes = self.nodes.write(); + for (id, node) in nodes.iter_mut() { + if let Some(mut process) = node.process.take() { + tracing::info!("Shutting down node '{}'", id); + let _ = process.kill(); + let _ = process.wait(); + } + } + } +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +impl Drop for ProcessManager { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/crates/bitcell-admin/src/setup.rs b/crates/bitcell-admin/src/setup.rs new file mode 100644 index 0000000..a8ec60c --- /dev/null +++ b/crates/bitcell-admin/src/setup.rs @@ -0,0 +1,115 @@ +//! Setup wizard for initial BitCell deployment + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::RwLock; + +/// Default setup file path +pub const SETUP_FILE_PATH: &str = ".bitcell/admin/setup.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetupState { + pub initialized: bool, + pub config_path: Option, + pub data_dir: Option, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeEndpoint { + pub id: String, + pub node_type: String, + pub metrics_endpoint: String, + pub rpc_endpoint: String, +} + +pub struct SetupManager { + state: RwLock, +} + +impl SetupManager { + pub fn new() -> Self { + Self { + state: RwLock::new(SetupState { + initialized: false, + config_path: None, + data_dir: None, + nodes: Vec::new(), + }), + } + } + + pub fn is_initialized(&self) -> bool { + self.state.read().unwrap().initialized + } + + pub fn get_state(&self) -> SetupState { + self.state.read().unwrap().clone() + } + + pub fn set_config_path(&self, path: PathBuf) { + let mut state = self.state.write().unwrap(); + state.config_path = Some(path); + } + + pub fn set_data_dir(&self, path: PathBuf) { + let mut state = self.state.write().unwrap(); + state.data_dir = Some(path); + } + + pub fn add_node(&self, node: NodeEndpoint) { + let mut state = self.state.write().unwrap(); + state.nodes.push(node); + } + + pub fn get_nodes(&self) -> Vec { + self.state.read().unwrap().nodes.clone() + } + + pub fn mark_initialized(&self) { + let mut state = self.state.write().unwrap(); + state.initialized = true; + } + + /// Load setup state from file + pub fn load_from_file(&self, path: &PathBuf) -> Result<(), String> { + if !path.exists() { + return Ok(()); // Not an error, just not initialized + } + + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read setup file: {}", e))?; + + let loaded_state: SetupState = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse setup file: {}", e))?; + + let mut state = self.state.write().unwrap(); + *state = loaded_state; + + Ok(()) + } + + /// Save setup state to file + pub fn save_to_file(&self, path: &PathBuf) -> Result<(), String> { + let state = self.state.read().unwrap(); + + let content = serde_json::to_string_pretty(&*state) + .map_err(|e| format!("Failed to serialize setup state: {}", e))?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create setup directory: {}", e))?; + } + + std::fs::write(path, content) + .map_err(|e| format!("Failed to write setup file: {}", e))?; + + Ok(()) + } +} + +impl Default for SetupManager { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/bitcell-admin/src/web/dashboard.rs b/crates/bitcell-admin/src/web/dashboard.rs new file mode 100644 index 0000000..18497b7 --- /dev/null +++ b/crates/bitcell-admin/src/web/dashboard.rs @@ -0,0 +1,1349 @@ +//! Dashboard web interface + +use axum::{ + response::{Html, IntoResponse}, + http::StatusCode, +}; + +/// Main dashboard page +pub async fn index() -> impl IntoResponse { + let html = r#" + + + + + + BitCell Admin Console + + + + +
+
+
+

βš™οΈ BitCell Setup Wizard

+

Configure your administrative console

+
+ + +
+
+
1
+
Paths
+
+
+
2
+
Nodes
+
+
+
3
+
Complete
+
+
+ + +
+

πŸ“ Configure Paths

+
+ + + Directory where node data will be stored +
+
+ + + Path to configuration file +
+
+ +
+
+ + +
+

πŸ–₯️ Add Node Endpoints

+

+ Register the endpoints of your BitCell nodes. You can add multiple nodes. +

+ +
+ + +
+
+ + +
+
+ + + Prometheus metrics endpoint +
+
+ + + JSON-RPC endpoint +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+
βœ…
+

Setup Complete!

+

+ Your BitCell admin console is now configured and ready to use. +

+ +
+
+
+
+ +
+

πŸ”¬ BitCell Admin Console

+

Blockchain Management & Monitoring Dashboard

+
+ +
+
+ +
+

⛓️ Chain Metrics

+
+ Block Height + - +
+
+ Transactions + - +
+
+ Pending TX + - +
+
+ Avg Block Time + - +
+
+ + +
+

🌐 Network Metrics

+
+ Connected Peers + - +
+
+ Bytes Sent + - +
+
+ Bytes Received + - +
+
+ Messages + - +
+
+ + +
+

πŸ›‘οΈ EBSL Metrics

+
+ Active Miners + - +
+
+ Banned Miners + - +
+
+ Avg Trust Score + - +
+
+ Slash Events + - +
+
+ + +
+

πŸ’» System Metrics

+
+ Uptime + - +
+
+ CPU Usage + - +
+
+ Memory + - +
+
+ Disk + - +
+
+
+ + +
+
+

πŸ–₯️ Registered Nodes

+ +
+
+
Loading nodes...
+
+
+ + +
+
+
+

Deploy New Nodes

+

Deploy new BitCell nodes to your network

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

βš”οΈ Cellular Automata Battle Visualization

+
+
+

Battle Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+

Visualization

+
+ + + Frame: 0/0 +
+
+ +
+
+
+ Glider A Region +
+
+
+ Glider B Region +
+
+
+ High Energy +
+
+
+
+
+
+ + + + + "#; + + (StatusCode::OK, Html(html)) +} diff --git a/crates/bitcell-admin/src/web/mod.rs b/crates/bitcell-admin/src/web/mod.rs new file mode 100644 index 0000000..4e24639 --- /dev/null +++ b/crates/bitcell-admin/src/web/mod.rs @@ -0,0 +1,20 @@ +//! Web interface module + +pub mod dashboard; + +use tera::Tera; +use std::sync::OnceLock; + +static TEMPLATES: OnceLock = OnceLock::new(); + +pub fn templates() -> &'static Tera { + TEMPLATES.get_or_init(|| { + match Tera::new("templates/**/*") { + Ok(t) => t, + Err(e) => { + tracing::error!("Template parsing error: {}", e); + Tera::default() + } + } + }) +} diff --git a/crates/bitcell-ca/src/battle.rs b/crates/bitcell-ca/src/battle.rs index 7fecd60..bb46a72 100644 --- a/crates/bitcell-ca/src/battle.rs +++ b/crates/bitcell-ca/src/battle.rs @@ -3,9 +3,8 @@ //! Simulates CA evolution with two gliders and determines the winner. use crate::glider::Glider; -use crate::grid::{Grid, Position, GRID_SIZE}; +use crate::grid::{Grid, Position}; use crate::rules::evolve_n_steps; -use crate::{Error, Result}; use serde::{Deserialize, Serialize}; /// Number of steps to simulate a battle @@ -67,7 +66,7 @@ impl Battle { } /// Simulate the battle and return the outcome - pub fn simulate(&self) -> Result { + pub fn simulate(&self) -> BattleOutcome { let initial_grid = self.setup_grid(); let final_grid = evolve_n_steps(&initial_grid, self.steps); @@ -82,11 +81,11 @@ impl Battle { BattleOutcome::Tie }; - Ok(outcome) + outcome } /// Measure energy in regions around spawn points - fn measure_regional_energy(&self, grid: &Grid) -> (u64, u64) { + pub fn measure_regional_energy(&self, grid: &Grid) -> (u64, u64) { let region_size = 128; // Region around spawn A @@ -129,6 +128,53 @@ impl Battle { let initial = self.setup_grid(); evolve_n_steps(&initial, self.steps) } + + /// Get grid states at specific steps for visualization. + /// + /// Returns a vector of grids at the requested step intervals in the same order + /// as the input `sample_steps` array. + /// Steps that exceed `self.steps` are silently skipped. + /// + /// # Performance Note + /// This implementation sorts steps internally for incremental evolution efficiency, + /// but returns grids in the original order requested. + /// + /// # Memory Overhead + /// Each grid clone can be expensive for large grids (e.g., 1024Γ—1024 grids). + /// Requesting many sample steps will require storing multiple grid copies in memory. + /// For example, 100 sample steps could require several hundred MB of memory. + pub fn grid_states(&self, sample_steps: &[usize]) -> Vec { + let initial = self.setup_grid(); + + // Filter and create (index, step) pairs to preserve original order + let mut indexed_steps: Vec<(usize, usize)> = sample_steps.iter() + .enumerate() + .filter(|(_, &step)| step <= self.steps) + .map(|(idx, &step)| (idx, step)) + .collect(); + + // Sort by step for efficient incremental evolution + indexed_steps.sort_unstable_by_key(|(_, step)| *step); + + // Evolve grids in sorted order + let mut evolved_grids = Vec::with_capacity(indexed_steps.len()); + let mut current_grid = initial; + let mut prev_step = 0; + + for (original_idx, step) in &indexed_steps { + let steps_to_evolve = step - prev_step; + // If steps_to_evolve is 0 (e.g., for step 0), the grid remains unchanged + if steps_to_evolve > 0 { + current_grid = evolve_n_steps(¤t_grid, steps_to_evolve); + } + evolved_grids.push((*original_idx, current_grid.clone())); + prev_step = *step; + } + + // Sort back to original order and extract grids + evolved_grids.sort_unstable_by_key(|(idx, _)| *idx); + evolved_grids.into_iter().map(|(_, grid)| grid).collect() + } } #[cfg(test)] @@ -164,7 +210,7 @@ mod tests { // Short battle for testing let battle = Battle::with_steps(glider_a, glider_b, 100); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // With higher initial energy, A should have advantage // (though CA evolution can be chaotic) @@ -177,7 +223,7 @@ mod tests { let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B); let battle = Battle::with_steps(glider_a, glider_b, 50); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // Identical gliders should trend toward tie (though not guaranteed due to asymmetry) // Just verify it completes @@ -193,7 +239,7 @@ mod tests { let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B); let battle = Battle::with_steps(glider_a, glider_b, 100); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // Heavier pattern has more cells and energy // Should generally win, but CA is chaotic diff --git a/crates/bitcell-ca/src/grid.rs b/crates/bitcell-ca/src/grid.rs index c80031f..b429b6e 100644 --- a/crates/bitcell-ca/src/grid.rs +++ b/crates/bitcell-ca/src/grid.rs @@ -138,6 +138,52 @@ impl Grid { *cell = Cell::dead(); } } + + /// Get a downsampled view of the grid for visualization. + /// + /// Uses max pooling to downsample the grid: divides the grid into blocks + /// and returns the maximum energy value in each block. This is useful for + /// visualizing large grids at lower resolutions. + /// + /// # Arguments + /// * `target_size` - The desired output grid size (must be > 0 and <= GRID_SIZE) + /// + /// # Returns + /// A 2D vector of size `target_size Γ— target_size` containing max energy values. + /// + /// # Panics + /// Panics if `target_size` is 0 or greater than `GRID_SIZE`. + /// + /// # Note + /// When `GRID_SIZE` is not evenly divisible by `target_size`, some cells near + /// the edges may not be sampled. For example, with `GRID_SIZE=1024` and + /// `target_size=100`, `block_size=10`, so only cells from indices 0-999 are + /// sampled, leaving rows/columns 1000-1023 unsampled. This is acceptable for + /// visualization purposes where approximate representation is sufficient. + pub fn downsample(&self, target_size: usize) -> Vec> { + if target_size == 0 || target_size > GRID_SIZE { + panic!("target_size must be between 1 and {}", GRID_SIZE); + } + + let block_size = GRID_SIZE / target_size; + let mut result = vec![vec![0u8; target_size]; target_size]; + + for y in 0..target_size { + for x in 0..target_size { + let mut max_energy = 0u8; + // Sample block + for by in 0..block_size { + for bx in 0..block_size { + let pos = Position::new(x * block_size + bx, y * block_size + by); + max_energy = max_energy.max(self.get(pos).energy()); + } + } + result[y][x] = max_energy; + } + } + + result + } } impl Default for Grid { diff --git a/tests/tournament_integration.rs b/tests/tournament_integration.rs index ac70ba6..9524429 100644 --- a/tests/tournament_integration.rs +++ b/tests/tournament_integration.rs @@ -50,10 +50,10 @@ fn test_full_tournament_flow() { let glider_b = Glider::new(reveals[1].pattern, Position::new(800, 800)); let battle = Battle::new(glider_a, glider_b); - let outcome = battle.simulate().expect("Battle should complete"); + let outcome = battle.simulate(); - // Winner should be one of the two participants - assert!(outcome.winner == 0 || outcome.winner == 1); + // Outcome should be one of the three valid results + assert!(matches!(outcome, bitcell_ca::BattleOutcome::AWins | bitcell_ca::BattleOutcome::BWins | bitcell_ca::BattleOutcome::Tie)); } #[test]