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>
329 lines
10 KiB
Vue
329 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
interface DailyPoint {
|
|
date: string;
|
|
session: number;
|
|
oauth: number;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
data: DailyPoint[];
|
|
}>();
|
|
|
|
const { t, locale } = useI18n();
|
|
|
|
const width = 720;
|
|
const height = 220;
|
|
const padLeft = 34;
|
|
const padBottom = 22;
|
|
const padTop = 10;
|
|
const plotW = width - padLeft - 8;
|
|
const plotH = height - padTop - padBottom;
|
|
|
|
const maxTotal = computed(() => Math.max(4, ...props.data.map((d) => d.session + d.oauth)));
|
|
|
|
const niceMax = computed(() => {
|
|
// redondea el techo del eje Y a un número "lindo"
|
|
const raw = maxTotal.value;
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(raw || 1)));
|
|
const step = Math.ceil(raw / magnitude) * magnitude;
|
|
|
|
return step <= 0 ? 4 : step;
|
|
});
|
|
|
|
const yTicks = computed(() => [0, 0.5, 1].map((f) => Math.round(niceMax.value * f)));
|
|
|
|
const barW = computed(() => Math.max(2, plotW / props.data.length - 2));
|
|
|
|
function x(i: number): number {
|
|
return padLeft + (i * plotW) / props.data.length;
|
|
}
|
|
function yFor(value: number): number {
|
|
return padTop + plotH - (value / niceMax.value) * plotH;
|
|
}
|
|
|
|
// Rect con SOLO las puntas de arriba redondeadas (4px) y la base cuadrada,
|
|
// para que el extremo libre del stack se redondee pero la base (donde se
|
|
// apoya en el baseline, o en el segmento de abajo) quede a escuadra.
|
|
function topRoundedRect(xPos: number, y: number, w: number, h: number): string {
|
|
const r = Math.min(4, w / 2, Math.max(0, h));
|
|
if (h <= 0) return '';
|
|
if (r <= 0) return `M${xPos},${y} h${w} v${h} h${-w} Z`;
|
|
|
|
return [
|
|
`M${xPos},${y + h}`,
|
|
`V${y + r}`,
|
|
`Q${xPos},${y} ${xPos + r},${y}`,
|
|
`H${xPos + w - r}`,
|
|
`Q${xPos + w},${y} ${xPos + w},${y + r}`,
|
|
`V${y + h}`,
|
|
'Z',
|
|
].join(' ');
|
|
}
|
|
|
|
const hovered = ref<number | null>(null);
|
|
|
|
function labelFor(i: number): string {
|
|
const d = props.data[i];
|
|
if (!d) return '';
|
|
const dt = new Date(d.date + 'T00:00:00');
|
|
|
|
return dt.toLocaleDateString(locale.value, { day: '2-digit', month: 'short' });
|
|
}
|
|
|
|
function ariaLabelFor(i: number): string {
|
|
const d = props.data[i];
|
|
if (!d) return '';
|
|
|
|
return t('analytics.barAriaLabel', { date: labelFor(i), oauth: d.oauth, session: d.session });
|
|
}
|
|
|
|
const showEveryNth = computed(() => Math.ceil(props.data.length / 6));
|
|
|
|
const tooltipStyle = computed(() => {
|
|
if (hovered.value === null) return { display: 'none' };
|
|
const left = x(hovered.value) + barW.value / 2;
|
|
const clampedLeft = Math.min(Math.max(left, 70), width - 70);
|
|
|
|
return {
|
|
left: (clampedLeft / width) * 100 + '%',
|
|
top: '4px',
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="daily-chart">
|
|
<div class="daily-chart__header">
|
|
<h3 class="daily-chart__title">{{ t('analytics.loginsByDay') }}</h3>
|
|
<div class="daily-chart__legend">
|
|
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--oauth"></span> {{ t('analytics.appsOAuth') }}</span>
|
|
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--session"></span> {{ t('analytics.panelAdmin') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="daily-chart__body">
|
|
<svg :viewBox="`0 0 ${width} ${height}`" class="daily-chart__svg" role="img" :aria-label="t('analytics.chartAriaLabel')">
|
|
<!-- gridlines -->
|
|
<g>
|
|
<line
|
|
v-for="tick in yTicks"
|
|
:key="tick"
|
|
:x1="padLeft"
|
|
:x2="width - 4"
|
|
:y1="yFor(tick)"
|
|
:y2="yFor(tick)"
|
|
class="daily-chart__gridline"
|
|
stroke-width="1"
|
|
/>
|
|
<text v-for="tick in yTicks" :key="'l' + tick" :x="padLeft - 6" :y="yFor(tick) + 3" text-anchor="end" class="daily-chart__axis-text" font-size="9">
|
|
{{ tick }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- bars -->
|
|
<g v-for="(d, i) in data" :key="d.date">
|
|
<!-- oauth segment (bottom, apoyado en el baseline -> cuadrado).
|
|
Si no hay segmento de sesión arriba, esta punta es la libre: se redondea. -->
|
|
<path
|
|
v-if="d.session > 0"
|
|
:d="`M${x(i)},${yFor(0)} h${barW} v${-(Math.max(0, yFor(0) - yFor(d.oauth)))} h${-barW} Z`"
|
|
class="daily-chart__bar daily-chart__bar--oauth"
|
|
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
|
/>
|
|
<path
|
|
v-else
|
|
:d="topRoundedRect(x(i), yFor(d.oauth), barW, Math.max(0, yFor(0) - yFor(d.oauth)))"
|
|
class="daily-chart__bar daily-chart__bar--oauth"
|
|
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
|
/>
|
|
<!-- session segment (stacked on top, 2px surface gap, punta libre redondeada) -->
|
|
<path
|
|
v-if="d.session > 0"
|
|
:d="topRoundedRect(x(i), yFor(d.oauth + d.session), barW, Math.max(0, yFor(d.oauth) - yFor(d.oauth + d.session) - 2))"
|
|
class="daily-chart__bar daily-chart__bar--session"
|
|
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
|
/>
|
|
<!-- hit target: toda la columna, más ancho que la barra visible -->
|
|
<rect
|
|
:x="x(i) - 1"
|
|
:y="padTop"
|
|
:width="barW + 2"
|
|
:height="plotH"
|
|
class="daily-chart__hit"
|
|
@mouseenter="hovered = i"
|
|
@mouseleave="hovered = null"
|
|
@focus="hovered = i"
|
|
@blur="hovered = null"
|
|
tabindex="0"
|
|
:aria-label="ariaLabelFor(i)"
|
|
/>
|
|
<text
|
|
v-if="i % showEveryNth === 0"
|
|
:x="x(i) + barW / 2"
|
|
:y="height - 4"
|
|
text-anchor="middle"
|
|
class="daily-chart__axis-text"
|
|
font-size="9"
|
|
>
|
|
{{ labelFor(i) }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- baseline -->
|
|
<line :x1="padLeft" :x2="width - 4" :y1="yFor(0)" :y2="yFor(0)" class="daily-chart__baseline" stroke-width="1" />
|
|
</svg>
|
|
|
|
<div v-if="hovered !== null" class="daily-chart__tooltip" :style="tooltipStyle">
|
|
<p class="daily-chart__tooltip-date">{{ labelFor(hovered) }}</p>
|
|
<p class="daily-chart__tooltip-row">
|
|
<span class="daily-chart__swatch daily-chart__swatch--oauth daily-chart__swatch--sm"></span>
|
|
{{ t('analytics.tooltipAppLabel') }} <strong>{{ data[hovered].oauth }}</strong>
|
|
</p>
|
|
<p class="daily-chart__tooltip-row">
|
|
<span class="daily-chart__swatch daily-chart__swatch--session daily-chart__swatch--sm"></span>
|
|
{{ t('analytics.tooltipSessionLabel') }} <strong>{{ data[hovered].session }}</strong>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
// Este componente usa <style scoped>, así que el mixin "dark" global (pensado
|
|
// para partials no-scoped) no aplica acá: hace falta ":global(.dark) &"
|
|
// directo para atravesar el compilador scoped de Vue.
|
|
.daily-chart {
|
|
--series-1: #2a78d6;
|
|
--series-2: #eb6834;
|
|
--muted: #898781;
|
|
--gridline: #e1e0d9;
|
|
--axis: #c3c2b7;
|
|
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 0.75rem;
|
|
padding: 1.25rem;
|
|
|
|
:global(.dark) & {
|
|
--series-1: #3987e5;
|
|
--series-2: #d95926;
|
|
--gridline: #2c2c2a;
|
|
--axis: #383835;
|
|
}
|
|
}
|
|
|
|
.daily-chart__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.daily-chart__title {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.daily-chart__legend {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.daily-chart__legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.daily-chart__swatch {
|
|
display: inline-block;
|
|
height: 0.5rem;
|
|
width: 0.75rem;
|
|
border-radius: 0.125rem;
|
|
|
|
&--oauth {
|
|
background: var(--series-1);
|
|
}
|
|
|
|
&--session {
|
|
background: var(--series-2);
|
|
}
|
|
|
|
&--sm {
|
|
height: 0.5rem;
|
|
width: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.daily-chart__body {
|
|
position: relative;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.daily-chart__svg {
|
|
width: 100%;
|
|
}
|
|
|
|
.daily-chart__gridline {
|
|
stroke: var(--gridline);
|
|
}
|
|
|
|
.daily-chart__baseline {
|
|
stroke: var(--axis);
|
|
}
|
|
|
|
.daily-chart__axis-text {
|
|
fill: var(--muted);
|
|
}
|
|
|
|
.daily-chart__bar {
|
|
&--oauth {
|
|
fill: var(--series-1);
|
|
}
|
|
|
|
&--session {
|
|
fill: var(--series-2);
|
|
}
|
|
}
|
|
|
|
.daily-chart__hit {
|
|
fill: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.daily-chart__tooltip {
|
|
pointer-events: none;
|
|
position: absolute;
|
|
transform: translateX(-50%);
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-surface);
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
box-shadow: var(--shadow-elevated);
|
|
}
|
|
|
|
.daily-chart__tooltip-date {
|
|
font-weight: 500;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.daily-chart__tooltip-row {
|
|
margin-top: 0.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
color: var(--color-text-secondary);
|
|
|
|
strong {
|
|
color: var(--color-text-primary);
|
|
}
|
|
}
|
|
</style>
|