Add connected technician tracking to dashboard

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 19:17:47 -07:00
parent f3b76b7b62
commit 448d3b75ac
5 changed files with 117 additions and 16 deletions

View File

@@ -9,6 +9,24 @@ use uuid::Uuid;
use crate::session::SessionManager; 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<crate::session::ViewerInfo> 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 /// Session info returned by API
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SessionInfo { pub struct SessionInfo {
@@ -17,6 +35,7 @@ pub struct SessionInfo {
pub agent_name: String, pub agent_name: String,
pub started_at: String, pub started_at: String,
pub viewer_count: usize, pub viewer_count: usize,
pub viewers: Vec<ViewerInfoApi>,
pub is_streaming: bool, pub is_streaming: bool,
pub is_online: bool, pub is_online: bool,
pub is_persistent: bool, pub is_persistent: bool,
@@ -35,6 +54,7 @@ impl From<crate::session::Session> for SessionInfo {
agent_name: s.agent_name, agent_name: s.agent_name,
started_at: s.started_at.to_rfc3339(), started_at: s.started_at.to_rfc3339(),
viewer_count: s.viewer_count, viewer_count: s.viewer_count,
viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
is_streaming: s.is_streaming, is_streaming: s.is_streaming,
is_online: s.is_online, is_online: s.is_online,
is_persistent: s.is_persistent, is_persistent: s.is_persistent,

View File

@@ -34,6 +34,12 @@ pub struct AgentParams {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ViewerParams { pub struct ViewerParams {
session_id: String, session_id: String,
#[serde(default = "default_viewer_name")]
viewer_name: String,
}
fn default_viewer_name() -> String {
"Technician".to_string()
} }
/// WebSocket handler for agent connections /// WebSocket handler for agent connections
@@ -58,9 +64,10 @@ pub async fn viewer_ws_handler(
Query(params): Query<ViewerParams>, Query(params): Query<ViewerParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let session_id = params.session_id; let session_id = params.session_id;
let viewer_name = params.viewer_name;
let sessions = state.sessions.clone(); 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 /// Handle an agent WebSocket connection
@@ -242,6 +249,7 @@ async fn handle_viewer_connection(
socket: WebSocket, socket: WebSocket,
sessions: SessionManager, sessions: SessionManager,
session_id_str: String, session_id_str: String,
viewer_name: String,
) { ) {
// Parse session ID // Parse session ID
let session_id = match uuid::Uuid::parse_str(&session_id_str) { 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(); let viewer_id = Uuid::new_v4().to_string();
// Join the session (this sends StartStream to agent if first viewer) // 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, Some(channels) => channels,
None => { None => {
warn!("Session not found: {}", session_id); 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(); let (mut ws_sender, mut ws_receiver) = socket.split();

View File

@@ -3,7 +3,7 @@
//! Manages active remote desktop sessions, tracking which agents //! Manages active remote desktop sessions, tracking which agents
//! are connected and which viewers are watching them. //! are connected and which viewers are watching them.
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tokio::sync::{broadcast, RwLock}; use tokio::sync::{broadcast, RwLock};
@@ -18,6 +18,14 @@ pub type AgentId = String;
/// Unique identifier for a viewer /// Unique identifier for a viewer
pub type ViewerId = String; 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<chrono::Utc>,
}
/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval) /// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
const HEARTBEAT_TIMEOUT_SECS: u64 = 90; const HEARTBEAT_TIMEOUT_SECS: u64 = 90;
@@ -29,6 +37,7 @@ pub struct Session {
pub agent_name: String, pub agent_name: String,
pub started_at: chrono::DateTime<chrono::Utc>, pub started_at: chrono::DateTime<chrono::Utc>,
pub viewer_count: usize, pub viewer_count: usize,
pub viewers: Vec<ViewerInfo>, // List of connected technicians
pub is_streaming: bool, pub is_streaming: bool,
pub is_online: bool, // Whether agent is currently connected pub is_online: bool, // Whether agent is currently connected
pub is_persistent: bool, // Persistent agent (no support code) vs support session pub is_persistent: bool, // Persistent agent (no support code) vs support session
@@ -56,8 +65,8 @@ struct SessionData {
/// Channel for input events (viewer -> agent) /// Channel for input events (viewer -> agent)
input_tx: InputSender, input_tx: InputSender,
input_rx: Option<InputReceiver>, input_rx: Option<InputReceiver>,
/// Set of connected viewer IDs /// Map of connected viewers (id -> info)
viewers: HashSet<ViewerId>, viewers: HashMap<ViewerId, ViewerInfo>,
/// Instant for heartbeat tracking /// Instant for heartbeat tracking
last_heartbeat_instant: Instant, last_heartbeat_instant: Instant,
} }
@@ -120,6 +129,7 @@ impl SessionManager {
agent_name, agent_name,
started_at: now, started_at: now,
viewer_count: 0, viewer_count: 0,
viewers: Vec::new(),
is_streaming: false, is_streaming: false,
is_online: true, is_online: true,
is_persistent, is_persistent,
@@ -135,7 +145,7 @@ impl SessionManager {
frame_tx: frame_tx.clone(), frame_tx: frame_tx.clone(),
input_tx, input_tx,
input_rx: None, input_rx: None,
viewers: HashSet::new(), viewers: HashMap::new(),
last_heartbeat_instant: Instant::now(), last_heartbeat_instant: Instant::now(),
}; };
@@ -217,21 +227,33 @@ impl SessionManager {
} }
/// Join a session as a viewer, returns channels and sends StartStream to agent /// 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 mut sessions = self.sessions.write().await;
let session_data = sessions.get_mut(&session_id)?; let session_data = sessions.get_mut(&session_id)?;
let was_empty = session_data.viewers.is_empty(); 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.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 frame_rx = session_data.frame_tx.subscribe();
let input_tx = session_data.input_tx.clone(); let input_tx = session_data.input_tx.clone();
// If this is the first viewer, send StartStream to agent // If this is the first viewer, send StartStream to agent
if was_empty { 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; 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)) Some((frame_rx, input_tx))
@@ -259,13 +281,19 @@ impl SessionManager {
pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) { pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) {
let mut sessions = self.sessions.write().await; let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.get_mut(&session_id) { 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.viewers.remove(viewer_id);
session_data.info.viewer_count = session_data.viewers.len(); 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 no more viewers, send StopStream to agent
if session_data.viewers.is_empty() { 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; 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_online = false;
session_data.info.is_streaming = false; session_data.info.is_streaming = false;
session_data.info.viewer_count = 0; session_data.info.viewer_count = 0;
session_data.info.viewers.clear();
session_data.viewers.clear(); session_data.viewers.clear();
} else { } else {
// Support session - remove entirely // Support session - remove entirely

View File

@@ -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() { function renderMachinesList() {
const container = document.getElementById("machinesList"); const container = document.getElementById("machinesList");
@@ -697,13 +730,19 @@
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 statusColor = m.is_online ? 'hsl(142, 76%, 50%)' : 'hsl(0, 0%, 50%)';
const statusText = m.is_online ? 'Online' : 'Offline'; const statusText = m.is_online ? 'Online' : 'Offline';
const viewersText = formatViewers(m.viewers);
const viewersHtml = viewersText
? '<div style="font-size: 11px; color: hsl(142, 76%, 50%); margin-left: auto; white-space: nowrap;">' + escapeHtml(viewersText) + '</div>'
: '';
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; width: 100%;">' +
'<div style="width: 10px; height: 10px; border-radius: 50%; background: ' + statusColor + ';"></div>' + '<div style="width: 10px; height: 10px; border-radius: 50%; background: ' + statusColor + '; flex-shrink: 0;"></div>' +
'<div>' + '<div style="flex: 1; min-width: 0;">' +
'<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));">' + statusText + ' • ' + started + '</div>' + '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + statusText + ' • ' + started + '</div>' +
'</div>' + '</div>' +
viewersHtml +
'</div>' + '</div>' +
'</div>'; '</div>';
}).join("") + '</div>'; }).join("") + '</div>';
@@ -836,7 +875,8 @@
} }
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}`; const viewerName = user?.name || user?.email || "Technician";
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`;
console.log("Connecting chat to:", wsUrl); console.log("Connecting chat to:", wsUrl);
chatSocket = new WebSocket(wsUrl); chatSocket = new WebSocket(wsUrl);

View File

@@ -174,6 +174,10 @@
window.close(); window.close();
} }
// Get viewer name from localStorage (same as dashboard)
const user = JSON.parse(localStorage.getItem('user') || 'null');
const viewerName = user?.name || user?.email || 'Technician';
// State // State
let ws = null; let ws = null;
let canvas = document.getElementById('viewer-canvas'); let canvas = document.getElementById('viewer-canvas');
@@ -593,7 +597,7 @@
function connect() { function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}`; const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`;
console.log('Connecting to:', wsUrl); console.log('Connecting to:', wsUrl);
updateStatus('connecting', 'Connecting...'); updateStatus('connecting', 'Connecting...');