Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and Docker Compose setup for local and production deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
179
resources/js/pages/admin/Analytics.vue
Normal file
179
resources/js/pages/admin/Analytics.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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 })));
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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" />
|
||||
</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__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">
|
||||
{{ 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__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__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>
|
||||
Reference in New Issue
Block a user