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:
Luca Haid
2025-11-04 11:31:02 +00:00
2 changed files with 942 additions and 144 deletions

View File

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

View File

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