use crate::agent::summary::PriceBounds;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubSummary {
pub account: HubAccount,
pub default_price_per_hour: Option<f64>,
pub price_bounds: HubBounds,
pub earnings: HubEarnings,
pub devices: Vec<HubDevice>,
pub web_base: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubAccount {
pub username: String,
pub email: String,
pub credits_balance: f64,
#[serde(default)]
pub zakuro_user_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubBounds {
pub min: f64,
pub max: f64,
pub step: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubEarnings {
pub today: f64,
pub last_7d: f64,
pub tz: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubDevice {
pub node_pubkey: String,
pub name: String,
pub freshness: String,
#[serde(default)]
pub freshness_reason: Option<String>,
#[serde(default)]
pub last_seen_at: Option<String>,
#[serde(default)]
pub workers: Vec<HubWorker>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubWorker {
pub id: i64,
pub worker_id: String,
pub status: String,
#[serde(default)]
pub last_seen: Option<String>,
pub price_per_hour: Option<f64>,
#[serde(default)]
pub reported_price_per_hour: Option<f64>,
pub effective_price_per_hour: Option<f64>,
#[serde(default)]
pub disabled: bool,
}
impl HubSummary {
pub fn this_mac_workers(&self, node_pubkey: &str) -> Vec<&HubWorker> {
self.devices
.iter()
.filter(|d| d.node_pubkey == node_pubkey)
.flat_map(|d| d.workers.iter())
.collect()
}
pub fn bounds(&self) -> PriceBounds {
let b = &self.price_bounds;
PriceBounds {
min: b.min.round().max(0.0) as u32,
max: b.max.round().max(0.0) as u32,
step: b.step.round().max(1.0) as u32,
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) const HUB_JSON: &str = r#"{
"account": {"username": "jean", "email": "jean@zakuro-ai.com", "credits_balance": 1240.5, "zakuro_user_id": "1234567890"},
"default_price_per_hour": 18.0,
"price_bounds": {"min": 1, "max": 120, "step": 1},
"earnings": {"today": 12.4, "last_7d": 88.1, "tz": "Asia/Tokyo"},
"devices": [
{"node_pubkey": "PK_THIS", "name": "jeans-mbp", "freshness": "fresh", "freshness_reason": "seen 20s ago",
"last_seen_at": "2026-09-13T01:41:30Z",
"workers": [
{"id": 123, "worker_id": "a1b2c3d4-w0", "status": "online", "last_seen": "2026-09-13T01:41:30Z",
"price_per_hour": null, "reported_price_per_hour": 3.6, "effective_price_per_hour": 18.0, "disabled": false}
]}
],
"web_base": "https://stg.hub.zakuro-ai.com"
}"#;
#[test]
fn parses_the_spec_example() {
let h: HubSummary = serde_json::from_str(HUB_JSON).unwrap();
assert_eq!(h.account.credits_balance, 1240.5);
assert_eq!(h.devices[0].workers[0].id, 123);
assert_eq!(h.devices[0].workers[0].price_per_hour, None);
assert_eq!(h.this_mac_workers("PK_THIS").len(), 1);
assert!(h.this_mac_workers("PK_OTHER").is_empty());
assert_eq!(
h.bounds(),
crate::agent::summary::PriceBounds {
min: 1,
max: 120,
step: 1
}
);
}
}