added new features for monitoring
This commit is contained in:
@@ -51,7 +51,8 @@ class DeviceController extends mfBaseController
|
|||||||
|
|
||||||
$this->layout()->set('additionalJS', [
|
$this->layout()->set('additionalJS', [
|
||||||
"plugins/chart.js/chart.4.4.6.js",
|
"plugins/chart.js/chart.4.4.6.js",
|
||||||
"plugins/chart.js/chartjs-adapter-moment.min.js"
|
"plugins/chart.js/chartjs-adapter-moment.min.js",
|
||||||
|
"plugins/chart.js/chartjs-plugin-zoom.min.js"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$JSGlobals = ["BASE_URL" => self::getUrl(""),
|
$JSGlobals = ["BASE_URL" => self::getUrl(""),
|
||||||
|
|||||||
@@ -40,25 +40,35 @@ Vue.component('device-monitoring-modal', {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'interfaces'">
|
<div v-if="activeTab === 'interfaces'">
|
||||||
<div class="p-2 border-bottom mb-3 d-flex justify-content-between align-items-center">
|
<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;">
|
<div style="flex-grow: 1; margin-right: 15px; min-width: 300px;">
|
||||||
<tt-select label="Schnittstellen zur Anzeige auswählen"
|
<tt-select label="Schnittstellen zur Anzeige auswählen"
|
||||||
:options="interfaceOptions"
|
:options="interfaceOptions"
|
||||||
v-model="selectedInterfaces"
|
v-model="selectedInterfaces"
|
||||||
sm multiple searchable/>
|
sm multiple searchable/>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="d-flex align-items-center flex-wrap">
|
||||||
<button v-for="range in timeRanges" :key="range.value"
|
<div class="btn-group btn-group-sm mr-2 mb-1">
|
||||||
:class="interfaceTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'"
|
<button @click="dataNormalizationMode = 'avg'" :class="dataNormalizationMode === 'avg' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Durchschnitt</button>
|
||||||
@click="interfaceTimeRange = range.value" class="btn">
|
<button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button>
|
||||||
{{range.text}}
|
</div>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<div v-if="loading.interfaces" class="text-center p-4"><div class="spinner-border"></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-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-else class="chart-container">
|
||||||
<div v-for="iface in selectedInterfacesData" :key="iface.name" class="chart-card border rounded p-2">
|
<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">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="chart-title">{{ iface.name }}</h6>
|
<h6 class="chart-title">{{ iface.name }}</h6>
|
||||||
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph">
|
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph">
|
||||||
@@ -117,7 +127,7 @@ Vue.component('device-monitoring-modal', {
|
|||||||
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
|
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
|
||||||
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
|
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
|
||||||
],
|
],
|
||||||
loading: { overview: false, interfaces: false, problems: false },
|
loading: { overview: false, interfaces: false, problems: false, individualInterfaces: {} },
|
||||||
generalData: null,
|
generalData: null,
|
||||||
problemData: [],
|
problemData: [],
|
||||||
allInterfaces: [],
|
allInterfaces: [],
|
||||||
@@ -129,6 +139,8 @@ Vue.component('device-monitoring-modal', {
|
|||||||
],
|
],
|
||||||
interfaceChartData: {},
|
interfaceChartData: {},
|
||||||
chartInstances: {},
|
chartInstances: {},
|
||||||
|
dataNormalizationMode: 'avg',
|
||||||
|
downsampleThreshold: 500,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -138,7 +150,18 @@ Vue.component('device-monitoring-modal', {
|
|||||||
selectedInterfacesData() {
|
selectedInterfacesData() {
|
||||||
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
|
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
|
||||||
},
|
},
|
||||||
// NEU: Statistik-Berechnung
|
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() {
|
statistics() {
|
||||||
if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {};
|
if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {};
|
||||||
|
|
||||||
@@ -151,12 +174,15 @@ Vue.component('device-monitoring-modal', {
|
|||||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||||
const mid = Math.floor(sorted.length / 2);
|
const mid = Math.floor(sorted.length / 2);
|
||||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||||
|
|
||||||
|
const p95 = this.calculateNormalized95thPercentile(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
min: this.formatStat(sorted[0]),
|
min: this.formatStat(sorted[0]),
|
||||||
max: this.formatStat(sorted[sorted.length - 1]),
|
max: this.formatStat(sorted[sorted.length - 1]),
|
||||||
avg: this.formatStat(sum / values.length),
|
avg: this.formatStat(sum / values.length),
|
||||||
median: this.formatStat(median),
|
median: this.formatStat(median),
|
||||||
p95: this.formatStat(sorted[Math.floor(sorted.length * 0.95)]),
|
p95: this.formatStat(p95),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
stats[iface.name] = {
|
stats[iface.name] = {
|
||||||
@@ -168,22 +194,24 @@ Vue.component('device-monitoring-modal', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
// GEÄNDERT: moment.locale gesetzt
|
// 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');
|
moment.locale('de');
|
||||||
this.fetchTabData();
|
this.fetchTabData();
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.destroyCharts();
|
this.destroyAllCharts();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// NEU: formatStat-Helfer
|
|
||||||
formatStat: val => typeof val === 'number' ? val.toFixed(2) : val,
|
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`,
|
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,
|
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',
|
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',
|
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() {
|
async fetchTabData() {
|
||||||
this.destroyCharts();
|
|
||||||
const tab = this.activeTab;
|
const tab = this.activeTab;
|
||||||
if (this.loading[tab]) return;
|
if (this.loading[tab]) return;
|
||||||
this.loading[tab] = true;
|
this.loading[tab] = true;
|
||||||
@@ -201,97 +229,189 @@ Vue.component('device-monitoring-modal', {
|
|||||||
} catch (e) { console.error(`Failed to load data for tab ${tab}`, e); window.notify('error', `Laden von ${tab}-Daten fehlgeschlagen.`); }
|
} 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; }
|
finally { this.loading[tab] = false; }
|
||||||
},
|
},
|
||||||
async fetchInterfaceHistory() {
|
|
||||||
this.destroyCharts();
|
async fetchAndRenderInterface(iface) {
|
||||||
if (this.selectedInterfaces.length === 0) return;
|
const itemIds = [iface.rx?.itemid, iface.tx?.itemid].filter(Boolean);
|
||||||
const itemIds = this.selectedInterfacesData.flatMap(i => [i.rx?.itemid, i.tx?.itemid]).filter(Boolean);
|
|
||||||
if (itemIds.length === 0) return;
|
if (itemIds.length === 0) return;
|
||||||
|
|
||||||
this.loading.interfaces = true;
|
this.$set(this.loading.individualInterfaces, iface.name, true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
|
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
|
||||||
this.interfaceChartData = res.data;
|
this.$set(this.interfaceChartData, iface.rx?.itemid, res.data[iface.rx?.itemid] || []);
|
||||||
this.$nextTick(() => this.renderAllCharts());
|
this.$set(this.interfaceChartData, iface.tx?.itemid, res.data[iface.tx?.itemid] || []);
|
||||||
} catch(e) { console.error('Failed to fetch interface history', e); }
|
this.$nextTick(() => this.renderChart(iface));
|
||||||
finally { this.loading.interfaces = false; }
|
} catch(e) { console.error(`Failed to fetch history for ${iface.name}`, e); }
|
||||||
|
finally { this.$set(this.loading.individualInterfaces, iface.name, 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]) {
|
async handleInterfaceSelectionChange(newSelection, oldSelection) {
|
||||||
this.chartInstances[iface.name].destroy();
|
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];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), {
|
for (const name of added) {
|
||||||
type: 'line',
|
const iface = this.allInterfaces.find(i => i.name === name);
|
||||||
data: {
|
if (iface) {
|
||||||
datasets: [
|
await this.fetchAndRenderInterface(iface);
|
||||||
{
|
}
|
||||||
label: 'Empfangen',
|
}
|
||||||
data: this.interfaceChartData[iface.rx?.itemid] || [],
|
},
|
||||||
borderColor: '#4CAF50',
|
|
||||||
borderWidth: 0,
|
async handleTimeOrNormalizationChange() {
|
||||||
barPercentage: 1,
|
this.destroyAllCharts();
|
||||||
categoryPercentage: 1,
|
this.interfaceChartData = {};
|
||||||
fill: true,
|
this.loading.interfaces = true;
|
||||||
backgroundColor: 'rgba(76, 175, 80, 0.8)',
|
|
||||||
pointRadius: 0,
|
const interfacesToFetch = this.selectedInterfacesData;
|
||||||
tension: 0.1
|
for (const iface of interfacesToFetch) {
|
||||||
},
|
await this.fetchAndRenderInterface(iface);
|
||||||
{
|
}
|
||||||
label: 'Gesendet',
|
|
||||||
data: this.interfaceChartData[iface.tx?.itemid] || [],
|
this.loading.interfaces = false;
|
||||||
borderColor: '#2196F3',
|
},
|
||||||
borderWidth: 0,
|
|
||||||
barPercentage: 1,
|
async renderChart(iface) {
|
||||||
categoryPercentage: 1,
|
let tries = 0;
|
||||||
fill: true,
|
while (!this.$refs['chartCanvas-' + iface.name]?.[0] && tries < 10) {
|
||||||
backgroundColor: 'rgba(33, 150, 243, 0.8)',
|
console.log(typeof this.$refs['chartCanvas-' + iface.name]?.[0]);
|
||||||
pointRadius: 0,
|
await Promise.all([
|
||||||
tension: 0.1
|
this.$nextTick(),
|
||||||
}
|
new Promise(resolve => setTimeout(resolve, 100))
|
||||||
]
|
]);
|
||||||
},
|
}
|
||||||
options: {
|
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
|
||||||
responsive: true, maintainAspectRatio: true,
|
|
||||||
scales: {
|
console.log(canvas, this.$refs);
|
||||||
x: {
|
if (!canvas) return;
|
||||||
type: 'time',
|
|
||||||
// GEÄNDERT: Deutsches Datumsformat und Locale
|
if (this.chartInstances[iface.name]) {
|
||||||
time: { tooltipFormat: 'DD.MM.YYYY HH:mm:ss' },
|
this.chartInstances[iface.name].destroy();
|
||||||
adapters: { date: { locale: 'de' } }
|
}
|
||||||
},
|
|
||||||
y: {
|
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), {
|
||||||
beginAtZero: true,
|
type: 'line',
|
||||||
title: { display: true, text: 'Mbps' }
|
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
|
||||||
},
|
},
|
||||||
plugins: {
|
{
|
||||||
legend: {
|
label: 'Gesendet',
|
||||||
display: true,
|
data: this.displayChartData[iface.tx?.itemid] || [],
|
||||||
position: 'bottom',
|
borderColor: '#2196F3',
|
||||||
labels: { boxWidth: 12, font: { size: 10 } }
|
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) {
|
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)}`;
|
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');
|
window.open(url, `livegraph_${iface.name.replace(/[^a-zA-Z0-9]/g, "_")}`, 'width=800,height=450,resizable=yes,scrollbars=yes');
|
||||||
},
|
},
|
||||||
destroyCharts() {
|
destroyChart(name) {
|
||||||
|
if (this.chartInstances[name]) {
|
||||||
|
this.chartInstances[name].destroy();
|
||||||
|
delete this.chartInstances[name];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroyAllCharts() {
|
||||||
Object.values(this.chartInstances).forEach(c => c.destroy());
|
Object.values(this.chartInstances).forEach(c => c.destroy());
|
||||||
this.chartInstances = {};
|
this.chartInstances = {};
|
||||||
},
|
},
|
||||||
|
resetAllChartsZoom() {
|
||||||
|
Object.values(this.chartInstances).forEach(chart => {
|
||||||
|
chart.resetZoom();
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
activeTab: 'fetchTabData',
|
activeTab: 'fetchTabData',
|
||||||
selectedInterfaces: 'fetchInterfaceHistory',
|
selectedInterfaces(newVal, oldVal) {
|
||||||
interfaceTimeRange: 'fetchInterfaceHistory'
|
this.handleInterfaceSelectionChange(newVal, oldVal);
|
||||||
|
},
|
||||||
|
interfaceTimeRange: 'handleTimeOrNormalizationChange',
|
||||||
|
dataNormalizationMode: 'handleTimeOrNormalizationChange',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
7
public/plugins/chart.js/chartjs-plugin-zoom.min.js
vendored
Normal file
7
public/plugins/chart.js/chartjs-plugin-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -29,12 +29,29 @@ Vue.component('tt-select', {
|
|||||||
this.selected = this.multiple
|
this.selected = this.multiple
|
||||||
? (Array.isArray(newVal) ? [...newVal] : [])
|
? (Array.isArray(newVal) ? [...newVal] : [])
|
||||||
: newVal;
|
: newVal;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enhancement: Handle focus and scroll on open
|
||||||
|
open(isOpen) {
|
||||||
|
if (isOpen) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.dropdownMenu) {
|
||||||
|
// 2. Scroll to the top when opened
|
||||||
|
this.$refs.dropdownMenu.scrollTop = 0;
|
||||||
|
}
|
||||||
|
if (this.searchable && this.$refs.searchInput) {
|
||||||
|
// 1. Focus the input when opened
|
||||||
|
this.$refs.searchInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
document.addEventListener('click', this.onClickOutside);
|
document.addEventListener('click', this.onClickOutside);
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
document.removeEventListener('click', this.onClickOutside);
|
document.removeEventListener('click', this.onClickOutside);
|
||||||
},
|
},
|
||||||
@@ -119,12 +136,22 @@ Vue.component('tt-select', {
|
|||||||
else arr.push(opt.value);
|
else arr.push(opt.value);
|
||||||
|
|
||||||
this.selected = arr;
|
this.selected = arr;
|
||||||
this.$emit('input', arr); // Fixed: Always emit the array when multiple
|
this.$emit('input', arr);
|
||||||
} else {
|
} else {
|
||||||
this.selected = opt.value;
|
this.selected = opt.value;
|
||||||
this.$emit('input', opt.value);
|
this.$emit('input', opt.value);
|
||||||
this.open = false;
|
this.open = false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enhancement: Handle Enter key press on search input
|
||||||
|
handleEnter() {
|
||||||
|
// 4. If search is active and only one result is left, select it
|
||||||
|
if (this.searchQuery && this.filteredOptions.length === 1) {
|
||||||
|
this.selectOption(this.filteredOptions[0]);
|
||||||
|
// Ensure dropdown closes for both single and multi-select mode
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,10 +160,10 @@ Vue.component('tt-select', {
|
|||||||
<label v-if="label"
|
<label v-if="label"
|
||||||
:for="label"
|
:for="label"
|
||||||
:class="{
|
:class="{
|
||||||
'col-form-label': row,
|
'col-form-label': row,
|
||||||
'col-sm-4': row,
|
'col-sm-4': row,
|
||||||
'col-form-label-sm': sm && row
|
'col-form-label-sm': sm && row
|
||||||
}">
|
}">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -148,39 +175,46 @@ Vue.component('tt-select', {
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click.stop="toggleDropdown"
|
@click.stop="toggleDropdown"
|
||||||
style="display:flex; align-items:center;">
|
style="display:flex; align-items:center;">
|
||||||
|
<span style="
|
||||||
<span style="
|
white-space: nowrap;
|
||||||
white-space: nowrap;
|
overflow: hidden;
|
||||||
overflow: hidden;
|
text-overflow: ellipsis;
|
||||||
text-overflow: ellipsis;
|
flex: 1;
|
||||||
flex: 1;
|
text-align: left;
|
||||||
">
|
">
|
||||||
{{ displayText || 'Auswählen...' }}
|
{{ displayText || 'Auswählen...' }}
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down ml-2"></i>
|
<i class="fas fa-chevron-down ml-2"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown-menu p-2"
|
<div class="dropdown-menu p-2"
|
||||||
|
ref="dropdownMenu"
|
||||||
:class="{ show: open }"
|
:class="{ show: open }"
|
||||||
style="
|
style="
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
">
|
">
|
||||||
<input v-if="searchable"
|
<input v-if="searchable"
|
||||||
|
ref="searchInput"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control mb-2"
|
class="form-control mb-2"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
placeholder="Suchen...">
|
placeholder="Suchen..."
|
||||||
|
@keydown.enter.prevent="handleEnter">
|
||||||
|
|
||||||
|
<div v-if="!filteredOptions.length && searchQuery" class="dropdown-item disabled text-muted">
|
||||||
|
Keine Ergebnisse gefunden.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-for="opt in filteredOptions"
|
<div v-for="opt in filteredOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
class="dropdown-item px-2 py-1"
|
class="dropdown-item px-2 py-1"
|
||||||
:class="{ disabled: opt.disabled }"
|
:class="{ disabled: opt.disabled }"
|
||||||
@click.stop>
|
@click.stop>
|
||||||
<label class="d-flex align-items-center m-0 w-100">
|
<label class="d-flex align-items-center m-0 w-100" style="cursor: pointer;">
|
||||||
<input v-if="multiple"
|
<input v-if="multiple"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="form-check-input mr-2"
|
class="form-check-input mr-2"
|
||||||
@@ -196,8 +230,8 @@ Vue.component('tt-select', {
|
|||||||
:disabled="opt.disabled"
|
:disabled="opt.disabled"
|
||||||
@change.prevent="selectOption(opt)">
|
@change.prevent="selectOption(opt)">
|
||||||
<span>
|
<span>
|
||||||
{{ opt.text }}<span v-if="suffix"> {{ suffix }}</span>
|
{{ opt.text }}<span v-if="suffix"> {{ suffix }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user