1080 lines
43 KiB
HTML
1080 lines
43 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GuruConnect - Dashboard</title>
|
|
<style>
|
|
:root {
|
|
--background: 222.2 84% 4.9%;
|
|
--foreground: 210 40% 98%;
|
|
--card: 222.2 84% 4.9%;
|
|
--card-foreground: 210 40% 98%;
|
|
--primary: 217.2 91.2% 59.8%;
|
|
--primary-foreground: 222.2 47.4% 11.2%;
|
|
--muted: 217.2 32.6% 17.5%;
|
|
--muted-foreground: 215 20.2% 65.1%;
|
|
--border: 217.2 32.6% 17.5%;
|
|
--input: 217.2 32.6% 17.5%;
|
|
--ring: 224.3 76.3% 48%;
|
|
--accent: 217.2 32.6% 17.5%;
|
|
--destructive: 0 62.8% 30.6%;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background-color: hsl(var(--background));
|
|
color: hsl(var(--foreground));
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid hsl(var(--border));
|
|
background: hsl(var(--card));
|
|
}
|
|
|
|
.header-left { display: flex; align-items: center; gap: 24px; }
|
|
|
|
.logo { font-size: 20px; font-weight: 700; color: hsl(var(--foreground)); }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 16px; }
|
|
|
|
.user-info { font-size: 14px; color: hsl(var(--muted-foreground)); }
|
|
|
|
.logout-btn {
|
|
padding: 8px 16px;
|
|
font-size: 14px;
|
|
background: transparent;
|
|
color: hsl(var(--muted-foreground));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.logout-btn:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); }
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
padding: 0 24px;
|
|
border-bottom: 1px solid hsl(var(--border));
|
|
background: hsl(var(--card));
|
|
}
|
|
|
|
.tab {
|
|
padding: 12px 20px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: hsl(var(--muted-foreground));
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
margin-bottom: -1px;
|
|
}
|
|
|
|
.tab:hover { color: hsl(var(--foreground)); }
|
|
.tab.active { color: hsl(var(--primary)); border-bottom-color: hsl(var(--primary)); }
|
|
|
|
.content { padding: 24px; max-width: 1400px; margin: 0 auto; }
|
|
|
|
.tab-panel { display: none; }
|
|
.tab-panel.active { display: block; }
|
|
|
|
.card {
|
|
background: hsl(var(--card));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card-title { font-size: 18px; font-weight: 600; }
|
|
.card-description { color: hsl(var(--muted-foreground)); font-size: 14px; margin-top: 4px; }
|
|
|
|
.btn {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
border: none;
|
|
}
|
|
|
|
.btn-primary { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
|
|
.btn-primary:hover { opacity: 0.9; }
|
|
.btn-outline { background: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); }
|
|
.btn-outline:hover { background: hsl(var(--accent)); }
|
|
|
|
.table-container { overflow-x: auto; }
|
|
|
|
table { width: 100%; border-collapse: collapse; }
|
|
|
|
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid hsl(var(--border)); }
|
|
|
|
th { font-size: 12px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); }
|
|
td { font-size: 14px; }
|
|
tr:hover { background: hsla(var(--muted), 0.3); }
|
|
|
|
.empty-state { text-align: center; padding: 48px 24px; color: hsl(var(--muted-foreground)); }
|
|
.empty-state h3 { font-size: 16px; margin-bottom: 8px; color: hsl(var(--foreground)); }
|
|
|
|
.badge { display: inline-block; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 9999px; }
|
|
.badge-success { background: hsla(142, 76%, 36%, 0.2); color: hsl(142, 76%, 50%); }
|
|
.badge-warning { background: hsla(45, 93%, 47%, 0.2); color: hsl(45, 93%, 55%); }
|
|
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
|
|
|
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
|
|
|
|
.form-group { display: flex; flex-direction: column; gap: 8px; }
|
|
.form-group label { font-size: 14px; font-weight: 500; }
|
|
|
|
.form-group input, .form-group select {
|
|
padding: 10px 14px;
|
|
font-size: 14px;
|
|
background: hsl(var(--input));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 6px;
|
|
color: hsl(var(--foreground));
|
|
outline: none;
|
|
}
|
|
|
|
.form-group input:focus, .form-group select:focus {
|
|
border-color: hsl(var(--ring));
|
|
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
|
|
}
|
|
|
|
.access-layout {
|
|
display: grid;
|
|
grid-template-columns: 250px 1fr 350px;
|
|
gap: 16px;
|
|
height: calc(100vh - 180px);
|
|
}
|
|
|
|
.sidebar-panel {
|
|
background: hsl(var(--card));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 8px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar-section { padding: 12px 16px; border-bottom: 1px solid hsl(var(--border)); }
|
|
.sidebar-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 8px; }
|
|
|
|
.sidebar-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
font-size: 14px;
|
|
color: hsl(var(--foreground));
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.sidebar-item:hover { background: hsl(var(--accent)); }
|
|
.sidebar-item.active { background: hsl(var(--accent)); color: hsl(var(--primary)); }
|
|
.sidebar-count { font-size: 12px; color: hsl(var(--muted-foreground)); }
|
|
|
|
.main-panel, .detail-panel {
|
|
background: hsl(var(--card));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 8px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.detail-panel { padding: 20px; }
|
|
.detail-section { margin-bottom: 20px; }
|
|
.detail-section-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 12px; }
|
|
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; border-bottom: 1px solid hsl(var(--border)); }
|
|
.detail-label { color: hsl(var(--muted-foreground)); }
|
|
.detail-value { color: hsl(var(--foreground)); text-align: right; }
|
|
|
|
/* Chat Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.modal-overlay.active { display: flex; }
|
|
|
|
.modal {
|
|
background: hsl(var(--card));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 12px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
max-height: 80vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid hsl(var(--border));
|
|
}
|
|
|
|
.modal-title { font-size: 18px; font-weight: 600; }
|
|
|
|
.modal-close {
|
|
background: transparent;
|
|
border: none;
|
|
color: hsl(var(--muted-foreground));
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
.modal-close:hover { color: hsl(var(--foreground)); }
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px 20px;
|
|
min-height: 300px;
|
|
max-height: 400px;
|
|
}
|
|
|
|
.chat-message {
|
|
margin-bottom: 12px;
|
|
padding: 10px 14px;
|
|
border-radius: 8px;
|
|
max-width: 80%;
|
|
}
|
|
|
|
.chat-message.technician {
|
|
background: hsl(var(--primary));
|
|
color: hsl(var(--primary-foreground));
|
|
margin-left: auto;
|
|
}
|
|
|
|
.chat-message.client {
|
|
background: hsl(var(--muted));
|
|
color: hsl(var(--foreground));
|
|
}
|
|
|
|
.chat-message-sender {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
opacity: 0.8;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.chat-message-content { font-size: 14px; line-height: 1.4; }
|
|
|
|
.chat-empty {
|
|
text-align: center;
|
|
color: hsl(var(--muted-foreground));
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.chat-input-container {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 16px 20px;
|
|
border-top: 1px solid hsl(var(--border));
|
|
}
|
|
|
|
.chat-input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
font-size: 14px;
|
|
background: hsl(var(--input));
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: 6px;
|
|
color: hsl(var(--foreground));
|
|
outline: none;
|
|
}
|
|
|
|
.chat-input:focus {
|
|
border-color: hsl(var(--ring));
|
|
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
|
|
}
|
|
|
|
.chat-send {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
background: hsl(var(--primary));
|
|
color: hsl(var(--primary-foreground));
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.chat-send:hover { opacity: 0.9; }
|
|
.chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<div class="logo">GuruConnect</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<span class="user-info" id="userInfo">Loading...</span>
|
|
<button class="logout-btn" id="logoutBtn">Sign Out</button>
|
|
</div>
|
|
</header>
|
|
|
|
<nav class="tabs">
|
|
<button class="tab active" data-tab="support">Support</button>
|
|
<button class="tab" data-tab="access">Access</button>
|
|
<button class="tab" data-tab="build">Build</button>
|
|
<button class="tab" data-tab="settings">Settings</button>
|
|
</nav>
|
|
|
|
<main class="content">
|
|
<!-- Support Tab -->
|
|
<div class="tab-panel active" id="support-panel">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2 class="card-title">Active Support Sessions</h2>
|
|
<p class="card-description">Temporary sessions initiated by support codes</p>
|
|
</div>
|
|
<button class="btn btn-primary" id="generateCodeBtn">Generate Code</button>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Code</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Technician</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sessionsTable">
|
|
<tr>
|
|
<td colspan="5">
|
|
<div class="empty-state">
|
|
<h3>No active sessions</h3>
|
|
<p>Generate a code to start a support session</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Access Tab -->
|
|
<div class="tab-panel" id="access-panel">
|
|
<div class="access-layout">
|
|
<div class="sidebar-panel">
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-title">Status</div>
|
|
<div class="sidebar-item active" data-filter="all">
|
|
<span>All Machines</span>
|
|
<span class="sidebar-count" id="countAll">0</span>
|
|
</div>
|
|
<div class="sidebar-item" data-filter="online">
|
|
<span>Online</span>
|
|
<span class="sidebar-count" id="countOnline">0</span>
|
|
</div>
|
|
<div class="sidebar-item" data-filter="offline">
|
|
<span>Offline</span>
|
|
<span class="sidebar-count" id="countOffline">0</span>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-title">By Company</div>
|
|
<div class="empty-state" style="padding: 16px;">
|
|
<p style="font-size: 12px;">No machines installed</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="main-panel" id="machinesList">
|
|
<div class="empty-state">
|
|
<h3>No machines</h3>
|
|
<p>Install the agent on a machine to see it here</p>
|
|
</div>
|
|
</div>
|
|
<div class="detail-panel" id="machineDetail">
|
|
<div class="empty-state">
|
|
<h3>Select a machine</h3>
|
|
<p>Click a machine to view details</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Build Tab -->
|
|
<div class="tab-panel" id="build-panel">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2 class="card-title">Installer Builder</h2>
|
|
<p class="card-description">Create customized agent installers for unattended access</p>
|
|
</div>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="buildName">Name</label>
|
|
<input type="text" id="buildName" placeholder="Machine name (auto if blank)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="buildCompany">Company</label>
|
|
<input type="text" id="buildCompany" placeholder="Client organization">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="buildSite">Site</label>
|
|
<input type="text" id="buildSite" placeholder="Physical location">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="buildDepartment">Department</label>
|
|
<input type="text" id="buildDepartment" placeholder="Business unit">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="buildDeviceType">Device Type</label>
|
|
<select id="buildDeviceType">
|
|
<option value="workstation">Workstation</option>
|
|
<option value="laptop">Laptop</option>
|
|
<option value="server">Server</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="buildTag">Tag</label>
|
|
<input type="text" id="buildTag" placeholder="Custom label">
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 24px; display: flex; gap: 12px;">
|
|
<button class="btn btn-primary" disabled>Build EXE (64-bit)</button>
|
|
<button class="btn btn-outline" disabled>Build EXE (32-bit)</button>
|
|
<button class="btn btn-outline" disabled>Build MSI</button>
|
|
</div>
|
|
<p style="margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground));">
|
|
Agent builds will be available once the agent is compiled.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings Tab -->
|
|
<div class="tab-panel" id="settings-panel">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2 class="card-title">Settings</h2>
|
|
<p class="card-description">Configure your GuruConnect preferences</p>
|
|
</div>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Theme</label>
|
|
<select disabled>
|
|
<option>Dark (Default)</option>
|
|
<option>Light</option>
|
|
<option>System</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Notifications</label>
|
|
<select disabled>
|
|
<option>All notifications</option>
|
|
<option>Important only</option>
|
|
<option>None</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<p style="margin-top: 24px; font-size: 13px; color: hsl(var(--muted-foreground));">
|
|
Additional settings coming soon.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Chat Modal -->
|
|
<div class="modal-overlay" id="chatModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<div class="modal-title">Chat with <span id="chatClientName">Client</span></div>
|
|
<button class="modal-close" id="chatClose">×</button>
|
|
</div>
|
|
<div class="chat-messages" id="chatMessages">
|
|
<div class="chat-empty">No messages yet. Start the conversation!</div>
|
|
</div>
|
|
<div class="chat-input-container">
|
|
<input type="text" class="chat-input" id="chatInput" placeholder="Type a message..." />
|
|
<button class="chat-send" id="chatSend">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Chat state
|
|
let chatSocket = null;
|
|
let chatSessionId = null;
|
|
let chatMessages = [];
|
|
|
|
// Tab switching
|
|
document.querySelectorAll(".tab").forEach(tab => {
|
|
tab.addEventListener("click", () => {
|
|
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
|
|
document.querySelectorAll(".tab-panel").forEach(p => p.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
document.getElementById(tab.dataset.tab + "-panel").classList.add("active");
|
|
});
|
|
});
|
|
|
|
// Check auth
|
|
const token = localStorage.getItem("token");
|
|
const user = JSON.parse(localStorage.getItem("user") || "null");
|
|
|
|
if (!token) {
|
|
document.getElementById("userInfo").textContent = "Demo Mode";
|
|
} else if (user) {
|
|
document.getElementById("userInfo").textContent = user.email || user.name || "Technician";
|
|
}
|
|
|
|
// Logout
|
|
document.getElementById("logoutBtn").addEventListener("click", () => {
|
|
localStorage.removeItem("token");
|
|
localStorage.removeItem("user");
|
|
window.location.href = "/login";
|
|
});
|
|
|
|
// Generate code
|
|
document.getElementById("generateCodeBtn").addEventListener("click", async () => {
|
|
try {
|
|
const response = await fetch("/api/codes", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ technician_name: user?.name || "Technician" })
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
alert("Support Code Generated: " + data.code + "\n\nShare this with the customer.");
|
|
loadSessions();
|
|
} else {
|
|
alert("Error: " + (data.error || "Failed to generate code"));
|
|
}
|
|
} catch (err) {
|
|
alert("Connection error");
|
|
}
|
|
});
|
|
|
|
// Load sessions
|
|
async function loadSessions() {
|
|
try {
|
|
const response = await fetch("/api/codes");
|
|
const codes = await response.json();
|
|
const tbody = document.getElementById("sessionsTable");
|
|
|
|
if (codes.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><h3>No active sessions</h3><p>Generate a code to start a support session</p></div></td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = codes.map(code => {
|
|
const created = new Date(code.created_at).toLocaleString();
|
|
const statusClass = code.status === "pending" ? "badge-warning" :
|
|
code.status === "connected" ? "badge-success" : "badge-muted";
|
|
const clientInfo = code.status === "connected" && code.client_name
|
|
? '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + code.client_name + '</div>'
|
|
: '';
|
|
const actionBtn = code.status === "connected"
|
|
? '<button class="btn btn-primary" onclick="joinSession(\'' + code.session_id + '\')">Join</button>' +
|
|
'<button class="btn btn-outline" style="margin-left: 8px;" onclick="openChat(\'' + code.session_id + '\', \'' + (code.client_name || 'Client').replace(/'/g, "\\'") + '\')">Chat</button>' +
|
|
'<button class="btn btn-outline" style="margin-left: 8px;" onclick="cancelCode(\'' + code.code + '\')">End</button>'
|
|
: '<button class="btn btn-outline" onclick="cancelCode(\'' + code.code + '\')">Cancel</button>';
|
|
return '<tr>' +
|
|
'<td><strong>' + code.code + '</strong>' + clientInfo + '</td>' +
|
|
'<td><span class="badge ' + statusClass + '">' + code.status + '</span></td>' +
|
|
'<td>' + created + '</td>' +
|
|
'<td>' + (code.created_by || "Unknown") + '</td>' +
|
|
'<td>' + actionBtn + '</td>' +
|
|
'</tr>';
|
|
}).join("");
|
|
} catch (err) {
|
|
console.error("Failed to load sessions:", err);
|
|
}
|
|
}
|
|
|
|
async function cancelCode(code) {
|
|
if (!confirm("Cancel code " + code + "?")) return;
|
|
try {
|
|
await fetch("/api/codes/" + code + "/cancel", { method: "POST" });
|
|
loadSessions();
|
|
} catch (err) {
|
|
alert("Error cancelling code");
|
|
}
|
|
}
|
|
|
|
function joinSession(sessionId) {
|
|
// Open viewer in new window
|
|
const viewerUrl = "/viewer.html?session_id=" + sessionId;
|
|
window.open(viewerUrl, "viewer_" + sessionId, "width=1280,height=800,menubar=no,toolbar=no,location=no,status=no");
|
|
}
|
|
|
|
// Initial load and auto-refresh every 3 seconds
|
|
loadSessions();
|
|
setInterval(loadSessions, 3000);
|
|
|
|
// Load connected machines (Access tab)
|
|
let machines = [];
|
|
let selectedMachine = null;
|
|
let currentFilter = 'all'; // 'all', 'online', 'offline'
|
|
|
|
// Setup filter click handlers
|
|
document.querySelectorAll('.sidebar-item[data-filter]').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
currentFilter = item.dataset.filter;
|
|
// Update active state
|
|
document.querySelectorAll('.sidebar-item[data-filter]').forEach(i => i.classList.remove('active'));
|
|
item.classList.add('active');
|
|
renderMachinesList();
|
|
});
|
|
});
|
|
|
|
async function loadMachines() {
|
|
try {
|
|
const response = await fetch("/api/sessions");
|
|
machines = await response.json();
|
|
|
|
// Update counts based on is_online status
|
|
const onlineCount = machines.filter(m => m.is_online).length;
|
|
const offlineCount = machines.filter(m => !m.is_online).length;
|
|
document.getElementById("countAll").textContent = machines.length;
|
|
document.getElementById("countOnline").textContent = onlineCount;
|
|
document.getElementById("countOffline").textContent = offlineCount;
|
|
|
|
renderMachinesList();
|
|
} catch (err) {
|
|
console.error("Failed to load machines:", err);
|
|
}
|
|
}
|
|
|
|
function renderMachinesList() {
|
|
const container = document.getElementById("machinesList");
|
|
|
|
// Filter machines based on current filter
|
|
let filteredMachines = machines;
|
|
if (currentFilter === 'online') {
|
|
filteredMachines = machines.filter(m => m.is_online);
|
|
} else if (currentFilter === 'offline') {
|
|
filteredMachines = machines.filter(m => !m.is_online);
|
|
}
|
|
|
|
if (filteredMachines.length === 0) {
|
|
const msg = currentFilter === 'all' ? 'Install the agent on a machine to see it here' :
|
|
currentFilter === 'online' ? 'No machines are currently online' :
|
|
'No machines are currently offline';
|
|
container.innerHTML = '<div class="empty-state"><h3>No machines</h3><p>' + msg + '</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<div style="padding: 12px;">' + filteredMachines.map(m => {
|
|
const started = new Date(m.started_at).toLocaleString();
|
|
const isSelected = selectedMachine?.id === m.id;
|
|
const statusColor = m.is_online ? 'hsl(142, 76%, 50%)' : 'hsl(0, 0%, 50%)';
|
|
const statusText = m.is_online ? 'Online' : 'Offline';
|
|
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="width: 10px; height: 10px; border-radius: 50%; background: ' + statusColor + ';"></div>' +
|
|
'<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>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join("") + '</div>';
|
|
}
|
|
|
|
function selectMachine(id) {
|
|
selectedMachine = machines.find(m => m.id === id);
|
|
renderMachinesList();
|
|
renderMachineDetail();
|
|
}
|
|
|
|
function renderMachineDetail() {
|
|
const container = document.getElementById("machineDetail");
|
|
|
|
if (!selectedMachine) {
|
|
container.innerHTML = '<div class="empty-state"><h3>Select a machine</h3><p>Click a machine to view details</p></div>';
|
|
return;
|
|
}
|
|
|
|
const m = selectedMachine;
|
|
const started = new Date(m.started_at).toLocaleString();
|
|
const statusColor = m.is_online ? 'hsl(142, 76%, 50%)' : 'hsl(0, 0%, 50%)';
|
|
const statusText = m.is_online ? 'Online' : 'Offline';
|
|
const connectDisabled = m.is_online ? '' : 'disabled';
|
|
const connectTitle = m.is_online ? '' : 'title="Agent is offline"';
|
|
|
|
container.innerHTML =
|
|
'<div class="detail-section">' +
|
|
'<div class="detail-section-title">Machine Info</div>' +
|
|
'<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value" style="color: ' + statusColor + ';">' + statusText + '</span></div>' +
|
|
'<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' +
|
|
'<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' +
|
|
'<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' +
|
|
'<div class="detail-row"><span class="detail-label">Viewers</span><span class="detail-value">' + m.viewer_count + '</span></div>' +
|
|
'</div>' +
|
|
'<div class="detail-section">' +
|
|
'<div class="detail-section-title">Actions</div>' +
|
|
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')" ' + connectDisabled + ' ' + connectTitle + '>Connect</button>' +
|
|
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Chat</button>' +
|
|
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' +
|
|
'<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function connectToMachine(sessionId) {
|
|
// Open viewer in new window
|
|
const viewerUrl = "/viewer.html?session_id=" + sessionId;
|
|
window.open(viewerUrl, "viewer_" + sessionId, "width=1280,height=800,menubar=no,toolbar=no,location=no,status=no");
|
|
}
|
|
|
|
async function disconnectMachine(sessionId, machineName) {
|
|
if (!confirm("Disconnect " + machineName + "?\\n\\nThis will end the remote session.")) return;
|
|
try {
|
|
const response = await fetch("/api/sessions/" + sessionId, { method: "DELETE" });
|
|
if (response.ok) {
|
|
selectedMachine = null;
|
|
renderMachineDetail();
|
|
loadMachines();
|
|
} else {
|
|
alert("Failed to disconnect: " + await response.text());
|
|
}
|
|
} catch (err) {
|
|
alert("Error disconnecting machine");
|
|
}
|
|
}
|
|
|
|
// Refresh machines every 5 seconds
|
|
loadMachines();
|
|
setInterval(loadMachines, 5000);
|
|
|
|
// ========== Chat Functions ==========
|
|
|
|
// Chat modal elements
|
|
const chatModal = document.getElementById("chatModal");
|
|
const chatMessagesEl = document.getElementById("chatMessages");
|
|
const chatInput = document.getElementById("chatInput");
|
|
const chatSend = document.getElementById("chatSend");
|
|
const chatClose = document.getElementById("chatClose");
|
|
const chatClientName = document.getElementById("chatClientName");
|
|
|
|
// Close chat modal
|
|
chatClose.addEventListener("click", () => {
|
|
closeChat();
|
|
});
|
|
|
|
// Click outside to close
|
|
chatModal.addEventListener("click", (e) => {
|
|
if (e.target === chatModal) {
|
|
closeChat();
|
|
}
|
|
});
|
|
|
|
// Send message on button click
|
|
chatSend.addEventListener("click", () => {
|
|
sendChatMessage();
|
|
});
|
|
|
|
// Send message on Enter key
|
|
chatInput.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") {
|
|
sendChatMessage();
|
|
}
|
|
});
|
|
|
|
function openChat(sessionId, clientName) {
|
|
chatSessionId = sessionId;
|
|
chatMessages = [];
|
|
chatClientName.textContent = clientName || "Client";
|
|
renderChatMessages();
|
|
chatModal.classList.add("active");
|
|
chatInput.focus();
|
|
|
|
// Connect to viewer WebSocket
|
|
connectChatSocket(sessionId);
|
|
}
|
|
|
|
function closeChat() {
|
|
chatModal.classList.remove("active");
|
|
chatSessionId = null;
|
|
|
|
if (chatSocket) {
|
|
chatSocket.close();
|
|
chatSocket = null;
|
|
}
|
|
}
|
|
|
|
function connectChatSocket(sessionId) {
|
|
if (chatSocket) {
|
|
chatSocket.close();
|
|
}
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}`;
|
|
|
|
console.log("Connecting chat to:", wsUrl);
|
|
chatSocket = new WebSocket(wsUrl);
|
|
|
|
chatSocket.onopen = () => {
|
|
console.log("Chat WebSocket connected");
|
|
addSystemMessage("Connected to session");
|
|
};
|
|
|
|
chatSocket.onclose = () => {
|
|
console.log("Chat WebSocket closed");
|
|
addSystemMessage("Disconnected from session");
|
|
};
|
|
|
|
chatSocket.onerror = (err) => {
|
|
console.error("Chat WebSocket error:", err);
|
|
addSystemMessage("Connection error");
|
|
};
|
|
|
|
chatSocket.onmessage = async (event) => {
|
|
try {
|
|
// Messages are binary protobuf
|
|
const data = await event.data.arrayBuffer();
|
|
handleChatMessage(new Uint8Array(data));
|
|
} catch (err) {
|
|
console.error("Failed to process message:", err);
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleChatMessage(data) {
|
|
// Simple protobuf parsing for ChatMessage
|
|
// The Message wrapper has payload oneof, ChatMessage is field 60
|
|
// For now, we'll try to extract chat content from the binary data
|
|
// This is a simplified approach - full protobuf.js would be better
|
|
|
|
// Look for string content in the message
|
|
// ChatMessage structure: id(1), sender(2), content(3), timestamp(4)
|
|
try {
|
|
// Try to decode as text to find message content
|
|
const text = new TextDecoder().decode(data);
|
|
|
|
// Check if this contains chat data (look for "client" or "technician" sender)
|
|
if (text.includes("client") || text.includes("technician")) {
|
|
// Parse protobuf manually (simplified)
|
|
const parsed = parseSimpleProtobuf(data);
|
|
if (parsed && parsed.content) {
|
|
addReceivedMessage(parsed.sender || "Client", parsed.content);
|
|
}
|
|
}
|
|
// Other message types (video frames, etc.) are ignored for chat
|
|
} catch (err) {
|
|
// Not a chat message, ignore
|
|
}
|
|
}
|
|
|
|
// Simplified protobuf parser for ChatMessage
|
|
function parseSimpleProtobuf(data) {
|
|
// This is a very basic parser - just extracts string fields
|
|
// For production, use protobuf.js library
|
|
let result = {};
|
|
let i = 0;
|
|
|
|
while (i < data.length) {
|
|
const tag = data[i];
|
|
const fieldNum = tag >> 3;
|
|
const wireType = tag & 0x07;
|
|
|
|
i++;
|
|
|
|
if (wireType === 2) { // Length-delimited (string)
|
|
let len = 0;
|
|
let shift = 0;
|
|
while (i < data.length && data[i] & 0x80) {
|
|
len |= (data[i] & 0x7f) << shift;
|
|
shift += 7;
|
|
i++;
|
|
}
|
|
if (i < data.length) {
|
|
len |= data[i] << shift;
|
|
i++;
|
|
}
|
|
|
|
if (i + len <= data.length) {
|
|
const strBytes = data.slice(i, i + len);
|
|
const str = new TextDecoder().decode(strBytes);
|
|
i += len;
|
|
|
|
// Map field numbers to ChatMessage fields
|
|
// Within the ChatMessage (nested in Message.payload):
|
|
// 1=id, 2=sender, 3=content
|
|
if (fieldNum === 2) result.sender = str;
|
|
else if (fieldNum === 3) result.content = str;
|
|
else if (str.length < 50) result.id = str; // id is short
|
|
}
|
|
} else if (wireType === 0) { // Varint
|
|
while (i < data.length && data[i] & 0x80) i++;
|
|
i++;
|
|
} else {
|
|
// Unknown wire type, skip
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result.content ? result : null;
|
|
}
|
|
|
|
function sendChatMessage() {
|
|
const content = chatInput.value.trim();
|
|
if (!content || !chatSocket || chatSocket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
// Create ChatMessage protobuf
|
|
const chatMsg = encodeChatMessage({
|
|
id: generateId(),
|
|
sender: "technician",
|
|
content: content,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
chatSocket.send(chatMsg);
|
|
addSentMessage(content);
|
|
chatInput.value = "";
|
|
}
|
|
|
|
// Simple protobuf encoder for ChatMessage wrapped in Message
|
|
function encodeChatMessage(msg) {
|
|
const encoder = new TextEncoder();
|
|
|
|
// Encode ChatMessage fields
|
|
const id = encoder.encode(msg.id);
|
|
const sender = encoder.encode(msg.sender);
|
|
const content = encoder.encode(msg.content);
|
|
|
|
// ChatMessage: id(1), sender(2), content(3), timestamp(4)
|
|
const chatMsgBytes = [];
|
|
|
|
// Field 1: id (string)
|
|
chatMsgBytes.push(0x0a); // field 1, wire type 2
|
|
chatMsgBytes.push(id.length);
|
|
chatMsgBytes.push(...id);
|
|
|
|
// Field 2: sender (string)
|
|
chatMsgBytes.push(0x12); // field 2, wire type 2
|
|
chatMsgBytes.push(sender.length);
|
|
chatMsgBytes.push(...sender);
|
|
|
|
// Field 3: content (string)
|
|
chatMsgBytes.push(0x1a); // field 3, wire type 2
|
|
chatMsgBytes.push(content.length);
|
|
chatMsgBytes.push(...content);
|
|
|
|
// Field 4: timestamp (int64) - simplified varint encoding
|
|
chatMsgBytes.push(0x20); // field 4, wire type 0
|
|
let ts = msg.timestamp;
|
|
while (ts > 0x7f) {
|
|
chatMsgBytes.push((ts & 0x7f) | 0x80);
|
|
ts >>>= 7;
|
|
}
|
|
chatMsgBytes.push(ts);
|
|
|
|
// Wrap in Message with payload field 60 (ChatMessage)
|
|
const wrapper = [];
|
|
const fieldTag = (60 << 3) | 2; // field 60, wire type 2
|
|
|
|
// Encode tag as varint (60 = 0x3c, which fits in 2 bytes as varint)
|
|
wrapper.push((fieldTag & 0x7f) | 0x80);
|
|
wrapper.push(fieldTag >> 7);
|
|
|
|
// Length of ChatMessage
|
|
wrapper.push(chatMsgBytes.length);
|
|
|
|
// ChatMessage bytes
|
|
wrapper.push(...chatMsgBytes);
|
|
|
|
return new Uint8Array(wrapper);
|
|
}
|
|
|
|
function generateId() {
|
|
return 'msg_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
function addSentMessage(content) {
|
|
chatMessages.push({
|
|
sender: "technician",
|
|
content: content,
|
|
timestamp: Date.now()
|
|
});
|
|
renderChatMessages();
|
|
}
|
|
|
|
function addReceivedMessage(sender, content) {
|
|
chatMessages.push({
|
|
sender: sender,
|
|
content: content,
|
|
timestamp: Date.now()
|
|
});
|
|
renderChatMessages();
|
|
}
|
|
|
|
function addSystemMessage(content) {
|
|
chatMessages.push({
|
|
sender: "system",
|
|
content: content,
|
|
timestamp: Date.now()
|
|
});
|
|
renderChatMessages();
|
|
}
|
|
|
|
function renderChatMessages() {
|
|
if (chatMessages.length === 0) {
|
|
chatMessagesEl.innerHTML = '<div class="chat-empty">No messages yet. Start the conversation!</div>';
|
|
return;
|
|
}
|
|
|
|
chatMessagesEl.innerHTML = chatMessages.map(msg => {
|
|
if (msg.sender === "system") {
|
|
return '<div style="text-align: center; padding: 8px; color: hsl(var(--muted-foreground)); font-size: 12px;">' + escapeHtml(msg.content) + '</div>';
|
|
}
|
|
const msgClass = msg.sender === "technician" ? "technician" : "client";
|
|
const senderName = msg.sender === "technician" ? "You" : msg.sender;
|
|
return '<div class="chat-message ' + msgClass + '">' +
|
|
'<div class="chat-message-sender">' + escapeHtml(senderName) + '</div>' +
|
|
'<div class="chat-message-content">' + escapeHtml(msg.content) + '</div>' +
|
|
'</div>';
|
|
}).join("");
|
|
|
|
// Scroll to bottom
|
|
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|