From 448d3b75ac4391e6bccfbfc81c0e25b50f114ea5 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sun, 28 Dec 2025 19:17:47 -0700 Subject: [PATCH] Add connected technician tracking to dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ViewerInfo struct to track viewer name and connection time - Update session manager to track viewers with names - Update API to return viewer list for each session - Update dashboard to display "Mike Connected (3 min)" on machine bars - Update viewer.html to pass viewer_name parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/api/mod.rs | 20 +++++++++++++++ server/src/relay/mod.rs | 14 ++++++++--- server/src/session/mod.rs | 45 +++++++++++++++++++++++++++------ server/static/dashboard.html | 48 +++++++++++++++++++++++++++++++++--- server/static/viewer.html | 6 ++++- 5 files changed, 117 insertions(+), 16 deletions(-) diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 57ddd49..44ad661 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -9,6 +9,24 @@ use uuid::Uuid; use crate::session::SessionManager; +/// Viewer info returned by API +#[derive(Debug, Serialize)] +pub struct ViewerInfoApi { + pub id: String, + pub name: String, + pub connected_at: String, +} + +impl From for ViewerInfoApi { + fn from(v: crate::session::ViewerInfo) -> Self { + Self { + id: v.id, + name: v.name, + connected_at: v.connected_at.to_rfc3339(), + } + } +} + /// Session info returned by API #[derive(Debug, Serialize)] pub struct SessionInfo { @@ -17,6 +35,7 @@ pub struct SessionInfo { pub agent_name: String, pub started_at: String, pub viewer_count: usize, + pub viewers: Vec, pub is_streaming: bool, pub is_online: bool, pub is_persistent: bool, @@ -35,6 +54,7 @@ impl From for SessionInfo { agent_name: s.agent_name, started_at: s.started_at.to_rfc3339(), viewer_count: s.viewer_count, + viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(), is_streaming: s.is_streaming, is_online: s.is_online, is_persistent: s.is_persistent, diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index 98e704b..86069fe 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -34,6 +34,12 @@ pub struct AgentParams { #[derive(Debug, Deserialize)] pub struct ViewerParams { session_id: String, + #[serde(default = "default_viewer_name")] + viewer_name: String, +} + +fn default_viewer_name() -> String { + "Technician".to_string() } /// WebSocket handler for agent connections @@ -58,9 +64,10 @@ pub async fn viewer_ws_handler( Query(params): Query, ) -> impl IntoResponse { let session_id = params.session_id; + let viewer_name = params.viewer_name; let sessions = state.sessions.clone(); - ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id)) + ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id, viewer_name)) } /// Handle an agent WebSocket connection @@ -242,6 +249,7 @@ async fn handle_viewer_connection( socket: WebSocket, sessions: SessionManager, session_id_str: String, + viewer_name: String, ) { // Parse session ID let session_id = match uuid::Uuid::parse_str(&session_id_str) { @@ -256,7 +264,7 @@ async fn handle_viewer_connection( let viewer_id = Uuid::new_v4().to_string(); // Join the session (this sends StartStream to agent if first viewer) - let (mut frame_rx, input_tx) = match sessions.join_session(session_id, viewer_id.clone()).await { + let (mut frame_rx, input_tx) = match sessions.join_session(session_id, viewer_id.clone(), viewer_name.clone()).await { Some(channels) => channels, None => { warn!("Session not found: {}", session_id); @@ -264,7 +272,7 @@ async fn handle_viewer_connection( } }; - info!("Viewer {} joined session: {}", viewer_id, session_id); + info!("Viewer {} ({}) joined session: {}", viewer_name, viewer_id, session_id); let (mut ws_sender, mut ws_receiver) = socket.split(); diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index c6e1fe3..ea68731 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -3,7 +3,7 @@ //! Manages active remote desktop sessions, tracking which agents //! are connected and which viewers are watching them. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; use tokio::sync::{broadcast, RwLock}; @@ -18,6 +18,14 @@ pub type AgentId = String; /// Unique identifier for a viewer pub type ViewerId = String; +/// Information about a connected viewer/technician +#[derive(Debug, Clone)] +pub struct ViewerInfo { + pub id: ViewerId, + pub name: String, + pub connected_at: chrono::DateTime, +} + /// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval) const HEARTBEAT_TIMEOUT_SECS: u64 = 90; @@ -29,6 +37,7 @@ pub struct Session { pub agent_name: String, pub started_at: chrono::DateTime, pub viewer_count: usize, + pub viewers: Vec, // List of connected technicians 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 @@ -56,8 +65,8 @@ struct SessionData { /// Channel for input events (viewer -> agent) input_tx: InputSender, input_rx: Option, - /// Set of connected viewer IDs - viewers: HashSet, + /// Map of connected viewers (id -> info) + viewers: HashMap, /// Instant for heartbeat tracking last_heartbeat_instant: Instant, } @@ -120,6 +129,7 @@ impl SessionManager { agent_name, started_at: now, viewer_count: 0, + viewers: Vec::new(), is_streaming: false, is_online: true, is_persistent, @@ -135,7 +145,7 @@ impl SessionManager { frame_tx: frame_tx.clone(), input_tx, input_rx: None, - viewers: HashSet::new(), + viewers: HashMap::new(), last_heartbeat_instant: Instant::now(), }; @@ -217,21 +227,33 @@ impl SessionManager { } /// Join a session as a viewer, returns channels and sends StartStream to agent - pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId) -> Option<(FrameReceiver, InputSender)> { + pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId, viewer_name: String) -> Option<(FrameReceiver, InputSender)> { let mut sessions = self.sessions.write().await; let session_data = sessions.get_mut(&session_id)?; let was_empty = session_data.viewers.is_empty(); - session_data.viewers.insert(viewer_id.clone()); + + // Add viewer info + let viewer_info = ViewerInfo { + id: viewer_id.clone(), + name: viewer_name.clone(), + connected_at: chrono::Utc::now(), + }; + session_data.viewers.insert(viewer_id.clone(), viewer_info); + + // Update session info session_data.info.viewer_count = session_data.viewers.len(); + session_data.info.viewers = session_data.viewers.values().cloned().collect(); let frame_rx = session_data.frame_tx.subscribe(); let input_tx = session_data.input_tx.clone(); // If this is the first viewer, send StartStream to agent if was_empty { - tracing::info!("First viewer {} joined session {}, sending StartStream", viewer_id, session_id); + tracing::info!("Viewer {} ({}) joined session {}, sending StartStream", viewer_name, viewer_id, session_id); Self::send_start_stream_internal(session_data, &viewer_id).await; + } else { + tracing::info!("Viewer {} ({}) joined session {}", viewer_name, viewer_id, session_id); } Some((frame_rx, input_tx)) @@ -259,13 +281,19 @@ impl SessionManager { pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) { let mut sessions = self.sessions.write().await; if let Some(session_data) = sessions.get_mut(&session_id) { + let viewer_name = session_data.viewers.get(viewer_id).map(|v| v.name.clone()); session_data.viewers.remove(viewer_id); session_data.info.viewer_count = session_data.viewers.len(); + session_data.info.viewers = session_data.viewers.values().cloned().collect(); // If no more viewers, send StopStream to agent if session_data.viewers.is_empty() { - tracing::info!("Last viewer {} left session {}, sending StopStream", viewer_id, session_id); + tracing::info!("Last viewer {} ({}) left session {}, sending StopStream", + viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id); Self::send_stop_stream_internal(session_data, viewer_id).await; + } else { + tracing::info!("Viewer {} ({}) left session {}", + viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id); } } } @@ -300,6 +328,7 @@ impl SessionManager { session_data.info.is_online = false; session_data.info.is_streaming = false; session_data.info.viewer_count = 0; + session_data.info.viewers.clear(); session_data.viewers.clear(); } else { // Support session - remove entirely diff --git a/server/static/dashboard.html b/server/static/dashboard.html index edc824b..4efb5df 100644 --- a/server/static/dashboard.html +++ b/server/static/dashboard.html @@ -673,6 +673,39 @@ } } + // Format duration since connection + function formatDuration(connectedAt) { + const now = new Date(); + const connected = new Date(connectedAt); + const diffMs = now - connected; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return diffMins + ' min'; + if (diffHours < 24) return diffHours + ' hr'; + return Math.floor(diffHours / 24) + ' day'; + } + + // Format connected technicians display + function formatViewers(viewers) { + if (!viewers || viewers.length === 0) return ''; + + // Get names (filter out default "Technician" if there's a real name) + const names = viewers.map(v => v.name || 'Technician'); + + // Find earliest connection for duration + const earliest = viewers.reduce((min, v) => { + const t = new Date(v.connected_at); + return t < min ? t : min; + }, new Date(viewers[0].connected_at)); + + const duration = formatDuration(earliest); + const nameList = names.join(', '); + + return nameList + ' Connected (' + duration + ')'; + } + function renderMachinesList() { const container = document.getElementById("machinesList"); @@ -697,13 +730,19 @@ 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'; + const viewersText = formatViewers(m.viewers); + const viewersHtml = viewersText + ? '
' + escapeHtml(viewersText) + '
' + : ''; + return '