Files
thetool/public/js/pages/Radius/RadiusTransferModal.js
2025-12-09 05:34:24 +00:00

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);
}