223 lines
24 KiB
JavaScript
223 lines
24 KiB
JavaScript
/* ===== 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"><code>{{ radacctData.ip || '—' }}</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="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>
|
|
</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>
|
|
</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 }),
|
|
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' }) })); }
|
|
},
|
|
mounted() {
|
|
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; },
|
|
loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; },
|
|
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; 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(); });
|
|
},
|
|
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;
|
|
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 } } });
|
|
}
|
|
}
|
|
}); |