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
JavaScript
// 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