Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9a0a4f7
feat: add Storybook with stories for every lib component
CodeMaster4711 Sep 17, 2026
870381e
style: apply cargo fmt and clippy fixes
github-actions[bot] Sep 17, 2026
4c31261
feat: redesign nodes page with filter panel, sortable metrics table, …
CodeMaster4711 Sep 17, 2026
b0e3687
fix: correct node status colors and add filter/sort dropdowns to node…
CodeMaster4711 Sep 17, 2026
4d4c1ab
feat: add server rack image with fade overlay to nodes info panel
CodeMaster4711 Sep 17, 2026
37a496b
feat: replace node detail slider with dedicated page
CodeMaster4711 Sep 18, 2026
1b9c3d6
feat: redesign node detail page with load simulation
CodeMaster4711 Sep 18, 2026
45c0a6a
feat: add hardware alert engine and node maintenance mode
CodeMaster4711 Sep 18, 2026
6b78e21
style: apply cargo fmt and clippy fixes
github-actions[bot] Sep 18, 2026
be0e43b
feat: add hardware alert engine, maintenance mode and node workloads tab
CodeMaster4711 Sep 18, 2026
af8ebd3
feat: restructure nodes page layout and add search bar
CodeMaster4711 Sep 18, 2026
67f069e
feat: add spotlight command palette with cmd+k shortcut
CodeMaster4711 Sep 18, 2026
68e8a34
feat: add per-node disk inventory with SMART health data
CodeMaster4711 Sep 18, 2026
130514c
style: apply cargo fmt and clippy fixes
github-actions[bot] Sep 18, 2026
4bd3231
feat: add resource group icon upload and redesign detail layout
CodeMaster4711 Sep 19, 2026
f4f416a
feat: add blobatar avatar fallback with org-wide toggle
CodeMaster4711 Sep 19, 2026
0b34f9d
style: apply cargo fmt and clippy fixes
github-actions[bot] Sep 19, 2026
f6dd88a
feat: replace login split panel with full-bleed background and floati…
CodeMaster4711 Sep 20, 2026
001c01b
feat: add dark and light background variants to login page
CodeMaster4711 Sep 20, 2026
1fb8f3c
feat: replace node icon svg with server image on node detail page
CodeMaster4711 Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions agent/src/disks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use serde::Serialize;
use std::path::Path;
use sysinfo::Disks;
use tokio::process::Command;

#[derive(Debug, Clone, Serialize)]
pub struct SmartInfo {
pub health: String,
pub power_on_hours: Option<u64>,
pub temperature_celsius: Option<f32>,
pub reallocated_sectors: Option<u64>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiskInfo {
pub device: String,
pub model: Option<String>,
pub media_type: String,
pub total_bytes: u64,
pub used_bytes: u64,
pub mount_point: Option<String>,
pub smart: Option<SmartInfo>,
}

pub async fn collect_disks() -> Vec<DiskInfo> {
if std::env::var("CSFX_FAKE_DISKS").is_ok() {
return fake_disks();
}

let block_devices = list_block_devices();
let mounts = Disks::new_with_refreshed_list();

let mut disks = Vec::with_capacity(block_devices.len());
for device in block_devices {
let media_type = detect_media_type(&device);
let model = read_sys_string(&device, "device/model");
let mount = mounts
.iter()
.find(|d| d.name().to_string_lossy().contains(&device));

let total_bytes = mount.map(|d| d.total_space()).unwrap_or(0);
let used_bytes = mount
.map(|d| d.total_space() - d.available_space())
.unwrap_or(0);
let mount_point = mount.map(|d| d.mount_point().to_string_lossy().to_string());

disks.push(DiskInfo {
smart: collect_smart(&device).await,
device,
model,
media_type,
total_bytes,
used_bytes,
mount_point,
});
}

disks
}

fn fake_disks() -> Vec<DiskInfo> {
vec![
DiskInfo {
device: "nvme0n1".to_string(),
model: Some("Samsung SSD 990 PRO 2TB".to_string()),
media_type: "nvme".to_string(),
total_bytes: 2_000_398_934_016,
used_bytes: 812_000_000_000,
mount_point: Some("/".to_string()),
smart: Some(SmartInfo {
health: "healthy".to_string(),
power_on_hours: Some(3120),
temperature_celsius: Some(41.0),
reallocated_sectors: Some(0),
}),
},
DiskInfo {
device: "sda".to_string(),
model: Some("Seagate IronWolf 8TB".to_string()),
media_type: "hdd".to_string(),
total_bytes: 8_001_563_222_016,
used_bytes: 5_200_000_000_000,
mount_point: Some("/mnt/csfx-volumes".to_string()),
smart: Some(SmartInfo {
health: "healthy".to_string(),
power_on_hours: Some(18544),
temperature_celsius: Some(34.0),
reallocated_sectors: Some(0),
}),
},
DiskInfo {
device: "sdb".to_string(),
model: Some("Seagate IronWolf 8TB".to_string()),
media_type: "hdd".to_string(),
total_bytes: 8_001_563_222_016,
used_bytes: 7_600_000_000_000,
mount_point: None,
smart: Some(SmartInfo {
health: "failing".to_string(),
power_on_hours: Some(31022),
temperature_celsius: Some(52.0),
reallocated_sectors: Some(12),
}),
},
]
}

fn list_block_devices() -> Vec<String> {
let Ok(entries) = std::fs::read_dir("/sys/block") else {
return Vec::new();
};

entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|name| !name.starts_with("loop") && !name.starts_with("ram"))
.collect()
}

fn read_sys_string(device: &str, relative_path: &str) -> Option<String> {
let path = format!("/sys/block/{device}/{relative_path}");
std::fs::read_to_string(path)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}

fn detect_media_type(device: &str) -> String {
if device.starts_with("nvme") {
return "nvme".to_string();
}
match read_sys_string(device, "queue/rotational").as_deref() {
Some("0") => "ssd".to_string(),
Some("1") => "hdd".to_string(),
_ => "unknown".to_string(),
}
}

async fn collect_smart(device: &str) -> Option<SmartInfo> {
if !Path::new("/dev").join(device).exists() {
return None;
}

let output = Command::new("smartctl")
.args(["-j", "-a", &format!("/dev/{device}")])
.output()
.await
.ok()?;

let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
parse_smart_json(&json)
}

fn parse_smart_json(json: &serde_json::Value) -> Option<SmartInfo> {
let passed = json
.get("smart_status")
.and_then(|s| s.get("passed"))
.and_then(|p| p.as_bool());

let health = match passed {
Some(true) => "healthy",
Some(false) => "failing",
None => return None,
}
.to_string();

let power_on_hours = json
.get("power_on_time")
.and_then(|p| p.get("hours"))
.and_then(|h| h.as_u64());

let temperature_celsius = json
.get("temperature")
.and_then(|t| t.get("current"))
.and_then(|c| c.as_f64())
.map(|c| c as f32);

let reallocated_sectors = json
.get("ata_smart_attributes")
.and_then(|a| a.get("table"))
.and_then(|t| t.as_array())
.and_then(|attrs| {
attrs
.iter()
.find(|a| a.get("id").and_then(|i| i.as_u64()) == Some(5))
})
.and_then(|a| a.get("raw"))
.and_then(|r| r.get("value"))
.and_then(|v| v.as_u64());

Some(SmartInfo {
health,
power_on_hours,
temperature_celsius,
reallocated_sectors,
})
}
1 change: 1 addition & 0 deletions agent/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod agent_stream;
mod client;
mod config;
mod disks;
mod firecracker;
mod nftables;
mod pki;
Expand Down
5 changes: 4 additions & 1 deletion agent/src/qemu/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ impl QemuRuntime {

match crate::runtime::Runtime::list_managed_workloads(self).await {
Ok(recovered) => {
info!(count = recovered.len(), "Reconciled running vms from disk state");
info!(
count = recovered.len(),
"Reconciled running vms from disk state"
);
recovered
}
Err(e) => {
Expand Down
18 changes: 18 additions & 0 deletions agent/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::system::LiveMetricsCollector;
const TICKET_HEADER: &str = "X-Csfx-Proxy-Ticket";
pub const METRICS_TICKET_SCOPE: &str = "__node_metrics__";
pub const POWER_TICKET_SCOPE: &str = "__power__";
pub const DISKS_TICKET_SCOPE: &str = "__disks__";

#[derive(Clone)]
pub struct ServerState {
Expand All @@ -42,6 +43,7 @@ pub async fn run(state: ServerState, port: u16) -> Result<()> {
.route("/vnc/{workload_id}", get(vnc_handler))
.route("/metrics/stream", get(metrics_stream_handler))
.route("/power", post(power_handler))
.route("/disks", get(disks_handler))
.with_state(state);

let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
Expand Down Expand Up @@ -445,6 +447,22 @@ impl PowerAction {
}
}

async fn disks_handler(
State(state): State<ServerState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::disks::DiskInfo>>, (StatusCode, String)> {
if !is_internal_source(&addr) {
warn!(source = %addr, "rejected agent inbound request from non-internal source");
return Err((StatusCode::FORBIDDEN, "source not allowed".to_string()));
}

verify_ticket(&headers, DISKS_TICKET_SCOPE, &state.agent_id.to_string())
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;

Ok(Json(crate::disks::collect_disks().await))
}

async fn power_handler(
State(state): State<ServerState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Expand Down
90 changes: 85 additions & 5 deletions agent/src/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,27 @@ pub struct SystemMetrics {

pub struct LiveMetricsCollector {
sys: System,
simulator: Option<LoadSimulator>,
}

impl LiveMetricsCollector {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_cpu_usage();
Self { sys }
let simulator = std::env::var("CSFX_SIMULATE_LOAD")
.ok()
.filter(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.map(|_| LoadSimulator::new(sys.total_memory(), sys.cpus().len() as u32));
Self { sys, simulator }
}

pub fn sample(&mut self) -> SystemMetrics {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();

let cpu_usage_percent = self.sys.global_cpu_usage();
let cpu_cores = self.sys.cpus().len() as u32;
let memory_total_bytes = self.sys.total_memory();
let memory_used_bytes = self.sys.used_memory();
if let Some(simulator) = &mut self.simulator {
return simulator.sample();
}

let disks = Disks::new_with_refreshed_list();
let (disk_total_bytes, disk_used_bytes) =
Expand All @@ -50,6 +54,11 @@ impl LiveMetricsCollector {
)
});

let cpu_usage_percent = self.sys.global_cpu_usage();
let cpu_cores = self.sys.cpus().len() as u32;
let memory_total_bytes = self.sys.total_memory();
let memory_used_bytes = self.sys.used_memory();

let networks = Networks::new_with_refreshed_list();
let (network_rx_bytes, network_tx_bytes) =
networks.iter().fold((0u64, 0u64), |(rx, tx), (_, data)| {
Expand All @@ -70,6 +79,77 @@ impl LiveMetricsCollector {
}
}

struct LoadSimulator {
memory_total_bytes: u64,
disk_total_bytes: u64,
cpu_cores: u32,
cpu_usage_percent: f32,
memory_usage_percent: f32,
disk_usage_percent: f32,
network_rx_bytes: u64,
network_tx_bytes: u64,
seed: u64,
}

impl LoadSimulator {
fn new(memory_total_bytes: u64, cpu_cores: u32) -> Self {
Self {
memory_total_bytes,
disk_total_bytes: 512 * 1_073_741_824,
cpu_cores: cpu_cores.max(1),
cpu_usage_percent: 25.0,
memory_usage_percent: 35.0,
disk_usage_percent: 42.0,
network_rx_bytes: 0,
network_tx_bytes: 0,
seed: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1),
}
}

fn next_random(&mut self) -> f32 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 10_000) as f32 / 10_000.0
}

fn walk(&mut self, value: f32, max_step: f32, min: f32, max: f32) -> f32 {
let step = (self.next_random() - 0.5) * 2.0 * max_step;
(value + step).clamp(min, max)
}

fn sample(&mut self) -> SystemMetrics {
self.cpu_usage_percent = self.walk(self.cpu_usage_percent, 8.0, 3.0, 95.0);
self.memory_usage_percent = self.walk(self.memory_usage_percent, 3.0, 10.0, 90.0);
self.disk_usage_percent = self.walk(self.disk_usage_percent, 0.2, 5.0, 85.0);

let rx_rate_bytes = (self.walk(2.0, 1.5, 0.05, 12.0) * 1_048_576.0) as u64;
let tx_rate_bytes = (self.walk(0.8, 0.6, 0.02, 5.0) * 1_048_576.0) as u64;
self.network_rx_bytes += rx_rate_bytes;
self.network_tx_bytes += tx_rate_bytes;

let memory_used_bytes =
((self.memory_usage_percent / 100.0) as f64 * self.memory_total_bytes as f64) as u64;
let disk_used_bytes =
((self.disk_usage_percent / 100.0) as f64 * self.disk_total_bytes as f64) as u64;

SystemMetrics {
cpu_usage_percent: self.cpu_usage_percent,
cpu_cores: self.cpu_cores,
memory_total_bytes: self.memory_total_bytes,
memory_used_bytes,
disk_total_bytes: self.disk_total_bytes,
disk_used_bytes,
network_rx_bytes: self.network_rx_bytes,
network_tx_bytes: self.network_tx_bytes,
uptime_seconds: System::uptime(),
}
}
}

fn parse_os_release_field(content: &str, field: &str) -> Option<String> {
content
.lines()
Expand Down
3 changes: 3 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ vite.config.ts.timestamp-*

.DS_Store
.Thumbs.db

*storybook.log
storybook-static
Loading
Loading