441 lines
21 KiB
JavaScript
441 lines
21 KiB
JavaScript
const RadiusTransferModal = {
|
|
name: 'RadiusTransferModal',
|
|
props: {
|
|
show: Boolean,
|
|
username: String
|
|
},
|
|
template: `
|
|
<tt-dialog
|
|
:show="show"
|
|
:title="'Transfer Statistik für ' + username"
|
|
@close="close"
|
|
size="wide"
|
|
>
|
|
<div class="modal-body-scrollable">
|
|
<div v-if="transferYearlyData || transferInitialLoading">
|
|
<div class="unselectable">
|
|
<div class="cluster"
|
|
style="justify-content: space-between; margin-bottom: 16px; flex-wrap: nowrap; margin-top: 4px; padding-left: 8px;">
|
|
<div class="cluster">
|
|
<div class="custom-dropdown">
|
|
<button class="dropdown-toggle"
|
|
@click="!transferInitialLoading && (showYearDropdown = !showYearDropdown)"
|
|
:class="{'is-open': showYearDropdown}">
|
|
<span>{{ transferYear }}</span>
|
|
<i class="fa-solid fa-chevron-down"></i>
|
|
</button>
|
|
<transition name="ac-pop">
|
|
<div v-if="showYearDropdown" class="dropdown-panel">
|
|
<div v-for="y in availableYears" :key="y" class="dropdown-item" @click="selectYear(y)">
|
|
{{ y }}
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
<div class="cluster" style="gap: 4px;">
|
|
<button v-for="m in allMonths" :key="m.month" class="tab-btn"
|
|
:class="{active: transferMonth === m.month}"
|
|
:disabled="isMonthDisabled(m.month)"
|
|
@click="changeTransferMonth(m.month)">{{ m.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="cluster" style="gap: 16px;">
|
|
<div class="muted small mono" style="text-align: right; flex-shrink: 0;">
|
|
Gesamt {{ transferYear }}:<br>
|
|
<strong v-if="transferInitialLoading || !transferYearlyData">
|
|
<tt-skeleton width="110px" height="16px" style="margin-left:auto;" />
|
|
</strong>
|
|
<strong v-else>{{ window.TT_CORE.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}</strong>
|
|
</div>
|
|
<button class="ghost-btn" @click="prepareEmailModal"
|
|
:disabled="transferInitialLoading || !transferYearlyData || !transferYearlyData.yearlySummary || transferYearlyData.yearlySummary.grandTotalBytes === 0"
|
|
data-tooltip="Statistik per E-Mail senden"
|
|
data-tooltip-align="bottom-left"
|
|
data-tooltip-wrap="true">
|
|
<i class="fa-duotone fa-paper-plane"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="grid g-4 cols-4">
|
|
<div class="stat-card-v2 stat-total">
|
|
<div class="stat-icon"><i class="fa-duotone fa-grid-2"></i></div>
|
|
<div>
|
|
<div class="stat-label">Monat gesamt</div>
|
|
<div class="stat-value">
|
|
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
|
|
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-v2 stat-download">
|
|
<div class="stat-icon"><i class="fa-duotone fa-arrow-down-to-line"></i></div>
|
|
<div>
|
|
<div class="stat-label">Download</div>
|
|
<div class="stat-value">
|
|
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
|
|
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-v2 stat-upload">
|
|
<div class="stat-icon"><i class="fa-duotone fa-arrow-up-from-line"></i></div>
|
|
<div>
|
|
<div class="stat-label">Upload</div>
|
|
<div class="stat-value">
|
|
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
|
|
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-v2 stat-duration">
|
|
<div class="stat-icon"><i class="fa-duotone fa-hourglass-clock"></i></div>
|
|
<div>
|
|
<div class="stat-label">Dauer</div>
|
|
<div class="stat-value">
|
|
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="80px" height="18px" /></span>
|
|
<span v-else>{{ window.TT_CORE.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<div class="chart-card mt-3" style="height: 250px;">
|
|
<div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder">
|
|
<tt-skeleton width="100%" height="100%" style="border-radius: var(--radius);" />
|
|
</div>
|
|
<div
|
|
v-else-if="!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length"
|
|
class="chart-placeholder">
|
|
<i class="fa-duotone fa-chart-pie"></i>
|
|
<span>Keine Daten in diesem Monat verfügbar</span>
|
|
</div>
|
|
<canvas
|
|
v-show="!transferMonthlyLoading && !transferInitialLoading && transferMonthlyData?.details?.length"
|
|
ref="transferChartCanvas">
|
|
</canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Details Table -->
|
|
<div class="table-wrap mt-3" style="height: 350px;">
|
|
<div
|
|
v-if="!transferInitialLoading && !transferMonthlyLoading && (!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length)"
|
|
class="table-placeholder-fixed-height">
|
|
<i class="fa-duotone fa-database"></i>
|
|
<span>Keine detaillierten Daten für diesen Monat.</span>
|
|
</div>
|
|
<table v-else class="tt-table compact">
|
|
<thead>
|
|
<tr>
|
|
<th>Startzeit</th>
|
|
<th>Dauer</th>
|
|
<th>IP-Adresse</th>
|
|
<th style="text-align: right;">Download</th>
|
|
<th style="text-align: right;">Upload</th>
|
|
<th style="text-align: right;">Gesamt</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-if="transferInitialLoading || transferMonthlyLoading">
|
|
<tr v-for="n in 10" :key="'skel'+n">
|
|
<td><tt-skeleton /></td>
|
|
<td><tt-skeleton /></td>
|
|
<td><tt-skeleton /></td>
|
|
<td><tt-skeleton /></td>
|
|
<td><tt-skeleton /></td>
|
|
<td><tt-skeleton /></td>
|
|
</tr>
|
|
</template>
|
|
<template v-else>
|
|
<tr v-for="(d, i) in transferMonthlyData.details" :key="i">
|
|
<td class="mono small">{{ d.startTime }}</td>
|
|
<td class="mono small">{{ window.TT_CORE.formatDuration(d.durationSeconds) }}</td>
|
|
<td class="mono small">{{ d.ipAddress }}</td>
|
|
<td class="mono small" style="text-align: right;">{{ window.TT_CORE.formatBytes(d.downloadBytes) }}</td>
|
|
<td class="mono small" style="text-align: right;">{{ window.TT_CORE.formatBytes(d.uploadBytes) }}</td>
|
|
<td class="mono small" style="text-align: right;"><strong>{{ window.TT_CORE.formatBytes(d.totalBytes) }}</strong></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="!transferInitialLoading" class="table-placeholder" style="min-height: 400px;">
|
|
<i class="fa-duotone fa-wifi-slash"></i>
|
|
<div>Daten konnten nicht geladen werden.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Embedded Email Modal (Logic kept here as it depends on local chart/data) -->
|
|
<tt-dialog :show="showEmailModal" title="Statistik per E-Mail senden" @close="showEmailModal=false">
|
|
<div>
|
|
<div class="field">
|
|
<label style="margin-bottom: 8px; font-size: 14px;">Empfänger-E-Mail</label>
|
|
<div class="input-wrap">
|
|
<i class="fa-duotone fa-envelope input-icon"></i>
|
|
<input
|
|
class="ri"
|
|
type="email"
|
|
v-model.trim="recipientEmail"
|
|
placeholder="name@domain.com"
|
|
@keydown.enter="isValidEmail && !isSendingEmail && sendTransferEmail()"
|
|
autocomplete="nope"
|
|
/>
|
|
</div>
|
|
<p v-if="recipientEmail && !isValidEmail" class="muted small" style="color: var(--bad); margin-top: 4px;">
|
|
Bitte geben Sie eine gültige E-Mail-Adresse ein.
|
|
</p>
|
|
</div>
|
|
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
|
|
<button class="ghost-btn" @click="showEmailModal=false" :disabled="isSendingEmail">Abbrechen</button>
|
|
<button class="primary-btn" @click="sendTransferEmail" :disabled="!isValidEmail || isSendingEmail" style="min-width: 100px;">
|
|
<span v-if="!isSendingEmail">Senden</span>
|
|
<span v-else class="btn-loader"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</tt-dialog>
|
|
</tt-dialog>
|
|
`,
|
|
data: () => ({
|
|
window: window,
|
|
transferInitialLoading: false,
|
|
transferMonthlyLoading: false,
|
|
transferYear: new Date().getFullYear(),
|
|
transferMonth: new Date().getMonth() + 1,
|
|
transferYearlyData: null,
|
|
transferMonthlyData: null,
|
|
transferChartInstance: null,
|
|
showYearDropdown: false,
|
|
|
|
// Email Logic
|
|
showEmailModal: false,
|
|
isSendingEmail: false,
|
|
recipientEmail: ''
|
|
}),
|
|
computed: {
|
|
availableYears() {
|
|
const c = new Date().getFullYear(), s = 2021;
|
|
if (s > c) return [c];
|
|
return Array.from({length: c - s + 1}, (_, i) => c - i);
|
|
},
|
|
allMonths() {
|
|
return Array.from({length: 12}, (_, i) => ({
|
|
month: i + 1,
|
|
name: new Date(2000, i, 1).toLocaleString('de-DE', {month: 'short'})
|
|
}));
|
|
},
|
|
isValidEmail() {
|
|
if (!this.recipientEmail) return false;
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail);
|
|
}
|
|
},
|
|
watch: {
|
|
show(val) {
|
|
if (val && this.username) {
|
|
this.transferYear = new Date().getFullYear();
|
|
this.transferMonth = new Date().getMonth() + 1;
|
|
this.fetchTransferYearData();
|
|
} else {
|
|
// Cleanup
|
|
this.close();
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
close() {
|
|
this.$emit('close');
|
|
this.transferYearlyData = null;
|
|
this.transferMonthlyData = null;
|
|
this.showYearDropdown = false;
|
|
this.showEmailModal = false;
|
|
this.recipientEmail = '';
|
|
this.isSendingEmail = false;
|
|
if (this.transferChartInstance) {
|
|
this.transferChartInstance.destroy();
|
|
this.transferChartInstance = null;
|
|
}
|
|
},
|
|
async fetchTransferYearData() {
|
|
this.transferInitialLoading = true;
|
|
this.transferYearlyData = null;
|
|
try {
|
|
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
|
|
params: {
|
|
action2: 'transferStatistic',
|
|
username: this.username,
|
|
year: this.transferYear,
|
|
month: 0
|
|
}
|
|
});
|
|
if (data && data.monthlySummary) {
|
|
this.transferYearlyData = data;
|
|
const last = [...data.monthlySummary].reverse().find(m => m.grandTotalBytes > 0);
|
|
this.transferMonth = last ? last.month : new Date().getMonth() + 1;
|
|
await this.fetchTransferMonthData();
|
|
} else {
|
|
this.transferYearlyData = null;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.transferYearlyData = null;
|
|
}
|
|
this.transferInitialLoading = false;
|
|
},
|
|
async fetchTransferMonthData() {
|
|
this.transferMonthlyLoading = true;
|
|
this.transferMonthlyData = null;
|
|
if (this.transferChartInstance) this.transferChartInstance.destroy();
|
|
try {
|
|
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
|
|
params: {
|
|
action2: 'transferStatistic',
|
|
username: this.username,
|
|
year: this.transferYear,
|
|
month: this.transferMonth
|
|
}
|
|
});
|
|
this.transferMonthlyData = data || null;
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.transferMonthlyData = null;
|
|
}
|
|
this.transferMonthlyLoading = false;
|
|
this.$nextTick(() => {
|
|
if (this.show) this.renderTransferChart();
|
|
});
|
|
},
|
|
isMonthDisabled(month) {
|
|
if (this.transferInitialLoading || this.transferMonthlyLoading) return true;
|
|
if (!this.transferYearlyData?.monthlySummary) return true;
|
|
const m = this.transferYearlyData.monthlySummary.find(m => m.month === month);
|
|
return !m || m.grandTotalBytes === 0;
|
|
},
|
|
selectYear(year) {
|
|
this.showYearDropdown = false;
|
|
if (this.transferYear !== year) this.changeTransferYear(year);
|
|
},
|
|
async changeTransferYear(year) {
|
|
this.transferYear = year;
|
|
await this.fetchTransferYearData();
|
|
},
|
|
async changeTransferMonth(month) {
|
|
this.transferMonth = month;
|
|
await this.fetchTransferMonthData();
|
|
},
|
|
prepareEmailModal() {
|
|
if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return;
|
|
this.recipientEmail = '';
|
|
this.showEmailModal = true;
|
|
},
|
|
async sendTransferEmail() {
|
|
if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return;
|
|
this.isSendingEmail = true;
|
|
try {
|
|
const chartImageBase64 = this.transferChartInstance.toBase64Image();
|
|
const payload = {
|
|
username: this.username,
|
|
year: this.transferYear,
|
|
month: this.transferMonth,
|
|
monthlySummary: this.transferMonthlyData.summary,
|
|
monthlyDetails: this.transferMonthlyData.details,
|
|
chartImage: chartImageBase64,
|
|
recipient: this.recipientEmail
|
|
};
|
|
await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, payload);
|
|
window.notify('success', 'E-Mail wurde erfolgreich versendet.');
|
|
this.showEmailModal = false;
|
|
} catch (e) {
|
|
console.error("Failed to send transfer email:", e);
|
|
window.notify('error', 'Fehler beim Senden der E-Mail.');
|
|
} finally {
|
|
this.isSendingEmail = false;
|
|
}
|
|
},
|
|
processChartData(details) {
|
|
if (!details || !details.length) return {labels: [], datasets: []};
|
|
const daily = details.reduce((a, s) => {
|
|
const d = s.startTime.split(' ')[0];
|
|
if (!a[d]) a[d] = {downloadBytes: 0, uploadBytes: 0};
|
|
a[d].downloadBytes += Number(s.downloadBytes) || 0;
|
|
a[d].uploadBytes += Number(s.uploadBytes) || 0;
|
|
return a;
|
|
}, {});
|
|
const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b));
|
|
return {
|
|
labels: dates,
|
|
datasets: [{
|
|
label: 'Download',
|
|
data: dates.map(d => daily[d].downloadBytes),
|
|
borderColor: 'rgba(15, 157, 88, 0.8)',
|
|
backgroundColor: 'rgba(15, 157, 88, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 2,
|
|
borderWidth: 1.5
|
|
}, {
|
|
label: 'Upload',
|
|
data: dates.map(d => daily[d].uploadBytes),
|
|
borderColor: 'rgba(0, 83, 132, 0.8)',
|
|
backgroundColor: 'rgba(0, 83, 132, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 2,
|
|
borderWidth: 1.5
|
|
}]
|
|
};
|
|
},
|
|
renderTransferChart() {
|
|
if (this.transferChartInstance) this.transferChartInstance.destroy();
|
|
if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return;
|
|
const d = this.processChartData(this.transferMonthlyData.details);
|
|
if (!d.labels.length) return;
|
|
const chartBackgroundColorPlugin = {
|
|
id: 'customCanvasBackgroundColor',
|
|
beforeDraw: (chart) => {
|
|
const {ctx} = chart;
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'destination-over';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, chart.width, chart.height);
|
|
ctx.restore();
|
|
}
|
|
};
|
|
this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: d,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: {
|
|
type: 'time',
|
|
time: {unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: {day: 'DD.MM'}},
|
|
grid: {display: false},
|
|
ticks: {maxRotation: 0, autoSkip: true, maxTicksLimit: 15}
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {callback: (v) => window.TT_CORE.formatBytes(v, 0)},
|
|
grid: {color: 'rgba(0,0,0,0.05)'}
|
|
}
|
|
},
|
|
plugins: {
|
|
tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.TT_CORE.formatBytes(c.parsed.y)}`}},
|
|
legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}}
|
|
},
|
|
interaction: {mode: 'index', intersect: false}
|
|
},
|
|
plugins: [chartBackgroundColorPlugin]
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
if (window.VueApp) {
|
|
window.VueApp.component('radius-transfer-modal', RadiusTransferModal);
|
|
} |