Add is_online/is_persistent for persistent agent sessions

- Sessions now track whether agent is online or offline
- Persistent agents (no support code) stay in session list when disconnected
- Dashboard shows online/offline status with color indicator
- Connect/Chat buttons disabled when agent is offline
- Agent reconnection reuses existing session
This commit is contained in:
2025-12-28 17:52:26 -07:00
parent 3c2e0708ef
commit 1cc94c61e7
4 changed files with 81 additions and 8 deletions

View File

@@ -18,6 +18,8 @@ pub struct SessionInfo {
pub started_at: String, pub started_at: String,
pub viewer_count: usize, pub viewer_count: usize,
pub is_streaming: bool, pub is_streaming: bool,
pub is_online: bool,
pub is_persistent: bool,
pub last_heartbeat: String, pub last_heartbeat: String,
pub os_version: Option<String>, pub os_version: Option<String>,
pub is_elevated: bool, pub is_elevated: bool,
@@ -34,6 +36,8 @@ impl From<crate::session::Session> for SessionInfo {
started_at: s.started_at.to_rfc3339(), started_at: s.started_at.to_rfc3339(),
viewer_count: s.viewer_count, viewer_count: s.viewer_count,
is_streaming: s.is_streaming, is_streaming: s.is_streaming,
is_online: s.is_online,
is_persistent: s.is_persistent,
last_heartbeat: s.last_heartbeat.to_rfc3339(), last_heartbeat: s.last_heartbeat.to_rfc3339(),
os_version: s.os_version, os_version: s.os_version,
is_elevated: s.is_elevated, is_elevated: s.is_elevated,

View File

@@ -97,7 +97,9 @@ async fn handle_agent_connection(
} }
// Register the agent and get channels // Register the agent and get channels
let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone()).await; // Persistent agents (no support code) keep their session when disconnected
let is_persistent = support_code.is_none();
let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone(), is_persistent).await;
info!("Session created: {} (agent in idle mode)", session_id); info!("Session created: {} (agent in idle mode)", session_id);
@@ -221,7 +223,8 @@ async fn handle_agent_connection(
// Cleanup // Cleanup
input_forward.abort(); input_forward.abort();
cancel_check.abort(); cancel_check.abort();
sessions_cleanup.remove_session(session_id).await; // Mark agent as disconnected (persistent agents stay in list as offline)
sessions_cleanup.mark_agent_disconnected(session_id).await;
// Mark support code as completed if one was used (unless cancelled) // Mark support code as completed if one was used (unless cancelled)
if let Some(ref code) = support_code_cleanup { if let Some(ref code) = support_code_cleanup {

View File

@@ -30,6 +30,8 @@ pub struct Session {
pub started_at: chrono::DateTime<chrono::Utc>, pub started_at: chrono::DateTime<chrono::Utc>,
pub viewer_count: usize, pub viewer_count: usize,
pub is_streaming: bool, pub is_streaming: bool,
pub is_online: bool, // Whether agent is currently connected
pub is_persistent: bool, // Persistent agent (no support code) vs support session
pub last_heartbeat: chrono::DateTime<chrono::Utc>, pub last_heartbeat: chrono::DateTime<chrono::Utc>,
// Agent status info // Agent status info
pub os_version: Option<String>, pub os_version: Option<String>,
@@ -76,7 +78,35 @@ impl SessionManager {
} }
/// Register a new agent and create a session /// Register a new agent and create a session
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String) -> (SessionId, FrameSender, InputReceiver) { /// If agent was previously connected (offline session exists), reuse that session
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String, is_persistent: bool) -> (SessionId, FrameSender, InputReceiver) {
// Check if this agent already has an offline session (reconnecting)
{
let agents = self.agents.read().await;
if let Some(&existing_session_id) = agents.get(&agent_id) {
let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.get_mut(&existing_session_id) {
if !session_data.info.is_online {
// Reuse existing session - mark as online and create new channels
tracing::info!("Agent {} reconnecting to existing session {}", agent_id, existing_session_id);
let (frame_tx, _) = broadcast::channel(16);
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);
session_data.info.is_online = true;
session_data.info.last_heartbeat = chrono::Utc::now();
session_data.info.agent_name = agent_name; // Update name in case it changed
session_data.frame_tx = frame_tx.clone();
session_data.input_tx = input_tx;
session_data.last_heartbeat_instant = Instant::now();
return (existing_session_id, frame_tx, input_rx);
}
}
}
}
// Create new session
let session_id = Uuid::new_v4(); let session_id = Uuid::new_v4();
// Create channels // Create channels
@@ -91,6 +121,8 @@ impl SessionManager {
started_at: now, started_at: now,
viewer_count: 0, viewer_count: 0,
is_streaming: false, is_streaming: false,
is_online: true,
is_persistent,
last_heartbeat: now, last_heartbeat: now,
os_version: None, os_version: None,
is_elevated: false, is_elevated: false,
@@ -255,10 +287,37 @@ impl SessionManager {
} }
} }
/// Remove a session (when agent disconnects) /// Mark agent as disconnected
/// For persistent agents: keep session but mark as offline
/// For support sessions: remove session entirely
pub async fn mark_agent_disconnected(&self, session_id: SessionId) {
let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.get_mut(&session_id) {
if session_data.info.is_persistent {
// Persistent agent - keep session but mark as offline
tracing::info!("Persistent agent {} marked offline (session {} preserved)",
session_data.info.agent_id, session_id);
session_data.info.is_online = false;
session_data.info.is_streaming = false;
session_data.info.viewer_count = 0;
session_data.viewers.clear();
} else {
// Support session - remove entirely
let agent_id = session_data.info.agent_id.clone();
sessions.remove(&session_id);
drop(sessions); // Release sessions lock before acquiring agents lock
let mut agents = self.agents.write().await;
agents.remove(&agent_id);
tracing::info!("Support session {} removed", session_id);
}
}
}
/// Remove a session entirely (for cleanup)
pub async fn remove_session(&self, session_id: SessionId) { pub async fn remove_session(&self, session_id: SessionId) {
let mut sessions = self.sessions.write().await; let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.remove(&session_id) { if let Some(session_data) = sessions.remove(&session_id) {
drop(sessions);
let mut agents = self.agents.write().await; let mut agents = self.agents.write().await;
agents.remove(&session_data.info.agent_id); agents.remove(&session_data.info.agent_id);
} }

View File

@@ -670,12 +670,14 @@
container.innerHTML = '<div style="padding: 12px;">' + machines.map(m => { container.innerHTML = '<div style="padding: 12px;">' + machines.map(m => {
const started = new Date(m.started_at).toLocaleString(); const started = new Date(m.started_at).toLocaleString();
const isSelected = selectedMachine?.id === m.id; const isSelected = selectedMachine?.id === m.id;
const statusColor = m.is_online ? 'hsl(142, 76%, 50%)' : 'hsl(0, 0%, 50%)';
const statusText = m.is_online ? 'Online' : 'Offline';
return '<div class="sidebar-item' + (isSelected ? ' active' : '') + '" onclick="selectMachine(\'' + m.id + '\')" style="margin-bottom: 8px; padding: 12px;">' + return '<div class="sidebar-item' + (isSelected ? ' active' : '') + '" onclick="selectMachine(\'' + m.id + '\')" style="margin-bottom: 8px; padding: 12px;">' +
'<div style="display: flex; align-items: center; gap: 12px;">' + '<div style="display: flex; align-items: center; gap: 12px;">' +
'<div style="width: 10px; height: 10px; border-radius: 50%; background: hsl(142, 76%, 50%);"></div>' + '<div style="width: 10px; height: 10px; border-radius: 50%; background: ' + statusColor + ';"></div>' +
'<div>' + '<div>' +
'<div style="font-weight: 500;">' + (m.agent_name || m.agent_id.slice(0,8)) + '</div>' + '<div style="font-weight: 500;">' + (m.agent_name || m.agent_id.slice(0,8)) + '</div>' +
'<div style="font-size: 12px; color: hsl(var(--muted-foreground));">Connected ' + started + '</div>' + '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + statusText + ' • ' + started + '</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>'; '</div>';
@@ -698,10 +700,15 @@
const m = selectedMachine; const m = selectedMachine;
const started = new Date(m.started_at).toLocaleString(); const started = new Date(m.started_at).toLocaleString();
const statusColor = m.is_online ? 'hsl(142, 76%, 50%)' : 'hsl(0, 0%, 50%)';
const statusText = m.is_online ? 'Online' : 'Offline';
const connectDisabled = m.is_online ? '' : 'disabled';
const connectTitle = m.is_online ? '' : 'title="Agent is offline"';
container.innerHTML = container.innerHTML =
'<div class="detail-section">' + '<div class="detail-section">' +
'<div class="detail-section-title">Machine Info</div>' + '<div class="detail-section-title">Machine Info</div>' +
'<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value" style="color: ' + statusColor + ';">' + statusText + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' + '<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' + '<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' + '<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' +
@@ -709,8 +716,8 @@
'</div>' + '</div>' +
'<div class="detail-section">' + '<div class="detail-section">' +
'<div class="detail-section-title">Actions</div>' + '<div class="detail-section-title">Actions</div>' +
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')">Connect</button>' + '<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')" ' + connectDisabled + ' ' + connectTitle + '>Connect</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')">Chat</button>' + '<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Chat</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' + '<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' +
'<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' + '<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
'</div>'; '</div>';