diff --git a/src/bin/super/mod.rs b/src/bin/super/mod.rs index 911b938d..efb4348c 100644 --- a/src/bin/super/mod.rs +++ b/src/bin/super/mod.rs @@ -268,6 +268,7 @@ pub async fn super_handle( owner_pubkey: None, workspace_type: None, cln_pubkey: None, + last_heartbeat_at: None, }; let hm = state_write(proj, |s| add_new_swarm_details(s, swarm_detail)).await; Some(serde_json::to_string(&hm)?) @@ -293,6 +294,7 @@ pub async fn super_handle( owner_pubkey: s.stacks[ui].owner_pubkey.clone(), workspace_type: s.stacks[ui].workspace_type.clone(), cln_pubkey: s.stacks[ui].cln_pubkey.clone(), + last_heartbeat_at: None, }; AddSwarmResponse { success: true, @@ -365,6 +367,7 @@ pub async fn super_handle( owner_pubkey: None, workspace_type: None, cln_pubkey: None, + last_heartbeat_at: None, }; let hm = state_write(proj, |s| add_new_swarm_from_child_swarm(s, swarm_details)).await; diff --git a/src/bin/super/service/child_swarm/update_public_ip.rs b/src/bin/super/service/child_swarm/update_public_ip.rs index d344fe91..02dc0650 100644 --- a/src/bin/super/service/child_swarm/update_public_ip.rs +++ b/src/bin/super/service/child_swarm/update_public_ip.rs @@ -120,6 +120,8 @@ pub async fn handle_update_child_swarm_public_ip( match stack_info { Some((current_ip, host, _default_host, route53_domain_names)) => { if current_ip.as_deref() == Some(&info.public_ip) { + // Heartbeat with unchanged IP: record liveness, no route53 work needed. + record_last_heartbeat(proj, &swarm_id).await; return SuperSwarmResponse { success: true, message: "Public IP is the same as the current one, no update needed" @@ -157,6 +159,7 @@ pub async fn handle_update_child_swarm_public_ip( state_write(proj, |state| { if let Some(s) = state.stacks.iter_mut().find(|s| s.id == swarm_id_for_write) { s.public_ip_address = Some(public_ip); + s.last_heartbeat_at = Some(unix_now()); } }) .await; @@ -175,6 +178,27 @@ pub async fn handle_update_child_swarm_public_ip( } } +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +async fn record_last_heartbeat(proj: &str, swarm_id: &str) { + let now = unix_now(); + state_write(proj, |state| { + if let Some(s) = state + .stacks + .iter_mut() + .find(|s| s.id.as_deref() == Some(swarm_id)) + { + s.last_heartbeat_at = Some(now); + } + }) + .await; +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/bin/super/service/swarm_reserver/assign_reserved_swarm.rs b/src/bin/super/service/swarm_reserver/assign_reserved_swarm.rs index ba5363dd..a2ce787a 100644 --- a/src/bin/super/service/swarm_reserver/assign_reserved_swarm.rs +++ b/src/bin/super/service/swarm_reserver/assign_reserved_swarm.rs @@ -124,6 +124,7 @@ pub async fn handle_assign_reserved_swarm( owner_pubkey: info.owner_pubkey.clone(), workspace_type: info.workspace_type.clone(), cln_pubkey: None, + last_heartbeat_at: None, }; // Call child swarm to activate (I/O — no lock) @@ -209,6 +210,7 @@ pub async fn handle_assign_reserved_swarm( owner_pubkey: info.owner_pubkey.clone(), workspace_type: info.workspace_type.clone(), cln_pubkey: None, + last_heartbeat_at: None, }; let x_api_key = selected_reserved_instance.x_api_key.clone(); let ec2_id = selected_reserved_instance.instance_id.clone(); diff --git a/src/bin/super/state.rs b/src/bin/super/state.rs index 6b0b7644..2fc4172c 100644 --- a/src/bin/super/state.rs +++ b/src/bin/super/state.rs @@ -43,6 +43,9 @@ pub struct RemoteStack { pub workspace_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cln_pubkey: Option, + /// Unix timestamp (seconds) of the last public-IP heartbeat received from this swarm. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_heartbeat_at: Option, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Default, Clone)] @@ -217,6 +220,7 @@ impl Super { owner_pubkey: n.owner_pubkey.clone(), workspace_type: n.workspace_type.clone(), cln_pubkey: n.cln_pubkey.clone(), + last_heartbeat_at: None, }) .collect(); let bots = self @@ -289,6 +293,7 @@ impl Super { owner_pubkey: None, workspace_type: None, cln_pubkey: None, + last_heartbeat_at: None, }); } let pos = self.stacks.iter().position(|s| s.host == host); diff --git a/src/bin/super/superapp/src/Remotes.svelte b/src/bin/super/superapp/src/Remotes.svelte index e98b3875..c44b654a 100644 --- a/src/bin/super/superapp/src/Remotes.svelte +++ b/src/bin/super/superapp/src/Remotes.svelte @@ -211,7 +211,14 @@ } else if (r.default_host) { swarmNumber = getSwarmNumber(r.default_host); } - return { ...r, id: r.host, number: swarmNumber }; + return { + ...r, + id: r.host, + number: swarmNumber, + last_heartbeat_at: r.last_heartbeat_at + ? new Date(r.last_heartbeat_at * 1000).toLocaleString() + : "never", + }; } function reserveRemoteRow(r: ReservedRemote) { @@ -663,6 +670,7 @@ { key: "ec2", value: "Instance", sort: (a, b) => (a || "").localeCompare(b || "") }, { key: "public_ip_address", value: "Public IP", sort: (a, b) => (a || "").localeCompare(b || "") }, { key: "private_ip_address", value: "Private IP", sort: (a, b) => (a || "").localeCompare(b || "") }, + { key: "last_heartbeat_at", value: "Last Heartbeat" }, { key: "cpu", value: "CPU %" }, { key: "update_env", value: "Update Env" }, { key: "view", value: "View" }, diff --git a/src/bin/super/superapp/src/types/types.ts b/src/bin/super/superapp/src/types/types.ts index 525e78b9..6de0088e 100644 --- a/src/bin/super/superapp/src/types/types.ts +++ b/src/bin/super/superapp/src/types/types.ts @@ -37,6 +37,7 @@ export interface Remote { default_host?: string; ec2_instance_id: string; public_ip_address?: string; + last_heartbeat_at?: number; id?: string; } diff --git a/src/bin/super/util.rs b/src/bin/super/util.rs index 3b041ab4..5edeac50 100644 --- a/src/bin/super/util.rs +++ b/src/bin/super/util.rs @@ -1098,6 +1098,7 @@ pub async fn create_swarm_ec2( owner_pubkey: info.owner_pubkey.clone(), workspace_type: info.workspace_type.clone(), cln_pubkey: None, + last_heartbeat_at: None, }; state_write(proj, |state| { @@ -1694,6 +1695,7 @@ pub fn get_child_swarm_credentials( owner_pubkey: None, workspace_type: None, cln_pubkey: None, + last_heartbeat_at: None, }) } else { None @@ -1759,6 +1761,7 @@ mod tests { owner_pubkey: None, workspace_type: None, cln_pubkey: None, + last_heartbeat_at: None, }); state } diff --git a/src/service/public_ip.rs b/src/service/public_ip.rs index 2e962eec..6046249d 100644 --- a/src/service/public_ip.rs +++ b/src/service/public_ip.rs @@ -8,17 +8,24 @@ use crate::{ pub async fn handle_check_public_ip_via_cron(proj: &str) -> Result<(), Error> { let current_ip = get_public_ip().await?; - let alert_super_admin = config::stack_read(|s| { + let ip_changed = config::stack_read(|s| { s.ip.is_none() || s.ip.as_deref() != Some(¤t_ip) }) .await; - if alert_super_admin { + // Always report to super admin (heartbeat every 5 minutes), even when the IP is + // unchanged. Super admin records the last heartbeat (liveness signal) and only + // touches route53 when the IP actually changed. + if ip_changed { log::info!("Public IP has changed to {}", current_ip); - // updating super admin of the change - if let Err(e) = update_super_admin_of_ip_change(¤t_ip).await { - log::error!("Failed to notify super admin of IP change: {:?}", e); - } else { + } else { + log::debug!("Heartbeat: public IP unchanged ({})", current_ip); + } + + if let Err(e) = update_super_admin_of_ip_change(¤t_ip).await { + log::error!("Failed to notify super admin of IP change: {:?}", e); + } else { + if ip_changed { log::info!("Successfully notified super admin of IP change"); config::stack_write(proj, |s| { s.ip = Some(current_ip);