UNPKG

shell-mirror

Version:

Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.

1,216 lines (1,065 loc) โ€ข 61.4 kB
// Dashboard functionality for Shell Mirror // Session color palette (matches terminal.js) const SESSION_COLORS = [ { border: '#2196f3', text: '#1565c0' }, // Blue { border: '#4caf50', text: '#2e7d32' }, // Green { border: '#ff9800', text: '#e65100' }, // Orange { border: '#9c27b0', text: '#6a1b9a' }, // Purple { border: '#00bcd4', text: '#00838f' }, // Teal { border: '#e91e63', text: '#ad1457' }, // Pink ]; class ShellMirrorDashboard { constructor() { this.isAuthenticated = false; this.user = null; this.agents = []; this.sessions = []; this.agentSessions = {}; // Maps agentId to sessions array this.websocket = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.refreshInterval = null; this.lastRefresh = null; this.isRefreshing = false; this.connectionStatusDebounce = null; this.currentConnectionStatus = null; this.sessionBroadcast = null; // For cross-tab session sync this.init(); } async init() { this.showLoading(); this.loadVersionInfo(); // Load version info immediately // Wait for GA script to load and send page view setTimeout(() => { console.log('๐Ÿ” [DASHBOARD DEBUG] Checking Google Analytics setup...'); console.log('๐Ÿ” [DASHBOARD DEBUG] gtag function type:', typeof gtag); console.log('๐Ÿ” [DASHBOARD DEBUG] gtagLoaded flag:', window.gtagLoaded); // Send dashboard page view event if (typeof sendGAEvent === 'function') { sendGAEvent('page_view', { page_title: 'Shell Mirror Dashboard', page_location: window.location.href }); } else { console.warn('โŒ [DASHBOARD DEBUG] sendGAEvent function not available'); } }, 1000); try { const authStatus = await this.checkAuthStatus(); if (authStatus.isAuthenticated) { this.isAuthenticated = true; this.user = authStatus.user; await this.loadDashboardData(); this.renderAuthenticatedDashboard(); this.updateLastRefreshTime(); this.enableHttpOnlyMode(); // Use HTTP-only mode (no persistent WebSocket) this.startAutoRefresh(); // Start auto-refresh for authenticated users this.setupSessionSync(); // Listen for real-time session updates from terminal tabs } else { this.renderUnauthenticatedDashboard(); } } catch (error) { console.error('Dashboard initialization failed:', error); this.renderErrorState(); } finally { this.hideLoading(); } } startAutoRefresh() { // Clear any existing interval if (this.refreshInterval) { clearInterval(this.refreshInterval); } // Refresh agent data every 10 seconds (sessions sync instantly via BroadcastChannel) this.refreshInterval = setInterval(async () => { if (this.isAuthenticated && !this.isRefreshing) { await this.refreshDashboardData(); } }, 10000); } setupSessionSync() { // Use BroadcastChannel for instant cross-tab session sync try { this.sessionBroadcast = new BroadcastChannel('shell-mirror-sessions'); this.sessionBroadcast.onmessage = (event) => { console.log('[DASHBOARD] ๐Ÿ“ก Received session update from terminal:', event.data); if (event.data.type === 'session-update') { this.handleSessionUpdate(event.data); } }; console.log('[DASHBOARD] ๐Ÿ“ก Session sync channel established'); } catch (e) { console.log('[DASHBOARD] BroadcastChannel not supported, using localStorage polling'); } // Also listen for localStorage changes (fallback for same-origin tabs) window.addEventListener('storage', (event) => { if (event.key === 'shell-mirror-sessions') { console.log('[DASHBOARD] ๐Ÿ’พ localStorage session update detected'); this.loadSessionsFromLocalStorage(); this.updateAgentsDisplay(); } }); // Initial load from localStorage this.loadSessionsFromLocalStorage(); } handleSessionUpdate(data) { const { agentId, sessions } = data; if (agentId && sessions) { this.agentSessions[agentId] = sessions; console.log('[DASHBOARD] โœ… Sessions updated for agent:', agentId, sessions); this.updateAgentsDisplay(); } } async refreshDashboardData() { this.isRefreshing = true; try { await this.loadDashboardData(); this.updateAgentsDisplay(); this.updateLastRefreshTime(); } catch (error) { console.error('Auto-refresh failed:', error); // Implement exponential backoff on failure clearInterval(this.refreshInterval); const backoffDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); setTimeout(() => { if (this.isAuthenticated) { this.startAutoRefresh(); this.reconnectAttempts++; } }, backoffDelay); } finally { this.isRefreshing = false; } } updateLastRefreshTime() { this.lastRefresh = Date.now(); const refreshStatus = document.getElementById('refresh-status'); if (refreshStatus) { refreshStatus.textContent = `Agents updated: ${new Date(this.lastRefresh).toLocaleTimeString()}`; } } setupWebSocket() { // Detect production environment const isProduction = window.location.hostname === 'shellmirror.app' || window.location.hostname === 'www.shellmirror.app' || window.location.hostname === 'www.igori.eu' || window.location.hostname === 'igori.eu'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl; if (isProduction) { // For production, try the Heroku WebSocket app URL // This may need to be adjusted based on actual deployment architecture wsUrl = `wss://shell-mirror-30aa5479ceaf.herokuapp.com/?role=dashboard`; console.log('[DASHBOARD] ๐ŸŒ Production environment detected, using Heroku WebSocket URL'); } else { // For development, use same host wsUrl = `${protocol}//${window.location.host}/?role=dashboard`; } console.log('[DASHBOARD] ๐Ÿ”Œ Connecting to WebSocket:', wsUrl); try { this.websocket = new WebSocket(wsUrl); this.websocket.onopen = () => { console.log('[DASHBOARD] โœ… WebSocket connected to:', wsUrl); this.reconnectAttempts = 0; this.updateConnectionStatus('connected'); // Send authentication info if available const user = this.user; if (user) { console.log('[DASHBOARD] ๐Ÿ” Sending authentication to WebSocket'); this.websocket.send(JSON.stringify({ type: 'authenticate', userId: user.id || user.email, email: user.email })); } }; this.websocket.onmessage = (event) => { this.handleWebSocketMessage(event.data); }; this.websocket.onclose = (event) => { const closeReasons = { 1000: 'Normal Closure', 1001: 'Going Away', 1002: 'Protocol Error', 1003: 'Unsupported Data', 1004: 'Reserved', 1005: 'No Status Rcvd', 1006: 'Abnormal Closure', 1007: 'Invalid frame payload data', 1008: 'Policy Violation', 1009: 'Message too big', 1010: 'Mandatory Extension', 1011: 'Internal Server Error', 1015: 'TLS Handshake' }; const reason = closeReasons[event.code] || 'Unknown'; console.log(`[DASHBOARD] ๐Ÿ”Œ WebSocket closed: ${event.code} (${reason})`, event.reason); if (event.code === 1008) { console.error('[DASHBOARD] โŒ Authentication/Policy violation - switching to HTTP-only mode'); this.updateConnectionStatus('failed'); this.enableHttpOnlyMode(); return; } else if (event.code === 1006) { console.error('[DASHBOARD] โŒ Abnormal closure - WebSocket endpoint may not exist'); } this.updateConnectionStatus('disconnected'); this.attemptReconnect(); }; this.websocket.onerror = (error) => { console.error('[DASHBOARD] โŒ WebSocket error:', error); console.log('[DASHBOARD] ๐Ÿ” WebSocket URL attempted:', wsUrl); this.updateConnectionStatus('error'); }; // Send periodic ping to keep connection alive setInterval(() => { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(JSON.stringify({ type: 'ping' })); } }, 30000); } catch (error) { console.error('[DASHBOARD] โŒ Failed to setup WebSocket:', error); this.updateConnectionStatus('error'); } } handleWebSocketMessage(data) { try { const message = JSON.parse(data); console.log('[DASHBOARD] ๐Ÿ“จ WebSocket message:', message); switch (message.type) { case 'agent-list': // Initial agent list or refresh this.handleAgentListUpdate(message.agents); break; case 'agent-connected': this.handleAgentConnected(message.agentId); break; case 'agent-disconnected': this.handleAgentDisconnected(message.agentId); break; case 'pong': console.log('[DASHBOARD] ๐Ÿ“ Received pong'); break; default: console.log('[DASHBOARD] โ“ Unknown message type:', message.type); } } catch (error) { console.error('[DASHBOARD] โŒ Error handling WebSocket message:', error); } } handleAgentListUpdate(agentIds) { console.log('[DASHBOARD] ๐Ÿ“‹ Agent list update:', agentIds); // This is just the initial list of agent IDs, we still need to load full data this.refreshDashboardData(); } handleAgentConnected(agentId) { console.log('[DASHBOARD] โœ… Agent connected:', agentId); this.showAgentNotification(agentId, 'connected'); // Refresh data to get the new agent's details this.refreshDashboardData(); } handleAgentDisconnected(agentId) { console.log('[DASHBOARD] โŒ Agent disconnected:', agentId); this.showAgentNotification(agentId, 'disconnected'); // Refresh data to update agent status this.refreshDashboardData(); } showAgentNotification(agentId, status) { const message = status === 'connected' ? `๐ŸŸข Agent ${agentId} connected` : `๐Ÿ”ด Agent ${agentId} disconnected`; // Create notification element const notification = document.createElement('div'); notification.className = `agent-notification ${status}`; notification.textContent = message; // Add to page document.body.appendChild(notification); // Auto-remove after 5 seconds setTimeout(() => { if (document.body.contains(notification)) { document.body.removeChild(notification); } }, 5000); } attemptReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts && this.isAuthenticated) { const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); console.log(`[DASHBOARD] ๐Ÿ”„ Attempting reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); setTimeout(() => { this.reconnectAttempts++; this.setupWebSocket(); }, delay); } else { console.log('[DASHBOARD] โŒ Max reconnection attempts reached, switching to HTTP-only mode'); this.updateConnectionStatus('offline'); this.enableHttpOnlyMode(); } } enableHttpOnlyMode() { console.log('[DASHBOARD] ๐Ÿ“ก Enabling HTTP-only mode (no real-time updates)'); this.websocket = null; // Update UI to show HTTP-only mode const connectionStatus = document.getElementById('connection-status'); if (connectionStatus) { connectionStatus.textContent = '๐Ÿ“ก HTTP Only'; connectionStatus.className = 'connection-status offline'; connectionStatus.title = 'Real-time updates unavailable - using polling'; } // Continue with HTTP polling only console.log('[DASHBOARD] โœ… Dashboard running in HTTP-only mode'); } updateConnectionStatus(status) { // Debounce rapid status changes to prevent UI flickering if (this.connectionStatusDebounce) { clearTimeout(this.connectionStatusDebounce); } // Don't update if status is the same if (this.currentConnectionStatus === status) { return; } // For rapid disconnection/reconnection, only show status after a delay if (status === 'disconnected' && this.currentConnectionStatus === 'connected') { this.connectionStatusDebounce = setTimeout(() => { this.setConnectionStatusUI(status); }, 2000); // Wait 2 seconds before showing disconnected return; } // For reconnection, show immediately but don't flicker if (status === 'connected' && this.connectionStatusDebounce) { clearTimeout(this.connectionStatusDebounce); this.connectionStatusDebounce = null; } this.setConnectionStatusUI(status); } setConnectionStatusUI(status) { this.currentConnectionStatus = status; const connectionStatus = document.getElementById('connection-status'); if (connectionStatus) { connectionStatus.className = `connection-status ${status}`; // Only show status when there are issues or special modes const statusConfig = { connected: { text: '', show: false }, // Hide when everything is working disconnected: { text: '๐ŸŸก Reconnecting...', show: true }, error: { text: '๐Ÿ”ด Connection Error', show: true }, failed: { text: '๐Ÿ”ด Offline', show: true }, offline: { text: '๐Ÿ“ก HTTP Only', show: true } }; const config = statusConfig[status] || { text: 'โ“ Unknown', show: true }; connectionStatus.textContent = config.text; connectionStatus.style.display = config.show ? 'inline-block' : 'none'; } } async manualRefresh() { if (this.isRefreshing) { console.log('[DASHBOARD] โš ๏ธ Refresh already in progress, ignoring manual request'); return; } console.log('[DASHBOARD] ๐Ÿ”„ Manual refresh triggered'); // Show loading state on refresh button const refreshBtn = document.getElementById('refresh-btn'); if (refreshBtn) { refreshBtn.classList.add('loading'); refreshBtn.disabled = true; } try { await this.refreshDashboardData(); console.log('[DASHBOARD] โœ… Manual refresh completed'); } catch (error) { console.error('[DASHBOARD] โŒ Manual refresh failed:', error); } finally { // Remove loading state if (refreshBtn) { refreshBtn.classList.remove('loading'); refreshBtn.disabled = false; } } } updateAgentsDisplay() { const wrapper = document.querySelector('.dashboard-content-wrapper'); if (wrapper) { // Replace the entire wrapper to avoid duplication wrapper.outerHTML = this.renderActiveAgents(); } else { // Fallback for initial render const agentsCard = document.querySelector('.dashboard-card'); if (agentsCard) { agentsCard.outerHTML = this.renderActiveAgents(); } } } showLoading() { document.getElementById('loading-overlay').style.display = 'flex'; } hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; } async checkAuthStatus() { try { const response = await fetch('/php-backend/api/auth-status.php'); const data = await response.json(); if (data.success && data.data && data.data.authenticated) { return { isAuthenticated: true, user: data.data.user }; } } catch (error) { console.log('Auth check failed:', error); } return { isAuthenticated: false, user: null }; } async loadDashboardData() { try { // Load active agents const agentsResponse = await fetch('/php-backend/api/agents-list.php', { credentials: 'include' // Include authentication cookies }); const agentsData = await agentsResponse.json(); if (agentsData.success && agentsData.data && agentsData.data.agents) { this.agents = agentsData.data.agents; // Populate agentSessions from API response (sessions are sent via agent heartbeat) // But DON'T reset - merge with localStorage sessions for instant updates this.agents.forEach(agent => { if (agent.sessions && agent.sessions.length > 0) { // Merge API sessions with any localStorage sessions const localSessions = this.agentSessions[agent.agentId] || []; const apiSessionIds = new Set(agent.sessions.map(s => s.id)); // Keep local sessions that aren't in API yet (just created) const newLocalSessions = localSessions.filter(s => !apiSessionIds.has(s.id)); this.agentSessions[agent.agentId] = [...agent.sessions, ...newLocalSessions]; } }); // Load sessions from localStorage (created by terminal tabs) this.loadSessionsFromLocalStorage(); } else { this.agents = []; this.agentSessions = {}; } // TODO: Load session history when API is available this.sessions = [ { id: 1, agentId: 'local-MacBookPro-1755126622411', startTime: new Date('2025-08-13T22:10:00Z'), duration: '15 minutes', status: 'completed' }, { id: 2, agentId: 'local-MacBookPro-1755123565749', startTime: new Date('2025-08-13T21:30:00Z'), duration: '45 minutes', status: 'completed' } ]; } catch (error) { console.error('[DASHBOARD] โŒ Failed to load dashboard data:', error); this.agents = []; // Show error in UI const agentsCard = document.querySelector('.dashboard-card'); if (agentsCard) { agentsCard.innerHTML = ` <div class="card-header"> <h2>๐Ÿ–ฅ๏ธ Active Agents</h2> <span class="agent-count">Error</span> </div> <div class="card-content"> <div class="api-error"> <p>โš ๏ธ Failed to load agents: ${error.message}</p> <button onclick="dashboard.manualRefresh()" class="btn-primary">Retry</button> </div> </div> `; } } } renderUnauthenticatedDashboard() { // Update user section document.getElementById('user-section').innerHTML = ` <button class="btn-primary small" onclick="handleLogin()">Sign In</button> `; // Show actual dashboard content but blurred document.getElementById('dashboard-main').innerHTML = ` <div class="dashboard-grid blurred"> <div class="dashboard-card"> ${this.renderActiveAgentsPreview()} </div> </div> `; // Show centered login button const loginOverlay = document.getElementById('login-button-overlay'); if (loginOverlay) { loginOverlay.style.display = 'flex'; } } renderAuthenticatedDashboard() { // Update user section - minimal design document.getElementById('user-section').innerHTML = ` <span id="connection-status" class="connection-status" style="display: none;"></span> <div class="user-info"> <span class="user-name">${this.user?.name || this.user?.email || 'User'}</span> <div class="user-dropdown"> <button class="dropdown-btn">โš™๏ธ</button> <div class="dropdown-content"> <a href="#" onclick="dashboard.showAgentInstructions()">Help</a> <a href="/">Home</a> <a href="#" onclick="dashboard.logout()">Logout</a> </div> </div> </div> `; // Render main dashboard content - agents only document.getElementById('dashboard-main').innerHTML = ` <div class="dashboard-grid"> ${this.renderActiveAgents()} </div> `; // Hide login button overlay const loginOverlay = document.getElementById('login-button-overlay'); if (loginOverlay) { loginOverlay.style.display = 'none'; } } renderActiveAgents() { // Filter for agents that should be displayed (only online/recent - offline agents can't be recovered) const displayAgents = this.agents.filter(agent => { return agent.status === 'online' || agent.status === 'recent'; }); // All displayed agents are now connectable (online/recent only) const agentCount = displayAgents.length; const agentsHtml = displayAgents.map(agent => { const sessions = this.agentSessions[agent.agentId] || []; const sessionCount = sessions.length; const isConnectable = agent.status === 'online' || agent.status === 'recent'; const statusText = { 'online': 'Live', 'recent': 'Recent', 'offline': 'Offline' }[agent.status] || agent.status; const lastSeenText = agent.timeSinceLastSeen !== undefined ? this.formatPreciseLastSeen(agent.timeSinceLastSeen) : this.formatLastSeen(agent.lastSeen); // Build inline session list with colors const sessionsHtml = sessions.map((session, index) => { const color = SESSION_COLORS[index % SESSION_COLORS.length]; return ` <div class="inline-session-item" style="border-left: 3px solid ${color.border};"> <span class="session-status-dot" style="background-color: ${color.border};"></span> <span class="session-name" style="color: ${color.border};">${session.name}</span> <button class="btn-session-connect" onclick="dashboard.connectToSession('${agent.agentId}', '${session.id}')" style="background-color: ${color.border};"> Connect </button> </div> `; }).join(''); return ` <div class="agent-item ${!isConnectable ? 'agent-offline' : ''}"> <div class="agent-header"> <div class="agent-info"> <div class="agent-name-row"> <span class="agent-name">${agent.machineName || agent.agentId}</span> <div class="agent-menu"> <button class="btn-agent-menu" onclick="event.stopPropagation(); dashboard.toggleAgentMenu('${agent.agentId}')">โ‹ฎ</button> <div class="agent-menu-dropdown" id="agent-menu-${agent.agentId}"> <button onclick="dashboard.showShutdownConfirm('${agent.agentId}')">Shut down agent</button> </div> </div> </div> <div class="agent-status ${agent.status}"> ${statusText}${sessionCount > 0 ? ` ยท ${sessionCount} session${sessionCount !== 1 ? 's' : ''}` : ''} </div> </div> </div> ${isConnectable ? ` <div class="agent-sessions-inline"> ${sessionCount > 0 ? ` <div class="sessions-list"> ${sessionsHtml} </div> ` : ''} <button class="btn-new-session" onclick="dashboard.createNewSession('${agent.agentId}')"> <span class="plus-icon">+</span> Create New Session </button> </div> ` : ` <div class="agent-offline-message">Agent offline</div> `} </div> `; }).join(''); return ` <div class="dashboard-content-wrapper"> <div class="dashboard-card"> <div class="card-header"> <h2>Agents</h2> ${agentCount > 0 ? `<span class="agent-count">${agentCount}</span>` : ''} </div> <div class="card-content"> ${agentCount > 0 ? agentsHtml : this.renderEmptyAgentState()} </div> </div> ${agentCount > 0 ? this.renderCollapsibleHelp() : ''} </div> `; } renderEmptyAgentState() { return ` <div class="empty-agent-state"> <p class="empty-state-title">No agents connected</p> <div class="empty-state-steps"> <div class="command-step"> <span class="step-label">1. Install</span> <div class="command-box"> <code>npm install -g shell-mirror</code> <button class="copy-btn" onclick="navigator.clipboard.writeText('npm install -g shell-mirror'); this.textContent = 'โœ“'; setTimeout(() => this.textContent = 'Copy', 1500)">Copy</button> </div> </div> <div class="command-step"> <span class="step-label">2. Run</span> <div class="command-box"> <code>shell-mirror</code> <button class="copy-btn" onclick="navigator.clipboard.writeText('shell-mirror'); this.textContent = 'โœ“'; setTimeout(() => this.textContent = 'Copy', 1500)">Copy</button> </div> </div> </div> <p class="empty-state-note">The agent will automatically connect to your dashboard</p> <div class="empty-state-troubleshooting"> <span class="troubleshooting-title">Troubleshooting</span> <ul> <li>Make sure you're logged in with the same Google account</li> <li>Check that the agent is running in your terminal</li> <li>Refresh this dashboard after starting the agent</li> </ul> </div> </div> `; } renderCollapsibleHelp() { return ` <div class="collapsible-help"> <button class="help-toggle" onclick="dashboard.toggleHelp()"> <span>Need help adding another agent?</span> <svg class="help-arrow" width="12" height="12" viewBox="0 0 12 12"> <path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/> </svg> </button> <div class="help-content" id="help-content"> <div class="help-step"> <span class="help-step-label">1. Install</span> <code>npm install -g shell-mirror</code> </div> <div class="help-step"> <span class="help-step-label">2. Run</span> <code>shell-mirror</code> </div> <p class="help-note">The agent will automatically connect to your dashboard</p> </div> </div> `; } toggleHelp() { const content = document.getElementById('help-content'); const arrow = document.querySelector('.help-arrow'); const isExpanded = content.classList.contains('expanded'); if (isExpanded) { content.classList.remove('expanded'); arrow.classList.remove('rotated'); } else { content.classList.add('expanded'); arrow.classList.add('rotated'); } } renderQuickActions() { return ` <div class="dashboard-card"> <h2>โšก Quick Actions</h2> <div class="card-content"> <div class="action-buttons"> <button class="action-btn" onclick="dashboard.startNewSession()"> <span class="action-icon">๐Ÿš€</span> <span class="action-text">New Terminal Session</span> </button> <button class="action-btn" onclick="dashboard.showAgentInstructions()"> <span class="action-icon">๐Ÿ“ฅ</span> <span class="action-text">Download Agent</span> </button> <button class="action-btn" onclick="dashboard.showSettings()"> <span class="action-icon">โš™๏ธ</span> <span class="action-text">Settings</span> </button> </div> </div> </div> `; } renderRecentSessions() { // Get inactive agents (not seen in last 5 minutes and offline) const inactiveAgents = this.agents.filter(agent => { const timeSinceLastSeen = Date.now() / 1000 - agent.lastSeen; return agent.onlineStatus === 'offline' && timeSinceLastSeen >= 300; // More than 5 minutes }); // Combine real sessions with inactive agents as past connections const allSessions = [ // Add inactive agents as past connections ...inactiveAgents.map(agent => ({ type: 'past_agent', agentId: agent.machineName || agent.agentId, startTime: new Date(agent.lastSeen * 1000), duration: 'Last connection', status: 'disconnected' })), // Add actual session history ...this.sessions ]; // Sort by most recent allSessions.sort((a, b) => b.startTime - a.startTime); const sessionsHtml = allSessions.map(session => ` <div class="session-item compact"> <div class="session-info"> <div class="session-agent">${session.agentId}</div> <div class="session-time">${this.formatDate(session.startTime)}</div> </div> <div class="session-details"> <span class="session-duration">${session.duration}</span> <span class="session-status ${session.status}">${session.status}</span> </div> </div> `).join(''); return ` <div class="dashboard-card full-width"> <h2>๐Ÿ“Š Recent Sessions</h2> <div class="card-content"> ${allSessions.length > 0 ? sessionsHtml : '<p class="no-data">No recent sessions</p>'} </div> </div> `; } renderActiveAgentsPreview() { // Show sample agent data for preview const sampleAgents = [ { machineName: 'MacBook Pro', onlineStatus: 'online', lastSeen: Date.now() / 1000 - 60 }, { machineName: 'Mac Studio', onlineStatus: 'offline', lastSeen: Date.now() / 1000 - 3600 } ]; const agentsHtml = sampleAgents.map(agent => ` <div class="agent-item"> <div class="agent-info"> <div class="agent-name">${agent.machineName}</div> <div class="agent-status ${agent.onlineStatus}">${agent.onlineStatus}</div> <div class="agent-last-seen">Last seen: ${this.formatLastSeen(agent.lastSeen)}</div> </div> <button class="btn-connect" disabled> Connect </button> </div> `).join(''); return ` <div class="card-header"> <h2>๐Ÿ–ฅ๏ธ Active Agents</h2> <span class="agent-count">${sampleAgents.length} agents</span> </div> <div class="card-content"> ${agentsHtml} </div> `; } renderRecentSessionsPreview() { // Show sample session data for preview const sampleSessions = [ { agentId: 'MacBook-Pro-xyz', startTime: new Date(Date.now() - 86400000), // 1 day ago duration: '45 minutes', status: 'completed' }, { agentId: 'Mac-Studio-abc', startTime: new Date(Date.now() - 3600000), // 1 hour ago duration: '2 hours', status: 'completed' } ]; const sessionsHtml = sampleSessions.map(session => ` <div class="session-item"> <div class="session-info"> <div class="session-agent">${session.agentId}</div> <div class="session-time">${this.formatDate(session.startTime)}</div> </div> <div class="session-details"> <span class="session-duration">${session.duration}</span> <span class="session-status ${session.status}">${session.status}</span> </div> </div> `).join(''); return ` <h2>๐Ÿ“Š Recent Sessions</h2> <div class="card-content"> ${sessionsHtml} </div> `; } renderErrorState() { document.getElementById('dashboard-main').innerHTML = ` <div class="error-state"> <h2>โš ๏ธ Something went wrong</h2> <p>Unable to load dashboard. Please try refreshing the page.</p> <button class="btn-primary" onclick="location.reload()">Refresh</button> </div> `; } // Utility methods formatLastSeen(lastSeen) { if (!lastSeen) return 'Unknown'; const now = Date.now() / 1000; const diff = now - lastSeen; if (diff < 60) return 'Just now'; if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; return `${Math.floor(diff / 86400)} days ago`; } formatPreciseLastSeen(timeSinceLastSeen) { if (!timeSinceLastSeen && timeSinceLastSeen !== 0) return 'Unknown'; if (timeSinceLastSeen < 10) return 'Just now'; if (timeSinceLastSeen < 60) return `${Math.floor(timeSinceLastSeen)} seconds ago`; if (timeSinceLastSeen < 3600) return `${Math.floor(timeSinceLastSeen / 60)} minutes ago`; if (timeSinceLastSeen < 86400) return `${Math.floor(timeSinceLastSeen / 3600)} hours ago`; return `${Math.floor(timeSinceLastSeen / 86400)} days ago`; } formatDate(date) { return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date); } // Load version info for footer async loadVersionInfo() { try { const response = await fetch('/build-info.json'); const buildInfo = await response.json(); const versionElement = document.getElementById('dashboard-version-info'); const footerElement = versionElement?.parentElement?.parentElement; // Get the footer element if (versionElement && buildInfo) { const buildDateTime = new Date(buildInfo.buildTime).toLocaleString(); versionElement.textContent = `Shell Mirror Dashboard v${buildInfo.version} โ€ข Built ${buildDateTime}`; // Apply random footer color from build info if (footerElement && buildInfo.footerColor) { footerElement.style.background = buildInfo.footerColor; console.log(`๐ŸŽจ Applied footer color: ${buildInfo.footerColor}`); } } } catch (error) { console.log('Could not load build info for dashboard:', error); // Keep default version and color if build-info.json not available } } showAgentConnectionTest(agentId, status) { // Find the agent item in the DOM and show loading state const agentItems = document.querySelectorAll('.agent-item'); agentItems.forEach(item => { const connectBtn = item.querySelector('.btn-connect'); if (connectBtn && connectBtn.onclick.toString().includes(agentId)) { if (status === 'testing') { connectBtn.textContent = 'Testing...'; connectBtn.disabled = true; connectBtn.classList.add('testing'); } else if (status === 'done') { connectBtn.disabled = false; connectBtn.classList.remove('testing'); // Original text will be restored on next refresh } } }); } showConnectionError(agent, message) { // Show a more user-friendly error message const notification = document.createElement('div'); notification.className = 'connection-error-notification'; notification.innerHTML = ` <div class="error-content"> <strong>Connection Failed</strong><br> Agent: ${agent.machineName || agent.agentId}<br> ${message} </div> <button onclick="this.parentElement.remove()">ร—</button> `; document.body.appendChild(notification); // Auto-remove after 10 seconds setTimeout(() => { if (document.body.contains(notification)) { document.body.removeChild(notification); } }, 10000); } async testAgentConnectivity(agentId) { try { console.log('[DASHBOARD] ๐Ÿ” Testing connectivity for agent:', agentId); // Try to ping the agent through the API const response = await fetch(`/php-backend/api/ping-agent.php?agentId=${encodeURIComponent(agentId)}`, { method: 'GET', credentials: 'include', timeout: 5000 // 5 second timeout }); if (response.ok) { const data = await response.json(); console.log('[DASHBOARD] ๐Ÿ” Ping response:', data); return data.success && data.data && data.data.reachable; } console.log('[DASHBOARD] โš ๏ธ Ping request failed:', response.status); return false; } catch (error) { console.log('[DASHBOARD] โš ๏ธ Agent connectivity test failed:', error); return false; } } // Action handlers async connectToAgent(agentId) { console.log('[DASHBOARD] ๐Ÿ” DEBUG: connectToAgent called with agentId:', agentId); // First, test if agent is actually reachable const agent = this.agents.find(a => a.agentId === agentId); if (!agent) { alert('Agent not found. Please refresh and try again.'); return; } // Check agent status if (agent.status === 'offline') { alert(`Agent "${agent.machineName || agentId}" is offline. Please ensure the agent is running.`); return; } // Test agent connectivity for recent agents with loading indicator if (agent.status === 'recent') { console.log('[DASHBOARD] ๐Ÿ” Testing agent connectivity...'); // Show loading state on the specific agent this.showAgentConnectionTest(agentId, 'testing'); const isReachable = await this.testAgentConnectivity(agentId); // Hide loading state this.showAgentConnectionTest(agentId, 'done'); if (!isReachable) { this.showConnectionError(agent, 'Connection test failed - agent may be offline'); // Refresh agent list to get updated status await this.refreshDashboardData(); return; } } console.log('[DASHBOARD] ๐Ÿ” DEBUG: Current agentSessions:', this.agentSessions); // Check if there are existing sessions for this agent const sessions = this.agentSessions[agentId] || []; console.log('[DASHBOARD] ๐Ÿ” DEBUG: Sessions for agent:', sessions); const activeSessions = sessions.filter(s => s.status === 'active'); console.log('[DASHBOARD] ๐Ÿ” DEBUG: Active sessions:', activeSessions); if (activeSessions.length > 0) { // Reconnect to the most recently active session const mostRecentSession = activeSessions.reduce((latest, session) => session.lastActivity > latest.lastActivity ? session : latest ); console.log(`[DASHBOARD] โœ… Reconnecting to existing session: ${mostRecentSession.id}`); // Track terminal connection in Google Analytics if (typeof sendGAEvent === 'function') { sendGAEvent('terminal_connect', { event_category: 'terminal', event_label: 'existing_session', agent_id: agentId, session_id: mostRecentSession.id }); } window.location.href = `/app/terminal.html?agent=${agentId}&session=${mostRecentSession.id}`; } else { // No existing sessions, create new one console.log(`[DASHBOARD] ๐Ÿ†• Creating new session for agent: ${agentId}`); // Track new session creation in Google Analytics if (typeof sendGAEvent === 'function') { sendGAEvent('terminal_connect', { event_category: 'terminal', event_label: 'new_session', agent_id: agentId }); } window.location.href = `/app/terminal.html?agent=${agentId}`; } } async connectToSession(agentId, sessionId) { // Validate agent is reachable before connecting const isReachable = await this.validateAgentBeforeConnect(agentId); if (!isReachable) return; // Track specific session connection in Google Analytics if (typeof sendGAEvent === 'function') { sendGAEvent('terminal_connect', { event_category: 'terminal', event_label: 'specific_session', agent_id: agentId, session_id: sessionId }); } // Open in new browser tab so user can return to dashboard window.open(`/app/terminal.html?agent=${agentId}&session=${sessionId}`, '_blank'); } async createNewSession(agentId) { // Validate agent is reachable before connecting const isReachable = await this.validateAgentBeforeConnect(agentId); if (!isReachable) return; console.log(`[DASHBOARD] Creating new session for agent: ${agentId}`); // Track explicit new session creation in Google Analytics if (typeof sendGAEvent === 'function') { sendGAEvent('terminal_connect', { event_category: 'terminal', event_label: 'force_new_session', agent_id: agentId }); } // Open in new browser tab so user can return to dashboard window.open(`/app/terminal.html?agent=${agentId}&newSession=true`, '_blank'); } async validateAgentBeforeConnect(agentId) { const agent = this.agents.find(a => a.agentId === agentId); if (!agent) { alert('Agent not found. Please refresh and try again.'); return false; } // Always test connectivity before connecting (not just for 'recent') console.log('[DASHBOARD] ๐Ÿ” Validating agent connectivity...'); this.showAgentConnectionTest(agentId, 'testing'); const isReachable = await this.testAgentConnectivity(agentId); this.showAgentConnectionTest(agentId, 'done'); if (!isReachable) { this.showConnectionError(agent, 'Agent is not reachable. It may be offline or disconnected.'); await this.refreshDashboardData(); return false; } return true; } toggleAgentMenu(agentId) { // Close all other menus first document.querySelectorAll('.agent-menu-dropdown.show').forEach(el => { el.classList.remove('show'); }); const menu = document.getElementById(`agent-menu-${agentId}`); if (menu) { menu.classList.toggle('show'); } } showShutdownConfirm(agentId) { // Close menu document.querySelectorAll('.agent-menu-dropdown.show').forEach(el => { el.classList.remove('show'); }); this.showShutdownModal(agentId); } showShutdownModal(agentId) { const agent = this.agents.find(a => a.agentId === agentId); const agentName = agent ? (agent.machineName || agentId) : agentId; const modalOverlay = document.createElement('div'); modalOverlay.className = 'modal-overlay'; modalOverlay.id = 'shutdown-modal'; modalOverlay.onclick = (e) => { if (e.target === modalOverlay) { document.body.removeChild(modalOverlay); } }; modalOverlay.innerHTML = ` <div class="modal" onclick="event.stopPropagation()"> <div class="modal-header"> <h3>Shut down agent</h3> <button class="modal-close" onclick="document.getElementById('shutdown-modal').remove()">ร—</button> </div> <div class="modal-body"> <p style="margin-bottom: 12px; color: var(--text-secondary);"> Are you sure you want to shut down<br><strong style="color: var(--text-primary); word-break: break-all;">${agentName}</strong>? </p> <p style="font-size: 0.85rem; color: var(--text-muted);"> This will unregister t