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:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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...');
|
||||||
|
|||||||
Reference in New Issue
Block a user