new funcionality

This commit is contained in:
2026-07-27 17:20:02 +02:00
parent c2b5291a74
commit c5ce8a7a36
101 changed files with 6889 additions and 176 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import StatTile from '../../components/charts/StatTile.vue';
@@ -26,10 +26,28 @@ onMounted(load);
const appItems = computed(() => (summary.value?.logins_by_app ?? []).map((a) => ({ label: a.client_name, value: a.count })));
const userItems = computed(() => (summary.value?.top_users ?? []).map((u) => ({ label: u.name, value: u.count })));
const activeTokenItems = computed(() => (summary.value?.active_tokens_by_client ?? []).map((c) => ({ label: c.client_name, value: c.count })));
const scopeItems = computed(() => (summary.value?.scope_usage ?? []).map((s) => ({ label: s.description, value: s.count })));
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function exportPdf(): Promise<void> {
const tableWasHidden = !showTable.value;
if (tableWasHidden) showTable.value = true;
await nextTick();
// Fuerza tema claro para la impresión (ahorra tinta, evita fondos oscuros
// recortados) y lo restaura después de que el usuario cierre el diálogo.
const wasDark = document.documentElement.classList.contains('dark');
document.documentElement.classList.remove('dark');
window.print();
if (wasDark) document.documentElement.classList.add('dark');
if (tableWasHidden) showTable.value = false;
}
</script>
<template>
@@ -40,12 +58,18 @@ function formatDateTime(iso: string): string {
<p class="page-subtitle">{{ t('analytics.subtitle') }}</p>
</div>
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
<select v-model.number="days" class="pill-select">
<option :value="7">{{ t('analytics.last7') }}</option>
<option :value="30">{{ t('analytics.last30') }}</option>
<option :value="90">{{ t('analytics.last90') }}</option>
</select>
<div class="analytics__header-controls no-print">
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
<select v-model.number="days" class="pill-select">
<option :value="7">{{ t('analytics.last7') }}</option>
<option :value="30">{{ t('analytics.last30') }}</option>
<option :value="90">{{ t('analytics.last90') }}</option>
</select>
<button v-if="summary" @click="exportPdf" type="button" class="btn btn--secondary">
{{ t('analytics.exportPdf') }}
</button>
</div>
</div>
<p v-if="loading && !summary" class="analytics__loading">{{ t('common.loading') }}</p>
@@ -56,6 +80,16 @@ function formatDateTime(iso: string): string {
<StatTile :label="t('analytics.activeSessions')" :value="summary.kpis.active_sessions" :hint="t('analytics.activeSessionsHint')" />
<StatTile :label="t('analytics.loginsToday')" :value="summary.kpis.logins_today" />
<StatTile :label="t('analytics.logins7d')" :value="summary.kpis.logins_7d" />
<StatTile
:label="t('analytics.loginFailureRate')"
:value="`${summary.kpis.login_failure_rate_7d}%`"
:hint="t('analytics.loginFailureRateHint', { count: summary.kpis.login_failures_7d })"
/>
<StatTile
:label="t('analytics.oauthApprovalRate')"
:value="`${summary.oauth_funnel.approval_rate}%`"
:hint="t('analytics.oauthApprovalRateHint', { approved: summary.oauth_funnel.approved, attempted: summary.oauth_funnel.attempted })"
/>
</div>
<div class="analytics__chart" :class="{ 'analytics__section--loading': loading }">
@@ -67,10 +101,43 @@ function formatDateTime(iso: string): string {
<BarList :title="t('analytics.topUsers')" :items="userItems" :empty-text="t('analytics.noUserActivity')" />
</div>
<div class="card analytics__funnel" :class="{ 'analytics__section--loading': loading }">
<h3 class="card__title">{{ t('analytics.funnelTitle') }}</h3>
<p v-if="!summary.oauth_funnel_by_client.length" class="analytics__recent-count">{{ t('analytics.funnelEmpty') }}</p>
<div v-else class="analytics__table-wrap">
<table class="table">
<thead>
<tr>
<th class="table__head-cell">{{ t('analytics.colClient') }}</th>
<th class="table__head-cell">{{ t('analytics.colAttempted') }}</th>
<th class="table__head-cell">{{ t('analytics.colApproved') }}</th>
<th class="table__head-cell">{{ t('analytics.colDenied') }}</th>
<th class="table__head-cell">{{ t('analytics.colApprovalRate') }}</th>
</tr>
</thead>
<tbody class="table__body">
<tr v-for="row in summary.oauth_funnel_by_client" :key="row.client_name">
<td class="table__cell table__cell--primary">{{ row.client_name }}</td>
<td class="table__cell">{{ row.attempted }}</td>
<td class="table__cell">{{ row.approved }}</td>
<td class="table__cell">{{ row.denied }}</td>
<td class="table__cell">{{ row.attempted ? Math.round((row.approved / row.attempted) * 100) : 0 }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
<BarList :title="t('analytics.activeTokensByClient')" :items="activeTokenItems" :empty-text="t('analytics.noActiveTokens')" />
<BarList :title="t('analytics.scopeUsageTitle')" :items="scopeItems" :empty-text="t('analytics.noScopeUsage')" />
</div>
<div class="card analytics__recent" :class="{ 'analytics__section--loading': loading }">
<div class="analytics__recent-header">
<h3 class="card__title">{{ t('analytics.recentActivity') }}</h3>
<button @click="showTable = !showTable" class="link analytics__toggle">
<button @click="showTable = !showTable" class="link analytics__toggle no-print">
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
</button>
</div>
@@ -109,6 +176,12 @@ function formatDateTime(iso: string): string {
--series-2: #eb6834;
}
.analytics__header-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.analytics__loading {
margin-top: 1.5rem;
color: var(--color-text-secondary);
@@ -132,6 +205,10 @@ function formatDateTime(iso: string): string {
gap: 1rem;
}
.analytics__funnel {
margin-top: 1rem;
}
.analytics__recent {
margin-top: 1rem;
}