Merge branch 'Device/add-monitoring' into 'master'
added zabbix integration for statistics See merge request fronk/thetool!1503
This commit is contained in:
@@ -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());
|
}, 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(""),
|
$JSGlobals = ["BASE_URL" => self::getUrl(""),
|
||||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||||
|
|||||||
@@ -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;
|
$this->apiKey = $apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function zabbixRequest($method, $params) {
|
public function zabbixRequest($method, $params, $die = false) {
|
||||||
$data = array(
|
$data = array(
|
||||||
'jsonrpc' => '2.0',
|
'jsonrpc' => '2.0',
|
||||||
'method' => $method,
|
'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);
|
$context = stream_context_create($options);
|
||||||
$result = file_get_contents($this->url, false, $context);
|
$result = file_get_contents($this->url, false, $context);
|
||||||
|
|
||||||
|
|||||||
@@ -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', {
|
Vue.component('DeviceTable', {
|
||||||
//language=Vue
|
//language=Vue
|
||||||
template: `
|
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'">
|
<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'">
|
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Device/add'">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
Device hinzufügen
|
Device hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:name="{ row }">
|
<template v-slot:name="{ row }">
|
||||||
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Device/Detail?id=' + row.id">{{row.name}}</a>
|
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Device/Detail?id=' + row.id">{{row.name}}</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:locationtext="{ row }">
|
<template v-slot:locationtext="{ row }">
|
||||||
<template v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
<template v-if="window['TT_CONFIG']['IS_ADMIN'] === '1'">
|
||||||
<a target="_blank" :href="row['locationUrl']">{{row.locationText}}</a>
|
<a target="_blank" :href="row['locationUrl']">{{row.locationText}}</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{row.locationText}}
|
{{row.locationText}}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:actions="{ row }">
|
<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/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"
|
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Device/delete/?id=' + row.id"
|
||||||
onclick="if(!confirm('Device wirklich löschen?')) return false;"
|
onclick="if(!confirm('Device wirklich löschen?')) return false;"
|
||||||
class="text-danger"
|
class="text-danger"
|
||||||
title="Löschen"><i class="fas fa-trash "></i></a>
|
title="Löschen"><i class="fas fa-trash "></i></a>
|
||||||
|
|
||||||
<template v-if="row.zabbix_host_id !== '0' && row.zabbix_host_id !== null">
|
<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"
|
<a :href="window['TT_CONFIG']['ZABBIX_URL'] + '/zabbix.php?action=latest.view&hostids%5B%5D=' + row.zabbix_host_id"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-info"
|
class="text-info"
|
||||||
title="Zabbix"><i class="fas fa-server"></i></a>
|
title="Zabbix"><i class="fas fa-server"></i></a>
|
||||||
</template>
|
<a href="#" @click.prevent="$emit('show-monitoring', row)"
|
||||||
</template>
|
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() {
|
data() {
|
||||||
return {
|
return {
|
||||||
window: window,
|
window: window,
|
||||||
@@ -249,16 +253,26 @@ Vue.component('DeviceType', {
|
|||||||
Vue.component('Device', {
|
Vue.component('Device', {
|
||||||
//language=Vue
|
//language=Vue
|
||||||
template: `
|
template: `
|
||||||
<tt-card>
|
<tt-card>
|
||||||
<device-view-switch v-show="window['TT_CONFIG']['IS_ADMIN'] === '1'" v-model="currentView"></device-view-switch>
|
<device-monitoring-modal
|
||||||
|
v-if="monitoringModalHostId"
|
||||||
|
:host-id="monitoringModalHostId"
|
||||||
|
:device-name="selectedDeviceName"
|
||||||
|
@close="closeMonitoringModal">
|
||||||
|
</device-monitoring-modal>
|
||||||
|
|
||||||
<component :is="currentView"></component>
|
<device-view-switch v-show="window['TT_CONFIG']['IS_ADMIN'] === '1'" v-model="currentView"></device-view-switch>
|
||||||
</tt-card>
|
|
||||||
`,
|
<component :is="currentView" @show-monitoring="showMonitoringModal"></component>
|
||||||
|
</tt-card>
|
||||||
|
`,
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
window: window,
|
window: window,
|
||||||
currentView: 'DeviceTable',
|
currentView: 'DeviceTable',
|
||||||
|
// MODIFIED: Add data properties to manage the modal state
|
||||||
|
monitoringModalHostId: null,
|
||||||
|
selectedDeviceName: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
@@ -272,4 +286,15 @@ Vue.component('Device', {
|
|||||||
window.localStorage.setItem('tt-device-view', this.currentView);
|
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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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