Files
thetool/public/js/pages/Device/DeviceMonitoring.js
2025-06-30 10:26:36 +02:00

417 lines
20 KiB
JavaScript

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 flex-wrap">
<div style="flex-grow: 1; margin-right: 15px; min-width: 300px;">
<tt-select label="Schnittstellen zur Anzeige auswählen"
:options="interfaceOptions"
v-model="selectedInterfaces"
sm multiple searchable/>
</div>
<div class="d-flex align-items-center flex-wrap">
<div class="btn-group btn-group-sm mr-2 mb-1">
<button @click="dataNormalizationMode = 'avg'" :class="dataNormalizationMode === 'avg' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Durchschnitt</button>
<button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button>
</div>
<div class="btn-group btn-group-sm mr-2 mb-1">
<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>
<button @click="resetAllChartsZoom" class="btn btn-sm btn-info mb-1" title="Zoom zurücksetzen"><i class="fas fa-search-minus"></i> Zoom zurücksetzen</button>
</div>
</div>
<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 v-if="loading.individualInterfaces[iface.name]" class="chart-loader">
<div class="spinner-border spinner-border-sm"></div>
</div>
<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, individualInterfaces: {} },
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: {},
dataNormalizationMode: 'avg',
downsampleThreshold: 500,
};
},
computed: {
interfaceOptions() {
return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name }));
},
selectedInterfacesData() {
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
},
displayChartData() {
const processedData = {};
for (const itemId in this.interfaceChartData) {
const data = this.interfaceChartData[itemId];
if (data.length > this.downsampleThreshold) {
processedData[itemId] = this.downsampleData(data, this.dataNormalizationMode);
} else {
processedData[itemId] = data;
}
}
return processedData;
},
statistics() {
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];
const p95 = this.calculateNormalized95thPercentile(data);
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(p95),
};
};
stats[iface.name] = {
rx: calculate(this.interfaceChartData[iface.rx?.itemid]),
tx: calculate(this.interfaceChartData[iface.tx?.itemid]),
};
});
return stats;
}
},
async mounted() {
// We need chartjs-plugin-zoom for this to work. Assuming it's globally available.
if (typeof Chart.register === 'function' && window.ChartZoom) {
Chart.register(window.ChartZoom);
}
moment.locale('de');
this.fetchTabData();
},
beforeDestroy() {
this.destroyAllCharts();
},
methods: {
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() {
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 fetchAndRenderInterface(iface) {
const itemIds = [iface.rx?.itemid, iface.tx?.itemid].filter(Boolean);
if (itemIds.length === 0) return;
this.$set(this.loading.individualInterfaces, iface.name, true);
try {
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
this.$set(this.interfaceChartData, iface.rx?.itemid, res.data[iface.rx?.itemid] || []);
this.$set(this.interfaceChartData, iface.tx?.itemid, res.data[iface.tx?.itemid] || []);
this.$nextTick(() => this.renderChart(iface));
} catch(e) { console.error(`Failed to fetch history for ${iface.name}`, e); }
finally { this.$set(this.loading.individualInterfaces, iface.name, false); }
},
async handleInterfaceSelectionChange(newSelection, oldSelection) {
const added = newSelection.filter(name => !oldSelection.includes(name));
const removed = oldSelection.filter(name => !newSelection.includes(name));
removed.forEach(name => {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
this.destroyChart(iface.name);
delete this.interfaceChartData[iface.rx?.itemid];
delete this.interfaceChartData[iface.tx?.itemid];
}
});
for (const name of added) {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
await this.fetchAndRenderInterface(iface);
}
}
},
async handleTimeOrNormalizationChange() {
this.destroyAllCharts();
this.interfaceChartData = {};
this.loading.interfaces = true;
const interfacesToFetch = this.selectedInterfacesData;
for (const iface of interfacesToFetch) {
await this.fetchAndRenderInterface(iface);
}
this.loading.interfaces = false;
},
async renderChart(iface) {
let tries = 0;
while (!this.$refs['chartCanvas-' + iface.name]?.[0] && tries < 10) {
console.log(typeof this.$refs['chartCanvas-' + iface.name]?.[0]);
await Promise.all([
this.$nextTick(),
new Promise(resolve => setTimeout(resolve, 100))
]);
}
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
console.log(canvas, this.$refs);
if (!canvas) return;
if (this.chartInstances[iface.name]) {
this.chartInstances[iface.name].destroy();
}
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [
{
label: 'Empfangen',
data: this.displayChartData[iface.rx?.itemid] || [],
borderColor: '#4CAF50',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(76, 175, 80, 0.2)',
pointRadius: 0,
tension: 0.1
},
{
label: 'Gesendet',
data: this.displayChartData[iface.tx?.itemid] || [],
borderColor: '#2196F3',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(33, 150, 243, 0.2)',
pointRadius: 0,
tension: 0.1
}
]
},
options: {
responsive: true, maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
type: 'time',
time: { tooltipFormat: 'DD.MM.YYYY HH:mm:ss' },
adapters: { date: { locale: 'de' } }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Mbps' }
}
},
plugins: {
legend: {
display: true, position: 'bottom',
labels: { boxWidth: 12, font: { size: 10 } }
},
zoom: {
pan: { enabled: true, mode: 'x' },
zoom: { wheel: { enabled: false }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
}
}
}
});
},
downsampleData(data, mode) {
const bucketSize = Math.ceil(data.length / this.downsampleThreshold);
const downsampled = [];
for (let i = 0; i < data.length; i += bucketSize) {
const chunk = data.slice(i, i + bucketSize);
if (chunk.length === 0) continue;
const representativeX = chunk[Math.floor(chunk.length / 2)].x;
let representativeY;
if (mode === 'max') {
representativeY = Math.max(...chunk.map(p => p.y));
} else { // avg
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
representativeY = sum / chunk.length;
}
downsampled.push({ x: representativeX, y: representativeY });
}
return downsampled;
},
calculateNormalized95thPercentile(data) {
if (!data || data.length < 3) return null;
const averagedValues = [];
for (let i = 0; i <= data.length - 3; i += 3) {
const chunk = data.slice(i, i + 3);
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
averagedValues.push(sum / 3);
}
if (averagedValues.length === 0) return null;
const sorted = averagedValues.sort((a, b) => a - b);
const index = Math.floor(sorted.length * 0.95);
return sorted[index];
},
openLiveChartPopup(iface) {
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');
},
destroyChart(name) {
if (this.chartInstances[name]) {
this.chartInstances[name].destroy();
delete this.chartInstances[name];
}
},
destroyAllCharts() {
Object.values(this.chartInstances).forEach(c => c.destroy());
this.chartInstances = {};
},
resetAllChartsZoom() {
Object.values(this.chartInstances).forEach(chart => {
chart.resetZoom();
});
}
},
watch: {
activeTab: 'fetchTabData',
selectedInterfaces(newVal, oldVal) {
this.handleInterfaceSelectionChange(newVal, oldVal);
},
interfaceTimeRange: 'handleTimeOrNormalizationChange',
dataNormalizationMode: 'handleTimeOrNormalizationChange',
}
});