Merge branch 'Radius/connect' into 'master'
Enhance IP address tooltip and add Ctrl+Click functionality for scanning See merge request fronk/thetool!1864
This commit is contained in:
@@ -147,18 +147,128 @@ Vue.component('radius-processing-indicator', {
|
||||
|
||||
/* ---------- Online state chip (fetches radacct when visible) ---------- */
|
||||
Vue.component('radius-online-state', {
|
||||
props: { username: String }, data: () => ({ data: null, observed: false, ob: null }),
|
||||
props: { username: String },
|
||||
data: () => ({
|
||||
data: null,
|
||||
observed: false,
|
||||
ob: null,
|
||||
isHovering: false,
|
||||
ctrlPressed: false,
|
||||
tooltipText: 'IP-Adresse kopieren'
|
||||
}),
|
||||
template: `
|
||||
<div class="radius-scope ros-wrap" ref="root">
|
||||
<template v-if="data===null"><span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span></template>
|
||||
<template v-else-if="data!==null"><span class="ros-chip" :class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]" :data-tooltip="data.ip ? 'IP-Adresse kopieren' : null" @click="copyIp($event)"><span class="dot"></span><span class="ip">{{ data.ip || '—' }}</span></span></template>
|
||||
<template v-if="data===null">
|
||||
<span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span>
|
||||
</template>
|
||||
<template v-else-if="data!==null">
|
||||
<span class="ros-chip"
|
||||
:class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]"
|
||||
:data-tooltip="tooltipText"
|
||||
@click="onClickIp"
|
||||
@mouseover="onIpMouseOver"
|
||||
@mouseout="onIpMouseOut"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="ip">{{ data.ip || '—' }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
mounted() { this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); if (this.$refs.root) this.ob.observe(this.$refs.root); },
|
||||
beforeDestroy() { this.ob?.disconnect(); },
|
||||
watch: {
|
||||
data(newData) {
|
||||
// Update tooltip text when data is loaded
|
||||
if (newData && newData.ip) {
|
||||
this.tooltipText = 'IP-Adresse kopieren';
|
||||
} else {
|
||||
this.tooltipText = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 });
|
||||
if (this.$refs.root) this.ob.observe(this.$refs.root);
|
||||
// Listen for Ctrl/Meta key presses globally
|
||||
document.addEventListener('keydown', this.handleKey);
|
||||
document.addEventListener('keyup', this.handleKey);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.ob?.disconnect();
|
||||
// Clean up global listeners
|
||||
document.removeEventListener('keydown', this.handleKey);
|
||||
document.removeEventListener('keyup', this.handleKey);
|
||||
},
|
||||
methods: {
|
||||
async fetchState() { try { const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); this.data = r.ok ? await r.json() : { online: false, ip: null }; } catch { this.data = { online: false, ip: null }; } },
|
||||
async copyIp(event) { if (!this.data?.ip) return; const c = event.currentTarget; if (!c || c.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(this.data.ip); c.classList.add('is-copied'); const o = c.dataset.tooltip; if (o) c.dataset.tooltip = 'Kopiert!'; setTimeout(() => { c.classList.remove('is-copied'); if (o) c.dataset.tooltip = o; }, 1500); }
|
||||
async fetchState() {
|
||||
try {
|
||||
const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`);
|
||||
this.data = r.ok ? await r.json() : { online: false, ip: null };
|
||||
} catch {
|
||||
this.data = { online: false, ip: null };
|
||||
}
|
||||
},
|
||||
async copyIp(event) {
|
||||
if (!this.data?.ip) return;
|
||||
const c = event.currentTarget;
|
||||
if (!c || c.classList.contains('is-copied')) return;
|
||||
await window.RadiusUtils.copyToClipboard(this.data.ip);
|
||||
c.classList.add('is-copied');
|
||||
|
||||
// Temporarily change tooltip to "Kopiert!"
|
||||
const originalTooltip = this.tooltipText;
|
||||
this.tooltipText = 'Kopiert!';
|
||||
|
||||
setTimeout(() => {
|
||||
c.classList.remove('is-copied');
|
||||
// Restore original tooltip
|
||||
this.tooltipText = originalTooltip;
|
||||
// Re-run updateTooltip in case Ctrl is still pressed
|
||||
this.updateTooltip();
|
||||
}, 1500);
|
||||
},
|
||||
// --- New methods for Ctrl+Click ---
|
||||
handleKey(event) {
|
||||
const newCtrlPressed = event.ctrlKey || event.metaKey;
|
||||
if (newCtrlPressed !== this.ctrlPressed) {
|
||||
this.ctrlPressed = newCtrlPressed;
|
||||
// If hovering, update tooltip live
|
||||
if (this.isHovering) {
|
||||
this.updateTooltip();
|
||||
}
|
||||
}
|
||||
},
|
||||
onIpMouseOver(event) {
|
||||
this.isHovering = true;
|
||||
this.ctrlPressed = event.ctrlKey || event.metaKey;
|
||||
this.updateTooltip();
|
||||
},
|
||||
onIpMouseOut() {
|
||||
this.isHovering = false;
|
||||
this.ctrlPressed = false; // Reset on mouse out
|
||||
this.updateTooltip();
|
||||
},
|
||||
updateTooltip() {
|
||||
if (!this.data?.ip) {
|
||||
this.tooltipText = null;
|
||||
} else if (this.isHovering && this.ctrlPressed) {
|
||||
this.tooltipText = 'Scan starten & verbinden';
|
||||
} else {
|
||||
this.tooltipText = 'IP-Adresse kopieren';
|
||||
}
|
||||
},
|
||||
onClickIp(event) {
|
||||
if (!this.data?.ip) return;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl+Click or Meta+Click
|
||||
event.preventDefault();
|
||||
this.$emit('scan-ip', { ip: this.data.ip });
|
||||
} else {
|
||||
// Normal click
|
||||
this.copyIp(event);
|
||||
}
|
||||
}
|
||||
// --- End new methods ---
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,4 +357,4 @@ Vue.component('radius', {
|
||||
methods: {
|
||||
switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,138 +1,826 @@
|
||||
/* ===== RadiusUsers.js ===== */
|
||||
Vue.component('radius-users', {
|
||||
template: `
|
||||
<div class="radius-scope">
|
||||
<div class="filters-layout">
|
||||
<div class="field"><radius-autocomplete v-model="billAddrDisplay" :wide="true" placeholder="Kunde suchen" @select="onAddrSelect" @enter="loadRadiusUsers" @mode-change="onModeChange"/></div>
|
||||
<div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span><i class="fa-duotone fa-user input-icon"></i><input class="ri" v-model="username" placeholder="Username" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="text" name="radius-username"/></div></div>
|
||||
<div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span><div class="input-wrap"><i class="fa-duotone fa-network-wired input-icon"></i><input class="ri" v-model="ip" placeholder="IP-Adresse" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="decimal" name="radius-ip"/></div></div></div>
|
||||
<div class="field"><div class="input-wrap"><i class="fa-duotone fa-note-sticky input-icon"></i><input class="ri" v-model="info" placeholder="Info" @keydown.enter="loadRadiusUsers"/></div></div>
|
||||
<div class="cluster" style="gap: 8px;">
|
||||
<div class="field"><label class="switch-field"><span class="mini muted">Online-Status</span><span class="switch"><input type="checkbox" v-model="checkOnlineState"><span class="switch-track"><i class="fa-duotone fa-signal-bars-good on"></i><i class="fa-duotone fa-signal-bars-slash off"></i></span></span></label></div>
|
||||
<button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;"><span v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span><span v-else class="btn-loader"></span></button>
|
||||
<button class="danger-btn" @click="clearFilters" :disabled="!hasFilters" data-tooltip="Eingaben leeren" data-tooltip-align="left"><i class="fa-duotone fa-xmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-container mt-between">
|
||||
<radius-table-view :items="visibleUsers" :is-loading="isLoading" :has-searched="hasSearched" :skeleton-row-count="6" initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben.">
|
||||
<template #head><thead><tr><th style="text-align: center; width: 170px;">Kundennummer</th><th style="text-align: center; width: 183px;">Username</th><th style="text-align: center">Info</th><th style="text-align: center; width: 190px;">Status</th><th style="text-align: center; width: 115px;">Aktionen</th></tr></thead></template>
|
||||
<template #skeleton-row><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line" style="height: 36px;"></div></td></template>
|
||||
<template #row="{ item }">
|
||||
<td><a class="link" target="_blank" :href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber" data-tooltip="Kunden in neuem Tab öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a></td>
|
||||
<td class="nowrap"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.username" data-tooltip="User in Radius öffnen" data-tooltip-align="right">{{ item.username }}</a><button class="icon-btn sm" data-tooltip="Kopieren" data-tooltip-align="right" @click="copy(item.username, $event)"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></td>
|
||||
<td class="mono clamp-2">{{ item.info }}</td>
|
||||
<td><radius-online-state v-if="checkOnlineState" :username="item.username" :key="item.username + '_'+searchCount"/></td>
|
||||
<td class="nowrap cluster" style="gap: 4px; justify-content: center;"><button class="ghost-btn" @click="fetchRadacctData(item.username)" data-tooltip="Details"><i class="fa-duotone fa-circle-info"></i></button><button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik" data-tooltip-align="left"><i class="fa-duotone fa-chart-line"></i></button></td>
|
||||
</template>
|
||||
<template #observer><div ref="sentinel" style="height: 1px;"></div></template>
|
||||
</radius-table-view>
|
||||
<div v-if="hasSearched" class="results-summary"><span v-if="isLoading">Suche läuft...</span><span v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span></div>
|
||||
</div>
|
||||
<radius-modal :show="showRadacctModal" title="RADIUS Daten" @close="showRadacctModal=false">
|
||||
<div class="kv-redesign">
|
||||
<div class="kv-row"><span class="kv-label">Status</span><div class="kv-value"><div v-if="radacctData"><strong class="chip" :class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong></div><div v-else><div class="skeleton-line" style="width: 80px; --h: 24px; margin-left: auto;"></div></div></div></div>
|
||||
<div class="kv-row"><span class="kv-label">IP</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><a v-if="radacctData.ip" class="link" @click="scanIp(radacctData.ip)" data-tooltip="IP-Scan starten" data-tooltip-align="right" style="cursor: pointer;"><code>{{ radacctData.ip }}</code></a><code v-else>—</code><button v-if="radacctData.ip" class="icon-btn sm" @click="copy(radacctData.ip, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 120px; margin-left: auto;"></div></div></div></div>
|
||||
<div class="kv-row"><span class="kv-label">Username</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username" data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a><button class="icon-btn sm" @click="copy(radacctData.username, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 150px; margin-left: auto;"></div></div></div></div>
|
||||
<template v-if="radacctData"><div class="kv-row"><span class="kv-label">Kundennummer</span><code class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><code class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div><div class="kv-row"><span class="kv-label">Bandbreite</span><code class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div></template>
|
||||
<template v-else><div class="kv-row"><span class="kv-label">Kundennummer</span><div class="kv-value"><div class="skeleton-line" style="width: 70px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value"><div class="skeleton-line" style="width: 200px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value"><div class="skeleton-line" style="--h:14px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><div class="kv-value"><div class="skeleton-line" style="width: 100px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Bandbreite</span><div class="kv-value"><div class="skeleton-line" style="width: 180px; margin-left: auto;"></div></div></div></template>
|
||||
</div>
|
||||
</radius-modal>
|
||||
<radius-modal :show="showTransferModal" :title="'Transfer Statistik für ' + transferModalUsername" @close="closeTransferModal" modal-class="modal-card-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"><div class="skeleton-line" style="width: 110px; height: 16px; margin-left:auto;"></div></strong><strong v-else>{{ window.RadiusUtils.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>
|
||||
<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"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.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"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.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"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.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"><div class="skeleton-line" style="width: 80px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span></div></div></div></div>
|
||||
<div class="chart-card mt-3" style="height: 250px;"><div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder"><div class="skeleton-line" style="width: 100%; height: 100%; border-radius: var(--radius);"></div></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>
|
||||
<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><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></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.RadiusUtils.formatDuration(d.durationSeconds) }}</td><td class="mono small">{{ d.ipAddress }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.downloadBytes) }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.uploadBytes) }}</td><td class="mono small" style="text-align: right;"><strong>{{ window.RadiusUtils.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>
|
||||
</radius-modal>
|
||||
<radius-modal :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" data-1p-ignore data-lpignore="true" data-form-type="other" data-bwignore></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>
|
||||
</radius-modal>
|
||||
</div>
|
||||
`,
|
||||
data: () => ({ window: window, billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '', searchMode: 'autocomplete', radiusUsers: [], checkOnlineState: false, isLoading: false, showRadacctModal: false, radacctData: null, searchCount: 0, hasSearched: false, visibleCount: 50, observer: null, showTransferModal: false, transferInitialLoading: false, transferMonthlyLoading: false, transferModalUsername: '', transferYear: new Date().getFullYear(), transferMonth: new Date().getMonth() + 1, transferYearlyData: null, transferMonthlyData: null, transferChartInstance: null, showYearDropdown: false, isSendingEmail: false, showEmailModal: false, recipientEmail: '' }),
|
||||
computed: {
|
||||
hasFilters() { return this.billAddrDisplay || this.username || this.ip || this.info; },
|
||||
visibleUsers() { return this.radiusUsers.slice(0, this.visibleCount); },
|
||||
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); }
|
||||
},
|
||||
mounted() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const infoParam = urlParams.get('info');
|
||||
if (infoParam) {
|
||||
this.info = infoParam;
|
||||
this.loadRadiusUsers();
|
||||
}
|
||||
this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 });
|
||||
if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
|
||||
},
|
||||
beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); },
|
||||
updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } },
|
||||
methods: {
|
||||
onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; },
|
||||
onModeChange(newMode) { this.searchMode = newMode; },
|
||||
async loadRadiusUsers() { this.isLoading = true; this.radiusUsers = []; this.hasSearched = true; this.visibleCount = 50; try { const p = new URLSearchParams({ username: this.username || '', info: this.info || '', ip: this.ip || '' }); if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); if (r.ok) { const u = await r.json(); if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; this.radiusUsers = Array.isArray(u) ? u : []; } } catch (e) { console.error(e); } this.isLoading = false; this.searchCount++; },
|
||||
async fetchRadacctData(username) { this.showRadacctModal = true; this.radacctData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); if (r.ok) this.radacctData = await r.json(); } catch (e) { console.error(e); this.radacctData = {}; } },
|
||||
async copy(text, event) { if (!event || !event.currentTarget) return; const btn = event.currentTarget; if (btn.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(text); btn.classList.add('is-copied'); btn.disabled = true; setTimeout(() => { btn.classList.remove('is-copied'); btn.disabled = false; }, 1500); },
|
||||
clearFilters() { this.billAddrDisplay = ''; this.billAddrCustnum = ''; this.username = ''; this.ip = ''; this.info = ''; this.radiusUsers = []; this.hasSearched = false; this.searchCount++; this.visibleCount = 50; },
|
||||
loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; },
|
||||
// ===== NEW METHOD =====
|
||||
async scanIp(ip) {
|
||||
if (!ip) return;
|
||||
if (window.notify) {
|
||||
window.notify('info', `Starte Scan für ${ip}...`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8094/scan?ip=${ip}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP-Fehler! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
template: `
|
||||
<div class="radius-scope">
|
||||
<div class="filters-layout">
|
||||
<div class="field">
|
||||
<radius-autocomplete v-model="billAddrDisplay" :wide="true" placeholder="Kunde suchen"
|
||||
@select="onAddrSelect" @enter="loadRadiusUsers" @mode-change="onModeChange"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span><i
|
||||
class="fa-duotone fa-user input-icon"></i><input class="ri" v-model="username" placeholder="Username"
|
||||
@keydown.enter="loadRadiusUsers" autocomplete="off"
|
||||
autocapitalize="none" autocorrect="off"
|
||||
inputmode="text" name="radius-username"/></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span>
|
||||
<div class="input-wrap"><i class="fa-duotone fa-network-wired input-icon"></i><input class="ri"
|
||||
v-model="ip"
|
||||
placeholder="IP-Adresse"
|
||||
@keydown.enter="loadRadiusUsers"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
inputmode="decimal"
|
||||
name="radius-ip"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="input-wrap"><i class="fa-duotone fa-note-sticky input-icon"></i><input class="ri" v-model="info"
|
||||
placeholder="Info"
|
||||
@keydown.enter="loadRadiusUsers"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster" style="gap: 8px;">
|
||||
<div class="field"><label class="switch-field"><span class="mini muted">Online-Status</span><span
|
||||
class="switch"><input type="checkbox" v-model="checkOnlineState"><span class="switch-track"><i
|
||||
class="fa-duotone fa-signal-bars-good on"></i><i class="fa-duotone fa-signal-bars-slash off"></i></span></span></label>
|
||||
</div>
|
||||
<button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;"><span
|
||||
v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span><span v-else
|
||||
class="btn-loader"></span>
|
||||
</button>
|
||||
<button class="danger-btn" @click="clearFilters" :disabled="!hasFilters" data-tooltip="Eingaben leeren"
|
||||
data-tooltip-align="left"><i class="fa-duotone fa-xmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-container mt-between">
|
||||
<radius-table-view :items="visibleUsers" :is-loading="isLoading" :has-searched="hasSearched"
|
||||
:skeleton-row-count="6"
|
||||
initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben.">
|
||||
<template #head>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: center; width: 170px;">Kundennummer</th>
|
||||
<th style="text-align: center; width: 183px;">Username</th>
|
||||
<th style="text-align: center">Info</th>
|
||||
<th style="text-align: center; width: 190px;">Status</th>
|
||||
<th style="text-align: center; width: 115px;">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
<template #skeleton-row>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line" style="height: 36px;"></div>
|
||||
</td>
|
||||
</template>
|
||||
<template #row="{ item }">
|
||||
<td><a class="link" target="_blank"
|
||||
:href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber"
|
||||
data-tooltip="Kunden in neuem Tab öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a>
|
||||
</td>
|
||||
<td class="nowrap"><a class="link" target="_blank"
|
||||
:href="'http://radius.xinon.at/edit_user.php?user=' + item.username"
|
||||
data-tooltip="User in Radius öffnen"
|
||||
data-tooltip-align="right">{{ item.username }}</a>
|
||||
<button class="icon-btn sm" data-tooltip="Kopieren" data-tooltip-align="right"
|
||||
@click="copy(item.username, $event)"><i class="fa-duotone fa-copy copy-icon"></i><i
|
||||
class="fa-duotone fa-check check-icon"></i></button>
|
||||
</td>
|
||||
<td class="mono clamp-2">{{ item.info || '—' }}</td>
|
||||
<td>
|
||||
<radius-online-state
|
||||
v-if="checkOnlineState"
|
||||
:username="item.username"
|
||||
:key="item.username + '_'+searchCount"
|
||||
@scan-ip="onScanIp($event, item)"
|
||||
/>
|
||||
</td>
|
||||
<td class="nowrap cluster" style="gap: 4px; justify-content: center;">
|
||||
<button class="ghost-btn" @click="fetchRadacctData(item.username)" data-tooltip="Details"><i
|
||||
class="fa-duotone fa-circle-info"></i></button>
|
||||
<button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik"
|
||||
data-tooltip-align="left"><i class="fa-duotone fa-chart-line"></i></button>
|
||||
</td>
|
||||
</template>
|
||||
<template #observer>
|
||||
<div ref="sentinel" style="height: 1px;"></div>
|
||||
</template>
|
||||
</radius-table-view>
|
||||
<div v-if="hasSearched" class="results-summary"><span v-if="isLoading">Suche läuft...</span><span
|
||||
v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span></div>
|
||||
</div>
|
||||
<radius-modal :show="showRadacctModal" title="RADIUS Daten" @close="showRadacctModal=false">
|
||||
<div class="kv-redesign">
|
||||
<div class="kv-row"><span class="kv-label">Status</span>
|
||||
<div class="kv-value">
|
||||
<div v-if="radacctData"><strong class="chip"
|
||||
:class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="skeleton-line" style="width: 80px; --h: 24px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-row"><span class="kv-label">IP</span>
|
||||
<div class="kv-value">
|
||||
<div v-if="radacctData" class="inline-copy"><code v-if="radacctData.ip">{{ radacctData.ip }}</code><code
|
||||
v-else>—</code>
|
||||
<button v-if="radacctData.ip" class="icon-btn sm" @click="copy(radacctData.ip, $event)"
|
||||
data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i
|
||||
class="fa-duotone fa-check check-icon"></i></button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="skeleton-line" style="width: 120px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-row"><span class="kv-label">Username</span>
|
||||
<div class="kv-value">
|
||||
<div v-if="radacctData" class="inline-copy"><a class="link" target="_blank"
|
||||
:href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username"
|
||||
data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a>
|
||||
<button class="icon-btn sm" @click="copy(radacctData.username, $event)" data-tooltip="Kopieren"><i
|
||||
class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="skeleton-line" style="width: 150px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="radacctData">
|
||||
<div class="kv-row"><span class="kv-label">Kundennummer</span><code
|
||||
class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div>
|
||||
<div class="kv-row"><span class="kv-label">Kundenname</span>
|
||||
<div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">Info</span>
|
||||
<div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">WLAN Password</span><code
|
||||
class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div>
|
||||
<div class="kv-row"><span class="kv-label">Bandbreite</span><code
|
||||
class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="kv-row"><span class="kv-label">Kundennummer</span>
|
||||
<div class="kv-value">
|
||||
<div class="skeleton-line" style="width: 70px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">Kundenname</span>
|
||||
<div class="kv-value">
|
||||
<div class="skeleton-line" style="width: 200px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">Info</span>
|
||||
<div class="kv-value">
|
||||
<div class="skeleton-line" style="--h:14px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">WLAN Password</span>
|
||||
<div class="kv-value">
|
||||
<div class="skeleton-line" style="width: 100px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-label">Bandbreite</span>
|
||||
<div class="kv-value">
|
||||
<div class="skeleton-line" style="width: 180px; margin-left: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</radius-modal>
|
||||
<radius-modal :show="showTransferModal" :title="'Transfer Statistik für ' + transferModalUsername"
|
||||
@close="closeTransferModal" modal-class="modal-card-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">
|
||||
<div class="skeleton-line" style="width: 110px; height: 16px; margin-left:auto;"></div>
|
||||
</strong><strong
|
||||
v-else>{{ window.RadiusUtils.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>
|
||||
<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"><div
|
||||
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
|
||||
v-else>{{ window.RadiusUtils.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"><div
|
||||
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
|
||||
v-else>{{ window.RadiusUtils.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"><div
|
||||
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
|
||||
v-else>{{ window.RadiusUtils.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"><div
|
||||
class="skeleton-line" style="width: 80px; height: 18px;"></div></span><span
|
||||
v-else>{{ window.RadiusUtils.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card mt-3" style="height: 250px;">
|
||||
<div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder">
|
||||
<div class="skeleton-line" style="width: 100%; height: 100%; border-radius: var(--radius);"></div>
|
||||
</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>
|
||||
<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>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="skeleton-line"></div>
|
||||
</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.RadiusUtils.formatDuration(d.durationSeconds) }}</td>
|
||||
<td class="mono small">{{ d.ipAddress }}</td>
|
||||
<td class="mono small" style="text-align: right;">
|
||||
{{ window.RadiusUtils.formatBytes(d.downloadBytes) }}
|
||||
</td>
|
||||
<td class="mono small" style="text-align: right;">
|
||||
{{ window.RadiusUtils.formatBytes(d.uploadBytes) }}
|
||||
</td>
|
||||
<td class="mono small" style="text-align: right;">
|
||||
<strong>{{ window.RadiusUtils.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>
|
||||
</radius-modal>
|
||||
<radius-modal :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"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore></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>
|
||||
</radius-modal>
|
||||
<radius-modal :show="showExtensionIdModal" title="Extension ID Konfigurieren" @close="showExtensionIdModal=false">
|
||||
<div>
|
||||
<div class="field"><label style="margin-bottom: 8px; font-size: 14px;">Chrome Extension ID</label>
|
||||
<div class="input-wrap"><i class="fa-duotone fa-puzzle-piece input-icon"></i><input class="ri" type="text"
|
||||
v-model.trim="extensionId"
|
||||
placeholder="z.B. jglijfiddilckddlmbnlojmmlahboffh">
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
|
||||
<button class="primary-btn" @click="saveExtensionId">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</radius-modal>
|
||||
</div>
|
||||
`,
|
||||
data: () => ({
|
||||
window: window,
|
||||
billAddrDisplay: '',
|
||||
billAddrCustnum: '',
|
||||
username: '',
|
||||
ip: '',
|
||||
info: '',
|
||||
searchMode: 'autocomplete',
|
||||
radiusUsers: [],
|
||||
checkOnlineState: false,
|
||||
isLoading: false,
|
||||
showRadacctModal: false,
|
||||
radacctData: null,
|
||||
searchCount: 0,
|
||||
hasSearched: false,
|
||||
visibleCount: 50,
|
||||
observer: null,
|
||||
showTransferModal: false,
|
||||
transferInitialLoading: false,
|
||||
transferMonthlyLoading: false,
|
||||
transferModalUsername: '',
|
||||
transferYear: new Date().getFullYear(),
|
||||
transferMonth: new Date().getMonth() + 1,
|
||||
transferYearlyData: null,
|
||||
transferMonthlyData: null,
|
||||
transferChartInstance: null,
|
||||
showYearDropdown: false,
|
||||
isSendingEmail: false,
|
||||
showEmailModal: false,
|
||||
recipientEmail: '',
|
||||
showExtensionIdModal: false,
|
||||
extensionId: 'jglijfiddilckddlmbnlojmmlahboffh'
|
||||
}),
|
||||
computed: {
|
||||
hasFilters() {
|
||||
return this.billAddrDisplay || this.username || this.ip || this.info;
|
||||
},
|
||||
visibleUsers() {
|
||||
return this.radiusUsers.slice(0, this.visibleCount);
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const infoParam = urlParams.get('info');
|
||||
if (infoParam) {
|
||||
this.info = infoParam;
|
||||
this.loadRadiusUsers();
|
||||
}
|
||||
this.observer = new IntersectionObserver(([e]) => {
|
||||
if (e && e.isIntersecting) this.loadMore();
|
||||
}, {root: this.$refs.tableWrap, threshold: 0.1});
|
||||
if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
|
||||
|
||||
if (data.status === 'success' && data.url) {
|
||||
window.open(data.url, '_blank');
|
||||
} else if (data.status === 'not_found') {
|
||||
if (window.notify) {
|
||||
window.notify('warning', `Kein Gerät für ${ip} gefunden.`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Ungültige oder unerwartete Antwort vom Scan-Server.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IP-Scan fehlgeschlagen:', error);
|
||||
if (window.notify) {
|
||||
window.notify('error', `Scan für ${ip} fehlgeschlagen.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
// ===== END NEW METHOD =====
|
||||
async openTransferModal(username) { this.showTransferModal = true; this.transferModalUsername = username; this.transferYear = new Date().getFullYear(); this.transferMonth = new Date().getMonth() + 1; await this.fetchTransferYearData(); },
|
||||
closeTransferModal() { this.showTransferModal = false; this.transferModalUsername = ''; 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 r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); if (r.ok) { const d = await r.json(); if(d && d.monthlySummary) { this.transferYearlyData = d; const last = [...d.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 r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); this.transferMonthlyData = r.ok ? await r.json() : null; } catch (e) { console.error(e); this.transferMonthlyData = null; } this.transferMonthlyLoading = false; this.$nextTick(() => { if(this.showTransferModal) this.renderTransferChart(); }); },
|
||||
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.transferModalUsername, year: this.transferYear, month: this.transferMonth, monthlySummary: this.transferMonthlyData.summary, monthlyDetails: this.transferMonthlyData.details, chartImage: chartImageBase64, recipient: this.recipientEmail }; const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { if (window.notify) window.notify('success', 'E-Mail wurde erfolgreich versendet.'); this.showEmailModal = false; } else { throw new Error('Server responded with an error.'); } } catch (e) { console.error("Failed to send transfer email:", e); if (window.notify) window.notify('error', 'Fehler beim Senden der E-Mail.'); } finally { this.isSendingEmail = false; } },
|
||||
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(); },
|
||||
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.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); }
|
||||
}
|
||||
});
|
||||
const savedExtensionId = localStorage.getItem('radiusExtensionId');
|
||||
if (savedExtensionId) {
|
||||
this.extensionId = savedExtensionId;
|
||||
}
|
||||
window.addEventListener('keydown', this.handleKeydown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.observer) this.observer.disconnect();
|
||||
if (this.transferChartInstance) this.transferChartInstance.destroy();
|
||||
window.removeEventListener('keydown', this.handleKeydown);
|
||||
},
|
||||
updated() {
|
||||
if (this.observer && this.$refs.sentinel) {
|
||||
this.observer.disconnect();
|
||||
this.observer.observe(this.$refs.sentinel);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleKeydown(e) {
|
||||
console.log(e);
|
||||
if (e.code === 'KeyE' && e.ctrlKey && e.altKey) {
|
||||
e.preventDefault();
|
||||
this.openExtensionIdModal();
|
||||
}
|
||||
},
|
||||
openExtensionIdModal() {
|
||||
this.showExtensionIdModal = true;
|
||||
},
|
||||
saveExtensionId() {
|
||||
localStorage.setItem('radiusExtensionId', this.extensionId);
|
||||
this.showExtensionIdModal = false;
|
||||
window.notify('success', 'Extension ID gespeichert.');
|
||||
},
|
||||
onAddrSelect({custnum, display}) {
|
||||
this.billAddrCustnum = custnum || '';
|
||||
this.billAddrDisplay = display || '';
|
||||
},
|
||||
onModeChange(newMode) {
|
||||
this.searchMode = newMode;
|
||||
},
|
||||
async loadRadiusUsers() {
|
||||
this.isLoading = true;
|
||||
this.radiusUsers = [];
|
||||
this.hasSearched = true;
|
||||
this.visibleCount = 50;
|
||||
try {
|
||||
const p = new URLSearchParams({
|
||||
username: this.username || '',
|
||||
info: this.info || '',
|
||||
ip: this.ip || ''
|
||||
});
|
||||
if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || '');
|
||||
const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`);
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true;
|
||||
this.radiusUsers = Array.isArray(u) ? u : [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.isLoading = false;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const connectParam = urlParams.get('connect');
|
||||
if (connectParam && connectParam.toLowerCase() === 'true' && this.radiusUsers.length === 1) {
|
||||
const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.radiusUsers[0].username)}`);
|
||||
if (r.ok) {
|
||||
const radacct = await r.json();
|
||||
if (radacct && radacct.ip) {
|
||||
this.onScanIp({ip: radacct.ip}, this.radiusUsers[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.searchCount++;
|
||||
},
|
||||
async fetchRadacctData(username) {
|
||||
this.showRadacctModal = true;
|
||||
this.radacctData = null;
|
||||
try {
|
||||
const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`);
|
||||
if (r.ok) this.radacctData = await r.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.radacctData = {};
|
||||
}
|
||||
},
|
||||
async copy(text, event) {
|
||||
if (!event || !event.currentTarget) return;
|
||||
const btn = event.currentTarget;
|
||||
if (btn.classList.contains('is-copied')) return;
|
||||
await window.RadiusUtils.copyToClipboard(text);
|
||||
btn.classList.add('is-copied');
|
||||
btn.disabled = true;
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('is-copied');
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
},
|
||||
clearFilters() {
|
||||
this.billAddrDisplay = '';
|
||||
this.billAddrCustnum = '';
|
||||
this.username = '';
|
||||
this.ip = '';
|
||||
this.info = '';
|
||||
this.radiusUsers = [];
|
||||
this.hasSearched = false;
|
||||
this.searchCount++;
|
||||
this.visibleCount = 50;
|
||||
},
|
||||
loadMore() {
|
||||
if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50;
|
||||
},
|
||||
async onScanIp(payload, item) {
|
||||
const {ip} = payload;
|
||||
const info = item.info;
|
||||
if (!ip) return;
|
||||
|
||||
window.notify('info', `Starte Scan für ${ip}...`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8094/scan?ip=${ip}`);
|
||||
|
||||
if (!response.ok) {
|
||||
window.notify('error', `Scan-Server-Fehler: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.url) {
|
||||
const extensionId = this.extensionId;
|
||||
const message = {
|
||||
type: "START_RADIUS_SCAN",
|
||||
payload: {
|
||||
ip: ip,
|
||||
url: data.url,
|
||||
info: info
|
||||
}
|
||||
};
|
||||
|
||||
if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) {
|
||||
try {
|
||||
chrome.runtime.sendMessage(extensionId, message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message);
|
||||
window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden.');
|
||||
} else {
|
||||
console.log("Erweiterung hat geantwortet:", response);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Senden an die Erweiterung:", e);
|
||||
window.notify('error', 'Fehler beim Senden an die Erweiterung.');
|
||||
}
|
||||
} else {
|
||||
console.warn("Chrome Extension Messaging API nicht verfügbar.");
|
||||
window.notify('warning', 'Chrome Messaging API nicht gefunden.');
|
||||
}
|
||||
|
||||
} else if (data.status === 'not_found') {
|
||||
window.notify('warning', `Kein Gerät für ${ip} gefunden.`);
|
||||
} else {
|
||||
window.notify('error', 'Ungültige Antwort vom Scan-Server.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IP-Scan fehlgeschlagen:', error);
|
||||
window.notify('error', `Scan für ${ip} fehlgeschlagen.`);
|
||||
}
|
||||
},
|
||||
async openTransferModal(username) {
|
||||
this.showTransferModal = true;
|
||||
this.transferModalUsername = username;
|
||||
this.transferYear = new Date().getFullYear();
|
||||
this.transferMonth = new Date().getMonth() + 1;
|
||||
await this.fetchTransferYearData();
|
||||
},
|
||||
closeTransferModal() {
|
||||
this.showTransferModal = false;
|
||||
this.transferModalUsername = '';
|
||||
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 r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`);
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (d && d.monthlySummary) {
|
||||
this.transferYearlyData = d;
|
||||
const last = [...d.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 r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`);
|
||||
this.transferMonthlyData = r.ok ? await r.json() : null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.transferMonthlyData = null;
|
||||
}
|
||||
this.transferMonthlyLoading = false;
|
||||
this.$nextTick(() => {
|
||||
if (this.showTransferModal) this.renderTransferChart();
|
||||
});
|
||||
},
|
||||
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.transferModalUsername,
|
||||
year: this.transferYear,
|
||||
month: this.transferMonth,
|
||||
monthlySummary: this.transferMonthlyData.summary,
|
||||
monthlyDetails: this.transferMonthlyData.details,
|
||||
chartImage: chartImageBase64,
|
||||
recipient: this.recipientEmail
|
||||
};
|
||||
const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
window.notify('success', 'E-Mail wurde erfolgreich versendet.');
|
||||
this.showEmailModal = false;
|
||||
} else {
|
||||
throw new Error('Server responded with an error.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send transfer email:", e);
|
||||
window.notify('error', 'Fehler beim Senden der E-Mail.');
|
||||
} finally {
|
||||
this.isSendingEmail = false;
|
||||
}
|
||||
},
|
||||
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();
|
||||
},
|
||||
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.RadiusUtils.formatBytes(v, 0)},
|
||||
grid: {color: 'rgba(0,0,0,0.05)'}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}`}},
|
||||
legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}}
|
||||
},
|
||||
interaction: {mode: 'index', intersect: false}
|
||||
},
|
||||
plugins: [chartBackgroundColorPlugin]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user