456 lines
23 KiB
JavaScript
456 lines
23 KiB
JavaScript
// Remote Panel Logic
|
|
let remoteDataLoaded = false;
|
|
|
|
function toggleRemotePanel() {
|
|
const panel = document.getElementById('remotePanel');
|
|
const backdrop = document.getElementById('remoteBackdrop');
|
|
|
|
if (panel.classList.contains('translate-x-full')) {
|
|
// Open
|
|
panel.classList.remove('translate-x-full');
|
|
backdrop.classList.remove('hidden');
|
|
setTimeout(() => backdrop.classList.remove('opacity-0'), 10);
|
|
|
|
if (!remoteDataLoaded) {
|
|
loadRemoteList();
|
|
}
|
|
} else {
|
|
// Close
|
|
panel.classList.add('translate-x-full');
|
|
backdrop.classList.add('opacity-0');
|
|
setTimeout(() => backdrop.classList.add('hidden'), 300);
|
|
}
|
|
}
|
|
|
|
function loadRemoteList() {
|
|
const container = document.getElementById('remoteListContainer');
|
|
container.innerHTML = '<div class="text-center text-slate-400 mt-10"><i class="fas fa-spinner fa-spin text-2xl mb-2"></i><br>Loading remote IDs...</div>';
|
|
|
|
fetch('<?= $baseUrl ?>/api/agents/remote-list')
|
|
.then(res => res.text())
|
|
.then(text => {
|
|
let res;
|
|
try {
|
|
res = JSON.parse(text);
|
|
} catch (e) {
|
|
console.error("JSON Parse Error:", e, "Response Text:", text);
|
|
container.innerHTML = '<div class="text-center text-rose-500 mt-10 text-xs text-left p-4 overflow-auto"><i class="fas fa-bug mb-2"></i><br>Parse error. Server returned:<br><code>' + text.substring(0, 200).replace(/</g, "<") + '</code></div>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (res.status === 'success') {
|
|
remoteDataLoaded = true;
|
|
renderRemoteList(res.data);
|
|
} else {
|
|
container.innerHTML = '<div class="text-center text-rose-500 mt-10"><i class="fas fa-exclamation-triangle text-2xl mb-2"></i><br>' + (res.message || 'Failed to load data.') + '</div>';
|
|
}
|
|
} catch (e) {
|
|
console.error("Render Error:", e);
|
|
container.innerHTML = '<div class="text-center text-rose-500 mt-10"><i class="fas fa-bug mb-2"></i><br>UI Error: ' + e.message + '</div>';
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Fetch Error:", err);
|
|
container.innerHTML = '<div class="text-center text-rose-500 mt-10"><i class="fas fa-exclamation-triangle text-2xl mb-2"></i><br>Connection error: ' + err.message + '</div>';
|
|
});
|
|
}
|
|
|
|
function toggleRemoteGroup(listId, iconId) {
|
|
const list = document.getElementById(listId);
|
|
const icon = document.getElementById(iconId);
|
|
if (list.classList.contains('hidden')) {
|
|
list.classList.remove('hidden');
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-up');
|
|
} else {
|
|
list.classList.add('hidden');
|
|
icon.classList.remove('fa-chevron-up');
|
|
icon.classList.add('fa-chevron-down');
|
|
}
|
|
}
|
|
|
|
function renderRemoteList(data) {
|
|
const container = document.getElementById('remoteListContainer');
|
|
container.innerHTML = '';
|
|
|
|
if (data.length === 0) {
|
|
container.innerHTML = '<div class="text-center text-slate-400 mt-10">No agents found.</div>';
|
|
return;
|
|
}
|
|
|
|
// Sort alphabetically by name
|
|
data.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const onlineAgents = data.filter(a => a.status === 'active');
|
|
const offlineAgents = data.filter(a => a.status !== 'active');
|
|
|
|
// Build HTML for Online Group (Expanded by default)
|
|
const onlineHtml = `
|
|
<div class="group-online border-b border-slate-200">
|
|
<button onclick="toggleRemoteGroup('online-list', 'online-icon')" class="w-full flex justify-between items-center bg-slate-50 p-3 text-xs font-bold text-slate-700 hover:bg-slate-100 transition-colors">
|
|
<div class="flex items-center">
|
|
<span class="w-2 h-2 rounded-full bg-emerald-500 mr-2 shadow-[0_0_5px_rgba(16,185,129,0.5)]"></span>
|
|
ออนไลน์ (<span id="online-count">${onlineAgents.length}</span>)
|
|
</div>
|
|
<i id="online-icon" class="fas fa-chevron-up text-slate-400 transition-transform text-[10px]"></i>
|
|
</button>
|
|
<div id="online-list" class="transition-all duration-300">
|
|
${onlineAgents.map(ag => createRemoteItemHtml(ag, true)).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Build HTML for Offline Group (Collapsed by default)
|
|
const offlineHtml = `
|
|
<div class="group-offline border-b border-slate-200">
|
|
<button onclick="toggleRemoteGroup('offline-list', 'offline-icon')" class="w-full flex justify-between items-center bg-slate-50 p-3 text-xs font-bold text-slate-700 hover:bg-slate-100 transition-colors">
|
|
<div class="flex items-center">
|
|
<span class="w-2 h-2 rounded-full bg-slate-400 mr-2"></span>
|
|
ออฟไลน์ (<span id="offline-count">${offlineAgents.length}</span>)
|
|
</div>
|
|
<i id="offline-icon" class="fas fa-chevron-down text-slate-400 transition-transform text-[10px]"></i>
|
|
</button>
|
|
<div id="offline-list" class="transition-all duration-300 hidden">
|
|
${offlineAgents.map(ag => createRemoteItemHtml(ag, false)).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = onlineHtml + offlineHtml;
|
|
filterRemoteList();
|
|
}
|
|
|
|
function createRemoteItemHtml(ag, isOnline) {
|
|
const dotColor = isOnline ? 'bg-emerald-500 shadow-[0_0_5px_rgba(16,185,129,0.5)]' : 'bg-slate-300';
|
|
|
|
let displayId = ag.remote_id ? ag.remote_id.toString() : '-';
|
|
|
|
let programIcon = '<i class="fas fa-desktop text-slate-300"></i>';
|
|
if (ag.remote_type === 'teamviewer') {
|
|
programIcon = '<i class="fas fa-network-wired text-blue-500" title="TeamViewer"></i>';
|
|
} else if (ag.remote_type === 'anydesk') {
|
|
programIcon = '<i class="fas fa-network-wired text-rose-500" title="AnyDesk"></i>';
|
|
}
|
|
|
|
return `
|
|
<div class="remote-item flex items-center justify-between px-3 py-2 border-b border-slate-50 hover:bg-slate-50 cursor-default bg-white transition-colors">
|
|
<div class="flex items-center space-x-2 w-5/12">
|
|
<div class="relative flex-shrink-0 text-center w-5">
|
|
${programIcon}
|
|
</div>
|
|
<span class="text-slate-700 text-xs font-medium truncate name-field" title="${ag.name}">${ag.name}</span>
|
|
</div>
|
|
<div class="flex items-center justify-end space-x-3 w-7/12">
|
|
<span class="font-mono text-slate-600 text-xs id-field whitespace-nowrap">${displayId}</span>
|
|
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0 ${dotColor}" title="${isOnline ? 'ออนไลน์' : 'ออฟไลน์'}"></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
function filterRemoteList() {
|
|
const input = document.getElementById('remoteSearch').value.toLowerCase();
|
|
const hideNoRemote = document.getElementById('hideNoRemote').checked;
|
|
const items = document.querySelectorAll('.remote-item');
|
|
let onlineVisible = 0;
|
|
let offlineVisible = 0;
|
|
|
|
items.forEach(item => {
|
|
const name = item.querySelector('.name-field').innerText.toLowerCase();
|
|
const id = item.querySelector('.id-field').innerText.trim().toLowerCase();
|
|
const isOnline = item.parentElement.id === 'online-list';
|
|
|
|
const hasNoId = (id === '-' || id === '');
|
|
const matchSearch = name.includes(input) || id.includes(input) || input.replace(/\s/g, '') === id.replace(/\s/g, '');
|
|
|
|
if (matchSearch) {
|
|
if (hideNoRemote && hasNoId) {
|
|
item.style.display = 'none';
|
|
} else {
|
|
item.style.display = 'flex';
|
|
if (isOnline) onlineVisible++; else offlineVisible++;
|
|
}
|
|
} else {
|
|
item.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
document.getElementById('online-count').innerText = onlineVisible;
|
|
document.getElementById('offline-count').innerText = offlineVisible;
|
|
}
|
|
|
|
$(document).ready(function() {
|
|
$('#agentsTable').DataTable({
|
|
pageLength: 10,
|
|
language: {
|
|
search: "_INPUT_",
|
|
searchPlaceholder: "Search agents..."
|
|
},
|
|
dom: '<"flex flex-col md:flex-row justify-between items-center mb-4"lf>rt<"flex flex-col md:flex-row justify-between items-center mt-4"ip>'
|
|
});
|
|
});
|
|
|
|
window.viewSpecs = function(agentId, agentName) {
|
|
Swal.fire({
|
|
title: '<?= __('Hardware Specs') ?> - ' + agentName,
|
|
html: '<div class="text-center py-4"><i class="fas fa-spinner fa-spin text-3xl text-slate-300"></i></div>',
|
|
showConfirmButton: false,
|
|
showCloseButton: true,
|
|
width: '800px',
|
|
customClass: { popup: 'rounded-xl shadow-2xl' }
|
|
});
|
|
|
|
$.ajax({
|
|
url: `<?= $baseUrl ?>/api/agents/${agentId}/details`,
|
|
method: 'GET',
|
|
success: function(res) {
|
|
if (res.status === 'success') {
|
|
const tmpl = document.getElementById('specsTemplate').innerHTML;
|
|
Swal.update({ html: tmpl });
|
|
|
|
const modal = Swal.getHtmlContainer();
|
|
const hw = (res.hardware && res.hardware.length > 0) ? res.hardware[0] : {};
|
|
|
|
// CPU
|
|
if (hw.cpu && hw.cpu.name) {
|
|
const cores = hw.cpu.cores ? ` (${hw.cpu.cores} Cores)` : '';
|
|
modal.querySelector('#spec-cpu').innerText = hw.cpu.name + cores;
|
|
} else {
|
|
modal.querySelector('#spec-cpu').innerText = '-';
|
|
}
|
|
|
|
// RAM
|
|
if (hw.ram && hw.ram.total) {
|
|
const totalMB = hw.ram.total / 1024;
|
|
const gb = (totalMB / 1024).toFixed(2);
|
|
|
|
let usageHtml = `${gb} GB`;
|
|
if (hw.ram.free !== undefined) {
|
|
const freeMB = hw.ram.free / 1024;
|
|
const usageMB = Math.max(0, totalMB - freeMB);
|
|
const pct = Math.round((usageMB / totalMB) * 100);
|
|
const alertThresh = <?= $_ENV['ALERT_RAM_PERCENT'] ?? 85 ?>;
|
|
const colorClass = pct >= alertThresh ? 'text-rose-600 font-bold' : (pct >= alertThresh - 15 ? 'text-orange-500' : 'text-emerald-600');
|
|
usageHtml = `${gb} GB <span class="ml-2 text-xs px-2 py-0.5 bg-slate-100 rounded-full ${colorClass}">${pct}% Used</span>`;
|
|
}
|
|
modal.querySelector('#spec-ram').innerHTML = usageHtml;
|
|
} else {
|
|
modal.querySelector('#spec-ram').innerText = '-';
|
|
}
|
|
|
|
// Disk
|
|
const diskContainer = modal.querySelector('#spec-disk');
|
|
let diskTotal = 0, diskFree = 0, diskUsed = 0;
|
|
|
|
if (res.disk_info) {
|
|
let diskData = res.disk_info;
|
|
if (typeof diskData === 'string') {
|
|
try { diskData = JSON.parse(diskData); } catch (e) {}
|
|
}
|
|
if (diskData && !Array.isArray(diskData) && typeof diskData === 'object') {
|
|
diskData = [diskData];
|
|
}
|
|
if (diskData && Array.isArray(diskData)) {
|
|
diskData = diskData.filter(disk => {
|
|
let d = String(disk.DriveLetter || disk.Drive || disk.DeviceID || disk.Name || '');
|
|
return d.toUpperCase().includes('C');
|
|
});
|
|
|
|
if (diskData.length > 0) {
|
|
let dInfo = diskData[0];
|
|
diskTotal = parseFloat(dInfo.Total_GB || dInfo.TotalSpace || dInfo.SizeGB || (dInfo.Size ? dInfo.Size / 1073741824 : 0));
|
|
diskFree = parseFloat(dInfo.Free_GB || dInfo.FreeGB || (dInfo.FreeSpace ? dInfo.FreeSpace / 1073741824 : 0));
|
|
|
|
// If parsed values are extraordinarily large (e.g., FreeSpace was just a number in bytes but not named FreeSpace)
|
|
// or if TotalSpace was in bytes
|
|
if (dInfo.TotalSpace && dInfo.TotalSpace > 100000) diskTotal = diskTotal / 1073741824;
|
|
if (dInfo.FreeSpace && dInfo.FreeSpace > 100000) diskFree = diskFree / 1073741824;
|
|
|
|
diskUsed = parseFloat(dInfo.Used_GB || (diskTotal - diskFree));
|
|
diskUsed = Math.max(0, diskUsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (diskTotal > 0) {
|
|
const pct = Math.round((diskUsed / diskTotal) * 100);
|
|
const alertThresh = <?= $_ENV['ALERT_DISK_PERCENT'] ?? 85 ?>;
|
|
let colorClass = 'bg-emerald-500';
|
|
if (pct >= alertThresh) colorClass = 'bg-rose-500';
|
|
else if (pct >= alertThresh - 15) colorClass = 'bg-orange-500';
|
|
|
|
diskContainer.innerHTML = `
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="font-bold text-slate-700">Capacity: ${diskTotal.toFixed(2)} GB</span>
|
|
<span class="text-slate-500">${diskUsed.toFixed(2)} GB Used (${diskFree.toFixed(2)} GB Free)</span>
|
|
</div>
|
|
<div class="w-full bg-slate-200 rounded-full h-2.5">
|
|
<div class="${colorClass} h-2.5 rounded-full transition-all duration-500" style="width: ${pct}%"></div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
diskContainer.innerHTML = '<div class="text-slate-400 text-xs italic">No disk information available</div>';
|
|
}
|
|
|
|
// Remote Access
|
|
const remoteContainer = modal.querySelector('#spec-remote');
|
|
remoteContainer.innerHTML = '';
|
|
|
|
let tvInfo = null;
|
|
if (res.tv_info) {
|
|
let t = Array.isArray(res.tv_info) ? res.tv_info[0] : res.tv_info;
|
|
tvInfo = (t && t.TeamViewerID && t.TeamViewerID !== 'Not Found') ? t.TeamViewerID : null;
|
|
}
|
|
|
|
let adInfo = null;
|
|
if (res.ad_info) {
|
|
let a = Array.isArray(res.ad_info) ? res.ad_info[0] : res.ad_info;
|
|
adInfo = (a && a.AnyDeskID && a.AnyDeskID !== 'Not Found') ? a.AnyDeskID : null;
|
|
}
|
|
|
|
if (tvInfo || adInfo) {
|
|
if (tvInfo) {
|
|
let tvId = tvInfo.toString();
|
|
if (tvId.match(/^\d{9,10}$/)) {
|
|
tvId = tvId.replace(/(\d{1,2})(\d{3})(\d{3})(\d{3})?/, "$1 $2 $3 $4").trim();
|
|
}
|
|
remoteContainer.innerHTML += `
|
|
<div class="bg-blue-50 p-3 rounded-lg border border-blue-100 flex items-center justify-between">
|
|
<div class="flex items-center text-blue-700 font-bold text-xs"><i class="fas fa-desktop mr-2"></i> TeamViewer</div>
|
|
<div class="font-mono text-sm text-slate-800 bg-white px-2 py-1 rounded shadow-sm">${tvId}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
if (adInfo) {
|
|
let adId = adInfo.toString();
|
|
if (adId.match(/^\d{9,10}$/)) {
|
|
adId = adId.replace(/(\d{3})(\d{3})(\d{3})?/, "$1 $2 $3").trim();
|
|
}
|
|
remoteContainer.innerHTML += `
|
|
<div class="bg-rose-50 p-3 rounded-lg border border-rose-100 flex items-center justify-between">
|
|
<div class="flex items-center text-rose-700 font-bold text-xs"><i class="fas fa-desktop mr-2"></i> AnyDesk</div>
|
|
<div class="font-mono text-sm text-slate-800 bg-white px-2 py-1 rounded shadow-sm">${adId}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} else {
|
|
remoteContainer.innerHTML = '<div class="col-span-2 text-slate-400 text-xs italic">No remote access ID found</div>';
|
|
}
|
|
|
|
// Network
|
|
const netContainer = modal.querySelector('#spec-network');
|
|
netContainer.innerHTML = '';
|
|
|
|
let networkArray = [];
|
|
if (res.netiface && Array.isArray(res.netiface)) {
|
|
res.netiface.forEach(iface => {
|
|
let ips = [];
|
|
if (res.netaddr && Array.isArray(res.netaddr)) {
|
|
res.netaddr.forEach(addr => {
|
|
if (addr.iface === iface.name && addr.address) {
|
|
ips.push(addr.address);
|
|
}
|
|
});
|
|
}
|
|
networkArray.push({
|
|
name: iface.name,
|
|
mac: iface.mac,
|
|
state: iface.state,
|
|
ips: ips
|
|
});
|
|
});
|
|
}
|
|
|
|
if (networkArray.length > 0) {
|
|
let validIfaces = networkArray.filter(n => n.mac && n.mac !== '00:00:00:00:00:00' && n.ips && n.ips.length > 0);
|
|
|
|
validIfaces.forEach(iface => {
|
|
let stateText = iface.state ? iface.state.toLowerCase() : 'unknown';
|
|
if (stateText === 'unknown') stateText = (iface.ips.length > 0 ? 'up' : 'down');
|
|
iface._stateText = stateText;
|
|
|
|
let isExtra = false;
|
|
let n = iface.name.toLowerCase();
|
|
const extraWords = ['virtual', 'vmware', 'vbox', 'vethernet', 'bluetooth', 'pseudo', 'vpn', 'loopback', 'docker', 'br-', 'tailscale', 'zerotier', 'hamachi', 'wg', 'local area connection'];
|
|
for (let word of extraWords) {
|
|
if (n.includes(word)) { isExtra = true; break; }
|
|
}
|
|
iface._isExtra = isExtra;
|
|
});
|
|
|
|
validIfaces.sort((a, b) => {
|
|
if (a._stateText === 'up' && b._stateText !== 'up') return -1;
|
|
if (a._stateText !== 'up' && b._stateText === 'up') return 1;
|
|
if (!a._isExtra && b._isExtra) return -1;
|
|
if (a._isExtra && !b._isExtra) return 1;
|
|
return 0;
|
|
});
|
|
|
|
const toggleCb = document.getElementById('toggle-extra-net');
|
|
if (toggleCb) toggleCb.checked = false;
|
|
|
|
if (validIfaces.length > 0) {
|
|
validIfaces.forEach(iface => {
|
|
let stateBadge = '';
|
|
if (iface._stateText === 'up') {
|
|
stateBadge = '<span class="flex items-center text-[9px] font-bold text-emerald-600 bg-emerald-100 px-1.5 py-0.5 rounded uppercase"><span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1 animate-pulse"></span>Active</span>';
|
|
} else {
|
|
stateBadge = '<span class="flex items-center text-[9px] font-bold text-rose-600 bg-rose-100 px-1.5 py-0.5 rounded uppercase"><span class="w-1.5 h-1.5 rounded-full bg-rose-500 mr-1"></span>Offline</span>';
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.className = `bg-slate-50 p-2 rounded border border-slate-200 ${iface._isExtra ? 'iface-extra hidden' : ''}`;
|
|
div.innerHTML = `
|
|
<div class="flex justify-between items-center mb-2 pb-1 border-b border-slate-100">
|
|
<div class="font-bold text-slate-800 text-xs uppercase" title="${iface.name}">${iface.name.length > 20 ? iface.name.substring(0,20)+'...' : iface.name}</div>
|
|
${stateBadge}
|
|
</div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500">IP:</span>
|
|
<span class="font-mono text-blue-600 font-medium">${iface.ips.join(', ')}</span>
|
|
</div>
|
|
<div class="flex justify-between text-xs">
|
|
<span class="text-slate-500"><?= __('MAC Address') ?>:</span>
|
|
<span class="font-mono text-slate-700">${iface.mac || 'N/A'}</span>
|
|
</div>
|
|
`;
|
|
netContainer.appendChild(div);
|
|
});
|
|
|
|
let visibleCount = validIfaces.filter(i => !i._isExtra).length;
|
|
if (visibleCount === 0) {
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'text-slate-400 text-center py-2 col-span-full iface-placeholder';
|
|
placeholder.innerHTML = 'No primary interfaces found. Click "Show All" to view others.';
|
|
netContainer.appendChild(placeholder);
|
|
if (toggleCb) {
|
|
toggleCb.addEventListener('change', function() {
|
|
placeholder.style.display = this.checked ? 'none' : 'block';
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
netContainer.innerHTML = '<div class="text-slate-400 text-center py-2 col-span-full">No active LAN/WiFi interfaces found</div>';
|
|
}
|
|
} else {
|
|
netContainer.innerHTML = '<div class="text-slate-400 text-center py-2 col-span-full">No network data available</div>';
|
|
} } else {
|
|
Swal.update({
|
|
icon: 'error',
|
|
title: 'Error',
|
|
html: res.message || '<?= __('Failed to load specifications.') ?>',
|
|
showConfirmButton: true
|
|
});
|
|
}
|
|
},
|
|
error: function() {
|
|
Swal.update({
|
|
icon: 'error',
|
|
title: 'Error',
|
|
html: '<?= __('Failed to connect to the server.') ?>',
|
|
showConfirmButton: true
|
|
});
|
|
}
|
|
});
|
|
};
|