added zabbix integration for statistics
This commit is contained in:
112
Layout/default/Device/LiveGraph.php
Normal file
112
Layout/default/Device/LiveGraph.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live-Graph</title>
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; width: 100%; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||
.chart-container { position: relative; width: 100%; height: 100%; padding: 10px; box-sizing: border-box; }
|
||||
.loader { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
@keyframes blinker { 50% { opacity: 0; } }
|
||||
.blink_me { animation: blinker 1s linear infinite; color: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chart-container">
|
||||
<canvas id="liveChartCanvas"></canvas>
|
||||
<div class="loader" id="loader">
|
||||
<p>Lade initiale Daten...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/plugins/axios/axios.min.js"></script>
|
||||
<script src="/plugins/moment/moment.min.js"></script>
|
||||
<script src="/plugins/chart.js/chart.4.4.6.js"></script>
|
||||
<script src="/plugins/chart.js/chartjs-adapter-moment.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rxId = params.get('rx_id');
|
||||
const txId = params.get('tx_id');
|
||||
const interfaceName = params.get('name');
|
||||
const apiBaseUrl = '<?= $API_BASE_URL ?>';
|
||||
|
||||
let chartInstance = null;
|
||||
let pollInterval = null;
|
||||
|
||||
document.title = `Live: ${interfaceName}`;
|
||||
|
||||
const addPointToChart = (datasetIndex, newPoint) => {
|
||||
const dataset = chartInstance.data.datasets[datasetIndex].data;
|
||||
if (dataset.length > 0 && dataset[dataset.length - 1].x >= newPoint.x) return;
|
||||
dataset.push(newPoint);
|
||||
if (dataset.length > 120) dataset.shift(); // Keep rolling 10-minute window (120 points @ 5s interval)
|
||||
};
|
||||
|
||||
const fetchLatestValue = async (itemId) => {
|
||||
try {
|
||||
const res = await axios.get(`${apiBaseUrl}/liveData`, { params: { itemId } });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch live data for item ${itemId}`, e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
let isPolling = false;
|
||||
const startPolling = () => {
|
||||
pollInterval = setInterval(async () => {
|
||||
if (isPolling) return; // Prevent overlapping requests
|
||||
isPolling = true;
|
||||
const [rxPoint, txPoint] = await Promise.all([
|
||||
fetchLatestValue(rxId),
|
||||
fetchLatestValue(txId)
|
||||
]);
|
||||
if (rxPoint) addPointToChart(0, rxPoint);
|
||||
if (txPoint) addPointToChart(1, txPoint);
|
||||
chartInstance.update('none');
|
||||
isPolling = false;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const initChart = (initialData) => {
|
||||
document.getElementById('loader').style.display = 'none';
|
||||
const ctx = document.getElementById('liveChartCanvas').getContext('2d');
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: 'Empfangen', data: initialData[rxId] || [], borderColor: '#4CAF50', borderWidth: 1.5, pointRadius: 1, tension: 0.2, fill: true, backgroundColor: 'rgba(76, 175, 80, 0.1)' },
|
||||
{ label: 'Gesendet', data: initialData[txId] || [], borderColor: '#2196F3', borderWidth: 1.5, pointRadius: 1, tension: 0.2, fill: true, backgroundColor: 'rgba(33, 150, 243, 0.1)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { type: 'time', time: { unit: 'minute', tooltipFormat: 'HH:mm:ss' } },
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Mbps' } }
|
||||
},
|
||||
plugins: {
|
||||
title: { display: true, text: `Live-Traffic für ${interfaceName}`},
|
||||
legend: { position: 'bottom' }
|
||||
}
|
||||
}
|
||||
});
|
||||
startPolling();
|
||||
};
|
||||
|
||||
// Fetch initial data for the last 10 minutes
|
||||
axios.post(`${apiBaseUrl}/interfaceData`, { itemIds: [rxId, txId], timeRange: '10m' })
|
||||
.then(res => initChart(res.data))
|
||||
.catch(err => {
|
||||
document.getElementById('loader').innerText = "Fehler beim Laden der initialen Daten.";
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => clearInterval(pollInterval));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,6 +49,11 @@ class DeviceController extends mfBaseController
|
||||
];
|
||||
}, DevicetypeModel::getAll());
|
||||
|
||||
$this->layout()->set('additionalJS', [
|
||||
"plugins/chart.js/chart.4.4.6.js",
|
||||
"plugins/chart.js/chartjs-adapter-moment.min.js"
|
||||
]);
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl(""),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
|
||||
179
application/DeviceMonitoring/DeviceMonitoringController.php
Normal file
179
application/DeviceMonitoring/DeviceMonitoringController.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
class DeviceMonitoringController extends mfBaseController
|
||||
{
|
||||
private string $ZABBIX_API_URL = ZABBIX_API_URL;
|
||||
private string $ZABBIX_API_KEY = ZABBIX_API_KEY;
|
||||
private ?Zabbix $zabbix = null;
|
||||
|
||||
protected function init(): void
|
||||
{
|
||||
$this->needlogin = true;
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$this->layout()->set("me", $me);
|
||||
$this->me = $me;
|
||||
|
||||
if (!$this->me->is("Admin")) {
|
||||
http_response_code(403);
|
||||
self::returnJson(['success' => false, 'message' => 'Permission denied']);
|
||||
die();
|
||||
}
|
||||
|
||||
if (defined('ZABBIX_API_URL') && defined('ZABBIX_API_KEY') && ZABBIX_API_URL && ZABBIX_API_KEY) {
|
||||
$this->zabbix = new Zabbix($this->ZABBIX_API_URL, $this->ZABBIX_API_KEY);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
self::returnJson(['error' => 'Zabbix API is not configured on the server.']);
|
||||
die();
|
||||
}
|
||||
$this->postData = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all available interfaces, grouping Sent/Received items.
|
||||
*/
|
||||
protected function listInterfacesAction()
|
||||
{
|
||||
$hostId = $this->request->hostId;
|
||||
$items = $this->zabbix->getHostInterfaceItems($hostId);
|
||||
$interfaces = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$baseName = preg_replace('/:\s*Bits\s*(sent|received)$/i', '', $item['name']);
|
||||
$direction = str_contains(strtolower($item['name']), 'received') ? 'rx' : 'tx';
|
||||
|
||||
if (!isset($interfaces[$baseName])) {
|
||||
$interfaces[$baseName] = ['name' => $baseName, 'rx' => null, 'tx' => null];
|
||||
}
|
||||
$interfaces[$baseName][$direction] = $item;
|
||||
}
|
||||
|
||||
$sortedInterfaces = array_values($interfaces);
|
||||
usort($sortedInterfaces, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
self::returnJson($sortedInterfaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets historical data for a specific list of item IDs.
|
||||
*/
|
||||
protected function interfaceDataAction()
|
||||
{
|
||||
$itemIds = $this->postData['itemIds'] ?? [];
|
||||
if (empty($itemIds)) {
|
||||
self::returnJson([]);
|
||||
return;
|
||||
}
|
||||
$timeRange = $this->postData['timeRange'] ?? '24h';
|
||||
|
||||
$time_from = strtotime('-' . str_replace(['m', 'h', 'd'], [' minutes', ' hours', ' days'], $timeRange));
|
||||
|
||||
$params = [
|
||||
'itemids' => $itemIds,
|
||||
'output' => 'extend',
|
||||
'history' => 3, // Numeric (unsigned)
|
||||
'sortfield' => 'clock',
|
||||
'sortorder' => 'ASC',
|
||||
'time_from' => $time_from,
|
||||
];
|
||||
$history = $this->zabbix->zabbixRequest('history.get', $params)['result'] ?? [];
|
||||
|
||||
$historyByItemId = [];
|
||||
foreach ($history as $point) {
|
||||
$historyByItemId[$point['itemid']][] = [
|
||||
'x' => intval($point['clock']) * 1000,
|
||||
'y' => round(floatval($point['value']) / 1000000, 2)
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson($historyByItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets general monitoring data (Uptime, Ping, Temp).
|
||||
*/
|
||||
protected function generalDataAction() {
|
||||
$hostId = $this->request->hostId;
|
||||
|
||||
$itemsToFetch = [
|
||||
'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() {
|
||||
$hostId = $this->request->hostId;
|
||||
$problems = $this->zabbix->zabbixRequest('problem.get', [
|
||||
'hostids' => $hostId,
|
||||
'output' => 'extend',
|
||||
'recent' => true, // Use boolean true
|
||||
'sortfield' => ['eventid'],
|
||||
'sortorder' => 'DESC'
|
||||
])['result'] ?? [];
|
||||
|
||||
self::returnJson($problems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a Zabbix item check and returns the latest value for live graphs.
|
||||
*/
|
||||
protected function liveDataAction() {
|
||||
$itemId = $this->request->itemId;
|
||||
if(empty($itemId)) {
|
||||
http_response_code(400);
|
||||
self::returnJson(['error' => 'Item ID is required.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->zabbix->createTask($itemId);
|
||||
sleep(3);
|
||||
$history = $this->zabbix->getItemValues([$itemId], 1);
|
||||
|
||||
if(empty($history)) {
|
||||
self::returnJson(null);
|
||||
return;
|
||||
}
|
||||
|
||||
$point = $history[0];
|
||||
$formattedPoint = [
|
||||
'x' => intval($point['clock']) * 1000,
|
||||
'y' => round(floatval($point['value']) / 1000000, 2)
|
||||
];
|
||||
|
||||
self::returnJson($formattedPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dedicated HTML page for the live graph popup.
|
||||
*/
|
||||
public function liveGraphPageAction() {
|
||||
$this->layout(false);
|
||||
$this->layout()->set('API_BASE_URL', self::getUrl("DeviceMonitoring"));
|
||||
$this->layout()->setTemplate('Device/LiveGraph');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @property mixed|null $name
|
||||
*/
|
||||
class Graphing extends mfBaseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
class GraphingController extends mfBaseController{
|
||||
private string $ZABBIX_API_URL = ZABBIX_API_URL;
|
||||
private string $ZABBIX_API_KEY = ZABBIX_API_KEY;
|
||||
private Zabbix $zabbix;
|
||||
|
||||
protected function init(): void {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$this->layout()->set("me", $me);
|
||||
$this->me = $me;
|
||||
|
||||
if (!$this->me->isAdmin()) {
|
||||
$this->redirect("dashboard");
|
||||
}
|
||||
|
||||
$this->zabbix = new Zabbix($this->ZABBIX_API_URL, $this->ZABBIX_API_KEY);
|
||||
}
|
||||
|
||||
protected function indexAction() {
|
||||
$this->layout()->set('additionalJS', ["plugins/chart.js/chart.4.4.6.js", "plugins/chart.js/chartjs-adapter-moment.min.js"]);
|
||||
Helper::renderVue($this, "DeviceGraphing", $this->mod, []);
|
||||
}
|
||||
|
||||
|
||||
protected function dataAction() {
|
||||
header('Content-Type: application/json');
|
||||
$hostId = $this->request->hostId;
|
||||
$hostInterfaceItems = $this->zabbix->getHostInterfaceItems($hostId, '');
|
||||
// limit to 25 items
|
||||
$hostInterfaceItems = array_slice($hostInterfaceItems, 0, 25);
|
||||
|
||||
$itemIds = array_map(function($item) {
|
||||
return $item['itemid'];
|
||||
}, $hostInterfaceItems);
|
||||
|
||||
|
||||
$itemValues = $this->zabbix->getItemValues($itemIds, 1000);
|
||||
|
||||
$out = [];
|
||||
foreach ($hostInterfaceItems as $item) {
|
||||
$out[$item['itemid']] = [
|
||||
'name' => str_replace('Bits', 'Mbps', $item['name']),
|
||||
'units' => $item['units'],
|
||||
'values' => []
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($itemValues as $itemValue) {
|
||||
$out[$itemValue['itemid']]['values'][] = [
|
||||
'clock' => $itemValue['clock'],
|
||||
'value' => $itemValue['value'] / 1000000
|
||||
];
|
||||
}
|
||||
|
||||
// sort by name
|
||||
uasort($out, function($a, $b) {
|
||||
return strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
|
||||
die(json_encode($out));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class Zabbix {
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
public function zabbixRequest($method, $params) {
|
||||
public function zabbixRequest($method, $params, $die = false) {
|
||||
$data = array(
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => $method,
|
||||
@@ -26,6 +26,13 @@ class Zabbix {
|
||||
)
|
||||
);
|
||||
|
||||
// var dump options and data and die
|
||||
if ($die) {
|
||||
var_dump($options);
|
||||
echo json_encode($data);
|
||||
die();
|
||||
}
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($this->url, false, $context);
|
||||
|
||||
|
||||
21
public/js/pages/Device/Device.css
Normal file
21
public/js/pages/Device/Device.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.monitoring-tabs { display: flex; border-bottom: 1px solid #dee2e6; }
|
||||
.monitoring-tabs button { background: none; border: none; padding: 10px 15px; cursor: pointer; border-bottom: 3px solid transparent; font-size: 0.9rem; color: #495057; }
|
||||
.monitoring-tabs button:hover { color: #0056b3; }
|
||||
.monitoring-tabs button.active { border-bottom-color: #007bff; color: #007bff; font-weight: bold; }
|
||||
.monitoring-content { padding: 15px; min-height: 400px; }
|
||||
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; }
|
||||
.chart-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 15px; }
|
||||
.chart-title { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.problems-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.problem-card { display: flex; align-items: center; padding: 10px; border-radius: 5px; border-left-width: 5px; border-left-style: solid; background-color: #f8f9fa; }
|
||||
.problem-icon { font-size: 1.5rem; margin-right: 15px; width: 30px; text-align: center; }
|
||||
.problem-details { flex-grow: 1; }
|
||||
.problem-header { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.problem-name { font-weight: 500; }
|
||||
.problem-time { font-size: 0.8rem; color: #6c757d; }
|
||||
.problem-opdata { font-size: 0.85rem; color: #495057; margin-top: 4px; }
|
||||
.sev-info { border-left-color: #17a2b8; } .sev-info .problem-icon { color: #17a2b8; }
|
||||
.sev-warning { border-left-color: #ffc107; } .sev-warning .problem-icon { color: #ffc107; }
|
||||
.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-disaster { border-left-color: #7B014C; } .sev-disaster .problem-icon { color: #7B014C; }
|
||||
@@ -68,45 +68,49 @@ Vue.component('device-view-switch', {
|
||||
Vue.component('DeviceTable', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<tt-table :data="window['TT_CONFIG']['DEVICES']" :config="DeviceTableConfig" excel-export>
|
||||
<tt-table :data="window['TT_CONFIG']['DEVICES']" :config="DeviceTableConfig" excel-export>
|
||||
|
||||
<template v-slot:top-buttons v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
||||
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Device/add'">
|
||||
<i class="fas fa-plus"></i>
|
||||
Device hinzufügen
|
||||
</button>
|
||||
</template>
|
||||
<template v-slot:top-buttons v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
||||
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Device/add'">
|
||||
<i class="fas fa-plus"></i>
|
||||
Device hinzufügen
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-slot:name="{ row }">
|
||||
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Device/Detail?id=' + row.id">{{row.name}}</a>
|
||||
</template>
|
||||
<template v-slot:name="{ row }">
|
||||
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Device/Detail?id=' + row.id">{{row.name}}</a>
|
||||
</template>
|
||||
|
||||
<template v-slot:locationtext="{ row }">
|
||||
<template v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
||||
<a target="_blank" :href="row['locationUrl']">{{row.locationText}}</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{row.locationText}}
|
||||
</template>
|
||||
</template>
|
||||
<template v-slot:locationtext="{ row }">
|
||||
<template v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
||||
<a target="_blank" :href="row['locationUrl']">{{row.locationText}}</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{row.locationText}}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-slot:actions="{ row }">
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Device/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Device/delete/?id=' + row.id"
|
||||
onclick="if(!confirm('Device wirklich löschen?')) return false;"
|
||||
class="text-danger"
|
||||
title="Löschen"><i class="fas fa-trash "></i></a>
|
||||
<template v-slot:actions="{ row }">
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Device/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Device/delete/?id=' + row.id"
|
||||
onclick="if(!confirm('Device wirklich löschen?')) return false;"
|
||||
class="text-danger"
|
||||
title="Löschen"><i class="fas fa-trash "></i></a>
|
||||
|
||||
<template v-if="row.zabbix_host_id !== '0' && row.zabbix_host_id !== null">
|
||||
<a :href="window['TT_CONFIG']['ZABBIX_URL'] + '/zabbix.php?action=latest.view&hostids%5B%5D=' + row.zabbix_host_id"
|
||||
target="_blank"
|
||||
class="text-info"
|
||||
title="Zabbix"><i class="fas fa-server"></i></a>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="row.zabbix_host_id !== '0' && row.zabbix_host_id !== null">
|
||||
<a :href="window['TT_CONFIG']['ZABBIX_URL'] + '/zabbix.php?action=latest.view&hostids%5B%5D=' + row.zabbix_host_id"
|
||||
target="_blank"
|
||||
class="text-info"
|
||||
title="Zabbix"><i class="fas fa-server"></i></a>
|
||||
<a href="#" @click.prevent="$emit('show-monitoring', row)"
|
||||
class="text-success"
|
||||
style="margin-left: 5px;"
|
||||
title="Show Graphs"><i class="fas fa-chart-line"></i></a>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
`,
|
||||
</tt-table>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
@@ -249,16 +253,26 @@ Vue.component('DeviceType', {
|
||||
Vue.component('Device', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<tt-card>
|
||||
<device-view-switch v-show="window['TT_CONFIG']['IS_ADMIN'] === '1'" v-model="currentView"></device-view-switch>
|
||||
<tt-card>
|
||||
<device-monitoring-modal
|
||||
v-if="monitoringModalHostId"
|
||||
:host-id="monitoringModalHostId"
|
||||
:device-name="selectedDeviceName"
|
||||
@close="closeMonitoringModal">
|
||||
</device-monitoring-modal>
|
||||
|
||||
<component :is="currentView"></component>
|
||||
</tt-card>
|
||||
`,
|
||||
<device-view-switch v-show="window['TT_CONFIG']['IS_ADMIN'] === '1'" v-model="currentView"></device-view-switch>
|
||||
|
||||
<component :is="currentView" @show-monitoring="showMonitoringModal"></component>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
currentView: 'DeviceTable',
|
||||
// MODIFIED: Add data properties to manage the modal state
|
||||
monitoringModalHostId: null,
|
||||
selectedDeviceName: '',
|
||||
};
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -272,4 +286,15 @@ Vue.component('Device', {
|
||||
window.localStorage.setItem('tt-device-view', this.currentView);
|
||||
}
|
||||
},
|
||||
// MODIFIED: Add methods to handle modal state
|
||||
methods: {
|
||||
showMonitoringModal(deviceRow) {
|
||||
this.selectedDeviceName = deviceRow.name;
|
||||
this.monitoringModalHostId = deviceRow.zabbix_host_id;
|
||||
},
|
||||
closeMonitoringModal() {
|
||||
this.monitoringModalHostId = null;
|
||||
this.selectedDeviceName = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
297
public/js/pages/Device/DeviceMonitoring.js
Normal file
297
public/js/pages/Device/DeviceMonitoring.js
Normal file
@@ -0,0 +1,297 @@
|
||||
Vue.component('device-monitoring-modal', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<tt-modal :show="true"
|
||||
:title="'Monitoring für ' + deviceName"
|
||||
@update:show="$emit('close')"
|
||||
:save="false"
|
||||
:delete="false">
|
||||
|
||||
<div class="monitoring-tabs">
|
||||
<button v-for="tab in tabs" :key="tab.id"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id">
|
||||
<i :class="tab.icon"></i> {{ tab.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="monitoring-content">
|
||||
<div v-if="activeTab === 'overview'">
|
||||
<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 class="overview-grid">
|
||||
<tt-card v-if="generalData.ping" body-overflow-x-auto>
|
||||
<template v-slot:header><h6><i class="fas fa-network-wired"></i> Erreichbarkeit</h6></template>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr v-for="item in generalData.ping" :key="item.name">
|
||||
<td>{{ item.name.replace('ICMP: ', '') }}</td>
|
||||
<td class="text-right"><b>{{ formatGeneralValue(item) }}</b> {{ item.units }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</tt-card>
|
||||
<tt-card v-if="generalData.uptime" body-overflow-x-auto>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</tt-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'interfaces'">
|
||||
<div class="p-2 border-bottom mb-3 d-flex justify-content-between align-items-center">
|
||||
<div style="flex-grow: 1; margin-right: 15px;">
|
||||
<tt-select label="Schnittstellen zur Anzeige auswählen"
|
||||
:options="interfaceOptions"
|
||||
v-model="selectedInterfaces"
|
||||
sm multiple searchable/>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading.interfaces" class="text-center p-4"><div class="spinner-border"></div></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-for="iface in selectedInterfacesData" :key="iface.name" class="chart-card border rounded p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="chart-title">{{ iface.name }}</h6>
|
||||
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph">
|
||||
<i class="fas fa-play-circle"></i> Live
|
||||
</button>
|
||||
</div>
|
||||
<canvas :ref="'chartCanvas-' + iface.name"></canvas>
|
||||
<div v-if="statistics[iface.name]" class="chart-stats">
|
||||
<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>Median: {{ statistics[iface.name].rx.median }}</span>
|
||||
<span>Max: {{ statistics[iface.name].rx.max }}</span>
|
||||
<span>95%: {{ statistics[iface.name].rx.p95 }}</span>
|
||||
</div>
|
||||
<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>Median: {{ statistics[iface.name].tx.median }}</span>
|
||||
<span>Max: {{ statistics[iface.name].tx.max }}</span>
|
||||
<span>95%: {{ statistics[iface.name].tx.p95 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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.length === 0" class="alert alert-success text-center">Keine aktuellen Probleme für dieses Gerät gefunden.</div>
|
||||
<div v-else class="problems-list">
|
||||
<div v-for="p in problemData" :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).format('DD.MM.YYYY HH:mm:ss') }}</span>
|
||||
</div>
|
||||
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</tt-modal>
|
||||
`,
|
||||
props: ['hostId', 'deviceName'],
|
||||
data() {
|
||||
return {
|
||||
moment: window.moment,
|
||||
activeTab: 'overview',
|
||||
tabs: [
|
||||
{ id: 'overview', name: 'Übersicht', icon: 'fas fa-tachometer-alt' },
|
||||
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
|
||||
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
|
||||
],
|
||||
loading: { overview: false, interfaces: false, problems: false },
|
||||
generalData: null,
|
||||
problemData: [],
|
||||
allInterfaces: [],
|
||||
selectedInterfaces: [],
|
||||
interfaceTimeRange: '24h',
|
||||
timeRanges: [
|
||||
{ text: '6H', value: '6h' }, { text: '24H', value: '24h' },
|
||||
{ text: '7T', value: '7d' }, { text: '30T', value: '30d' },
|
||||
],
|
||||
interfaceChartData: {},
|
||||
chartInstances: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
interfaceOptions() {
|
||||
return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name }));
|
||||
},
|
||||
selectedInterfacesData() {
|
||||
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
|
||||
},
|
||||
// NEU: Statistik-Berechnung
|
||||
statistics() {
|
||||
if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {};
|
||||
|
||||
const stats = {};
|
||||
this.selectedInterfacesData.forEach(iface => {
|
||||
const calculate = (data) => {
|
||||
if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', median: 'N/A', p95: 'N/A' };
|
||||
const values = data.map(p => p.y);
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
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];
|
||||
return {
|
||||
min: this.formatStat(sorted[0]),
|
||||
max: this.formatStat(sorted[sorted.length - 1]),
|
||||
avg: this.formatStat(sum / values.length),
|
||||
median: this.formatStat(median),
|
||||
p95: this.formatStat(sorted[Math.floor(sorted.length * 0.95)]),
|
||||
};
|
||||
};
|
||||
stats[iface.name] = {
|
||||
rx: calculate(this.interfaceChartData[iface.rx?.itemid]),
|
||||
tx: calculate(this.interfaceChartData[iface.tx?.itemid]),
|
||||
};
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// GEÄNDERT: moment.locale gesetzt
|
||||
moment.locale('de');
|
||||
this.fetchTabData();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destroyCharts();
|
||||
},
|
||||
methods: {
|
||||
// NEU: formatStat-Helfer
|
||||
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`,
|
||||
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',
|
||||
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() {
|
||||
this.destroyCharts();
|
||||
const tab = this.activeTab;
|
||||
if (this.loading[tab]) return;
|
||||
this.loading[tab] = true;
|
||||
try {
|
||||
if (tab === 'overview' && !this.generalData) {
|
||||
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } });
|
||||
this.generalData = res.data;
|
||||
} 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 = res.data;
|
||||
} else if (tab === 'problems' && this.problemData.length === 0) {
|
||||
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } });
|
||||
this.problemData = res.data;
|
||||
}
|
||||
} 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; }
|
||||
},
|
||||
async fetchInterfaceHistory() {
|
||||
this.destroyCharts();
|
||||
if (this.selectedInterfaces.length === 0) return;
|
||||
const itemIds = this.selectedInterfacesData.flatMap(i => [i.rx?.itemid, i.tx?.itemid]).filter(Boolean);
|
||||
if (itemIds.length === 0) return;
|
||||
|
||||
this.loading.interfaces = true;
|
||||
try {
|
||||
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
|
||||
this.interfaceChartData = res.data;
|
||||
this.$nextTick(() => this.renderAllCharts());
|
||||
} catch(e) { console.error('Failed to fetch interface history', e); }
|
||||
finally { this.loading.interfaces = false; }
|
||||
},
|
||||
renderAllCharts() {
|
||||
this.selectedInterfacesData.forEach(async (iface) => {
|
||||
await this.$nextTick();
|
||||
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
|
||||
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.interfaceChartData[iface.rx?.itemid] || [],
|
||||
borderColor: '#4CAF50',
|
||||
borderWidth: 0,
|
||||
barPercentage: 1,
|
||||
categoryPercentage: 1,
|
||||
fill: true,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.8)',
|
||||
pointRadius: 0,
|
||||
tension: 0.1
|
||||
},
|
||||
{
|
||||
label: 'Gesendet',
|
||||
data: this.interfaceChartData[iface.tx?.itemid] || [],
|
||||
borderColor: '#2196F3',
|
||||
borderWidth: 0,
|
||||
barPercentage: 1,
|
||||
categoryPercentage: 1,
|
||||
fill: true,
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.8)',
|
||||
pointRadius: 0,
|
||||
tension: 0.1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
// GEÄNDERT: Deutsches Datumsformat und Locale
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
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)}`;
|
||||
window.open(url, `livegraph_${iface.name.replace(/[^a-zA-Z0-9]/g, "_")}`, 'width=800,height=450,resizable=yes,scrollbars=yes');
|
||||
},
|
||||
destroyCharts() {
|
||||
Object.values(this.chartInstances).forEach(c => c.destroy());
|
||||
this.chartInstances = {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeTab: 'fetchTabData',
|
||||
selectedInterfaces: 'fetchInterfaceHistory',
|
||||
interfaceTimeRange: 'fetchInterfaceHistory'
|
||||
}
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
Vue.component('tt-graph', {
|
||||
template: `
|
||||
<!-- use chart js with datasets etc everything needed for chart.js here to be usable width and height aswell -->
|
||||
<tt-card>
|
||||
<template v-slot:header>
|
||||
<h3 style="text-align: center;user-select: none">{{ header }}</h3>
|
||||
</template>
|
||||
|
||||
<div ref="container">
|
||||
<canvas ref="chart" :style="{width: width + 'px', height: height + 'px'}"></canvas></div>
|
||||
</tt-card>
|
||||
`,
|
||||
props: ['data', 'labels', 'header'],
|
||||
data() {
|
||||
return {
|
||||
chart: null,
|
||||
width: 400,
|
||||
height: 220
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const ctx = this.$refs.chart.getContext('2d');
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: this.labels,
|
||||
datasets: this.data
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
},
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
// time is epoch
|
||||
parser: 'X',
|
||||
// unit: 'hour',
|
||||
displayFormats: {
|
||||
minute: 'DD.M. HH:mm',
|
||||
}
|
||||
},
|
||||
// min: '00:00:00',
|
||||
// max: '24:00:00',
|
||||
ticks: {
|
||||
autoSkipPadding: 25,
|
||||
autoSkip: true,
|
||||
maxRotation: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// set width and height to the canvas element actual width and height
|
||||
this.width = this.$refs.container.width;
|
||||
this.height = this.$refs.container.height;
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('device-graphing', {
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: 45vw 45vw; gap: 1rem;">
|
||||
<h3 style="text-align: center;user-select: none;grid-column: 1 / span 2;">{{ hostname }}</h3>
|
||||
|
||||
<tt-loader v-if="graphs.length === 0"></tt-loader>
|
||||
|
||||
<template v-for="graph in graphs">
|
||||
<tt-graph :data="graph.data" :labels="graph.labels" :header="graph.name"></tt-graph>
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
graphs: [],
|
||||
hostname: ''
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// get hostname from url params
|
||||
this.hostname = new URLSearchParams(window.location.search).get('hostname');
|
||||
console.log(this.hostname);
|
||||
// get id from url params
|
||||
const id = new URLSearchParams(window.location.search).get('id');
|
||||
const response = await axios.get('/Graphing/data?id=' + id);
|
||||
const data = response.data;
|
||||
|
||||
const graphs = {};
|
||||
|
||||
for (const item in data) {
|
||||
// Create graphs.[item.name.split(':')[0]] if it doesn't exist
|
||||
if (!graphs[data[item].name.split(':')[0]]) {
|
||||
graphs[data[item].name.split(':')[0]] = {
|
||||
name: data[item].name.split(':')[0],
|
||||
data: [],
|
||||
labels: []
|
||||
}
|
||||
}
|
||||
|
||||
// Add the data to the graph but check if it's received or sent and already exists
|
||||
|
||||
if (data[item].name.split(':')[1].includes('received') && graphs[data[item].name.split(':')[0]].data.find(data => data.label.includes('received'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data[item].name.split(':')[1].includes('sent') && graphs[data[item].name.split(':')[0]].data.find(data => data.label.includes('sent'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
graphs[data[item].name.split(':')[0]].data.push({
|
||||
label: data[item].name.split(':')[1],
|
||||
data: data[item].values.map(value => value.value),
|
||||
fill: false,
|
||||
borderColor: data[item].name.includes('received') ? 'rgb(75, 192, 192)' : 'rgb(192, 75, 75)',
|
||||
tension: 0.1
|
||||
});
|
||||
}
|
||||
|
||||
// Add the labels to the this.graphs
|
||||
for (const graph in graphs) {
|
||||
graphs[graph].labels = data[Object.keys(data).find(key => data[key].name.includes(graph))].values.map(value => value.clock);
|
||||
this.graphs.push(graphs[graph]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user