bevy_fleet 0.1.0

bevy swarm diagnostic, event, metric, and telemetry client
Documentation
use bevy::prelude::*;
use serde::{Deserialize, Serialize};

/// Machine/system information
#[derive(Clone, Debug, Resource, Serialize, Deserialize)]
pub struct MachineInfo {
    pub os: String,
    pub os_version: String,
    pub kernel_version: String,
    pub hostname: String,
    pub cpu_count: usize,
    pub total_memory_bytes: u64,
    pub git_version: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gpu_info: Option<GpuInfo>,
}

/// GPU/Adapter information from WGPU
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct GpuInfo {
    pub name: String,
    pub vendor: u32,
    pub device: u32,
    pub device_type: String,
    pub backend: String,
    pub driver: String,
    pub driver_info: String,
}

#[cfg(feature = "bevy_render")]
use {
    bevy::render::{
        Render, RenderApp,
        renderer::{RenderAdapter, RenderAdapterInfo},
    },
    std::ops::Deref,
    std::sync::{Arc, Mutex},
};

#[cfg(feature = "bevy_render")]
#[derive(Clone, Resource, Default)]
struct SharedGpuInfo(Arc<Mutex<Option<GpuInfo>>>);

#[cfg(feature = "bevy_render")]
#[derive(Resource, Default)]
struct GpuSyncRegistered;

#[cfg(feature = "bevy_render")]
impl SharedGpuInfo {
    fn get(&self) -> Option<GpuInfo> {
        self.0.lock().ok().and_then(|guard| guard.clone())
    }

    fn set(&self, info: GpuInfo) {
        if let Ok(mut guard) = self.0.lock() {
            *guard = Some(info);
        }
    }
}

impl MachineInfo {
    /// Collects machine info from Bevy's SystemInfo resource if available,
    /// otherwise falls back to sysinfo crate
    pub fn collect(world: &World) -> Self {
        let git_version = get_git_version();

        // Try to get GPU info from render adapter
        let gpu_info = collect_gpu_info(world);

        use sysinfo::System;
        let sys = System::new_all();

        let mut machine_info = Self {
            os: System::name().unwrap_or_else(|| "Unknown".to_string()),
            os_version: System::os_version().unwrap_or_else(|| "Unknown".to_string()),
            kernel_version: System::kernel_version().unwrap_or_else(|| "Unknown".to_string()),
            hostname: System::host_name().unwrap_or_else(|| "Unknown".to_string()),
            cpu_count: sys.cpus().len(),
            total_memory_bytes: sys.total_memory() * 1024,
            git_version,
            gpu_info,
        };

        #[cfg(feature = "sysinfo_plugin")]
        {
            use bevy::diagnostic::SystemInfo;

            if let Some(system_info) = world.get_resource::<SystemInfo>() {
                if let Some(os) = normalize_system_info_value(&system_info.os) {
                    if machine_info.os.eq_ignore_ascii_case("unknown") {
                        machine_info.os = os.to_string();
                    }
                    if machine_info.os_version.eq_ignore_ascii_case("unknown") {
                        machine_info.os_version = os.to_string();
                    }
                }

                if let Some(kernel) = normalize_system_info_value(&system_info.kernel) {
                    machine_info.kernel_version = kernel.to_string();
                }

                if let Some(core_count) = normalize_system_info_value(&system_info.core_count)
                    .and_then(|value| value.parse::<usize>().ok())
                {
                    machine_info.cpu_count = core_count;
                }

                if let Some(memory_bytes) =
                    normalize_system_info_value(&system_info.memory).and_then(parse_memory_to_bytes)
                {
                    machine_info.total_memory_bytes = memory_bytes;
                }
            }
        }

        machine_info
    }
}

/// Collects GPU information from WGPU adapter if available
fn collect_gpu_info(_world: &World) -> Option<GpuInfo> {
    #[cfg(feature = "bevy_render")]
    {
        if let Some(shared) = _world.get_resource::<SharedGpuInfo>()
            && let Some(info) = shared.get()
        {
            return Some(info);
        }

        if let Some(adapter_info) = _world.get_resource::<RenderAdapterInfo>() {
            let info = adapter_info.0.deref();
            return Some(GpuInfo {
                name: info.name.clone(),
                vendor: info.vendor,
                device: info.device,
                device_type: format!("{:?}", info.device_type),
                backend: format!("{:?}", info.backend),
                driver: info.driver.clone(),
                driver_info: info.driver_info.clone(),
            });
        }

        if let Some(render_adapter) = _world.get_resource::<RenderAdapter>() {
            let info = render_adapter.get_info();
            return Some(GpuInfo {
                name: info.name.clone(),
                vendor: info.vendor,
                device: info.device,
                device_type: format!("{:?}", info.device_type),
                backend: format!("{:?}", info.backend),
                driver: info.driver.clone(),
                driver_info: info.driver_info.clone(),
            });
        }
    }

    None
}

#[cfg(feature = "bevy_render")]
fn setup_gpu_info_bridge(app: &mut App) {
    if app.world().contains_resource::<SharedGpuInfo>() {
        return;
    }

    let shared = SharedGpuInfo::default();
    app.insert_resource(shared.clone());

    if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
        render_app.insert_resource(shared);
        render_app.add_systems(Render, update_shared_gpu_info);
    }
}

#[cfg(feature = "bevy_render")]
fn update_shared_gpu_info(
    shared: Res<SharedGpuInfo>,
    adapter_info: Option<Res<RenderAdapterInfo>>,
) {
    if let Some(adapter_info) = adapter_info {
        let info = adapter_info.0.deref();
        shared.set(GpuInfo {
            name: info.name.clone(),
            vendor: info.vendor,
            device: info.device,
            device_type: format!("{:?}", info.device_type),
            backend: format!("{:?}", info.backend),
            driver: info.driver.clone(),
            driver_info: info.driver_info.clone(),
        });
    }
}

#[cfg(feature = "bevy_render")]
fn sync_gpu_info_from_shared(
    shared: Option<Res<SharedGpuInfo>>,
    machine_info: Option<ResMut<MachineInfo>>,
) {
    let (Some(shared), Some(mut machine_info)) = (shared, machine_info) else {
        return;
    };

    if let Some(info) = shared.get()
        && machine_info.gpu_info.as_ref() != Some(&info)
    {
        machine_info.gpu_info = Some(info);
    }
}

/// Gets the git version information
fn get_git_version() -> String {
    #[cfg(feature = "git-version")]
    {
        git_version::git_version!(
            args = ["--always", "--dirty=-modified"],
            fallback = "unknown"
        )
        .to_string()
    }

    #[cfg(not(feature = "git-version"))]
    {
        "unknown".to_string()
    }
}

/// Collects machine information and adds it as a resource
pub fn collect_machine_info(app: &mut App) {
    #[cfg(feature = "bevy_render")]
    setup_gpu_info_bridge(app);

    let mut machine_info = MachineInfo::collect(app.world());

    #[cfg(feature = "bevy_render")]
    if machine_info.gpu_info.is_none()
        && let Some(shared) = app.world().get_resource::<SharedGpuInfo>()
        && let Some(info) = shared.get()
    {
        machine_info.gpu_info = Some(info);
    }

    info!(
        "Machine info collected: {} CPUs, {} bytes memory",
        machine_info.cpu_count, machine_info.total_memory_bytes
    );
    if let Some(ref gpu) = machine_info.gpu_info {
        info!("GPU info collected: {} ({})", gpu.name, gpu.backend);
    }

    #[cfg(feature = "bevy_render")]
    {
        if !app.world().contains_resource::<GpuSyncRegistered>() {
            app.insert_resource(GpuSyncRegistered);
            app.add_systems(Update, sync_gpu_info_from_shared);
        }
    }

    app.insert_resource(machine_info);
}

#[cfg(feature = "sysinfo_plugin")]
fn normalize_system_info_value(value: &str) -> Option<&str> {
    let trimmed = value.trim();
    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("not available") {
        None
    } else {
        Some(trimmed)
    }
}

#[cfg(feature = "sysinfo_plugin")]
fn parse_memory_to_bytes(value: &str) -> Option<u64> {
    let mut parts = value.split_whitespace();
    let amount_str = parts.next()?;
    let unit = parts.next().unwrap_or("bytes");

    let normalized_amount = amount_str.replace(',', "");
    let amount: f64 = normalized_amount.parse().ok()?;

    const KIB: f64 = 1024.0;
    const MIB: f64 = KIB * 1024.0;
    const GIB: f64 = MIB * 1024.0;

    let bytes = match unit {
        "GiB" => amount * GIB,
        "MiB" => amount * MIB,
        "KiB" => amount * KIB,
        "bytes" | "B" => amount,
        _ => return None,
    };

    Some(bytes.round() as u64)
}