added zabbix integration for statistics

This commit is contained in:
Luca Haid
2025-06-29 20:33:54 +02:00
parent c7a759b4cc
commit 4c25455ee7
10 changed files with 685 additions and 245 deletions

View 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>

View File

@@ -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,

View 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');
}
}

View File

@@ -1,9 +0,0 @@
<?php
/**
* @property mixed|null $name
*/
class Graphing extends mfBaseModel
{
}

View File

@@ -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));
}
}

View File

@@ -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);

View 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; }

View File

@@ -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 = '';
}
}
});

View 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'
}
});

View File

@@ -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]);
}
}
});