Files
oauth/resources/js/pages/admin/Analytics.vue
2026-07-27 17:20:02 +02:00

257 lines
10 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import StatTile from '../../components/charts/StatTile.vue';
import BarList from '../../components/charts/BarList.vue';
import DailyLoginsChart from '../../components/charts/DailyLoginsChart.vue';
import type { AnalyticsSummary } from '../../types/models';
const { t, locale } = useI18n();
const loading = ref(true);
const days = ref(30);
const summary = ref<AnalyticsSummary | null>(null);
const showTable = ref(false);
async function load(): Promise<void> {
loading.value = true;
const { data } = await axios.get<AnalyticsSummary>('/web-api/admin/analytics/summary', { params: { days: days.value } });
summary.value = data;
loading.value = false;
}
watch(days, load);
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>
<div class="analytics">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('analytics.title') }}</h1>
<p class="page-subtitle">{{ t('analytics.subtitle') }}</p>
</div>
<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>
<template v-else-if="summary">
<div class="analytics__kpis" :class="{ 'analytics__section--loading': loading }">
<StatTile :label="t('analytics.totalUsers')" :value="summary.kpis.total_users" />
<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 }">
<DailyLoginsChart :data="summary.logins_by_day" />
</div>
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
<BarList :title="t('analytics.loginsByApp')" :items="appItems" :empty-text="t('analytics.noAppLogins')" />
<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 no-print">
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
</button>
</div>
<div v-if="showTable" class="analytics__table-wrap">
<table class="table">
<thead>
<tr>
<th class="table__head-cell">{{ t('analytics.colUser') }}</th>
<th class="table__head-cell">{{ t('analytics.colApp') }}</th>
<th class="table__head-cell">{{ t('analytics.colType') }}</th>
<th class="table__head-cell">{{ t('analytics.colIp') }}</th>
<th class="table__head-cell">{{ t('analytics.colWhen') }}</th>
</tr>
</thead>
<tbody class="table__body">
<tr v-for="(event, i) in summary.recent" :key="i">
<td class="table__cell table__cell--primary">{{ event.user_name ?? t('analytics.serviceNoUser') }}</td>
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : (event.client_name ?? t('analytics.deletedApp')) }}</td>
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : 'OAuth' }}</td>
<td class="table__cell">{{ event.ip_address }}</td>
<td class="table__cell analytics__when">{{ formatDateTime(event.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="analytics__recent-count">{{ t('analytics.recentCount', { count: summary.recent.length }) }}</p>
</div>
</template>
</div>
</template>
<style scoped>
.analytics {
--series-1: #2a78d6;
--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);
}
.analytics__kpis {
margin-top: 1.5rem;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.analytics__chart {
margin-top: 1rem;
}
.analytics__bar-lists {
margin-top: 1rem;
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.analytics__funnel {
margin-top: 1rem;
}
.analytics__recent {
margin-top: 1rem;
}
.analytics__recent-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.analytics__toggle {
font-size: 0.75rem;
}
.analytics__table-wrap {
margin-top: 0.75rem;
overflow-x: auto;
}
.analytics__when {
font-variant-numeric: tabular-nums;
}
.analytics__recent-count {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.analytics__section--loading {
opacity: 0.6;
}
@media (min-width: 640px) {
.analytics__kpis {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1024px) {
.analytics__bar-lists {
grid-template-columns: 1fr 1fr;
}
}
</style>