1315 lines
77 KiB
PHP
1315 lines
77 KiB
PHP
<?php $pageTitle = __('Agents Overview'); ob_start(); ?>
|
|
|
|
<!-- Stats Section -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-5 mb-8">
|
|
<div class="corp-card p-5 border-t-4 border-t-slate-800 flex flex-col justify-center">
|
|
<div class="flex justify-between items-center mb-1">
|
|
<h3 class="text-slate-500 text-xs font-bold uppercase tracking-wide"><?= __('Total Agents') ?></h3>
|
|
<i class="fas fa-server text-slate-600 text-lg"></i>
|
|
</div>
|
|
<p class="text-3xl font-semibold text-slate-800 transition-all duration-300"><?= number_format($stats['total']) ?></p>
|
|
</div>
|
|
|
|
<div class="corp-card p-5 border-t-4 border-t-emerald-500 flex flex-col justify-center">
|
|
<div class="flex justify-between items-center mb-1">
|
|
<h3 class="text-slate-500 text-xs font-bold uppercase tracking-wide"><?= __('Active (Online)') ?></h3>
|
|
<i class="fas fa-check-circle text-emerald-200 text-lg"></i>
|
|
</div>
|
|
<p class="text-3xl font-semibold text-emerald-600 transition-all duration-300"><?= number_format($stats['active']) ?></p>
|
|
</div>
|
|
|
|
<div class="corp-card p-5 border-t-4 border-t-rose-500 flex flex-col justify-center">
|
|
<div class="flex justify-between items-center mb-1">
|
|
<h3 class="text-slate-500 text-xs font-bold uppercase tracking-wide"><?= __('Disconnected (Offline)') ?></h3>
|
|
<i class="fas fa-times-circle text-rose-200 text-lg"></i>
|
|
</div>
|
|
<p class="text-3xl font-semibold text-rose-600 transition-all duration-300"><?= number_format($stats['disconnected']) ?></p>
|
|
</div>
|
|
|
|
<div class="corp-card p-5 border-t-4 border-t-slate-300 flex flex-col justify-center">
|
|
<div class="flex justify-between items-center mb-1">
|
|
<h3 class="text-slate-500 text-xs font-bold uppercase tracking-wide"><?= __('Never Connected') ?></h3>
|
|
<i class="fas fa-question-circle text-slate-800 text-lg"></i>
|
|
</div>
|
|
<p class="text-3xl font-semibold text-slate-500 transition-all duration-300"><?= number_format($stats['never_connected']) ?></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resource Warnings Section -->
|
|
<div class="corp-card overflow-hidden mb-8">
|
|
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
|
|
<div>
|
|
<h3 class="font-bold text-slate-800 flex items-center">
|
|
<i class="fas fa-exclamation-triangle text-amber-500 mr-2"></i> <?= __('Resource Warnings') ?>
|
|
</h3>
|
|
<p class="text-xs text-slate-500 mt-1"><?= __('Disk/RAM warnings from active alerts') ?></p>
|
|
</div>
|
|
</div>
|
|
<div class="p-4 bg-slate-50 border-b border-slate-100">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="bg-white border border-slate-200 rounded-lg p-4 flex items-center shadow-sm cursor-pointer hover:shadow-md hover:border-rose-200 transition-all" onclick="showResourceDetails('ram')">
|
|
<div class="rounded-full bg-rose-100 p-3 mr-4">
|
|
<i class="fas fa-memory text-rose-500 text-xl"></i>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">RAM Usage > <?= $_ENV['ALERT_RAM_PERCENT'] ?? 85 ?>%</div>
|
|
<div class="text-2xl font-bold text-slate-800" id="stat-ram-count"><i class="fas fa-circle-notch fa-spin text-slate-600 text-lg"></i></div>
|
|
</div>
|
|
<div class="ml-auto text-slate-600"><i class="fas fa-chevron-right"></i></div>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-lg p-4 flex items-center shadow-sm cursor-pointer hover:shadow-md hover:border-amber-200 transition-all" onclick="showResourceDetails('disk')">
|
|
<div class="rounded-full bg-amber-100 p-3 mr-4">
|
|
<i class="fas fa-hdd text-amber-500 text-xl"></i>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">Disk Usage > <?= $_ENV['ALERT_DISK_PERCENT'] ?? 85 ?>%</div>
|
|
<div class="text-2xl font-bold text-slate-800" id="stat-disk-count"><i class="fas fa-circle-notch fa-spin text-slate-600 text-lg"></i></div>
|
|
</div>
|
|
<div class="ml-auto text-slate-600"><i class="fas fa-chevron-right"></i></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-4">
|
|
<h4 class="font-bold text-slate-700 mb-3 text-sm"><i class="fas fa-history mr-1"></i> <?= __('Recent Resource Alerts (Last 24h)') ?></h4>
|
|
<?php if (empty($resourceAlerts)): ?>
|
|
<div class="text-center py-6 text-slate-500">
|
|
<i class="fas fa-check-circle text-emerald-400 text-3xl mb-3"></i>
|
|
<p><?= __('No resource warnings detected.') ?></p>
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="space-y-3">
|
|
<?php foreach ($resourceAlerts as $alert): ?>
|
|
<div class="p-3 bg-amber-50 border border-amber-200 rounded-md flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
|
<div>
|
|
<span class="text-xs font-bold text-amber-800 bg-amber-200 px-2 py-0.5 rounded mr-2">Rule <?= htmlspecialchars($alert['rule_id']) ?></span>
|
|
<span class="font-semibold text-slate-800 text-sm"><?= htmlspecialchars($alert['agent_name']) ?></span>
|
|
<p class="text-sm text-slate-600 mt-1"><?= htmlspecialchars($alert['description']) ?></p>
|
|
</div>
|
|
<span class="text-xs text-slate-500 whitespace-nowrap"><i class="far fa-clock mr-1"></i> <?= date('d M Y H:i', strtotime($alert['created_at'])) ?></span>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Agents Table -->
|
|
<div class="corp-card overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50">
|
|
<h3 class="font-bold text-slate-800 flex items-center">
|
|
<i class="fas fa-list text-slate-400 mr-2"></i> <?= __('Agents (Devices)') ?>
|
|
</h3>
|
|
</div>
|
|
|
|
<div class="p-0">
|
|
<div class="overflow-x-auto">
|
|
<table id="agentsTable" class="w-full text-left text-sm whitespace-nowrap">
|
|
<thead class="bg-slate-50 text-slate-500 text-xs uppercase">
|
|
<tr>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('Status') ?></th>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('ID') ?></th>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('Agent Name') ?></th>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('IP Address') ?></th>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('OS') ?></th>
|
|
<th class="px-6 py-4 font-semibold text-center"><?= __('Action') ?></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100 bg-white">
|
|
<?php foreach ($agents as $agent): ?>
|
|
<?php
|
|
$status = strtolower($agent['status'] ?? '');
|
|
$statusClass = 'bg-slate-100 text-slate-600';
|
|
$statusIcon = 'fa-question-circle';
|
|
$sortOrder = 9;
|
|
if ($status === 'active') {
|
|
$statusClass = 'bg-emerald-100 text-emerald-700';
|
|
$statusIcon = 'fa-check-circle';
|
|
$sortOrder = 5;
|
|
} elseif ($status === 'disconnected') {
|
|
$statusClass = 'bg-rose-100 text-rose-700';
|
|
$statusIcon = 'fa-times-circle';
|
|
$sortOrder = 6;
|
|
}
|
|
|
|
$osName = $agent['os']['name'] ?? 'Unknown';
|
|
$osVersion = $agent['os']['version'] ?? '';
|
|
$isWindows = (stripos($osName, 'windows') !== false);
|
|
?>
|
|
<tr id="row-agent-<?= htmlspecialchars($agent['id']) ?>" class="hover:bg-slate-50 transition-colors">
|
|
<td class="px-6 py-4" id="status-col-<?= htmlspecialchars($agent['id']) ?>">
|
|
<div class="flex justify-center items-center space-x-2">
|
|
<span class="hidden sort-key"><?= $sortOrder ?></span>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <?= $statusClass ?>">
|
|
<i class="fas <?= $statusIcon ?> mr-1.5"></i> <?= ucfirst($status) ?>
|
|
</span>
|
|
<!-- Warning icons will be injected here via JS -->
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 font-mono text-xs text-slate-500 text-center"><?= htmlspecialchars($agent['id']) ?></td>
|
|
<td class="px-6 py-4 font-semibold text-slate-700 text-center"><?= htmlspecialchars($agent['name']) ?></td>
|
|
<?php
|
|
$agentIp = $agent['ip'] ?? 'N/A';
|
|
if ($agentIp !== 'N/A' && strpos($agentIp, ',') !== false) {
|
|
$ipParts = explode(',', $agentIp);
|
|
$ipv4Parts = array_filter($ipParts, function($ip) {
|
|
return strpos($ip, ':') === false;
|
|
});
|
|
$agentIp = count($ipv4Parts) > 0 ? implode(', ', $ipv4Parts) : $agentIp;
|
|
} elseif (strpos($agentIp, ':') !== false && filter_var($agentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
// If it's only an IPv6 address, we keep it or show N/A based on preference. Let's just keep it if no IPv4.
|
|
}
|
|
?>
|
|
<td class="px-6 py-4 font-mono text-sm text-blue-600 text-center"><?= htmlspecialchars($agentIp) ?></td>
|
|
<td class="px-6 py-4 text-slate-600 text-center">
|
|
<?php if ($isWindows): ?>
|
|
<i class="fab fa-windows text-blue-500 mr-1 w-4 text-center"></i>
|
|
<?php elseif (stripos($osName, 'ubuntu') !== false || stripos($osName, 'debian') !== false): ?>
|
|
<i class="fab fa-ubuntu text-orange-500 mr-1 w-4 text-center"></i>
|
|
<?php elseif (stripos($osName, 'centos') !== false || stripos($osName, 'redhat') !== false): ?>
|
|
<i class="fab fa-redhat text-red-500 mr-1 w-4 text-center"></i>
|
|
<?php elseif (stripos($osName, 'mac') !== false): ?>
|
|
<i class="fab fa-apple text-slate-800 mr-1 w-4 text-center"></i>
|
|
<?php else: ?>
|
|
<i class="fab fa-linux text-slate-800 mr-1 w-4 text-center"></i>
|
|
<?php endif; ?>
|
|
<?= htmlspecialchars($osName . ' ' . $osVersion) ?>
|
|
</td>
|
|
<td class="px-6 py-4 text-center space-x-2">
|
|
<button onclick="viewSpecs('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['name']) ?>')" class="inline-flex items-center justify-center w-8 h-8 rounded-md bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700 transition" title="<?= __('View Specs') ?>">
|
|
<i class="fas fa-search"></i>
|
|
</button>
|
|
<button onclick="viewNetworkInspector('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['name']) ?>')" class="inline-flex items-center justify-center w-8 h-8 rounded-md bg-indigo-50 text-indigo-500 hover:bg-indigo-100 hover:text-indigo-700 transition" title="Network Inspector">
|
|
<i class="fas fa-network-wired"></i>
|
|
</button>
|
|
|
|
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template id="specsTemplate">
|
|
<div class="text-left text-sm p-2">
|
|
<h4 class="font-bold text-slate-700 mb-2 border-b pb-2"><i class="fas fa-microchip text-slate-400 mr-2"></i> Hardware</h4>
|
|
<div class="mb-4 space-y-2">
|
|
<div class="flex justify-between border-b border-slate-100 pb-1">
|
|
<span class="text-slate-500"><?= __('CPU') ?></span>
|
|
<span class="font-mono text-slate-800" id="spec-cpu">-</span>
|
|
</div>
|
|
<div class="flex justify-between border-b border-slate-100 pb-1">
|
|
<span class="text-slate-500"><?= __('RAM Total') ?></span>
|
|
<span class="font-mono text-slate-800" id="spec-ram">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 class="font-bold text-slate-700 mb-2 mt-4 border-b pb-2"><i class="fas fa-hdd text-slate-400 mr-2"></i> <?= __('Disk Storage (Drive C)') ?></h4>
|
|
<div id="spec-disk" class="space-y-3 mb-4">
|
|
<!-- Disk info will be injected here -->
|
|
</div>
|
|
|
|
<div class="flex justify-between items-end border-b pb-2 mb-2">
|
|
<h4 class="font-bold text-slate-700 m-0"><i class="fas fa-network-wired text-slate-400 mr-2"></i> <?= __('Network Interfaces') ?></h4>
|
|
<label class="flex items-center cursor-pointer" title="Show Virtual, Bluetooth, and Other Adapters">
|
|
<input type="checkbox" id="toggle-extra-net" class="sr-only peer" onchange="document.querySelectorAll('.iface-extra').forEach(e => e.classList.toggle('hidden', !this.checked));">
|
|
<div class="relative w-7 h-4 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-primary"></div>
|
|
<span class="ms-2 text-[10px] font-bold text-slate-500 uppercase tracking-wide">Show All</span>
|
|
</label>
|
|
</div>
|
|
<div id="spec-network" class="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-48 overflow-y-auto mb-4">
|
|
<!-- Network interfaces will be injected here -->
|
|
</div>
|
|
|
|
<h4 class="font-bold text-slate-700 mb-2 border-b pb-2"><i class="fas fa-desktop text-slate-400 mr-2"></i> <?= __('Remote Access') ?></h4>
|
|
<div id="spec-remote" class="grid grid-cols-2 gap-4">
|
|
<!-- Remote ID will be injected here -->
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Remote Panel Floating Button -->
|
|
<button onclick="toggleRemotePanel()" class="fixed top-1/2 right-0 transform -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-slate-800 p-3 rounded-l-lg shadow-[0_0_15px_rgba(0,0,0,0.2)] z-[9999] transition-colors flex flex-col items-center group">
|
|
<i class="fas fa-desktop mb-1"></i>
|
|
<span class="text-[9px] font-bold uppercase tracking-wider">Remote</span>
|
|
</button>
|
|
|
|
<!-- Remote Slide-out Panel -->
|
|
<div id="remoteBackdrop" onclick="toggleRemotePanel()" class="fixed inset-0 bg-white/20 backdrop-blur-sm z-[9998] hidden opacity-0 transition-opacity duration-300"></div>
|
|
|
|
<div id="remotePanel" class="fixed inset-y-0 right-0 w-80 md:w-96 bg-white shadow-2xl z-[9999] transform translate-x-full transition-transform duration-300 flex flex-col border-l border-slate-200">
|
|
<div class="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
|
<h3 class="font-bold text-slate-800"><i class="fas fa-desktop text-blue-500 mr-2"></i> Remote Agents List</h3>
|
|
<button onclick="toggleRemotePanel()" class="text-slate-400 hover:text-rose-500 transition-colors">
|
|
<i class="fas fa-times text-lg"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-3 border-b border-slate-100 bg-white">
|
|
<div class="relative">
|
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400"></i>
|
|
<input type="text" id="remoteSearch" onkeyup="filterRemoteList()" placeholder="Search agent or ID..." class="w-full text-sm border border-slate-300 rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
|
</div>
|
|
<div class="mt-2.5 px-1 flex items-center">
|
|
<label class="flex items-center text-xs text-slate-500 cursor-pointer hover:text-slate-800 transition-colors">
|
|
<input type="checkbox" id="hideNoRemote" checked class="mr-2 rounded border-slate-300 text-blue-500 focus:ring-blue-500" onchange="filterRemoteList()">
|
|
<span>ซ่อนเครื่องที่ไม่มี Remote ID</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto" id="remoteListContainer">
|
|
<!-- JS will inject list here -->
|
|
<div class="text-center text-slate-400 mt-10">
|
|
<p>Click "Load Data" to fetch remote IDs.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 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 copyToClipboard(text, el) {
|
|
if (text === '-' || !text) return;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
let originalText = el.innerText;
|
|
el.innerHTML = '<i class="fas fa-check text-emerald-500"></i> Copied';
|
|
el.classList.add('text-emerald-600');
|
|
setTimeout(() => {
|
|
el.innerText = originalText;
|
|
el.classList.remove('text-emerald-600');
|
|
}, 1500);
|
|
});
|
|
}
|
|
|
|
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-600"></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 cursor-pointer hover:text-blue-600 transition-colors" title="คลิกเพื่อคัดลอก" onclick="copyToClipboard('${displayId}', this)">${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() {
|
|
window.agentsTableDt = $('#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.viewNetworkInspector = function(agentId, agentName) {
|
|
// Load Chart.js if not available
|
|
if (typeof Chart === 'undefined') {
|
|
let script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
Swal.fire({
|
|
title: false,
|
|
background: '#ffffff', // Slate 900
|
|
color: '#1e293b',
|
|
html: '<div class="text-center py-16"><i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i><div class="mt-4 text-slate-400 tracking-wider text-sm">ANALYZING NETWORK TRAFFIC...</div></div>',
|
|
showConfirmButton: false,
|
|
showCloseButton: true,
|
|
width: '1200px',
|
|
customClass: {
|
|
popup: 'rounded-xl border border-slate-200 shadow-xl',
|
|
closeButton: 'text-slate-400 hover:text-slate-800'
|
|
},
|
|
didOpen: () => {
|
|
$.ajax({
|
|
url: '<?= $baseUrl ?>/api/agent/network?agent_id=' + agentId,
|
|
success: function(res) {
|
|
if (res.status === 'success') {
|
|
let ip = res.netiface && res.netiface.length > 0 ? res.netiface[0].ipv4 : 'N/A';
|
|
let mac = res.netiface && res.netiface.length > 0 ? res.netiface[0].mac : 'N/A';
|
|
|
|
let html = `
|
|
<div class="text-left font-sans">
|
|
|
|
<!-- Header -->
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
|
<div class="flex items-center space-x-4">
|
|
<div class="bg-blue-600/20 text-blue-400 p-4 rounded-xl border border-blue-500/30">
|
|
<i class="fas fa-laptop text-3xl"></i>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-2xl font-bold text-slate-800 mb-1 tracking-wide flex items-center">
|
|
${agentName}
|
|
<span class="ml-3 px-2.5 py-0.5 rounded-full border border-emerald-500/50 bg-emerald-500/10 text-emerald-400 text-xs font-medium"><i class="fas fa-circle text-[8px] mr-1"></i> Online</span>
|
|
</h2>
|
|
<div class="flex space-x-4 text-xs font-mono text-slate-400">
|
|
<span><i class="fas fa-plug text-slate-500 mr-1"></i> ${ip}</span>
|
|
<span><i class="fas fa-fingerprint text-slate-500 mr-1"></i> ${mac}</span>
|
|
<span><i class="fas fa-users text-slate-500 mr-1"></i> Corp_VLAN</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Top Grid: Stats, Donut, Bar -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
|
|
|
<!-- Left Column: Stats -->
|
|
<div class="lg:col-span-1 flex flex-col justify-between space-y-4">
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-center space-x-4">
|
|
<div class="bg-blue-500/10 text-blue-400 p-3 rounded-lg"><i class="fas fa-exchange-alt"></i></div>
|
|
<div>
|
|
<div class="text-slate-400 text-xs font-semibold tracking-wider uppercase mb-1">Total Connections (24h)</div>
|
|
<div class="text-2xl font-bold text-slate-800">${res.stats.total_connections_24h.toLocaleString()}</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-center space-x-4">
|
|
<div class="bg-emerald-500/10 text-emerald-400 p-3 rounded-lg"><i class="fas fa-door-open"></i></div>
|
|
<div>
|
|
<div class="text-slate-400 text-xs font-semibold tracking-wider uppercase mb-1">Open Ports</div>
|
|
<div class="text-2xl font-bold text-slate-800">${res.stats.open_ports}</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-center space-x-4">
|
|
<div class="bg-rose-500/10 text-rose-400 p-3 rounded-lg"><i class="fas fa-shield-alt"></i></div>
|
|
<div>
|
|
<div class="text-slate-400 text-xs font-semibold tracking-wider uppercase mb-1">Suspicious Events (24h)</div>
|
|
<div class="text-2xl font-bold text-rose-400">${res.stats.suspicious_alerts_24h}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Middle Column: Donut Chart -->
|
|
<div class="lg:col-span-1.5 bg-white border border-slate-200 rounded-xl p-4 relative" style="grid-column: span 1.5 / span 1.5;">
|
|
<h3 class="text-slate-800 font-bold mb-4 tracking-wide text-sm">Top Processes (L7 Proxy)</h3>
|
|
<div class="relative h-48 w-full flex justify-center items-center"><canvas id="appChart"></canvas></div>
|
|
</div>
|
|
|
|
<!-- Center Column: Categories -->
|
|
<div class="lg:col-span-1 bg-white border border-slate-200 rounded-xl p-4">
|
|
<h3 class="text-slate-800 font-bold mb-4 tracking-wide text-sm">Traffic Categories</h3>
|
|
<div class="relative h-48 w-full flex justify-center items-center"><canvas id="categoryChart"></canvas></div>
|
|
</div>
|
|
<!-- Right Column: Bar Chart -->
|
|
<div class="lg:col-span-1 bg-white border border-slate-200 rounded-xl p-4">
|
|
<h3 class="text-slate-800 font-bold mb-4 tracking-wide text-sm">Connection Activity (Last 6h)</h3>
|
|
<div class="relative h-48 w-full">
|
|
<canvas id="activityChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom Table: Flow Logs -->
|
|
<div class="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
|
<div class="p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
|
|
<div>
|
|
<h3 class="text-slate-800 font-bold text-lg">Deep Packet Inspection Flow Logs</h3>
|
|
<p class="text-slate-400 text-xs mt-1">Showing Layer 7 identified traffic and Sysmon Event ID 3 actions.</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<select onchange="window.filterFlowLogs(this.value)" class="bg-white border border-slate-200 text-slate-600 text-sm rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500">
|
|
<option value="All">All Categories</option>
|
|
<option value="Web Surfing">Web Surfing</option>
|
|
<option value="Developer Tools">Developer Tools</option>
|
|
<option value="Infrastructure">Infrastructure</option>
|
|
<option value="P2P File Sharing">P2P File Sharing</option>
|
|
<option value="DNS Query">DNS Query</option>
|
|
<option value="Uncategorized">Uncategorized</option>
|
|
</select>
|
|
<div class="relative">
|
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-500 text-sm"></i>
|
|
<input type="text" onkeyup="window.searchFlowLogs(this.value)" placeholder="Search IP, App..." class="bg-white border border-slate-200 text-slate-600 text-sm rounded-lg pl-9 pr-3 py-1.5 focus:outline-none focus:border-blue-500 w-48">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-x-auto overflow-y-auto max-h-[600px]">
|
|
<table class="w-full text-left border-collapse whitespace-nowrap">
|
|
<thead class="bg-slate-50 sticky top-0 z-10 border-b border-slate-200 shadow-sm text-xs text-slate-400 font-bold tracking-wider uppercase">
|
|
<tr>
|
|
<th class="px-6 py-4 font-semibold">Time (UTC)</th>
|
|
<th class="px-6 py-4 font-semibold">Destination</th>
|
|
<th class="px-6 py-4 font-semibold">Application / L7</th>
|
|
<th class="px-6 py-4 font-semibold">Category</th>
|
|
<th class="px-6 py-4 font-semibold">Payload Info</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100 text-sm">
|
|
`;
|
|
|
|
if (res.sysmon && res.sysmon.length > 0) {
|
|
console.log("SYSMON EVENTS:", res.sysmon); res.sysmon.forEach(s => {
|
|
// Mock category/payload based on process to match UI
|
|
let procLow = s.process.toLowerCase();
|
|
let cat = "Uncategorized";
|
|
let payload = s.payload;
|
|
let icon = "fa-brands fa-windows text-slate-400";
|
|
let action = "Pass";
|
|
|
|
// Colorized App Icons
|
|
if (procLow.includes("chrome")) icon = "fa-brands fa-chrome text-blue-500";
|
|
else if (procLow.includes("edge")) icon = "fa-brands fa-edge text-blue-600";
|
|
else if (procLow.includes("firefox")) icon = "fa-brands fa-firefox-browser text-orange-500";
|
|
else if (procLow.includes("sysmon")) icon = "fa-solid fa-eye text-cyan-600";
|
|
else if (procLow.includes("svchost") || procLow.includes("system")) icon = "fa-solid fa-server text-slate-500";
|
|
else if (procLow.includes("powershell") || procLow.includes("cmd")) icon = "fa-solid fa-terminal text-slate-700";
|
|
else if (procLow.includes("git")) icon = "fa-brands fa-git-alt text-orange-600";
|
|
else if (procLow.includes("code")) icon = "fa-solid fa-code text-blue-500";
|
|
else if (procLow.includes("torrent") || procLow.includes("p2p")) icon = "fa-solid fa-share-nodes text-purple-500";
|
|
|
|
if (s.type === 'dns') {
|
|
if (payload.includes('github') || payload.includes('gitlab')) {
|
|
cat = "Developer Tools";
|
|
} else if (payload.includes('google') || payload.includes('facebook') || payload.includes('arpa')) {
|
|
cat = "Web Surfing";
|
|
} else if (payload.includes('torrent')) {
|
|
cat = "P2P File Sharing"; action = "Drop";
|
|
} else {
|
|
cat = "DNS Query";
|
|
}
|
|
} else {
|
|
if (procLow.includes("chrome") || procLow.includes("edge") || procLow.includes("firefox")) {
|
|
cat = "Web Surfing";
|
|
} else if (procLow.includes("git") || procLow.includes("code")) {
|
|
cat = "Developer Tools";
|
|
} else if (procLow.includes("svchost") || procLow.includes("system") || procLow.includes("sysmon")) {
|
|
cat = "Infrastructure";
|
|
} else if (procLow.includes("torrent") || procLow.includes("p2p")) {
|
|
cat = "P2P File Sharing"; action = "Drop";
|
|
}
|
|
}
|
|
|
|
let fullPath = s.full_path || 'Unknown';
|
|
if (fullPath === 'Unknown') fullPath = 'Path not available';
|
|
// Escape backslashes for JS template literal
|
|
fullPath = fullPath.replace(/\\/g, '\\\\');
|
|
|
|
|
|
|
|
html += `
|
|
<tr class="hover:bg-slate-50/70 transition-colors flow-log-row" data-category="${cat}">
|
|
<td class="px-6 py-4 text-slate-400 font-mono text-xs">${s.timestamp}</td>
|
|
<td class="px-6 py-4">
|
|
<div class="text-slate-800 font-bold tracking-wide">${s.dst_ip}</div>
|
|
<div class="text-slate-500 font-mono text-xs mt-0.5">Port ${s.dst_port}</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="flex items-center">
|
|
<div class="w-8 h-8 rounded-lg bg-slate-100 border border-slate-200 flex justify-center items-center mr-3 flex-shrink-0">
|
|
<i class="${icon} text-lg"></i>
|
|
</div>
|
|
<div class="flex flex-col max-w-[200px] xl:max-w-[280px] truncate" title="${s.process}">
|
|
<span class="text-slate-800 font-semibold truncate leading-tight">${s.process}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<span class="px-3 py-1 rounded-md border border-slate-200 text-slate-600 text-xs">${cat}</span>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="text-amber-500/80 font-mono text-xs">${payload}</div>
|
|
<div class="text-slate-500 text-xs mt-0.5">${s.protocol} Protocol</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
} else {
|
|
html += `<tr><td colspan="6" class="px-6 py-10 text-center text-slate-500">No flow logs detected.</td></tr>`;
|
|
}
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
Swal.update({ html: html });
|
|
|
|
// Render Charts After HTML Injection
|
|
setTimeout(() => {
|
|
if (typeof Chart !== 'undefined') {
|
|
Chart.defaults.color = '#64748b';
|
|
Chart.defaults.font.family = 'Inter, sans-serif';
|
|
|
|
// App Donut Chart
|
|
let procLabels = Object.keys(res.top_processes);
|
|
let procData = Object.values(res.top_processes);
|
|
|
|
new Chart(document.getElementById('appChart'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: procLabels.length ? procLabels : ['No Data'],
|
|
datasets: [{
|
|
data: procData.length ? procData : [1],
|
|
backgroundColor: ['#6366f1', '#06b6d4', '#94a3b8', '#f59e0b', '#f43f5e'],
|
|
borderWidth: 0,
|
|
hoverOffset: 4
|
|
}]
|
|
},
|
|
options: {
|
|
cutout: '75%',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'right', labels: { boxWidth: 12, usePointStyle: true, padding: 15 } }
|
|
}
|
|
}
|
|
});
|
|
|
|
// Process Category Chart Data
|
|
let catCounts = {};
|
|
res.sysmon.forEach(s => {
|
|
let procLow = s.process.toLowerCase();
|
|
let cat = "Uncategorized";
|
|
let payload = s.payload || '';
|
|
if (s.type === 'dns') {
|
|
if (payload.includes('github') || payload.includes('gitlab')) cat = "Developer Tools";
|
|
else if (payload.includes('google') || payload.includes('facebook') || payload.includes('arpa')) cat = "Web Surfing";
|
|
else if (payload.includes('torrent')) cat = "P2P File Sharing";
|
|
else cat = "DNS Query";
|
|
} else {
|
|
if (procLow.includes("chrome") || procLow.includes("edge") || procLow.includes("firefox")) cat = "Web Surfing";
|
|
else if (procLow.includes("git") || procLow.includes("code")) cat = "Developer Tools";
|
|
else if (procLow.includes("svchost") || procLow.includes("system") || procLow.includes("sysmon")) cat = "Infrastructure";
|
|
else if (procLow.includes("torrent") || procLow.includes("p2p")) cat = "P2P File Sharing";
|
|
}
|
|
catCounts[cat] = (catCounts[cat] || 0) + 1;
|
|
});
|
|
|
|
new Chart(document.getElementById('categoryChart'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: Object.keys(catCounts).length ? Object.keys(catCounts) : ['No Data'],
|
|
datasets: [{
|
|
data: Object.values(catCounts).length ? Object.values(catCounts) : [1],
|
|
backgroundColor: ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#94a3b8', '#f43f5e'],
|
|
borderWidth: 0,
|
|
hoverOffset: 4
|
|
}]
|
|
},
|
|
options: {
|
|
cutout: '75%',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'right', labels: { boxWidth: 12, usePointStyle: true, padding: 15 } }
|
|
}
|
|
}
|
|
});
|
|
|
|
// Process Bar Chart Data
|
|
let barLabels = res.timeline.map(t => t.hr);
|
|
let barData = res.timeline.map(t => t.cnt);
|
|
|
|
new Chart(document.getElementById('activityChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: barLabels.length ? barLabels : ['15:00', '16:00', '17:00'],
|
|
datasets: [{
|
|
label: 'Connections',
|
|
data: barData.length ? barData : [0, 0, 0],
|
|
backgroundColor: '#6366f1',
|
|
borderRadius: 4,
|
|
barThickness: 12
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: { grid: { color: '#e2e8f0', drawBorder: false }, border: { dash: [4, 4] } },
|
|
x: { grid: { display: false, drawBorder: false } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}, 500);
|
|
|
|
} else {
|
|
Swal.update({ html: '<div class="text-rose-500 p-4"><i class="fas fa-exclamation-triangle mr-2"></i>' + res.message + '</div>' });
|
|
}
|
|
},
|
|
error: function() {
|
|
Swal.update({ html: '<div class="text-rose-500 p-4"><i class="fas fa-exclamation-triangle mr-2"></i>Failed to fetch network data.</div>' });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
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-600"></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 && addr.proto === 'ipv4') {
|
|
ips.push(addr.address);
|
|
} else if (addr.iface === iface.name && addr.address && !addr.proto && addr.address.includes('.')) {
|
|
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
|
|
});
|
|
}
|
|
});
|
|
};
|
|
// Resource Stats Calculation
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
|
const agents = <?= json_encode($agents) ?>;
|
|
const activeAgents = agents.filter(a => a.status === 'active');
|
|
|
|
if (activeAgents.length === 0) {
|
|
document.getElementById('stat-ram-count').innerText = '0';
|
|
document.getElementById('stat-disk-count').innerText = '0';
|
|
return;
|
|
}
|
|
|
|
let ramAlertCount = 0;
|
|
let diskAlertCount = 0;
|
|
const ramThresh = <?= $_ENV['ALERT_RAM_PERCENT'] ?? 85 ?>;
|
|
const diskThresh = <?= $_ENV['ALERT_DISK_PERCENT'] ?? 85 ?>;
|
|
|
|
// Concurrency limit function to not overload the server/Wazuh API
|
|
async function processInBatches(items, batchSize) {
|
|
for (let i = 0; i < items.length; i += batchSize) {
|
|
const batch = items.slice(i, i + batchSize);
|
|
await Promise.all(batch.map(async (agent) => {
|
|
try {
|
|
let res = await fetch('<?= $baseUrl ?>/api/agent/quick-details?agent_id=' + agent.id);
|
|
let data = await res.json();
|
|
|
|
if (data.status === 'success') {
|
|
let tvId = data.tv_info ? (data.tv_info.TeamViewerID || (Array.isArray(data.tv_info) && data.tv_info[0] ? data.tv_info[0].TeamViewerID : null)) : null;
|
|
let adId = data.ad_info ? (data.ad_info.AnyDeskID || (Array.isArray(data.ad_info) && data.ad_info[0] ? data.ad_info[0].AnyDeskID : null)) : null;
|
|
let remoteInfo = [];
|
|
if (tvId && tvId !== 'Not Found') remoteInfo.push(`<span class="text-blue-600 font-bold text-[11px]"><i class="fas fa-desktop"></i> TV: ${tvId}</span>`);
|
|
if (adId && adId !== 'Not Found') remoteInfo.push(`<span class="text-rose-600 font-bold text-[11px]"><i class="fas fa-desktop"></i> AD: ${adId}</span>`);
|
|
let remoteHtml = remoteInfo.length > 0 ? remoteInfo.join('<br>') : '<span class="text-slate-400 text-[11px]">-</span>';
|
|
|
|
let agentIp = agent.ip || 'N/A';
|
|
if (agentIp !== 'N/A' && agentIp.includes(',')) {
|
|
let ips = agentIp.split(',').map(ip => ip.trim()).filter(ip => !ip.includes(':'));
|
|
agentIp = ips.length > 0 ? ips[0] : agentIp;
|
|
} else if (agentIp.includes(':')) {
|
|
agentIp = 'N/A';
|
|
}
|
|
|
|
// Check RAM
|
|
if (data.hardware && data.hardware.ram && data.hardware.ram.total && data.hardware.ram.free !== undefined) {
|
|
let total = data.hardware.ram.total;
|
|
let free = data.hardware.ram.free;
|
|
let used = Math.max(0, total - free);
|
|
let pct = Math.round((used / total) * 100);
|
|
if (pct >= ramThresh) {
|
|
ramAlertCount++;
|
|
window.ramAlertAgents.push({
|
|
id: agent.id,
|
|
name: agent.name,
|
|
ip: agentIp,
|
|
remote: remoteHtml,
|
|
pct: pct,
|
|
detail: `${(used/1024/1024).toFixed(1)} GB / ${(total/1024/1024).toFixed(1)} GB`
|
|
});
|
|
|
|
// Inject RAM warning icon into Status column via DataTables API
|
|
if (window.agentsTableDt) {
|
|
let row = window.agentsTableDt.row('#row-agent-' + agent.id);
|
|
if (row.length) {
|
|
let cell = window.agentsTableDt.cell(row, 0);
|
|
let $cellHtml = $('<div>').html(cell.data());
|
|
$cellHtml.find('.sort-key').text('1');
|
|
$cellHtml.find('.flex').append(`<span class="text-rose-500 bg-rose-100 rounded-full px-1.5 py-0.5 text-[10px] font-bold" title="พบปัญหาการใช้งาน RAM สูง (${pct}%)"><i class="fas fa-memory"></i></span>`);
|
|
cell.data($cellHtml.html());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check Disk
|
|
if (data.disk_info) {
|
|
let disks = Array.isArray(data.disk_info) ? data.disk_info : [data.disk_info];
|
|
let cDrive = disks.find(d => {
|
|
let name = String(d.DriveLetter || d.Drive || d.DeviceID || d.Name || '');
|
|
return name.toUpperCase().includes('C');
|
|
});
|
|
if (cDrive) {
|
|
let dTotal = parseFloat(cDrive.Total_GB || cDrive.TotalSpace || cDrive.SizeGB || (cDrive.Size ? cDrive.Size / 1073741824 : 0));
|
|
let dFree = parseFloat(cDrive.Free_GB || cDrive.FreeGB || (cDrive.FreeSpace ? cDrive.FreeSpace / 1073741824 : 0));
|
|
if (cDrive.TotalSpace && cDrive.TotalSpace > 100000) dTotal = dTotal / 1073741824;
|
|
if (cDrive.FreeSpace && cDrive.FreeSpace > 100000) dFree = dFree / 1073741824;
|
|
|
|
if (dTotal > 0) {
|
|
let dUsed = dTotal - dFree;
|
|
let dPct = Math.round((dUsed / dTotal) * 100);
|
|
if (dPct >= diskThresh) {
|
|
diskAlertCount++;
|
|
window.diskAlertAgents.push({
|
|
id: agent.id,
|
|
name: agent.name,
|
|
ip: agentIp,
|
|
remote: remoteHtml,
|
|
pct: dPct,
|
|
detail: `${dUsed.toFixed(1)} GB / ${dTotal.toFixed(1)} GB`
|
|
});
|
|
|
|
// Inject Disk warning icon into Status column via DataTables API
|
|
if (window.agentsTableDt) {
|
|
let row = window.agentsTableDt.row('#row-agent-' + agent.id);
|
|
if (row.length) {
|
|
let cell = window.agentsTableDt.cell(row, 0);
|
|
let $cellHtml = $('<div>').html(cell.data());
|
|
$cellHtml.find('.sort-key').text('1');
|
|
$cellHtml.find('.flex').append(`<span class="text-amber-500 bg-amber-100 rounded-full px-1.5 py-0.5 text-[10px] font-bold" title="พบปัญหาการใช้งาน Disk สูง (${dPct}%)"><i class="fas fa-hdd"></i></span>`);
|
|
cell.data($cellHtml.html());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch specs for ' + agent.id, e);
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Process 5 agents at a time
|
|
await processInBatches(activeAgents, 5);
|
|
|
|
// Refresh DataTables sorting
|
|
if (window.agentsTableDt) {
|
|
window.agentsTableDt.order([0, 'asc']).draw(false);
|
|
}
|
|
|
|
document.getElementById('stat-ram-count').innerHTML = ramAlertCount > 0 ? `<span class="text-rose-600">${ramAlertCount}</span>` : '0';
|
|
document.getElementById('stat-disk-count').innerHTML = diskAlertCount > 0 ? `<span class="text-amber-500">${diskAlertCount}</span>` : '0';
|
|
});
|
|
|
|
window.ramAlertAgents = [];
|
|
window.diskAlertAgents = [];
|
|
|
|
window.showResourceDetails = function(type) {
|
|
let list = type === 'ram' ? window.ramAlertAgents : window.diskAlertAgents;
|
|
let title = type === 'ram' ? 'RAM Usage Warning' : 'Disk Usage Warning';
|
|
let icon = type === 'ram' ? '<i class="fas fa-memory text-rose-500"></i>' : '<i class="fas fa-hdd text-amber-500"></i>';
|
|
|
|
if (list.length === 0) {
|
|
Swal.fire({
|
|
icon: 'success',
|
|
title: 'All Good!',
|
|
text: 'No agents currently exceed the usage threshold.',
|
|
confirmButtonColor: '#3085d6'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Sort by highest percentage
|
|
list.sort((a, b) => b.pct - a.pct);
|
|
|
|
let rowsHtml = list.map(a => `
|
|
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
|
<td class="px-4 py-2 text-left">
|
|
<div class="font-bold text-slate-800 text-sm">${a.name}</div>
|
|
<div class="text-[10px] text-slate-500 font-mono">${a.id}</div>
|
|
</td>
|
|
<td class="px-4 py-2 text-left text-xs font-mono text-blue-600">${a.ip}</td>
|
|
<td class="px-4 py-2 text-left">${a.remote}</td>
|
|
<td class="px-4 py-2 text-right">
|
|
<div class="font-bold text-rose-600 text-sm">${a.pct}%</div>
|
|
<div class="text-[10px] text-slate-500">${a.detail}</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
let html = `
|
|
<div class="mt-2 border-t border-slate-200">
|
|
<div class="overflow-x-auto overflow-y-auto custom-scrollbar" style="max-height: 320px;">
|
|
<table class="w-full text-sm relative">
|
|
<thead class="sticky top-0 bg-slate-50 z-10 shadow-sm">
|
|
<tr class="text-slate-500 text-xs uppercase tracking-wider">
|
|
<th class="px-4 py-2 text-left">Agent</th>
|
|
<th class="px-4 py-2 text-left">IP Address</th>
|
|
<th class="px-4 py-2 text-left">Remote ID</th>
|
|
<th class="px-4 py-2 text-right">Usage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
${rowsHtml}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
Swal.fire({
|
|
title: `<div class="flex items-center gap-2 border-b-2 border-slate-200 pb-2">${icon} <span class="font-bold text-lg text-slate-800">${title}</span></div>`,
|
|
html: html,
|
|
width: '650px',
|
|
showCloseButton: true,
|
|
showConfirmButton: false,
|
|
customClass: {
|
|
popup: 'corp-card rounded-md p-0'
|
|
}
|
|
});
|
|
};
|
|
</script>
|
|
<script>
|
|
window.searchFlowLogs = function(val) {
|
|
let lowerVal = val.toLowerCase();
|
|
let catFilter = document.querySelector('select[onchange^="window.filterFlowLogs"]').value;
|
|
document.querySelectorAll('.flow-log-row').forEach(row => {
|
|
let text = row.innerText.toLowerCase();
|
|
let catMatch = (catFilter === 'All' || row.getAttribute('data-category') === catFilter);
|
|
if (catMatch && text.includes(lowerVal)) {
|
|
row.style.display = '';
|
|
} else {
|
|
row.style.display = 'none';
|
|
}
|
|
});
|
|
};
|
|
|
|
window.filterFlowLogs = function(val) {
|
|
let searchVal = document.querySelector('input[onkeyup^="window.searchFlowLogs"]').value.toLowerCase();
|
|
document.querySelectorAll('.flow-log-row').forEach(row => {
|
|
let text = row.innerText.toLowerCase();
|
|
let catMatch = (val === 'All' || row.getAttribute('data-category') === val);
|
|
if (catMatch && text.includes(searchVal)) {
|
|
row.style.display = '';
|
|
} else {
|
|
row.style.display = 'none';
|
|
}
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<?php
|
|
$content = ob_get_clean();
|
|
require __DIR__ . '/layout.php';
|
|
?>
|