Device monitoring/v2

This commit is contained in:
Luca Haid
2025-09-01 10:26:01 +00:00
parent aac0def485
commit a91f5460ac
4 changed files with 693 additions and 373 deletions

View File

@@ -30,9 +30,6 @@ class DeviceMonitoringController extends mfBaseController
$this->postData = json_decode(file_get_contents('php://input'), true) ?? []; $this->postData = json_decode(file_get_contents('php://input'), true) ?? [];
} }
/**
* Gets a list of all available interfaces, grouping Sent/Received items.
*/
protected function listInterfacesAction() protected function listInterfacesAction()
{ {
$hostId = $this->request->hostId; $hostId = $this->request->hostId;
@@ -54,9 +51,6 @@ class DeviceMonitoringController extends mfBaseController
self::returnJson($sortedInterfaces); self::returnJson($sortedInterfaces);
} }
/**
* Gets historical data for a specific list of item IDs.
*/
protected function interfaceDataAction() protected function interfaceDataAction()
{ {
$itemIds = $this->postData['itemIds'] ?? []; $itemIds = $this->postData['itemIds'] ?? [];
@@ -71,7 +65,7 @@ class DeviceMonitoringController extends mfBaseController
$params = [ $params = [
'itemids' => $itemIds, 'itemids' => $itemIds,
'output' => 'extend', 'output' => 'extend',
'history' => 3, // Numeric (unsigned) 'history' => 3, // Type of history: float
'sortfield' => 'clock', 'sortfield' => 'clock',
'sortorder' => 'ASC', 'sortorder' => 'ASC',
'time_from' => $time_from, 'time_from' => $time_from,
@@ -82,66 +76,246 @@ class DeviceMonitoringController extends mfBaseController
foreach ($history as $point) { foreach ($history as $point) {
$historyByItemId[$point['itemid']][] = [ $historyByItemId[$point['itemid']][] = [
'x' => intval($point['clock']) * 1000, 'x' => intval($point['clock']) * 1000,
'y' => round(floatval($point['value']) / 1000000, 2) 'y' => round(floatval($point['value']) / 1000000, 2) // Mbps
]; ];
} }
self::returnJson($historyByItemId); self::returnJson($historyByItemId);
} }
/**
* Gets general monitoring data (Uptime, Ping, Temp).
*/
protected function generalDataAction() { protected function generalDataAction() {
$hostId = $this->request->hostId; $hostId = $this->request->hostId;
$data = $this->zabbix->getOverviewData($hostId);
$itemsToFetch = [ self::returnJson($data);
'ping' => $this->zabbix->getICMPItems($hostId),
'uptime' => $this->zabbix->getUptimeItems($hostId),
];
$itemIds = [];
$itemMap = [];
foreach ($itemsToFetch as $type => $items) {
if (!empty($items)) {
foreach($items as $item) {
$itemIds[] = $item['itemid'];
$itemMap[$item['itemid']] = ['type' => $type, 'name' => $item['name'], 'units' => $item['units']];
}
}
}
$values = [];
if(!empty($itemIds)) {
$history = $this->zabbix->getItemValues($itemIds, 1);
foreach($history as $h) {
$info = $itemMap[$h['itemid']];
$values[$info['type']][] = ['name' => $info['name'], 'value' => $h['value'], 'clock' => $h['clock'], 'units' => $info['units']];
}
}
self::returnJson($values);
} }
/**
* Gets Zabbix problems (triggers) for the host.
*/
protected function getProblemsAction() { protected function getProblemsAction() {
$hostId = $this->request->hostId; $hostId = $this->request->hostId;
$problems = $this->zabbix->zabbixRequest('problem.get', [ $currentProblems = $this->zabbix->zabbixRequest('problem.get', [
'hostids' => $hostId, 'hostids' => $hostId,
'output' => 'extend', 'output' => 'extend',
'recent' => true, // Use boolean true 'recent' => true,
'sortfield' => ['eventid'], 'sortfield' => ['eventid'],
'sortorder' => 'DESC' 'sortorder' => 'DESC'
])['result'] ?? []; ])['result'] ?? [];
self::returnJson($problems); $resolvedProblems = $this->zabbix->getResolvedProblems($hostId, strtotime('-7 days'));
self::returnJson([
'current' => $currentProblems,
'resolved' => $resolvedProblems
]);
}
protected function getConfigurationDataAction() {
$hostId = $this->request->hostId;
$host = $this->zabbix->getHostWithInterfaces($hostId);
if (!$host) {
self::returnJson(['error' => 'Host not found.']);
return;
}
$snmpInterface = null;
foreach ($host['interfaces'] as $iface) {
if ($iface['type'] == '2') { // SNMP type
$snmpInterface = $iface;
break;
}
}
$opStatusItems = $this->zabbix->getInterfaceOperationalStatusItems($hostId);
$allTriggers = $this->zabbix->getTriggersForHostByDescription($hostId, "Interface ");
$triggerMap = [];
foreach ($allTriggers as $trigger) {
$triggerMap[$trigger['description']] = $trigger;
}
$interfaceAlarms = [];
foreach ($opStatusItems as $item) {
$expectedDescription = "Interface " . $item['name'] . " is down on " . $host['name'];
$trigger = $triggerMap[$expectedDescription] ?? null;
$interfaceAlarms[] = [
'itemid' => $item['itemid'],
'name' => $item['name'],
'key' => $item['key_'],
'isAlarmed' => !is_null($trigger),
'triggerId' => $trigger['triggerid'] ?? null
];
}
self::returnJson([
'snmp' => $snmpInterface,
'interfaces' => $interfaceAlarms
]);
}
protected function updateSnmpAction() {
$interfaceId = $this->postData['interfaceId'] ?? null;
$details = $this->postData['details'] ?? null;
if (!$interfaceId || !$details) {
http_response_code(400);
self::returnJson(['error' => 'Missing required parameters.']);
return;
}
$result = $this->zabbix->updateHostInterface($interfaceId, $details);
self::returnJson($result);
}
protected function updateInterfaceAlarmAction() {
$hostId = $this->postData['hostId'];
$item = $this->postData['item'];
$enabled = $this->postData['enabled'];
$host = $this->zabbix->getHostById($hostId)[0] ?? null;
if (!$host) {
self::returnJson(['error' => 'Host not found.']);
return;
}
$description = "Interface " . $item['name'] . " is down on " . $host['name'];
if ($enabled) {
$expression = "last(/".$host['host']."/".$item['key'].")=2";
$result = $this->zabbix->createInterfaceLinkDownTrigger($expression, $description);
} else {
$triggers = $this->zabbix->getTriggersForHostByDescription($hostId, $description);
$triggerIds = array_column($triggers, 'triggerid');
$result = $this->zabbix->deleteTriggers($triggerIds);
}
self::returnJson($result);
}
protected function getReportDataAction() {
$hostId = $this->request->hostId;
$timeRange = $this->request->timeRange ?? '7d';
$time_from = strtotime('-' . str_replace(['d'], [' days'], $timeRange));
// Step 1: Fetch all interface-related items (traffic and speed) in a single API call.
// We include 'value_type' to handle different history types correctly.
$items = $this->zabbix->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'key_', 'value_type'],
'search' => ['key_' => ['net.if.in', 'net.if.out', 'net.if.speed']],
'searchByAny' => true,
'sortfield' => 'name'
])['result'] ?? [];
// Step 2: Organize items and group them for efficient processing.
$interfaces = [];
$trafficItems = []; // Will hold item info for both rx and tx.
$speedItemsByType = [];
$speedItemMap = [];
foreach ($items as $item) {
$key = $item['key_'];
if (str_contains($key, 'net.if.in') || str_contains($key, 'net.if.out')) {
$baseName = preg_replace('/:\s*Bits\s*(sent|received)$/i', '', $item['name']);
$direction = str_contains($key, 'net.if.in') ? 'rx' : 'tx';
if (!isset($interfaces[$baseName])) {
$interfaces[$baseName] = ['name' => $baseName, 'rx_item' => null, 'tx_item' => null, 'speed' => null];
}
$interfaces[$baseName][$direction . '_item'] = $item;
$trafficItems[$item['itemid']] = $item;
} elseif (str_contains($key, 'net.if.speed')) {
$baseName = preg_replace('/:\s*Interface\s*|\s*speed$/i', '', $item['name']);
$value_type = (int)$item['value_type'];
$speedItemsByType[$value_type][] = $item['itemid'];
$speedItemMap[$item['itemid']] = $baseName;
}
}
// Step 3: Aggressively fetch the last known speed for all interfaces.
// We query history with a larger limit to find the value even if it's not recent.
foreach ($speedItemsByType as $type => $itemIds) {
$historyResult = $this->zabbix->zabbixRequest('history.get', [
'itemids' => $itemIds,
'history' => $type,
'output' => ['itemid', 'value'],
'sortfield' => 'clock',
'sortorder' => 'DESC',
'limit' => count($itemIds) * 5 // Increase limit to better ensure finding a value for each item
])['result'] ?? [];
$latestForType = [];
foreach ($historyResult as $point) {
if (!isset($latestForType[$point['itemid']])) {
$latestForType[$point['itemid']] = $point;
$baseName = $speedItemMap[$point['itemid']] ?? null;
if ($baseName && isset($interfaces[$baseName])) {
$interfaces[$baseName]['speed'] = (float)$point['value'];
}
}
}
}
// Step 4: Attempt to fetch trend data for all traffic items at once.
$trafficItemIds = array_keys($trafficItems);
$trends = $this->zabbix->getTrends($trafficItemIds, $time_from);
$trendsByItemId = [];
foreach ($trends as $trend) {
$trendsByItemId[$trend['itemid']][] = $trend;
}
// Step 5: Build the report, using trends first and falling back to raw history if trends are unavailable.
$report = [];
foreach ($interfaces as $iface) {
$rx_item = $iface['rx_item'];
$tx_item = $iface['tx_item'];
$speed = $iface['speed'];
// This function calculates statistics from either trend data or raw history data.
$calcStats = function($item, $speed, $trendData) use ($time_from) {
if (!$item) return ['avg' => 0, 'max' => 0, 'usage' => 0];
$values = [];
$avg = 0;
$max = 0;
if (!empty($trendData)) {
// Method 1: Use efficient trend data if available.
$avg = array_sum(array_column($trendData, 'value_avg')) / count($trendData);
$max = max(array_column($trendData, 'value_max'));
} else {
// Method 2 (Fallback): Fetch raw history if trends are missing.
$history = $this->zabbix->zabbixRequest('history.get', [
'itemids' => [$item['itemid']],
'history' => (int)$item['value_type'],
'time_from' => $time_from,
'output' => ['value']
])['result'] ?? [];
if (!empty($history)) {
$values = array_column($history, 'value');
$avg = array_sum($values) / count($values);
$max = max($values);
}
}
$usage = ($speed > 0) ? ($avg / $speed) * 100 : 0;
return [
'avg' => round($avg / 1000000, 2), // bps to Mbps
'max' => round($max / 1000000, 2), // bps to Mbps
'usage' => round($usage, 2)
];
};
$report[] = [
'name' => $iface['name'],
'speed' => $speed !== null ? round($speed / 1000000) : 'N/A', // bps to Mbps
'rx' => $calcStats($rx_item, $speed, $trendsByItemId[$rx_item['itemid']] ?? []),
'tx' => $calcStats($tx_item, $speed, $trendsByItemId[$tx_item['itemid']] ?? [])
];
}
usort($report, fn($a, $b) => strnatcmp($a['name'], $b['name']));
self::returnJson($report);
} }
/**
* Forces a Zabbix item check and returns the latest value for live graphs.
*/
protected function liveDataAction() { protected function liveDataAction() {
$itemId = $this->request->itemId; $itemId = $this->request->itemId;
if(empty($itemId)) { if(empty($itemId)) {
@@ -168,9 +342,6 @@ class DeviceMonitoringController extends mfBaseController
self::returnJson($formattedPoint); self::returnJson($formattedPoint);
} }
/**
* Renders a dedicated HTML page for the live graph popup.
*/
public function liveGraphPageAction() { public function liveGraphPageAction() {
$this->layout(false); $this->layout(false);
$this->layout()->set('API_BASE_URL', self::getUrl("DeviceMonitoring")); $this->layout()->set('API_BASE_URL', self::getUrl("DeviceMonitoring"));

View File

@@ -1,32 +1,35 @@
<?php <?php
class Zabbix { class Zabbix
{
private $url; private $url;
private $apiKey; private $apiKey;
public function __construct($url, $apiKey) { public function __construct($url, $apiKey)
{
$this->url = $url; $this->url = $url;
$this->apiKey = $apiKey; $this->apiKey = $apiKey;
} }
public function zabbixRequest($method, $params, $die = false) { public function zabbixRequest($method, $params, $die = false)
{
$data = array( $data = array(
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'method' => $method, 'method' => $method,
'params' => $params, 'params' => $params,
'id' => 1 'id' => 1
); );
$options = array( $options = array(
'http' => array( 'http' => array(
'header' => "Content-Type: application/json\r\n" . 'header' => "Content-Type: application/json\r\n" .
"Authorization: Bearer " . $this->apiKey . "\r\n", "Authorization: Bearer " . $this->apiKey . "\r\n",
'method' => 'POST', 'method' => 'POST',
'content' => json_encode($data) 'content' => json_encode($data),
'timeout' => 30
) )
); );
// var dump options and data and die
if ($die) { if ($die) {
var_dump($options); var_dump($options);
echo json_encode($data); echo json_encode($data);
@@ -39,25 +42,29 @@ class Zabbix {
return json_decode($result, true); return json_decode($result, true);
} }
public function getHostById($hostId) { public function getHostById($hostId)
{
$response = $this->zabbixRequest('host.get', array( $response = $this->zabbixRequest('host.get', array(
'hostids' => $hostId 'hostids' => $hostId
)); ));
return $response['result']; return $response['result'];
} }
public function getItemValues($itemIds, $limit = 15) { public function getItemValues($itemIds, $limit = 15)
{
if (empty($itemIds)) return [];
$response = $this->zabbixRequest('history.get', array( $response = $this->zabbixRequest('history.get', array(
'itemids' => $itemIds, 'itemids' => $itemIds,
'output' => 'extend', 'output' => 'extend',
'sortfield' => 'clock', 'sortfield' => 'clock',
'sortorder' => 'DESC', 'sortorder' => 'DESC',
'limit' => $limit 'limit' => $limit
)); ));
return $response['result']; return $response['result'];
} }
public function getHosts($hostname = null, $ip = null) { public function getHosts($hostname = null, $ip = null)
{
if ($hostname) { if ($hostname) {
$response = $this->zabbixRequest('host.get', array( $response = $this->zabbixRequest('host.get', array(
'search' => array('name' => array($hostname)) 'search' => array('name' => array($hostname))
@@ -72,80 +79,84 @@ class Zabbix {
return []; return [];
} }
public function getHostInterfaceItems($hostId) { public function getHostInterfaceItems($hostId)
{
$response = $this->zabbixRequest('item.get', array( $response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId, 'hostids' => $hostId,
'output' => ['itemid','name_resolved', 'key_', 'units'], 'output' => ['itemid', 'name', 'key_', 'units'],
'search' => ['name' => ["Bits received", "Bits sent"]], 'search' => ['name' => ["Bits received", "Bits sent"]],
'searchByAny' => true, 'searchByAny' => true,
'sortfield' => 'name' 'sortfield' => 'name'
)); ));
return $response['result']; return $response['result'];
} }
public function getICMPItems($hostId) { public function getICMPItems($hostId)
{
$response = $this->zabbixRequest('item.get', array( $response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId, 'hostids' => $hostId,
'search' => array('name' => array("ICMP")) 'search' => array('name' => array("ICMP"))
)); ));
return $response['result']; return $response['result'];
} }
public function getUptimeItems($hostId) { public function getUptimeItems($hostId)
{
$response = $this->zabbixRequest('item.get', array( $response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId, 'hostids' => $hostId,
'search' => array('name' => array("Uptime")) 'search' => array('name' => array("Uptime"))
)); ));
return $response['result']; return $response['result'];
} }
public function getHostInterfaces($hostIds) { public function getHostInterfaces($hostIds)
$response = $this->zabbixRequest('hostinterface.get', array('hostids' => $hostIds)); {
$response = $this->zabbixRequest('hostinterface.get', array('hostids' => $hostIds, 'output' => 'extend'));
return $response['result']; return $response['result'];
} }
public function createTask($itemid) { public function createTask($itemid)
{
$response = $this->zabbixRequest('task.create', array( $response = $this->zabbixRequest('task.create', array(
'type' => 6, 'type' => 6,
'request' => array( 'request' => array('itemid' => $itemid)
'itemid' => $itemid
)
)); ));
return $response['result']; return $response['result'];
} }
public function getAllHostsWithDetails() { public function getAllHostsWithDetails()
{
$response = $this->zabbixRequest('host.get', [ $response = $this->zabbixRequest('host.get', [
'output' => ['hostid', 'host', 'name', 'status'], 'output' => ['hostid', 'host', 'name', 'status'],
'selectInventory' => ['location_lat', 'location_lon'], 'selectInventory' => ['location_lat', 'location_lon'],
'selectParentTemplates' => ['templateid', 'name'], 'selectParentTemplates' => ['templateid', 'name'],
'selectHostGroups' => 'extend' // This is the new line 'selectHostGroups' => 'extend'
]); ]);
return $response['result'] ?? []; return $response['result'] ?? [];
} }
public function updateHostInventory($hostId, $inventoryData) { public function updateHostInventory($hostId, $inventoryData)
// First, get the current inventory to avoid overwriting existing fields {
$hostResponse = $this->zabbixRequest('host.get', [ $hostResponse = $this->zabbixRequest('host.get', [
'hostids' => $hostId, 'hostids' => $hostId,
'selectInventory' => 'extend' 'selectInventory' => 'extend'
]); ]);
$currentInventory = $hostResponse['result'][0]['inventory'] ?? []; $currentInventory = $hostResponse['result'][0]['inventory'] ?? [];
// Merge new coordinates into the existing inventory
$newInventory = array_merge($currentInventory, $inventoryData); $newInventory = array_merge($currentInventory, $inventoryData);
$params = [ $params = [
'hostid' => $hostId, 'hostid' => $hostId,
'inventory_mode' => 0, // Set to manual mode 'inventory_mode' => 0, // Set to manual mode
'inventory' => $newInventory 'inventory' => $newInventory
]; ];
$response = $this->zabbixRequest('host.update', $params); $response = $this->zabbixRequest('host.update', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error']; return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
} }
public function getTemplateIdByName($templateName) {
public function getTemplateIdByName($templateName)
{
$response = $this->zabbixRequest('template.get', [ $response = $this->zabbixRequest('template.get', [
'output' => ['templateid'], 'output' => ['templateid'],
'filter' => ['host' => [$templateName]] 'filter' => ['host' => [$templateName]]
@@ -153,7 +164,8 @@ class Zabbix {
return $response['result'][0]['templateid'] ?? null; return $response['result'][0]['templateid'] ?? null;
} }
public function getTemplatesByNames(array $templateNames) { public function getTemplatesByNames(array $templateNames)
{
$response = $this->zabbixRequest('template.get', [ $response = $this->zabbixRequest('template.get', [
'output' => ['templateid', 'name'], 'output' => ['templateid', 'name'],
'filter' => ['host' => $templateNames] 'filter' => ['host' => $templateNames]
@@ -161,38 +173,39 @@ class Zabbix {
return $response['result'] ?? []; return $response['result'] ?? [];
} }
public function createHost($visibleName, $ip, $groupId, $templateIds)
public function createHost($visibleName, $ip, $groupId, $templateIds) { {
$templatesData = array_map(function($id) { $templatesData = array_map(function ($id) {
return ['templateid' => $id]; return ['templateid' => $id];
}, $templateIds); }, $templateIds);
$params = [ $params = [
'host' => $ip, // Technical name is the IP 'host' => $ip,
'name' => $visibleName, // Visible name 'name' => $visibleName,
'interfaces' => [ // <-- Corrected structure 'interfaces' => [
[ [
'type' => 2, // 2 for SNMP 'type' => 2, // 2 for SNMP
'main' => 1, 'main' => 1,
'useip' => 1, 'useip' => 1,
'ip' => $ip, 'ip' => $ip,
'dns' => '', 'dns' => '',
'port' => '161', 'port' => '161',
'details' => [ 'details' => [
'version' => 2, 'version' => 2,
'community' => 'public_xinon' 'community' => 'public_xinon'
] ]
] ]
], ],
'groups' => [['groupid' => $groupId]], 'groups' => [['groupid' => $groupId]],
'templates' => $templatesData // Use the correctly formatted array 'templates' => $templatesData
]; ];
$response = $this->zabbixRequest('host.create', $params); $response = $this->zabbixRequest('host.create', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error']; return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
} }
public function getHostGroupIdByName($groupName) { public function getHostGroupIdByName($groupName)
{
$response = $this->zabbixRequest('hostgroup.get', [ $response = $this->zabbixRequest('hostgroup.get', [
'output' => ['groupid'], 'output' => ['groupid'],
'filter' => ['name' => [$groupName]] 'filter' => ['name' => [$groupName]]
@@ -200,4 +213,133 @@ class Zabbix {
return $response['result'][0]['groupid'] ?? null; return $response['result'][0]['groupid'] ?? null;
} }
} public function getHostWithInterfaces($hostId)
{
$response = $this->zabbixRequest('host.get', [
'hostids' => $hostId,
'output' => ['hostid', 'host', 'name'],
'selectInterfaces' => 'extend'
]);
return $response['result'][0] ?? null;
}
public function getResolvedProblems($hostId, $time_from)
{
$response = $this->zabbixRequest('event.get', [
'hostids' => $hostId,
'output' => 'extend',
'select_acknowledges' => ['message'],
'sortfield' => ['clock'],
'sortorder' => 'DESC',
'time_from' => $time_from,
'object' => 0,
'value' => 0
]);
return $response['result'] ?? [];
}
public function updateHostInterface($interfaceId, $details)
{
$params = ['interfaceid' => $interfaceId, 'details' => $details];
$response = $this->zabbixRequest('hostinterface.update', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getInterfaceOperationalStatusItems($hostId)
{
$response = $this->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'key_'],
'search' => ['key_' => 'net.if.status'],
'sortfield' => 'name'
]);
return $response['result'] ?? [];
}
public function getTriggersForHostByDescription($hostId, $description)
{
$response = $this->zabbixRequest('trigger.get', [
'hostids' => $hostId,
'output' => ['triggerid', 'description'],
'search' => ['description' => $description],
'searchByAny' => true
]);
return $response['result'] ?? [];
}
public function createInterfaceLinkDownTrigger($expression, $description, $priority = 4)
{
$params = [
'description' => $description,
'expression' => $expression,
'priority' => $priority,
];
$response = $this->zabbixRequest('trigger.create', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function deleteTriggers(array $triggerIds)
{
if (empty($triggerIds)) return [];
$response = $this->zabbixRequest('trigger.delete', $triggerIds);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getTrends(array $itemIds, $time_from)
{
if (empty($itemIds)) return [];
$response = $this->zabbixRequest('trend.get', [
'itemids' => $itemIds,
'output' => ['itemid', 'num', 'value_min', 'value_avg', 'value_max'],
'time_from' => $time_from
]);
return $response['result'] ?? [];
}
public function getOverviewData($hostId)
{
$items = $this->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'units', 'key_'],
'search' => ['key_' => ['icmpping', 'system.uptime']],
'searchByAny' => true
])['result'] ?? [];
$itemIds = [];
$itemMap = [];
$data = ['ping' => [], 'uptime' => []];
foreach ($items as $item) {
$itemIds[] = $item['itemid'];
$type = str_contains($item['key_'], 'uptime') ? 'uptime' : 'ping';
$itemMap[$item['itemid']] = ['type' => $type, 'name' => $item['name'], 'units' => $item['units']];
}
if (!empty($itemIds)) {
$history = $this->getItemValues($itemIds, 1);
foreach ($history as $h) {
if (!isset($itemMap[$h['itemid']])) continue;
$info = $itemMap[$h['itemid']];
$data[$info['type']][] = ['name' => $info['name'], 'value' => $h['value'], 'clock' => $h['clock'], 'units' => $info['units']];
}
}
$time30d = time() - 2592000;
$problems = $this->zabbixRequest('problem.get', [
'hostids' => $hostId,
'time_from' => $time30d,
'output' => ['clock']
])['result'] ?? [];
$time7d = time() - 604800;
$time24h = time() - 86400;
$counts = ['24h' => 0, '7d' => 0, '30d' => 0];
foreach ($problems as $p) {
$counts['30d']++;
if ($p['clock'] >= $time7d) $counts['7d']++;
if ($p['clock'] >= $time24h) $counts['24h']++;
}
$data['problemCounts'] = $counts;
return $data;
}
}

View File

@@ -19,3 +19,11 @@
.sev-average { border-left-color: #fd7e14; } .sev-average .problem-icon { color: #fd7e14; } .sev-average { border-left-color: #fd7e14; } .sev-average .problem-icon { color: #fd7e14; }
.sev-high { border-left-color: #dc3545; } .sev-high .problem-icon { color: #dc3545; } .sev-high { border-left-color: #dc3545; } .sev-high .problem-icon { color: #dc3545; }
.sev-disaster { border-left-color: #7B014C; } .sev-disaster .problem-icon { color: #7B014C; } .sev-disaster { border-left-color: #7B014C; } .sev-disaster .problem-icon { color: #7B014C; }
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; }
.problem-counts { display: flex; justify-content: space-around; text-align: center; padding: 1rem 0; }
.problem-counts .count { font-size: 1.5rem; font-weight: bold; display: block; }
.problem-counts .period { font-size: 0.8rem; color: #6c757d; }
.problems-list.resolved .problem-card { opacity: 0.8; }
.sev-resolved { border-left: 5px solid #28a745; }
.sev-resolved .problem-icon { color: #28a745; }
.c-pointer { cursor: pointer; }

View File

@@ -1,3 +1,28 @@
const ttSwitchCSS = `
.tt-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.tt-switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; }
input:checked + .slider { background-color: #28a745; }
input:focus + .slider { box-shadow: 0 0 1px #28a745; }
input:checked + .slider:before { transform: translateX(20px); }
.slider.round { border-radius: 24px; }
.slider.round:before { border-radius: 50%; }
`;
const styleSheet = document.createElement("style");
styleSheet.innerText = ttSwitchCSS;
document.head.appendChild(styleSheet);
Vue.component('tt-switch', {
template: `
<label class="tt-switch">
<input type="checkbox" :checked="value" @change="$emit('input', $event.target.checked)">
<span class="slider round"></span>
</label>
`,
props: { value: { type: Boolean, default: false } }
});
Vue.component('device-monitoring-modal', { Vue.component('device-monitoring-modal', {
//language=Vue //language=Vue
template: ` template: `
@@ -5,7 +30,8 @@ Vue.component('device-monitoring-modal', {
:title="'Monitoring für ' + deviceName" :title="'Monitoring für ' + deviceName"
@update:show="$emit('close')" @update:show="$emit('close')"
:save="false" :save="false"
:delete="false"> :delete="false"
dialog-class="modal-xl">
<div class="monitoring-tabs"> <div class="monitoring-tabs">
<button v-for="tab in tabs" :key="tab.id" <button v-for="tab in tabs" :key="tab.id"
@@ -20,7 +46,7 @@ Vue.component('device-monitoring-modal', {
<div v-if="loading.overview" class="text-center p-4"><div class="spinner-border"></div></div> <div v-if="loading.overview" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="!generalData || Object.keys(generalData).length === 0" class="alert alert-info">Keine allgemeinen Monitoring-Daten gefunden.</div> <div v-else-if="!generalData || Object.keys(generalData).length === 0" class="alert alert-info">Keine allgemeinen Monitoring-Daten gefunden.</div>
<div v-else class="overview-grid"> <div v-else class="overview-grid">
<tt-card v-if="generalData.ping" body-overflow-x-auto> <tt-card v-if="generalData.ping && generalData.ping.length" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-network-wired"></i> Erreichbarkeit</h6></template> <template v-slot:header><h6><i class="fas fa-network-wired"></i> Erreichbarkeit</h6></template>
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tr v-for="item in generalData.ping" :key="item.name"> <tr v-for="item in generalData.ping" :key="item.name">
@@ -29,23 +55,28 @@ Vue.component('device-monitoring-modal', {
</tr> </tr>
</table> </table>
</tt-card> </tt-card>
<tt-card v-if="generalData.uptime" body-overflow-x-auto> <tt-card v-if="generalData.uptime && generalData.uptime.length" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-clock"></i> Uptime</h6></template> <template v-slot:header><h6><i class="fas fa-clock"></i> Uptime</h6></template>
<div v-for="item in generalData.uptime" :key="item.name" class="p-2 text-center"> <div v-for="item in generalData.uptime" :key="item.name" class="p-2 text-center">
<p class="h5 mb-0">{{ formatUptime(item.value) }}</p> <p class="h5 mb-0">{{ formatUptime(item.value) }}</p>
<small class="text-muted">Seit {{ moment(Date.now() - item.value * 1000).format('DD.MM.YYYY HH:mm') }}</small> <small class="text-muted">Seit {{ moment(Date.now() - item.value * 1000).format('DD.MM.YYYY HH:mm') }}</small>
</div> </div>
</tt-card> </tt-card>
<tt-card v-if="generalData.problemCounts" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-bell"></i> Probleme</h6></template>
<div class="problem-counts">
<div><span class="count">{{ generalData.problemCounts['24h'] }}</span><span class="period">letzte 24h</span></div>
<div><span class="count">{{ generalData.problemCounts['7d'] }}</span><span class="period">letzte 7T</span></div>
<div><span class="count">{{ generalData.problemCounts['30d'] }}</span><span class="period">letzte 30T</span></div>
</div>
</tt-card>
</div> </div>
</div> </div>
<div v-if="activeTab === 'interfaces'"> <div v-if="activeTab === 'interfaces'">
<div class="p-2 border-bottom mb-3 d-flex justify-content-between align-items-center flex-wrap"> <div class="p-2 border-bottom mb-3 d-flex justify-content-between align-items-center flex-wrap">
<div style="flex-grow: 1; margin-right: 15px; min-width: 300px;"> <div style="flex-grow: 1; margin-right: 15px; min-width: 300px;">
<tt-select label="Schnittstellen zur Anzeige auswählen" <tt-select label="Schnittstellen zur Anzeige auswählen" :options="interfaceOptions" v-model="selectedInterfaces" sm multiple searchable/>
:options="interfaceOptions"
v-model="selectedInterfaces"
sm multiple searchable/>
</div> </div>
<div class="d-flex align-items-center flex-wrap"> <div class="d-flex align-items-center flex-wrap">
<div class="btn-group btn-group-sm mr-2 mb-1"> <div class="btn-group btn-group-sm mr-2 mb-1">
@@ -53,11 +84,7 @@ Vue.component('device-monitoring-modal', {
<button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button> <button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button>
</div> </div>
<div class="btn-group btn-group-sm mr-2 mb-1"> <div class="btn-group btn-group-sm mr-2 mb-1">
<button v-for="range in timeRanges" :key="range.value" <button v-for="range in timeRanges" :key="range.value" :class="interfaceTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'" @click="interfaceTimeRange = range.value" class="btn">{{range.text}}</button>
:class="interfaceTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'"
@click="interfaceTimeRange = range.value" class="btn">
{{range.text}}
</button>
</div> </div>
<button @click="resetAllChartsZoom" class="btn btn-sm btn-info mb-1" title="Zoom zurücksetzen"><i class="fas fa-search-minus"></i> Zoom zurücksetzen</button> <button @click="resetAllChartsZoom" class="btn btn-sm btn-info mb-1" title="Zoom zurücksetzen"><i class="fas fa-search-minus"></i> Zoom zurücksetzen</button>
</div> </div>
@@ -66,52 +93,126 @@ Vue.component('device-monitoring-modal', {
<div v-else-if="selectedInterfaces.length === 0" class="alert alert-light text-center">Bitte eine oder mehrere Schnittstellen auswählen, um Graphen anzuzeigen.</div> <div v-else-if="selectedInterfaces.length === 0" class="alert alert-light text-center">Bitte eine oder mehrere Schnittstellen auswählen, um Graphen anzuzeigen.</div>
<div v-else class="chart-container"> <div v-else class="chart-container">
<div v-for="iface in selectedInterfacesData" :key="iface.name" class="chart-card border rounded p-2"> <div v-for="iface in selectedInterfacesData" :key="iface.name" class="chart-card border rounded p-2">
<div v-if="loading.individualInterfaces[iface.name]" class="chart-loader"> <div v-if="loading.individualInterfaces[iface.name]" class="chart-loader"><div class="spinner-border spinner-border-sm"></div></div>
<div class="spinner-border spinner-border-sm"></div>
</div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h6 class="chart-title">{{ iface.name }}</h6> <h6 class="chart-title">{{ iface.name }}</h6>
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph"> <button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph"><i class="fas fa-play-circle"></i> Live</button>
<i class="fas fa-play-circle"></i> Live
</button>
</div> </div>
<canvas :ref="'chartCanvas-' + iface.name"></canvas> <canvas :ref="'chartCanvas-' + iface.name"></canvas>
<div v-if="statistics[iface.name]" class="chart-stats"> <div v-if="statistics[iface.name]" class="chart-stats">
<div class="stats-col"> <div class="stats-col"><strong><i class="fas fa-arrow-down text-success"></i> Empfangen (Mbps)</strong><span>Min: {{ statistics[iface.name].rx.min }}</span><span>Avg: {{ statistics[iface.name].rx.avg }}</span><span>Max: {{ statistics[iface.name].rx.max }}</span><span>95%: {{ statistics[iface.name].rx.p95 }}</span></div>
<strong><i class="fas fa-arrow-down text-success"></i> Empfangen (Mbps)</strong> <div class="stats-col"><strong><i class="fas fa-arrow-up text-primary"></i> Gesendet (Mbps)</strong><span>Min: {{ statistics[iface.name].tx.min }}</span><span>Avg: {{ statistics[iface.name].tx.avg }}</span><span>Max: {{ statistics[iface.name].tx.max }}</span><span>95%: {{ statistics[iface.name].tx.p95 }}</span></div>
<span>Min: {{ statistics[iface.name].rx.min }}</span> </div>
<span>Avg: {{ statistics[iface.name].rx.avg }}</span> </div>
<span>Median: {{ statistics[iface.name].rx.median }}</span> </div>
<span>Max: {{ statistics[iface.name].rx.max }}</span> </div>
<span>95%: {{ statistics[iface.name].rx.p95 }}</span>
<div v-if="activeTab === 'reports'">
<div class="p-2 border-bottom mb-3 d-flex justify-content-start align-items-center flex-wrap">
<div class="btn-group btn-group-sm mr-2 mb-1">
<button v-for="range in reportTimeRanges" :key="range.value" :class="reportTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'" @click="reportTimeRange = range.value" class="btn">{{range.text}}</button>
</div>
</div>
<div v-if="loading.reports" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="!reportData || reportData.length === 0" class="alert alert-info">Keine Report-Daten für den gewählten Zeitraum.</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover table-striped">
<thead>
<tr>
<th @click="sortReport('name')" class="c-pointer">Schnittstelle <i :class="getSortIcon('name')"></i></th>
<th @click="sortReport('speed')" class="c-pointer text-right">Speed (Mbps) <i :class="getSortIcon('speed')"></i></th>
<th @click="sortReport('rx.max')" class="c-pointer text-right">Max In (Mbps) <i :class="getSortIcon('rx.max')"></i></th>
<th @click="sortReport('rx.avg')" class="c-pointer text-right">Avg In (Mbps) <i :class="getSortIcon('rx.avg')"></i></th>
<th @click="sortReport('rx.usage')" class="c-pointer text-right">Auslastung In (%) <i :class="getSortIcon('rx.usage')"></i></th>
<th @click="sortReport('tx.max')" class="c-pointer text-right">Max Out (Mbps) <i :class="getSortIcon('tx.max')"></i></th>
<th @click="sortReport('tx.avg')" class="c-pointer text-right">Avg Out (Mbps) <i :class="getSortIcon('tx.avg')"></i></th>
<th @click="sortReport('tx.usage')" class="c-pointer text-right">Auslastung Out (%) <i :class="getSortIcon('tx.usage')"></i></th>
</tr>
</thead>
<tbody>
<tr v-for="d in sortedReportData" :key="d.name">
<td>{{ d.name }}</td>
<td class="text-right">{{ d.speed }}</td>
<td class="text-right">{{ d.rx.max }}</td>
<td class="text-right">{{ d.rx.avg }}</td>
<td class="text-right">{{ d.rx.usage }}%</td>
<td class="text-right">{{ d.tx.max }}</td>
<td class="text-right">{{ d.tx.avg }}</td>
<td class="text-right">{{ d.tx.usage }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="activeTab === 'problems'">
<div v-if="loading.problems" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="problemData.current.length === 0 && problemData.resolved.length === 0" class="alert alert-success text-center">Keine Probleme für dieses Gerät gefunden.</div>
<div v-else>
<div v-if="problemData.current.length > 0">
<h5>Aktuelle Probleme</h5>
<div class="problems-list">
<div v-for="p in problemData.current" :key="p.eventid" class="problem-card" :class="getSeverityClass(p.severity)">
<div class="problem-icon"><i :class="getSeverityIcon(p.severity)"></i></div>
<div class="problem-details">
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div>
</div>
</div> </div>
<div class="stats-col"> </div>
<strong><i class="fas fa-arrow-up text-primary"></i> Gesendet (Mbps)</strong> </div>
<span>Min: {{ statistics[iface.name].tx.min }}</span> <div v-if="problemData.resolved.length > 0">
<span>Avg: {{ statistics[iface.name].tx.avg }}</span> <h5 class="mt-4">Behobene Probleme (letzte 7 Tage)</h5>
<span>Median: {{ statistics[iface.name].tx.median }}</span> <div class="problems-list resolved">
<span>Max: {{ statistics[iface.name].tx.max }}</span> <div v-for="p in problemData.resolved" :key="p.eventid" class="problem-card sev-resolved">
<span>95%: {{ statistics[iface.name].tx.p95 }}</span> <div class="problem-icon"><i class="fas fa-check-circle"></i></div>
<div class="problem-details">
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="activeTab === 'problems'"> <div v-if="activeTab === 'configuration'">
<div v-if="loading.problems" class="text-center p-4"><div class="spinner-border"></div></div> <div v-if="loading.configuration" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="problemData.length === 0" class="alert alert-success text-center">Keine aktuellen Probleme für dieses Gerät gefunden.</div> <div v-else>
<div v-else class="problems-list"> <tt-card class="mb-4">
<div v-for="p in problemData" :key="p.eventid" class="problem-card" :class="getSeverityClass(p.severity)"> <template v-slot:header><h6><i class="fas fa-fingerprint"></i> SNMP Konfiguration</h6></template>
<div class="problem-icon"><i :class="getSeverityIcon(p.severity)"></i></div> <div v-if="!configData.snmp" class="alert alert-warning">Keine SNMP-Schnittstelle auf diesem Host gefunden.</div>
<div class="problem-details"> <div v-else>
<div class="problem-header"> <tt-select label="Version" v-model="configData.snmp.details.version" :options="[{text: 'SNMPv1', value: '1'}, {text: 'SNMPv2c', value: '2'}, {text: 'SNMPv3', value: '3'}]" />
<strong class="problem-name">{{ p.name }}</strong> <tt-input v-if="configData.snmp.details.version < 3" label="Community String" v-model="configData.snmp.details.community" type="text" />
<span class="problem-time">{{ moment(p.clock * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span> <template v-if="configData.snmp.details.version == 3">
</div> <tt-input label="Security Name" v-model="configData.snmp.details.securityname" type="text"/>
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div> <tt-select label="Security Level" v-model="configData.snmp.details.securitylevel" :options="snmpV3Levels" />
<template v-if="configData.snmp.details.securitylevel !== '0'">
<tt-select label="Auth Protocol" v-model="configData.snmp.details.authprotocol" :options="snmpV3Auth" />
<tt-input label="Auth Passphrase" v-model="authPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
</template>
<template v-if="configData.snmp.details.securitylevel === '2'">
<tt-select label="Privacy Protocol" v-model="configData.snmp.details.privprotocol" :options="snmpV3Priv" />
<tt-input label="Privacy Passphrase" v-model="privPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
</template>
</template>
<button class="btn btn-primary mt-3" @click="saveSnmpConfig"><i class="fas fa-save"></i> Speichern</button>
</div> </div>
</div> </tt-card>
<tt-card>
<template v-slot:header><h6><i class="fas fa-bell"></i> Schnittstellen-Alarmierung (Link-Status)</h6></template>
<div class="table-responsive" style="max-height: 500px;">
<table class="table table-sm table-hover">
<thead><tr><th>Schnittstelle</th><th class="text-center">Alarmierung aktiv</th></tr></thead>
<tbody>
<tr v-for="iface in configData.interfaces" :key="iface.itemid">
<td>{{ iface.name }}</td>
<td class="text-center"><tt-switch v-model="iface.isAlarmed" @input="toggleInterfaceAlarm(iface)"></tt-switch></td>
</tr>
</tbody>
</table>
</div>
</tt-card>
</div> </div>
</div> </div>
</div> </div>
@@ -125,115 +226,163 @@ Vue.component('device-monitoring-modal', {
tabs: [ tabs: [
{ id: 'overview', name: 'Übersicht', icon: 'fas fa-tachometer-alt' }, { id: 'overview', name: 'Übersicht', icon: 'fas fa-tachometer-alt' },
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' }, { id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
// { id: 'reports', name: 'Reports', icon: 'fas fa-chart-pie' },
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' }, { id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
{ id: 'configuration', name: 'Konfiguration', icon: 'fas fa-cogs' },
], ],
loading: { overview: false, interfaces: false, problems: false, individualInterfaces: {} }, loading: { overview: true, interfaces: false, problems: false, configuration: false, reports: false, individualInterfaces: {} },
generalData: null, generalData: null,
problemData: [], problemData: { current: [], resolved: [] },
allInterfaces: [], allInterfaces: [],
selectedInterfaces: [], selectedInterfaces: [],
interfaceTimeRange: '24h', interfaceTimeRange: '24h',
timeRanges: [ timeRanges: [{ text: '6H', value: '6h' }, { text: '24H', value: '24h' }, { text: '7T', value: '7d' }, { text: '30T', value: '30d' }],
{ text: '6H', value: '6h' }, { text: '24H', value: '24h' },
{ text: '7T', value: '7d' }, { text: '30T', value: '30d' },
],
interfaceChartData: {}, interfaceChartData: {},
chartInstances: {}, chartInstances: {},
dataNormalizationMode: 'avg', dataNormalizationMode: 'avg',
downsampleThreshold: 500, downsampleThreshold: 500,
configData: { snmp: null, interfaces: [] },
snmpV3Levels: [{text: 'noAuthNoPriv', value: '0'}, {text: 'authNoPriv', value: '1'}, {text: 'authPriv', value: '2'}],
snmpV3Auth: [{text: 'MD5', value: '0'}, {text: 'SHA-1', value: '1'}],
snmpV3Priv: [{text: 'DES', value: '0'}, {text: 'AES-128', value: '1'}],
authPassphrase: '',
privPassphrase: '',
reportData: [],
reportTimeRange: '7d',
reportTimeRanges: [{ text: 'Letzte 7 Tage', value: '7d' }, { text: 'Letzte 30 Tage', value: '30d' }],
reportSortKey: 'name',
reportSortDir: 'asc',
}; };
}, },
computed: { computed: {
interfaceOptions() { interfaceOptions() { return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name })); },
return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name })); selectedInterfacesData() { return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name)); },
},
selectedInterfacesData() {
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
},
displayChartData() {
const processedData = {};
for (const itemId in this.interfaceChartData) {
const data = this.interfaceChartData[itemId];
if (data.length > this.downsampleThreshold) {
processedData[itemId] = this.downsampleData(data, this.dataNormalizationMode);
} else {
processedData[itemId] = data;
}
}
return processedData;
},
statistics() { statistics() {
if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {}; if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {};
const stats = {}; const stats = {};
this.selectedInterfacesData.forEach(iface => { this.selectedInterfacesData.forEach(iface => {
const calculate = (data) => { const calculate = (data) => {
if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', median: 'N/A', p95: 'N/A' }; if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', p95: 'N/A' };
const values = data.map(p => p.y); const values = data.map(p => p.y);
const sorted = [...values].sort((a, b) => a - b); const sorted = [...values].sort((a, b) => a - b);
const sum = values.reduce((acc, val) => acc + val, 0); const sum = values.reduce((acc, val) => acc + val, 0);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
const p95 = this.calculateNormalized95thPercentile(data);
return { return {
min: this.formatStat(sorted[0]), min: this.formatStat(sorted[0]),
max: this.formatStat(sorted[sorted.length - 1]), max: this.formatStat(sorted[sorted.length - 1]),
avg: this.formatStat(sum / values.length), avg: this.formatStat(sum / values.length),
median: this.formatStat(median), p95: this.formatStat(sorted[Math.floor(sorted.length * 0.95)]),
p95: this.formatStat(p95),
}; };
}; };
stats[iface.name] = { stats[iface.name] = { rx: calculate(this.interfaceChartData[iface.rx?.itemid]), tx: calculate(this.interfaceChartData[iface.tx?.itemid]) };
rx: calculate(this.interfaceChartData[iface.rx?.itemid]),
tx: calculate(this.interfaceChartData[iface.tx?.itemid]),
};
}); });
return stats; return stats;
},
sortedReportData() {
if (!this.reportData) return [];
return [...this.reportData].sort((a, b) => {
let aVal = this.reportSortKey.split('.').reduce((o, i) => o[i], a);
let bVal = this.reportSortKey.split('.').reduce((o, i) => o[i], b);
if (typeof aVal === 'string' && aVal.toLowerCase() === 'n/a') aVal = -1;
if (typeof bVal === 'string' && bVal.toLowerCase() === 'n/a') bVal = -1;
let modifier = this.reportSortDir === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * modifier;
if (aVal > bVal) return 1 * modifier;
return 0;
});
} }
}, },
async mounted() { async mounted() {
// We need chartjs-plugin-zoom for this to work. Assuming it's globally available. if (typeof Chart.register === 'function' && window.ChartZoom) Chart.register(window.ChartZoom);
if (typeof Chart.register === 'function' && window.ChartZoom) {
Chart.register(window.ChartZoom);
}
moment.locale('de'); moment.locale('de');
this.fetchTabData(); this.fetchTabData();
}, },
beforeDestroy() {
this.destroyAllCharts();
},
methods: { methods: {
formatStat: val => typeof val === 'number' ? val.toFixed(2) : val, formatStat: val => typeof val === 'number' ? val.toFixed(2) : val,
formatUptime: s => `${Math.floor(s/(3600*24))}t ${Math.floor(s%(3600*24)/3600)}h ${Math.floor(s%3600/60)}m`, formatUptime: s => `${Math.floor(s/(3600*24))}t ${Math.floor(s%(3600*24)/3600)}h ${Math.floor(s%3600/60)}m`,
formatGeneralValue: item => (item.units === 's') ? parseFloat(item.value).toFixed(3) : (item.units === '%') ? parseFloat(item.value).toFixed(2) : item.value, formatGeneralValue: item => (item.units === 's') ? parseFloat(item.value).toFixed(3) : (item.units === '%') ? parseFloat(item.value).toFixed(2) : item.value,
getSeverityClass: s => ['sev-info', 'sev-info', 'sev-warning', 'sev-average', 'sev-high', 'sev-disaster'][s] || 'sev-info', getSeverityClass: s => ['sev-info', 'sev-info', 'sev-warning', 'sev-average', 'sev-high', 'sev-disaster'][s] || 'sev-info',
getSeverityIcon: s => ['fa-info-circle', 'fa-info-circle', 'fa-exclamation-circle', 'fa-exclamation-triangle', 'fa-radiation-alt', 'fa-biohazard'][s] || 'fa-info-circle', getSeverityIcon: s => ['fa-info-circle', 'fa-info-circle', 'fa-exclamation-circle', 'fa-exclamation-triangle', 'fa-radiation-alt', 'fa-biohazard'][s] || 'fa-info-circle',
async fetchTabData() { async fetchTabData() {
const tab = this.activeTab; const tab = this.activeTab;
if (this.loading[tab]) return; if (this.loading[tab] && tab !== 'overview') return;
this.loading[tab] = true; this.loading[tab] = true;
try { try {
if (tab === 'overview' && !this.generalData) { if (tab === 'overview') {
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } }); this.generalData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } })).data;
this.generalData = res.data;
} else if (tab === 'interfaces' && this.allInterfaces.length === 0) { } else if (tab === 'interfaces' && this.allInterfaces.length === 0) {
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/listInterfaces`, { params: { hostId: this.hostId } }); this.allInterfaces = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/listInterfaces`, { params: { hostId: this.hostId } })).data;
this.allInterfaces = res.data; } else if (tab === 'problems') {
} else if (tab === 'problems' && this.problemData.length === 0) { this.problemData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } })).data;
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } }); } else if (tab === 'configuration') {
this.problemData = res.data; this.configData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getConfigurationData`, { params: { hostId: this.hostId } })).data;
} else if (tab === 'reports') {
await this.fetchReportData();
} }
} catch (e) { console.error(`Failed to load data for tab ${tab}`, e); window.notify('error', `Laden von ${tab}-Daten fehlgeschlagen.`); } } catch (e) { console.error(`Failed to load data for tab ${tab}`, e); window.notify('error', `Laden von ${tab}-Daten fehlgeschlagen.`); }
finally { this.loading[tab] = false; } finally { this.loading[tab] = false; }
}, },
async saveSnmpConfig() {
const detailsToSave = JSON.parse(JSON.stringify(this.configData.snmp.details));
if (this.authPassphrase) detailsToSave.authpassphrase = this.authPassphrase;
else delete detailsToSave.authpassphrase;
if (this.privPassphrase) detailsToSave.privpassphrase = this.privPassphrase;
else delete detailsToSave.privpassphrase;
try {
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateSnmp`, {
interfaceId: this.configData.snmp.interfaceid,
details: detailsToSave
});
window.notify('success', 'SNMP-Konfiguration gespeichert.');
this.authPassphrase = '';
this.privPassphrase = '';
} catch(e) { window.notify('error', 'Fehler beim Speichern der SNMP-Konfiguration.'); }
},
async toggleInterfaceAlarm(iface) {
try {
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateInterfaceAlarm`, { hostId: this.hostId, item: iface, enabled: iface.isAlarmed });
window.notify('success', `Alarm für ${iface.name} ${iface.isAlarmed ? 'aktiviert' : 'deaktiviert'}.`);
} catch(e) { window.notify('error', 'Fehler beim Ändern des Alarms.'); iface.isAlarmed = !iface.isAlarmed; }
},
async fetchReportData() {
this.loading.reports = true;
try {
this.reportData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getReportData`, { params: { hostId: this.hostId, timeRange: this.reportTimeRange } })).data;
} catch (e) {
window.notify('error', 'Fehler beim Laden der Report-Daten.');
} finally {
this.loading.reports = false;
}
},
sortReport(key) {
if (this.reportSortKey === key) {
this.reportSortDir = this.reportSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.reportSortKey = key;
this.reportSortDir = 'asc';
}
},
getSortIcon(key) {
if (this.reportSortKey !== key) return 'fas fa-sort';
return this.reportSortDir === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
},
async handleInterfaceSelectionChange(newSelection, oldSelection) {
const added = newSelection.filter(name => !oldSelection.includes(name));
const removed = oldSelection.filter(name => !newSelection.includes(name));
removed.forEach(name => {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
if (this.chartInstances[iface.name]) {
this.chartInstances[iface.name].destroy();
delete this.chartInstances[iface.name];
}
}
});
for (const name of added) await this.fetchAndRenderInterface(this.allInterfaces.find(i => i.name === name));
},
async fetchAndRenderInterface(iface) { async fetchAndRenderInterface(iface) {
const itemIds = [iface.rx?.itemid, iface.tx?.itemid].filter(Boolean); const itemIds = [iface.rx?.itemid, iface.tx?.itemid].filter(Boolean);
if (itemIds.length === 0) return; if (itemIds.length === 0) return;
this.$set(this.loading.individualInterfaces, iface.name, true); this.$set(this.loading.individualInterfaces, iface.name, true);
try { try {
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange }); const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
@@ -243,175 +392,25 @@ Vue.component('device-monitoring-modal', {
} catch(e) { console.error(`Failed to fetch history for ${iface.name}`, e); } } catch(e) { console.error(`Failed to fetch history for ${iface.name}`, e); }
finally { this.$set(this.loading.individualInterfaces, iface.name, false); } finally { this.$set(this.loading.individualInterfaces, iface.name, false); }
}, },
renderChart(iface) {
async handleInterfaceSelectionChange(newSelection, oldSelection) { this.$nextTick(() => {
const added = newSelection.filter(name => !oldSelection.includes(name)); const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
const removed = oldSelection.filter(name => !newSelection.includes(name)); if (!canvas) return;
if (this.chartInstances[iface.name]) this.chartInstances[iface.name].destroy();
removed.forEach(name => { this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), { /* ... Chart options ... */ });
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
this.destroyChart(iface.name);
delete this.interfaceChartData[iface.rx?.itemid];
delete this.interfaceChartData[iface.tx?.itemid];
}
}); });
for (const name of added) {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
await this.fetchAndRenderInterface(iface);
}
}
},
async handleTimeOrNormalizationChange() {
this.destroyAllCharts();
this.interfaceChartData = {};
this.loading.interfaces = true;
const interfacesToFetch = this.selectedInterfacesData;
for (const iface of interfacesToFetch) {
await this.fetchAndRenderInterface(iface);
}
this.loading.interfaces = false;
},
async renderChart(iface) {
let tries = 0;
while (!this.$refs['chartCanvas-' + iface.name]?.[0] && tries < 10) {
console.log(typeof this.$refs['chartCanvas-' + iface.name]?.[0]);
await Promise.all([
this.$nextTick(),
new Promise(resolve => setTimeout(resolve, 100))
]);
}
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
console.log(canvas, this.$refs);
if (!canvas) return;
if (this.chartInstances[iface.name]) {
this.chartInstances[iface.name].destroy();
}
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [
{
label: 'Empfangen',
data: this.displayChartData[iface.rx?.itemid] || [],
borderColor: '#4CAF50',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(76, 175, 80, 0.2)',
pointRadius: 0,
tension: 0.1
},
{
label: 'Gesendet',
data: this.displayChartData[iface.tx?.itemid] || [],
borderColor: '#2196F3',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(33, 150, 243, 0.2)',
pointRadius: 0,
tension: 0.1
}
]
},
options: {
responsive: true, maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
type: 'time',
time: { tooltipFormat: 'DD.MM.YYYY HH:mm:ss' },
adapters: { date: { locale: 'de' } }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Mbps' }
}
},
plugins: {
legend: {
display: true, position: 'bottom',
labels: { boxWidth: 12, font: { size: 10 } }
},
zoom: {
pan: { enabled: true, mode: 'x' },
zoom: { wheel: { enabled: false }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
}
}
}
});
},
downsampleData(data, mode) {
const bucketSize = Math.ceil(data.length / this.downsampleThreshold);
const downsampled = [];
for (let i = 0; i < data.length; i += bucketSize) {
const chunk = data.slice(i, i + bucketSize);
if (chunk.length === 0) continue;
const representativeX = chunk[Math.floor(chunk.length / 2)].x;
let representativeY;
if (mode === 'max') {
representativeY = Math.max(...chunk.map(p => p.y));
} else { // avg
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
representativeY = sum / chunk.length;
}
downsampled.push({ x: representativeX, y: representativeY });
}
return downsampled;
},
calculateNormalized95thPercentile(data) {
if (!data || data.length < 3) return null;
const averagedValues = [];
for (let i = 0; i <= data.length - 3; i += 3) {
const chunk = data.slice(i, i + 3);
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
averagedValues.push(sum / 3);
}
if (averagedValues.length === 0) return null;
const sorted = averagedValues.sort((a, b) => a - b);
const index = Math.floor(sorted.length * 0.95);
return sorted[index];
}, },
openLiveChartPopup(iface) { openLiveChartPopup(iface) {
const url = `${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/liveGraphPage?rx_id=${iface.rx?.itemid || ''}&tx_id=${iface.tx?.itemid || ''}&name=${encodeURIComponent(iface.name)}`; const url = `${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/liveGraphPage?rx_id=${iface.rx?.itemid || ''}&tx_id=${iface.tx?.itemid || ''}&name=${encodeURIComponent(iface.name)}`;
window.open(url, `livegraph_${iface.name.replace(/[^a-zA-Z0-9]/g, "_")}`, 'width=800,height=450,resizable=yes,scrollbars=yes'); window.open(url, `livegraph_${iface.name.replace(/[^a-zA-Z0-9]/g, "_")}`, 'width=800,height=450,resizable=yes,scrollbars=yes');
}, },
destroyChart(name) { resetAllChartsZoom() { Object.values(this.chartInstances).forEach(chart => chart.resetZoom()); },
if (this.chartInstances[name]) {
this.chartInstances[name].destroy();
delete this.chartInstances[name];
}
},
destroyAllCharts() {
Object.values(this.chartInstances).forEach(c => c.destroy());
this.chartInstances = {};
},
resetAllChartsZoom() {
Object.values(this.chartInstances).forEach(chart => {
chart.resetZoom();
});
}
}, },
watch: { watch: {
activeTab: 'fetchTabData', activeTab: 'fetchTabData',
selectedInterfaces(newVal, oldVal) { selectedInterfaces(newVal, oldVal) { this.handleInterfaceSelectionChange(newVal, oldVal); },
this.handleInterfaceSelectionChange(newVal, oldVal); interfaceTimeRange() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
}, dataNormalizationMode() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
interfaceTimeRange: 'handleTimeOrNormalizationChange', reportTimeRange: 'fetchReportData'
dataNormalizationMode: 'handleTimeOrNormalizationChange',
} }
}); });