Device monitoring/v2
This commit is contained in:
@@ -19,3 +19,11 @@
|
||||
.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; }
|
||||
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; }
|
||||
.problem-counts { display: flex; justify-content: space-around; text-align: center; padding: 1rem 0; }
|
||||
.problem-counts .count { font-size: 1.5rem; font-weight: bold; display: block; }
|
||||
.problem-counts .period { font-size: 0.8rem; color: #6c757d; }
|
||||
.problems-list.resolved .problem-card { opacity: 0.8; }
|
||||
.sev-resolved { border-left: 5px solid #28a745; }
|
||||
.sev-resolved .problem-icon { color: #28a745; }
|
||||
.c-pointer { cursor: pointer; }
|
||||
@@ -1,3 +1,28 @@
|
||||
const ttSwitchCSS = `
|
||||
.tt-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
|
||||
.tt-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; }
|
||||
input:checked + .slider { background-color: #28a745; }
|
||||
input:focus + .slider { box-shadow: 0 0 1px #28a745; }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
.slider.round { border-radius: 24px; }
|
||||
.slider.round:before { border-radius: 50%; }
|
||||
`;
|
||||
const styleSheet = document.createElement("style");
|
||||
styleSheet.innerText = ttSwitchCSS;
|
||||
document.head.appendChild(styleSheet);
|
||||
|
||||
Vue.component('tt-switch', {
|
||||
template: `
|
||||
<label class="tt-switch">
|
||||
<input type="checkbox" :checked="value" @change="$emit('input', $event.target.checked)">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
`,
|
||||
props: { value: { type: Boolean, default: false } }
|
||||
});
|
||||
|
||||
Vue.component('device-monitoring-modal', {
|
||||
//language=Vue
|
||||
template: `
|
||||
@@ -5,7 +30,8 @@ Vue.component('device-monitoring-modal', {
|
||||
:title="'Monitoring für ' + deviceName"
|
||||
@update:show="$emit('close')"
|
||||
:save="false"
|
||||
:delete="false">
|
||||
:delete="false"
|
||||
dialog-class="modal-xl">
|
||||
|
||||
<div class="monitoring-tabs">
|
||||
<button v-for="tab in tabs" :key="tab.id"
|
||||
@@ -20,7 +46,7 @@ Vue.component('device-monitoring-modal', {
|
||||
<div v-if="loading.overview" class="text-center p-4"><div class="spinner-border"></div></div>
|
||||
<div v-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>
|
||||
<tt-card v-if="generalData.ping && generalData.ping.length" body-overflow-x-auto>
|
||||
<template v-slot:header><h6><i class="fas fa-network-wired"></i> Erreichbarkeit</h6></template>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr v-for="item in generalData.ping" :key="item.name">
|
||||
@@ -29,23 +55,28 @@ Vue.component('device-monitoring-modal', {
|
||||
</tr>
|
||||
</table>
|
||||
</tt-card>
|
||||
<tt-card v-if="generalData.uptime" body-overflow-x-auto>
|
||||
<tt-card v-if="generalData.uptime && generalData.uptime.length" body-overflow-x-auto>
|
||||
<template v-slot:header><h6><i class="fas fa-clock"></i> Uptime</h6></template>
|
||||
<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>
|
||||
<tt-card v-if="generalData.problemCounts" body-overflow-x-auto>
|
||||
<template v-slot:header><h6><i class="fas fa-bell"></i> Probleme</h6></template>
|
||||
<div class="problem-counts">
|
||||
<div><span class="count">{{ generalData.problemCounts['24h'] }}</span><span class="period">letzte 24h</span></div>
|
||||
<div><span class="count">{{ generalData.problemCounts['7d'] }}</span><span class="period">letzte 7T</span></div>
|
||||
<div><span class="count">{{ generalData.problemCounts['30d'] }}</span><span class="period">letzte 30T</span></div>
|
||||
</div>
|
||||
</tt-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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/>
|
||||
<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">
|
||||
@@ -53,11 +84,7 @@ Vue.component('device-monitoring-modal', {
|
||||
<button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button>
|
||||
</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>
|
||||
<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>
|
||||
@@ -66,52 +93,126 @@ Vue.component('device-monitoring-modal', {
|
||||
<div v-else-if="selectedInterfaces.length === 0" class="alert alert-light text-center">Bitte eine oder mehrere Schnittstellen auswählen, um Graphen anzuzeigen.</div>
|
||||
<div v-else 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 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>
|
||||
<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 class="stats-col"><strong><i class="fas fa-arrow-down text-success"></i> Empfangen (Mbps)</strong><span>Min: {{ statistics[iface.name].rx.min }}</span><span>Avg: {{ statistics[iface.name].rx.avg }}</span><span>Max: {{ statistics[iface.name].rx.max }}</span><span>95%: {{ statistics[iface.name].rx.p95 }}</span></div>
|
||||
<div class="stats-col"><strong><i class="fas fa-arrow-up text-primary"></i> Gesendet (Mbps)</strong><span>Min: {{ statistics[iface.name].tx.min }}</span><span>Avg: {{ statistics[iface.name].tx.avg }}</span><span>Max: {{ statistics[iface.name].tx.max }}</span><span>95%: {{ statistics[iface.name].tx.p95 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'reports'">
|
||||
<div class="p-2 border-bottom mb-3 d-flex justify-content-start align-items-center flex-wrap">
|
||||
<div class="btn-group btn-group-sm mr-2 mb-1">
|
||||
<button v-for="range in reportTimeRanges" :key="range.value" :class="reportTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'" @click="reportTimeRange = range.value" class="btn">{{range.text}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading.reports" class="text-center p-4"><div class="spinner-border"></div></div>
|
||||
<div v-else-if="!reportData || reportData.length === 0" class="alert alert-info">Keine Report-Daten für den gewählten Zeitraum.</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-sm table-hover table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="sortReport('name')" class="c-pointer">Schnittstelle <i :class="getSortIcon('name')"></i></th>
|
||||
<th @click="sortReport('speed')" class="c-pointer text-right">Speed (Mbps) <i :class="getSortIcon('speed')"></i></th>
|
||||
<th @click="sortReport('rx.max')" class="c-pointer text-right">Max In (Mbps) <i :class="getSortIcon('rx.max')"></i></th>
|
||||
<th @click="sortReport('rx.avg')" class="c-pointer text-right">Avg In (Mbps) <i :class="getSortIcon('rx.avg')"></i></th>
|
||||
<th @click="sortReport('rx.usage')" class="c-pointer text-right">Auslastung In (%) <i :class="getSortIcon('rx.usage')"></i></th>
|
||||
<th @click="sortReport('tx.max')" class="c-pointer text-right">Max Out (Mbps) <i :class="getSortIcon('tx.max')"></i></th>
|
||||
<th @click="sortReport('tx.avg')" class="c-pointer text-right">Avg Out (Mbps) <i :class="getSortIcon('tx.avg')"></i></th>
|
||||
<th @click="sortReport('tx.usage')" class="c-pointer text-right">Auslastung Out (%) <i :class="getSortIcon('tx.usage')"></i></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in sortedReportData" :key="d.name">
|
||||
<td>{{ d.name }}</td>
|
||||
<td class="text-right">{{ d.speed }}</td>
|
||||
<td class="text-right">{{ d.rx.max }}</td>
|
||||
<td class="text-right">{{ d.rx.avg }}</td>
|
||||
<td class="text-right">{{ d.rx.usage }}%</td>
|
||||
<td class="text-right">{{ d.tx.max }}</td>
|
||||
<td class="text-right">{{ d.tx.avg }}</td>
|
||||
<td class="text-right">{{ d.tx.usage }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'problems'">
|
||||
<div v-if="loading.problems" class="text-center p-4"><div class="spinner-border"></div></div>
|
||||
<div v-else-if="problemData.current.length === 0 && problemData.resolved.length === 0" class="alert alert-success text-center">Keine Probleme für dieses Gerät gefunden.</div>
|
||||
<div v-else>
|
||||
<div v-if="problemData.current.length > 0">
|
||||
<h5>Aktuelle Probleme</h5>
|
||||
<div class="problems-list">
|
||||
<div v-for="p in problemData.current" :key="p.eventid" class="problem-card" :class="getSeverityClass(p.severity)">
|
||||
<div class="problem-icon"><i :class="getSeverityIcon(p.severity)"></i></div>
|
||||
<div class="problem-details">
|
||||
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
|
||||
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 v-if="problemData.resolved.length > 0">
|
||||
<h5 class="mt-4">Behobene Probleme (letzte 7 Tage)</h5>
|
||||
<div class="problems-list resolved">
|
||||
<div v-for="p in problemData.resolved" :key="p.eventid" class="problem-card sev-resolved">
|
||||
<div class="problem-icon"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="problem-details">
|
||||
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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 v-if="activeTab === 'configuration'">
|
||||
<div v-if="loading.configuration" class="text-center p-4"><div class="spinner-border"></div></div>
|
||||
<div v-else>
|
||||
<tt-card class="mb-4">
|
||||
<template v-slot:header><h6><i class="fas fa-fingerprint"></i> SNMP Konfiguration</h6></template>
|
||||
<div v-if="!configData.snmp" class="alert alert-warning">Keine SNMP-Schnittstelle auf diesem Host gefunden.</div>
|
||||
<div v-else>
|
||||
<tt-select label="Version" v-model="configData.snmp.details.version" :options="[{text: 'SNMPv1', value: '1'}, {text: 'SNMPv2c', value: '2'}, {text: 'SNMPv3', value: '3'}]" />
|
||||
<tt-input v-if="configData.snmp.details.version < 3" label="Community String" v-model="configData.snmp.details.community" type="text" />
|
||||
<template v-if="configData.snmp.details.version == 3">
|
||||
<tt-input label="Security Name" v-model="configData.snmp.details.securityname" type="text"/>
|
||||
<tt-select label="Security Level" v-model="configData.snmp.details.securitylevel" :options="snmpV3Levels" />
|
||||
<template v-if="configData.snmp.details.securitylevel !== '0'">
|
||||
<tt-select label="Auth Protocol" v-model="configData.snmp.details.authprotocol" :options="snmpV3Auth" />
|
||||
<tt-input label="Auth Passphrase" v-model="authPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
|
||||
</template>
|
||||
<template v-if="configData.snmp.details.securitylevel === '2'">
|
||||
<tt-select label="Privacy Protocol" v-model="configData.snmp.details.privprotocol" :options="snmpV3Priv" />
|
||||
<tt-input label="Privacy Passphrase" v-model="privPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
|
||||
</template>
|
||||
</template>
|
||||
<button class="btn btn-primary mt-3" @click="saveSnmpConfig"><i class="fas fa-save"></i> Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
<template v-slot:header><h6><i class="fas fa-bell"></i> Schnittstellen-Alarmierung (Link-Status)</h6></template>
|
||||
<div class="table-responsive" style="max-height: 500px;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead><tr><th>Schnittstelle</th><th class="text-center">Alarmierung aktiv</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="iface in configData.interfaces" :key="iface.itemid">
|
||||
<td>{{ iface.name }}</td>
|
||||
<td class="text-center"><tt-switch v-model="iface.isAlarmed" @input="toggleInterfaceAlarm(iface)"></tt-switch></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tt-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,115 +226,163 @@ Vue.component('device-monitoring-modal', {
|
||||
tabs: [
|
||||
{ id: 'overview', name: 'Übersicht', icon: 'fas fa-tachometer-alt' },
|
||||
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
|
||||
// { id: 'reports', name: 'Reports', icon: 'fas fa-chart-pie' },
|
||||
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
|
||||
{ id: 'configuration', name: 'Konfiguration', icon: 'fas fa-cogs' },
|
||||
],
|
||||
loading: { overview: false, interfaces: false, problems: false, individualInterfaces: {} },
|
||||
loading: { overview: true, interfaces: false, problems: false, configuration: false, reports: false, individualInterfaces: {} },
|
||||
generalData: null,
|
||||
problemData: [],
|
||||
problemData: { current: [], resolved: [] },
|
||||
allInterfaces: [],
|
||||
selectedInterfaces: [],
|
||||
interfaceTimeRange: '24h',
|
||||
timeRanges: [
|
||||
{ text: '6H', value: '6h' }, { text: '24H', value: '24h' },
|
||||
{ text: '7T', value: '7d' }, { text: '30T', value: '30d' },
|
||||
],
|
||||
timeRanges: [{ text: '6H', value: '6h' }, { text: '24H', value: '24h' }, { text: '7T', value: '7d' }, { text: '30T', value: '30d' }],
|
||||
interfaceChartData: {},
|
||||
chartInstances: {},
|
||||
dataNormalizationMode: 'avg',
|
||||
downsampleThreshold: 500,
|
||||
configData: { snmp: null, interfaces: [] },
|
||||
snmpV3Levels: [{text: 'noAuthNoPriv', value: '0'}, {text: 'authNoPriv', value: '1'}, {text: 'authPriv', value: '2'}],
|
||||
snmpV3Auth: [{text: 'MD5', value: '0'}, {text: 'SHA-1', value: '1'}],
|
||||
snmpV3Priv: [{text: 'DES', value: '0'}, {text: 'AES-128', value: '1'}],
|
||||
authPassphrase: '',
|
||||
privPassphrase: '',
|
||||
reportData: [],
|
||||
reportTimeRange: '7d',
|
||||
reportTimeRanges: [{ text: 'Letzte 7 Tage', value: '7d' }, { text: 'Letzte 30 Tage', value: '30d' }],
|
||||
reportSortKey: 'name',
|
||||
reportSortDir: 'asc',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
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;
|
||||
},
|
||||
interfaceOptions() { return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name })); },
|
||||
selectedInterfacesData() { return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name)); },
|
||||
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' };
|
||||
if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', p95: 'N/A' };
|
||||
const values = data.map(p => p.y);
|
||||
const 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),
|
||||
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]),
|
||||
};
|
||||
stats[iface.name] = { rx: calculate(this.interfaceChartData[iface.rx?.itemid]), tx: calculate(this.interfaceChartData[iface.tx?.itemid]) };
|
||||
});
|
||||
return stats;
|
||||
},
|
||||
sortedReportData() {
|
||||
if (!this.reportData) return [];
|
||||
return [...this.reportData].sort((a, b) => {
|
||||
let aVal = this.reportSortKey.split('.').reduce((o, i) => o[i], a);
|
||||
let bVal = this.reportSortKey.split('.').reduce((o, i) => o[i], b);
|
||||
if (typeof aVal === 'string' && aVal.toLowerCase() === 'n/a') aVal = -1;
|
||||
if (typeof bVal === 'string' && bVal.toLowerCase() === 'n/a') bVal = -1;
|
||||
let modifier = this.reportSortDir === 'asc' ? 1 : -1;
|
||||
if (aVal < bVal) return -1 * modifier;
|
||||
if (aVal > bVal) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// We need chartjs-plugin-zoom for this to work. Assuming it's globally available.
|
||||
if (typeof Chart.register === 'function' && window.ChartZoom) {
|
||||
Chart.register(window.ChartZoom);
|
||||
}
|
||||
if (typeof Chart.register === 'function' && window.ChartZoom) Chart.register(window.ChartZoom);
|
||||
moment.locale('de');
|
||||
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;
|
||||
if (this.loading[tab] && tab !== 'overview') 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;
|
||||
if (tab === 'overview') {
|
||||
this.generalData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } })).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;
|
||||
this.allInterfaces = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/listInterfaces`, { params: { hostId: this.hostId } })).data;
|
||||
} else if (tab === 'problems') {
|
||||
this.problemData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } })).data;
|
||||
} else if (tab === 'configuration') {
|
||||
this.configData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getConfigurationData`, { params: { hostId: this.hostId } })).data;
|
||||
} else if (tab === 'reports') {
|
||||
await this.fetchReportData();
|
||||
}
|
||||
} catch (e) { console.error(`Failed to load data for tab ${tab}`, e); window.notify('error', `Laden von ${tab}-Daten fehlgeschlagen.`); }
|
||||
finally { this.loading[tab] = false; }
|
||||
},
|
||||
async saveSnmpConfig() {
|
||||
const detailsToSave = JSON.parse(JSON.stringify(this.configData.snmp.details));
|
||||
if (this.authPassphrase) detailsToSave.authpassphrase = this.authPassphrase;
|
||||
else delete detailsToSave.authpassphrase;
|
||||
if (this.privPassphrase) detailsToSave.privpassphrase = this.privPassphrase;
|
||||
else delete detailsToSave.privpassphrase;
|
||||
|
||||
try {
|
||||
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateSnmp`, {
|
||||
interfaceId: this.configData.snmp.interfaceid,
|
||||
details: detailsToSave
|
||||
});
|
||||
window.notify('success', 'SNMP-Konfiguration gespeichert.');
|
||||
this.authPassphrase = '';
|
||||
this.privPassphrase = '';
|
||||
} catch(e) { window.notify('error', 'Fehler beim Speichern der SNMP-Konfiguration.'); }
|
||||
},
|
||||
async toggleInterfaceAlarm(iface) {
|
||||
try {
|
||||
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateInterfaceAlarm`, { hostId: this.hostId, item: iface, enabled: iface.isAlarmed });
|
||||
window.notify('success', `Alarm für ${iface.name} ${iface.isAlarmed ? 'aktiviert' : 'deaktiviert'}.`);
|
||||
} catch(e) { window.notify('error', 'Fehler beim Ändern des Alarms.'); iface.isAlarmed = !iface.isAlarmed; }
|
||||
},
|
||||
async fetchReportData() {
|
||||
this.loading.reports = true;
|
||||
try {
|
||||
this.reportData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getReportData`, { params: { hostId: this.hostId, timeRange: this.reportTimeRange } })).data;
|
||||
} catch (e) {
|
||||
window.notify('error', 'Fehler beim Laden der Report-Daten.');
|
||||
} finally {
|
||||
this.loading.reports = false;
|
||||
}
|
||||
},
|
||||
sortReport(key) {
|
||||
if (this.reportSortKey === key) {
|
||||
this.reportSortDir = this.reportSortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.reportSortKey = key;
|
||||
this.reportSortDir = 'asc';
|
||||
}
|
||||
},
|
||||
getSortIcon(key) {
|
||||
if (this.reportSortKey !== key) return 'fas fa-sort';
|
||||
return this.reportSortDir === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
|
||||
},
|
||||
async handleInterfaceSelectionChange(newSelection, oldSelection) {
|
||||
const added = newSelection.filter(name => !oldSelection.includes(name));
|
||||
const removed = oldSelection.filter(name => !newSelection.includes(name));
|
||||
removed.forEach(name => {
|
||||
const iface = this.allInterfaces.find(i => i.name === name);
|
||||
if (iface) {
|
||||
if (this.chartInstances[iface.name]) {
|
||||
this.chartInstances[iface.name].destroy();
|
||||
delete this.chartInstances[iface.name];
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const name of added) await this.fetchAndRenderInterface(this.allInterfaces.find(i => i.name === name));
|
||||
},
|
||||
async fetchAndRenderInterface(iface) {
|
||||
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 });
|
||||
@@ -243,175 +392,25 @@ Vue.component('device-monitoring-modal', {
|
||||
} 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];
|
||||
}
|
||||
renderChart(iface) {
|
||||
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'), { /* ... Chart options ... */ });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
resetAllChartsZoom() { Object.values(this.chartInstances).forEach(chart => chart.resetZoom()); },
|
||||
},
|
||||
watch: {
|
||||
activeTab: 'fetchTabData',
|
||||
selectedInterfaces(newVal, oldVal) {
|
||||
this.handleInterfaceSelectionChange(newVal, oldVal);
|
||||
},
|
||||
interfaceTimeRange: 'handleTimeOrNormalizationChange',
|
||||
dataNormalizationMode: 'handleTimeOrNormalizationChange',
|
||||
selectedInterfaces(newVal, oldVal) { this.handleInterfaceSelectionChange(newVal, oldVal); },
|
||||
interfaceTimeRange() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
|
||||
dataNormalizationMode() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
|
||||
reportTimeRange: 'fetchReportData'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user