From 9272c01bdaa3f2464acd96b3a6b69ed62434aaed Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Thu, 20 Aug 2026 14:24:36 +0500 Subject: [PATCH 1/2] feat(admin-ui): add Auth Metrics dashboard for authentication and token activity (#2983) Signed-off-by: faisalsiddique4400 --- .../GluuDatePicker/GluuDatePicker.style.ts | 9 +- .../GluuDatePicker/GluuDatePicker.tsx | 2 + .../app/components/GluuDatePicker/types.ts | 6 + admin-ui/app/helpers/navigation.ts | 1 + admin-ui/app/locales/en/translation.json | 40 +- admin-ui/app/locales/es/translation.json | 40 +- admin-ui/app/locales/fr/translation.json | 40 +- admin-ui/app/locales/pt/translation.json | 40 +- admin-ui/app/utils/dayjsUtils.ts | 13 + admin-ui/openapi-merge.json | 3 + .../MAU/components/DateRangeSelector.style.ts | 55 ++- .../MAU/components/DateRangeSelector.tsx | 54 ++- .../__tests__/DateRangeSelector.test.tsx | 2 +- .../plugins/admin/components/MAU/constants.ts | 6 +- .../admin/components/MAU/types/MauTypes.ts | 16 +- .../AuthMetrics/AuthMetricsPage.style.ts | 103 +++++ .../AuthMetrics/AuthMetricsPage.tsx | 179 +++++++++ .../__tests__/AuthMetricsKpiStrip.test.tsx | 48 +++ .../__tests__/fetchAllMetricEntries.test.ts | 122 ++++++ .../AuthMetrics/__tests__/utils.test.ts | 374 ++++++++++++++++++ .../components/AcrBreakdownChart.tsx | 120 ++++++ .../components/AuthActivityChart.tsx | 110 ++++++ .../components/AuthMetricsKpiStrip.tsx | 57 +++ .../components/GranularityMenu.style.ts | 66 ++++ .../components/GranularityMenu.tsx | 78 ++++ .../components/TokenIssuanceChart.tsx | 118 ++++++ .../AuthMetrics/components/index.ts | 5 + .../fido/components/AuthMetrics/constants.ts | 142 +++++++ .../components/AuthMetrics/hooks/index.ts | 2 + .../AuthMetrics/hooks/useAuthMetricsCharts.ts | 139 +++++++ .../AuthMetrics/hooks/useMetricSeries.ts | 129 ++++++ .../fido/components/AuthMetrics/index.ts | 4 + .../AuthMetrics/types/AuthMetricsTypes.ts | 67 ++++ .../components/AuthMetrics/types/JsonTypes.ts | 12 + .../components/AuthMetrics/types/index.ts | 2 + .../fido/components/AuthMetrics/utils.ts | 246 ++++++++++++ admin-ui/plugins/fido/plugin-metadata.ts | 13 + 37 files changed, 2415 insertions(+), 48 deletions(-) create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/components/index.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/constants.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/index.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/types/index.ts create mode 100644 admin-ui/plugins/fido/components/AuthMetrics/utils.ts diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts index def50ed596..7f6ad61930 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts @@ -24,13 +24,14 @@ const buildPickerThemeColors = ( isDark: boolean, textColor?: string, backgroundColor?: string, + inputBackgroundColor?: string, ): PickerThemeColors => { const inputText = textColor || themeConfig.fontColor const borderColor = isDark ? 'transparent' : themeConfig.borderColor const hoverBg = getLoadingOverlayRgba(themeConfig.fontColor, getHoverOpacity(isDark)) return { labelBackground: backgroundColor || themeConfig.background, - inputBackground: themeConfig.inputBackground, + inputBackground: inputBackgroundColor || themeConfig.inputBackground, inputTextColor: inputText, labelColor: inputText, borderColor, @@ -462,13 +463,15 @@ export const useDatePickerStyles = (params: GluuDatePickerStyleParams) => { isDark, textColor, backgroundColor, + inputBackgroundColor, inputHeight, labelShrink = true, forceIcon = false, } = params const pickerTheme = useMemo( - () => buildPickerThemeColors(themeColors, isDark, textColor, backgroundColor), - [themeColors, isDark, textColor, backgroundColor], + () => + buildPickerThemeColors(themeColors, isDark, textColor, backgroundColor, inputBackgroundColor), + [themeColors, isDark, textColor, backgroundColor, inputBackgroundColor], ) const { classes } = useLayoutStyles({ labelColor: pickerTheme.labelColor }) diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx index 6ef08697cf..63bfccf593 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx @@ -40,6 +40,7 @@ const rangePropsEqual = (a: GluuDatePickerRangeProps, b: GluuDatePickerRangeProp a.inputHeight === b.inputHeight && a.textColor === b.textColor && a.backgroundColor === b.backgroundColor && + a.inputBackgroundColor === b.inputBackgroundColor && (a.dateFormat ?? a.format) === (b.dateFormat ?? b.format) && a.onStartDateChange === b.onStartDateChange && a.onEndDateChange === b.onEndDateChange && @@ -68,6 +69,7 @@ const GluuDatePicker = memo( isDark: isDarkTheme, textColor: props.textColor, backgroundColor: props.backgroundColor, + inputBackgroundColor: props.inputBackgroundColor, inputHeight: props.inputHeight, labelShrink, forceIcon: props.forceIcon, diff --git a/admin-ui/app/components/GluuDatePicker/types.ts b/admin-ui/app/components/GluuDatePicker/types.ts index 41c467867e..4fce1d77d1 100644 --- a/admin-ui/app/components/GluuDatePicker/types.ts +++ b/admin-ui/app/components/GluuDatePicker/types.ts @@ -24,6 +24,7 @@ export type GluuDatePickerStyleParams = { isDark: boolean textColor?: string backgroundColor?: string + inputBackgroundColor?: string inputHeight?: number labelShrink?: boolean forceIcon?: boolean @@ -33,7 +34,12 @@ type GluuDatePickerBase = { format?: string dateFormat?: string textColor?: string + // Sits behind the floating label so it does not collide with the outline. Named for what it + // backs, not for the field: use inputBackgroundColor to fill the field itself. backgroundColor?: string + // Fills the input. Left unset the field takes themeColors.inputBackground, which is right on a + // plain form; a field sitting in a toolbar may need to match the controls beside it instead. + inputBackgroundColor?: string inputHeight?: number showTime?: boolean forceIcon?: boolean diff --git a/admin-ui/app/helpers/navigation.ts b/admin-ui/app/helpers/navigation.ts index 49303fe1d6..9aee4c4a1b 100644 --- a/admin-ui/app/helpers/navigation.ts +++ b/admin-ui/app/helpers/navigation.ts @@ -138,6 +138,7 @@ const ROUTES = { FIDO_BASE: `${PLUGIN_BASE_PATHS.FIDO}/configuration`, FIDO_METRICS: `${PLUGIN_BASE_PATHS.FIDO}/metrics`, FIDO_SECURITY_MONITOR: `${PLUGIN_BASE_PATHS.FIDO}/security-monitor`, + FIDO_AUTH_METRICS: `${PLUGIN_BASE_PATHS.FIDO}/auth-metrics`, // ========== SMTP Plugin ========== SMTP_BASE: `${PLUGIN_BASE_PATHS.SMTP}/smtpmanagement`, diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index dc9566c244..83a029ca98 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -832,7 +832,35 @@ "metric": "Metric", "label": "Label", "secondary_value": "Secondary value", - "suspicious_ips": "Suspicious IP addresses" + "suspicious_ips": "Suspicious IP addresses", + "auth_metrics_unavailable": "Metrics are unavailable. Check that the metric plugin is deployed and your token carries the metric.readonly scope.", + "auth_activity_subtitle": "Successful and failed authentications over time.", + "auth_by_acr_subtitle": "Successful authentications split by authentication context.", + "token_issuance_subtitle": "Tokens and authorization codes issued over time.", + "auth_success": "Successful", + "auth_failure": "Failed", + "auth_attempts": "Attempts", + "acr_in_use": "ACRs in use", + "access_tokens": "Access tokens", + "id_tokens": "ID tokens", + "refresh_tokens": "Refresh tokens", + "authorization_codes": "Authorization codes", + "select_granularity": "Select granularity", + "date_preset_24h": "24 Hours", + "date_preset_7d": "7 Days", + "date_preset_30d": "30 Days", + "granularity": "Granularity", + "granularity_hourly": "Hourly", + "granularity_hours_3": "3 Hours", + "granularity_hours_12": "12 Hours", + "granularity_hours_24": "24 Hours", + "granularity_daily": "Daily", + "granularity_days_3": "3 Days", + "granularity_days_7": "7 Days", + "granularity_days_15": "15 Days", + "granularity_days_21": "21 Days", + "granularity_days_30": "30 Days", + "auth_metrics_truncated": "Showing partial data: this range holds more entries than one page walk can read. Narrow the range for exact totals." }, "languages": { "language": "Language", @@ -941,7 +969,8 @@ "allCategory": "All Category", "notification": "Notification", "mobileNavigation": "Mobile navigation", - "security_monitor": "Security Monitor" + "security_monitor": "Security Monitor", + "auth_metrics": "Auth Metrics" }, "footer": { "company_name": "Gluu Inc." @@ -1696,7 +1725,12 @@ "error_intelligence": "Error Types", "velocity_watch": "Attempts by User", "device_fingerprint_shift": "Authenticator Types", - "threat_origins_ips": "Threat Origins by IP" + "threat_origins_ips": "Threat Origins by IP", + "auth_metrics": "Auth Metrics", + "auth_activity": "Authentication Activity", + "auth_by_acr": "Authentications by ACR", + "token_issuance": "Token Issuance", + "auth_token_activity": "Authentication & Token Activity" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 9f03d51e26..141ae0b160 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -832,7 +832,35 @@ "metric": "Métrica", "label": "Etiqueta", "secondary_value": "Valor secundario", - "suspicious_ips": "Direcciones IP sospechosas" + "suspicious_ips": "Direcciones IP sospechosas", + "auth_metrics_unavailable": "Las métricas no están disponibles. Compruebe que el plugin de métricas esté desplegado y que su token tenga el permiso metric.readonly.", + "auth_activity_subtitle": "Autenticaciones correctas y fallidas a lo largo del tiempo.", + "auth_by_acr_subtitle": "Autenticaciones correctas divididas por contexto de autenticación.", + "token_issuance_subtitle": "Tokens y códigos de autorización emitidos a lo largo del tiempo.", + "auth_success": "Correctas", + "auth_failure": "Fallidas", + "auth_attempts": "Intentos", + "acr_in_use": "ACR en uso", + "access_tokens": "Tokens de acceso", + "id_tokens": "Tokens de ID", + "refresh_tokens": "Tokens de actualización", + "authorization_codes": "Códigos de autorización", + "select_granularity": "Seleccionar granularidad", + "date_preset_24h": "24 horas", + "date_preset_7d": "7 días", + "date_preset_30d": "30 días", + "granularity": "Granularidad", + "granularity_hourly": "Cada hora", + "granularity_hours_3": "3 horas", + "granularity_hours_12": "12 horas", + "granularity_hours_24": "24 horas", + "granularity_daily": "Diario", + "granularity_days_3": "3 días", + "granularity_days_7": "7 días", + "granularity_days_15": "15 días", + "granularity_days_21": "21 días", + "granularity_days_30": "30 días", + "auth_metrics_truncated": "Mostrando datos parciales: este intervalo contiene más entradas de las que se pueden leer. Reduzca el intervalo para obtener totales exactos." }, "languages": { "language": "Idioma", @@ -941,7 +969,8 @@ "allCategory": "Todas las categorías", "notification": "Notificación", "mobileNavigation": "Navegación móvil", - "security_monitor": "Monitor de Seguridad" + "security_monitor": "Monitor de Seguridad", + "auth_metrics": "Métricas de autenticación" }, "footer": { "company_name": "Gluu Inc." @@ -1699,7 +1728,12 @@ "error_intelligence": "Tipos de error", "velocity_watch": "Intentos por usuario", "device_fingerprint_shift": "Tipos de autenticador", - "threat_origins_ips": "Orígenes de amenazas por IP" + "threat_origins_ips": "Orígenes de amenazas por IP", + "auth_metrics": "Métricas de autenticación", + "auth_activity": "Actividad de autenticación", + "auth_by_acr": "Autenticaciones por ACR", + "token_issuance": "Emisión de tokens", + "auth_token_activity": "Actividad de autenticación y tokens" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 439970ff9c..805e00df9c 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -162,7 +162,8 @@ "allCategory": "Toutes les catégories", "notification": "Notification", "mobileNavigation": "Navigation mobile", - "security_monitor": "Moniteur de Sécurité" + "security_monitor": "Moniteur de Sécurité", + "auth_metrics": "Métriques d'authentification" }, "actions": { "accept": "J'accepte", @@ -945,7 +946,35 @@ "metric": "Métrique", "label": "Libellé", "secondary_value": "Valeur secondaire", - "suspicious_ips": "Adresses IP suspectes" + "suspicious_ips": "Adresses IP suspectes", + "auth_metrics_unavailable": "Les métriques sont indisponibles. Vérifiez que le plugin de métriques est déployé et que votre jeton possède la portée metric.readonly.", + "auth_activity_subtitle": "Authentifications réussies et échouées au fil du temps.", + "auth_by_acr_subtitle": "Authentifications réussies réparties par contexte d'authentification.", + "token_issuance_subtitle": "Jetons et codes d'autorisation émis au fil du temps.", + "auth_success": "Réussies", + "auth_failure": "Échouées", + "auth_attempts": "Tentatives", + "acr_in_use": "ACR utilisés", + "access_tokens": "Jetons d'accès", + "id_tokens": "Jetons ID", + "refresh_tokens": "Jetons de rafraîchissement", + "authorization_codes": "Codes d'autorisation", + "select_granularity": "Sélectionner la granularité", + "date_preset_24h": "24 heures", + "date_preset_7d": "7 jours", + "date_preset_30d": "30 jours", + "granularity": "Granularité", + "granularity_hourly": "Horaire", + "granularity_hours_3": "3 heures", + "granularity_hours_12": "12 heures", + "granularity_hours_24": "24 heures", + "granularity_daily": "Quotidien", + "granularity_days_3": "3 jours", + "granularity_days_7": "7 jours", + "granularity_days_15": "15 jours", + "granularity_days_21": "21 jours", + "granularity_days_30": "30 jours", + "auth_metrics_truncated": "Données partielles affichées : cette plage contient plus d'entrées qu'il n'est possible de lire. Réduisez la plage pour obtenir des totaux exacts." }, "footer": { "company_name": "Gluu Inc." @@ -1704,7 +1733,12 @@ "error_intelligence": "Types d'erreur", "velocity_watch": "Tentatives par utilisateur", "device_fingerprint_shift": "Types d'authentificateur", - "threat_origins_ips": "Origines des menaces par IP" + "threat_origins_ips": "Origines des menaces par IP", + "auth_metrics": "Métriques d'authentification", + "auth_activity": "Activité d'authentification", + "auth_by_acr": "Authentifications par ACR", + "token_issuance": "Émission de jetons", + "auth_token_activity": "Activité d'authentification et de jetons" }, "options": { "admin": "ADMINISTRER", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 5ed35d8ed6..c8da8e6681 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -161,7 +161,8 @@ "allCategory": "Todas as categorias", "notification": "Notificação", "mobileNavigation": "Navegação móvel", - "security_monitor": "Monitor de Segurança" + "security_monitor": "Monitor de Segurança", + "auth_metrics": "Métricas de autenticação" }, "actions": { "accept": "Aceitar", @@ -941,7 +942,35 @@ "metric": "Métrica", "label": "Rótulo", "secondary_value": "Valor secundário", - "suspicious_ips": "Endereços IP suspeitos" + "suspicious_ips": "Endereços IP suspeitos", + "auth_metrics_unavailable": "As métricas não estão disponíveis. Verifique se o plugin de métricas está implementado e se o seu token tem o âmbito metric.readonly.", + "auth_activity_subtitle": "Autenticações bem-sucedidas e falhadas ao longo do tempo.", + "auth_by_acr_subtitle": "Autenticações bem-sucedidas divididas por contexto de autenticação.", + "token_issuance_subtitle": "Tokens e códigos de autorização emitidos ao longo do tempo.", + "auth_success": "Bem-sucedidas", + "auth_failure": "Falhadas", + "auth_attempts": "Tentativas", + "acr_in_use": "ACR em uso", + "access_tokens": "Tokens de acesso", + "id_tokens": "Tokens de ID", + "refresh_tokens": "Tokens de atualização", + "authorization_codes": "Códigos de autorização", + "select_granularity": "Selecionar granularidade", + "date_preset_24h": "24 horas", + "date_preset_7d": "7 dias", + "date_preset_30d": "30 dias", + "granularity": "Granularidade", + "granularity_hourly": "Por hora", + "granularity_hours_3": "3 horas", + "granularity_hours_12": "12 horas", + "granularity_hours_24": "24 horas", + "granularity_daily": "Diário", + "granularity_days_3": "3 dias", + "granularity_days_7": "7 dias", + "granularity_days_15": "15 dias", + "granularity_days_21": "21 dias", + "granularity_days_30": "30 dias", + "auth_metrics_truncated": "A mostrar dados parciais: este intervalo contém mais entradas do que é possível ler. Reduza o intervalo para obter totais exatos." }, "footer": { "company_name": "Gluu Inc." @@ -1700,7 +1729,12 @@ "error_intelligence": "Tipos de erro", "velocity_watch": "Tentativas por usuário", "device_fingerprint_shift": "Tipos de autenticador", - "threat_origins_ips": "Origens de ameaças por IP" + "threat_origins_ips": "Origens de ameaças por IP", + "auth_metrics": "Métricas de autenticação", + "auth_activity": "Atividade de autenticação", + "auth_by_acr": "Autenticações por ACR", + "token_issuance": "Emissão de tokens", + "auth_token_activity": "Atividade de autenticação e tokens" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/utils/dayjsUtils.ts b/admin-ui/app/utils/dayjsUtils.ts index c8a839c775..138e0f0349 100644 --- a/admin-ui/app/utils/dayjsUtils.ts +++ b/admin-ui/app/utils/dayjsUtils.ts @@ -1,10 +1,12 @@ import dayjs from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' import customParseFormat from 'dayjs/plugin/customParseFormat' +import utc from 'dayjs/plugin/utc' import type { Dayjs, OpUnitType, ManipulateType } from 'dayjs' dayjs.extend(isSameOrBefore) dayjs.extend(customParseFormat) +dayjs.extend(utc) export type { Dayjs } from 'dayjs' @@ -73,6 +75,17 @@ export const createDate = ( return dayjs(date) } +// For APIs that work wholly in UTC. A value carrying an offset is converted to UTC; one without an +// offset is read as UTC rather than as the viewer's local time. That matches how such a server +// parses the dates it is sent, so a range requested and the timestamps returned stay on one clock. +// Use createDate instead wherever the API speaks the viewer's local time. +export const createUtcDate = (date?: string | number | Date | Dayjs | null): Dayjs => { + if (date == null) { + return dayjs.utc() + } + return dayjs.utc(date) +} + export const parseDateStrict = (date: string, format: string): Dayjs | null => { const parsed = dayjs(date, format, true) return parsed.isValid() ? parsed : null diff --git a/admin-ui/openapi-merge.json b/admin-ui/openapi-merge.json index d173ed096d..d05ca8d3c1 100644 --- a/admin-ui/openapi-merge.json +++ b/admin-ui/openapi-merge.json @@ -26,6 +26,9 @@ }, { "inputURL": "https://raw.githubusercontent.com/JanssenProject/jans/main/jans-config-api/plugins/docs/lock-plugin-swagger.yaml" + }, + { + "inputURL": "https://raw.githubusercontent.com/JanssenProject/jans/main/jans-config-api/plugins/docs/metric-plugin-swagger.yaml" } ], "output": "./configApiSpecs.yaml" diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts index b3ffa69f26..64ec5b5f13 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts @@ -1,10 +1,16 @@ import { makeStyles } from 'tss-react/mui' import { fontFamily, fontWeights, fontSizes, lineHeights, letterSpacing } from '@/styles/fonts' import { BORDER_RADIUS, getSegmentedButtonStyle, SEGMENTED_CONTROL, SPACING } from '@/constants' +import { SHARED_DROPDOWN_STYLES } from '@/components/GluuDropdown/sharedDropdownStyles' const PRESET_BUTTON_MIN_WIDTH = SEGMENTED_CONTROL.BUTTON_MIN_WIDTH const VIEW_BUTTON_MIN_WIDTH = 96 +// Above the cards the menu overhangs, and matching the shared dropdown keeps the two consistent +// where both could be open on one screen. +const PRESET_MENU_Z_INDEX = SHARED_DROPDOWN_STYLES.menuZIndex +const PRESET_MENU_GAP = SHARED_DROPDOWN_STYLES.margin + export const VIEW_BUTTON_STYLE = { minWidth: VIEW_BUTTON_MIN_WIDTH, borderRadius: BORDER_RADIUS.SMALL_MEDIUM, @@ -14,10 +20,14 @@ export const VIEW_BUTTON_STYLE = { letterSpacing: letterSpacing.button, } -export const getPresetButtonStyle = (isFirst: boolean, isLast: boolean) => ({ - minWidth: PRESET_BUTTON_MIN_WIDTH, - ...getSegmentedButtonStyle(isFirst, isLast), -}) +export const getPresetButtonStyle = (isFirst: boolean, isLast: boolean) => { + // The border overlap moves to the slot: buttons sit inside positioned wrappers now, and a + // negative margin on the button would shift it within its own slot rather than pull neighbouring + // slots together, leaving a one-pixel seam and a doubled border between segments. + const segmented = getSegmentedButtonStyle(isFirst, isLast) + + return { minWidth: PRESET_BUTTON_MIN_WIDTH, ...segmented, marginLeft: 0 } +} const useStyles = makeStyles()((theme) => ({ container: { @@ -58,6 +68,16 @@ const useStyles = makeStyles()((theme) => ({ justifyContent: 'flex-end', }, }, + // Inside the controls column and left-aligned, which puts it on the same edge as the preset + // group: the column shrinks to its contents at md and up, so that edge tracks the controls + // however wide the heading gets. + secondaryRow: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap' as const, + gap: SPACING.CARD_BUTTON_GAP, + marginTop: SPACING.CARD_BUTTON_GAP, + }, presetColWrap: { width: '100%', [theme.breakpoints.up('md')]: { @@ -68,18 +88,41 @@ const useStyles = makeStyles()((theme) => ({ 'display': 'flex', 'gap': 0, 'width': '100%', - '& > button': { + // Sizing sits on the slot rather than the button, so a menu can be anchored to one segment + // without the button losing the flex behaviour it had when it was a direct child. + '& > *': { flex: 1, minWidth: 0, }, + '& > * > button': { + width: '100%', + }, + // Carries the overlap that used to live on the button, collapsing adjacent borders. + '& > * + *': { + marginLeft: SEGMENTED_CONTROL.BORDER_OVERLAP, + }, [theme.breakpoints.up('md')]: { 'width': 'auto', - '& > button': { + '& > *': { flex: 'none', minWidth: PRESET_BUTTON_MIN_WIDTH, }, }, }, + presetSlot: { + position: 'relative', + }, + // Hangs off the segment that was clicked, which is why the slot exists at all. Centred on that + // segment so the menu's arrow points back at the button that opened it, the same relationship the + // header dropdowns have with their triggers. + presetMenu: { + position: 'absolute', + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + zIndex: PRESET_MENU_Z_INDEX, + marginTop: PRESET_MENU_GAP, + }, datePickerCol: { flex: 1, minWidth: 0, diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx index 8526fd6ea3..ad799d63ae 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx @@ -23,6 +23,11 @@ const DateRangeSelector: React.FC = ({ onPresetSelect, onApply, isLoading, + headingKey = 'titles.usage_token_analytics', + presets = DATE_PRESETS, + applyLabelKey = 'actions.view', + presetMenu, + presetMenuAnchor, }) => { const { t } = useTranslation() const { state } = useTheme() @@ -30,41 +35,46 @@ const DateRangeSelector: React.FC = ({ const themeColors = getThemeColor(selectedTheme) const { classes } = useStyles() + // The unselected preset fill doubles as the date field fill, so the whole filter row reads as one + // surface with only the active preset lifted out of it. + const unselectedBg = themeColors.dashboard.supportCard ?? themeColors.menu.background const presetButtonBg = (isSelected: boolean) => - isSelected - ? themeColors.inputBackground - : (themeColors.dashboard.supportCard ?? themeColors.menu.background) + isSelected ? themeColors.inputBackground : unselectedBg const presetButtonBorder = themeColors.borderColor return ( - {t('titles.usage_token_analytics')} + {t(headingKey)} - {DATE_PRESETS.map((preset, index) => { - const isSelected = selectedPreset === preset.months + {presets.map((preset, index) => { + const isSelected = selectedPreset === preset.value const isFirst = index === 0 - const isLast = index === DATE_PRESETS.length - 1 + const isLast = index === presets.length - 1 return ( - onPresetSelect(preset.months)} - theme={selectedTheme} - outlined={!isSelected} - textColor={themeColors.fontColor} - backgroundColor={presetButtonBg(isSelected)} - borderColor={presetButtonBorder} - disableHoverStyles - style={getPresetButtonStyle(isFirst, isLast)} - > - {t(preset.labelKey)} - + + onPresetSelect(preset.value)} + theme={selectedTheme} + outlined={!isSelected} + textColor={themeColors.fontColor} + backgroundColor={presetButtonBg(isSelected)} + borderColor={presetButtonBorder} + disableHoverStyles + style={getPresetButtonStyle(isFirst, isLast)} + > + {t(preset.labelKey)} + + {presetMenu && presetMenuAnchor === preset.value ? ( + {presetMenu} + ) : null} + ) })} @@ -78,6 +88,8 @@ const DateRangeSelector: React.FC = ({ endDate={endDate} onStartDateChange={onStartDateChange} onEndDateChange={onEndDateChange} + inputBackgroundColor={unselectedBg} + backgroundColor={unselectedBg} /> @@ -91,7 +103,7 @@ const DateRangeSelector: React.FC = ({ fontWeight={fontWeights.bold} style={VIEW_BUTTON_STYLE} > - {t('actions.view')} + {t(applyLabelKey)} diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx index 514022d81b..1788ae48be 100644 --- a/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx @@ -33,7 +33,7 @@ describe('DateRangeSelector', () => { const onPresetSelect = jest.fn() renderSelector({ onPresetSelect }) fireEvent.click(screen.getByRole('button', { name: '6 Months' })) - expect(onPresetSelect).toHaveBeenCalledWith(DATE_PRESETS[1].months) + expect(onPresetSelect).toHaveBeenCalledWith(DATE_PRESETS[1].value) }) it('calls onApply when the view button is clicked', () => { diff --git a/admin-ui/plugins/admin/components/MAU/constants.ts b/admin-ui/plugins/admin/components/MAU/constants.ts index d03e068f52..d29fd3a142 100644 --- a/admin-ui/plugins/admin/components/MAU/constants.ts +++ b/admin-ui/plugins/admin/components/MAU/constants.ts @@ -14,9 +14,9 @@ export const CHART_MARGIN = { top: 10, right: 40, left: 0, bottom: 0 } as const export const MOBILE_CHART_MARGIN = { top: 10, right: 40, left: 0, bottom: 0 } as const export const DATE_PRESETS: DateRangePreset[] = [ - { labelKey: 'fields.date_preset_3m', months: 3 }, - { labelKey: 'fields.date_preset_6m', months: 6 }, - { labelKey: 'fields.date_preset_1y', months: 12 }, + { labelKey: 'fields.date_preset_3m', value: 3 }, + { labelKey: 'fields.date_preset_6m', value: 6 }, + { labelKey: 'fields.date_preset_1y', value: 12 }, ] as const const sharedMauColors = { diff --git a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts index 5ed39d473a..a3f22652ba 100644 --- a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts +++ b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import type { Dayjs } from 'dayjs' export type MauStatEntry = { @@ -38,7 +39,7 @@ export type MauSummary = { export type DateRangePreset = { labelKey: string - months: number + value: number } export type MauChartProps = { @@ -51,7 +52,18 @@ export type DateRangeSelectorProps = { selectedPreset: number | null onStartDateChange: (date: Dayjs | null) => void onEndDateChange: (date: Dayjs | null) => void - onPresetSelect: (months: number) => void + onPresetSelect: (value: number) => void onApply: () => void isLoading?: boolean + // Heading and presets are overridable so other dashboards can mount the same control surface. + // `value` is deliberately unitless: MAU reads it as months, callers with shorter retention read + // it as days, and the selector itself never needs to know which. + headingKey?: string + presets?: readonly DateRangePreset[] + applyLabelKey?: string + // Hung under one preset button rather than beside the group, so a control that qualifies the + // chosen range appears against the segment that set it. Anchored by preset value; nothing is + // rendered when the anchor matches no preset, which is how the menu stays closed. + presetMenu?: ReactNode + presetMenuAnchor?: number | null } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts new file mode 100644 index 0000000000..612199b062 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts @@ -0,0 +1,103 @@ +import { makeStyles } from 'tss-react/mui' +import type { ThemeConfig } from '@/context/theme/config' +import { + BORDER_RADIUS, + MOBILE_MEDIA_QUERY, + SPACING, + SUMMARY_CARD, + TABLET_MAX_MEDIA_QUERY, + WIDE_MAX_MEDIA_QUERY, +} from '@/constants' +import { fontFamily, fontWeights, fontSizes, lineHeights } from '@/styles/fonts' +import { getCardBorderStyle } from '@/styles/cardBorderStyles' +import { SECURITY_CHART_HEIGHT } from '../SecurityMonitor/constants' + +interface StylesParams { + isDark: boolean + themeColors: ThemeConfig +} + +// Five cards rather than the Security Monitor's four, so the widest breakpoint carries an extra +// column; every step below reuses that page's breakpoints so the two dashboards reflow together. +const KPI_COLUMNS = 5 + +export const useAuthMetricsStyles = makeStyles()(( + _theme, + { isDark, themeColors }, +) => { + const cardBorderStyle = getCardBorderStyle({ isDark, borderRadius: BORDER_RADIUS.DEFAULT }) + const cardBg = themeColors.settings?.cardBackground ?? themeColors.card?.background + + return { + notice: { + fontFamily, + fontSize: fontSizes.base, + color: themeColors.fontColor, + marginBottom: SPACING.CARD_GAP, + }, + // The MAU DateRangeSelector brings its own grid and now hosts the granularity menu, so this + // only carries the gap down to the KPI strip. + filterRow: { + width: '100%', + marginBottom: SPACING.CARD_GAP, + }, + kpiGrid: { + display: 'grid', + gridTemplateColumns: `repeat(${KPI_COLUMNS}, minmax(0, 1fr))`, + gap: SPACING.CARD_BUTTON_GAP, + width: '100%', + marginBottom: SPACING.CARD_GAP, + [`@media ${WIDE_MAX_MEDIA_QUERY}`]: { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + [`@media ${MOBILE_MEDIA_QUERY}`]: { + gridTemplateColumns: 'minmax(0, 1fr)', + }, + }, + kpiCard: { + backgroundColor: cardBg, + ...cardBorderStyle, + borderRadius: BORDER_RADIUS.DEFAULT, + minHeight: SUMMARY_CARD.MIN_HEIGHT, + width: '100%', + padding: `${SUMMARY_CARD.PADDING_VERTICAL}px ${SUMMARY_CARD.PADDING_HORIZONTAL}px`, + boxSizing: 'border-box' as const, + display: 'flex', + flexDirection: 'column' as const, + justifyContent: 'center', + gap: SUMMARY_CARD.CONTENT_GAP, + }, + kpiLabel: { + fontFamily, + fontSize: fontSizes.xl, + fontWeight: fontWeights.medium, + lineHeight: lineHeights.tight, + color: themeColors.fontColor, + margin: 0, + [`@media ${TABLET_MAX_MEDIA_QUERY}`]: { + fontSize: fontSizes.md, + }, + }, + kpiValue: { + fontFamily, + fontSize: fontSizes['4xl'], + fontWeight: fontWeights.semiBold, + lineHeight: lineHeights.normal, + color: themeColors.fontColor, + margin: 0, + [`@media ${TABLET_MAX_MEDIA_QUERY}`]: { + fontSize: fontSizes['2xl'], + }, + }, + fullWidthRow: { + width: '100%', + marginBottom: SPACING.CARD_GAP, + }, + // ResponsiveContainer measures its parent, so the canvas needs a height of its own. Shared + // with the Security Monitor charts so the cards line up when both pages are open. + chartCanvas: { + width: '100%', + height: SECURITY_CHART_HEIGHT, + }, + } +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx new file mode 100644 index 0000000000..5b9ec7c56a --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { GluuPageContent } from 'Components' +import { useTranslation } from 'react-i18next' +import SetTitle from 'Utils/SetTitle' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' +import GluuText from 'Routes/Apps/Gluu/GluuText' +import { usePermission } from '@/cedarling/hooks/usePermission' +import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' +import dayjs, { type Dayjs } from 'dayjs' +import DateRangeSelector from 'Plugins/admin/components/MAU/components/DateRangeSelector' +import { useSecurityTheme } from '../SecurityMonitor/hooks' +import { useAuthMetricsCharts } from './hooks' +import { endOfDay, granularitiesForRange, resolveGranularity, startOfDay } from './utils' +import { + AcrBreakdownChart, + AuthActivityChart, + AuthMetricsKpiStrip, + GranularityMenu, + TokenIssuanceChart, +} from './components' +import { DATE_PRESETS, DEFAULT_GRANULARITY, DEFAULT_SELECTED_RANGE_DAYS } from './constants' +import { useAuthMetricsStyles } from './AuthMetricsPage.style' +import type { Granularity, MetricRange } from './types' + +const AUTH_METRICS_RESOURCE_ID = ADMIN_UI_RESOURCES.FIDO + +const startOfWindow = (days: number) => startOfDay(dayjs().subtract(days, 'day')) + +const endOfToday = () => endOfDay(dayjs()) + +const AuthMetricsPage: React.FC = () => { + const { t } = useTranslation() + SetTitle(t('titles.auth_metrics')) + + const { themeColors, isDark } = useSecurityTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const { canRead: canView } = usePermission(AUTH_METRICS_RESOURCE_ID) + + const [startDate, setStartDate] = useState(() => + startOfWindow(DEFAULT_SELECTED_RANGE_DAYS), + ) + const [endDate, setEndDate] = useState(endOfToday) + const [selectedPreset, setSelectedPreset] = useState(DEFAULT_SELECTED_RANGE_DAYS) + const [granularity, setGranularity] = useState(DEFAULT_GRANULARITY) + // Held here rather than inside the dropdown so picking a preset can open it. Closed on load. + const [isGranularityMenuOpen, setIsGranularityMenuOpen] = useState(false) + + // Draft dates are held separately from the applied range so the charts only refetch on View, + // the same contract the MAU dashboard uses. + const [appliedRange, setAppliedRange] = useState(() => ({ + startDate: startOfWindow(DEFAULT_SELECTED_RANGE_DAYS).toDate(), + endDate: endOfToday().toDate(), + })) + + // Snapped to day boundaries so picking a single date covers that whole day. The picker carries a + // time of day the user never chose, and left as-is it cut the first hours off the start date. + const handleStartDateChange = useCallback((date: Dayjs | null) => { + if (!date) return + setStartDate(startOfDay(date)) + // Any hand-picked date leaves the presets, so none of them should read as active. + setSelectedPreset(null) + }, []) + + const handleEndDateChange = useCallback((date: Dayjs | null) => { + if (!date) return + setEndDate(endOfDay(date)) + setSelectedPreset(null) + }, []) + + // Presets carry days here rather than the MAU dashboard's months, since auth rows expire on + // metricReporterKeepDataDays. + const handlePresetSelect = useCallback((days: number) => { + setSelectedPreset(days) + setStartDate(startOfWindow(days)) + setEndDate(endOfToday()) + // A new range changes which granularities apply, so the menu opens on the new set instead of + // leaving the user to notice for themselves that the options moved under the collapsed label. + setIsGranularityMenuOpen(true) + }, []) + + const handleApply = useCallback(() => { + setAppliedRange({ startDate: startDate.toDate(), endDate: endDate.toDate() }) + }, [startDate, endDate]) + + // Follows the dates being edited rather than the applied range, so the toggle shows what the + // pending selection allows before View is pressed. Granularity is a client-side fold, so + // narrowing it re-buckets what is already loaded without waiting for a refetch. + const allowedGranularities = useMemo( + () => granularitiesForRange(startDate.toDate(), endDate.toDate()), + [startDate, endDate], + ) + + // Resolved during render rather than corrected through an effect, which keeps `granularity` as a + // record of what the user last asked for: pick 5 min on 24 Hours, move to 30 Days and back, and + // 5 min returns instead of a reset default. + const effectiveGranularity = resolveGranularity(granularity, allowedGranularities) + + const { authRows, acrRows, acrSeries, tokenRows, totals, isBusy, isError, isTruncated } = + useAuthMetricsCharts({ range: appliedRange, granularity: effectiveGranularity }) + + const granularityLabelOf = useCallback( + (value: Granularity) => t(`fields.granularity_${value.toLowerCase()}`), + [t], + ) + + const granularityAria = t('fields.select_granularity') + + const granularityOptions = useMemo( + () => allowedGranularities.map((value) => ({ value, label: granularityLabelOf(value) })), + [allowedGranularities, granularityLabelOf], + ) + + const closeGranularityMenu = useCallback(() => setIsGranularityMenuOpen(false), []) + + return ( + + + +
+ + } + /> +
+ + {isError ? ( + + {t('fields.auth_metrics_unavailable')} + + ) : null} + + {/* Stated outright: a capped page walk means the totals below undercount, and a silent + short chart is exactly the failure this replaced. */} + {!isError && isTruncated ? ( + + {t('fields.auth_metrics_truncated')} + + ) : null} + + + +
+ +
+
+ +
+
+ +
+
+
+
+ ) +} + +export default AuthMetricsPage diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx new file mode 100644 index 0000000000..273c8b37f3 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import AuthMetricsKpiStrip from 'Plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip' + +type Totals = React.ComponentProps['totals'] + +const totals: Totals = { + attempts: 1234, + success: 1200, + failure: 34, + successRate: 97.24, + acrCount: 2, +} + +// The strip reads theme colours through useChartTheme, so it needs the provider the app supplies. +const renderStrip = (props: Totals) => + render( + + + , + ) + +describe('AuthMetricsKpiStrip', () => { + it('renders each total with thousands separators', () => { + renderStrip(totals) + + expect(screen.getByText('1,234')).toBeInTheDocument() + expect(screen.getByText('1,200')).toBeInTheDocument() + expect(screen.getByText('34')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('shows the success rate to one decimal place', () => { + renderStrip(totals) + + expect(screen.getByText('97.2%')).toBeInTheDocument() + }) + + // A flat 0% with nothing recorded would read as every authentication having failed, which is a + // very different claim from having no data. + it('shows a dash rather than 0% when no attempts were recorded', () => { + renderStrip({ attempts: 0, success: 0, failure: 0, successRate: null, acrCount: 0 }) + + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.queryByText('0.0%')).not.toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts new file mode 100644 index 0000000000..f87bee9498 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts @@ -0,0 +1,122 @@ +import type { GetMetricEntriesParams, MetricDataEntry, MetricEntryPagedResult } from 'JansConfigApi' +import { fetchAllMetricEntries } from 'Plugins/fido/components/AuthMetrics/hooks/useMetricSeries' +import { MAX_ENTRY_PAGES, PAGE_SIZE } from 'Plugins/fido/components/AuthMetrics/constants' + +const BASE_PARAMS: GetMetricEntriesParams = { + metricType: 'user_authentication_success', + start_date: '2026-08-12T00:00:00.000Z', + end_date: '2026-08-19T00:00:00.000Z', +} + +const row = (index: number): MetricDataEntry => ({ + id: `row-${index}`, + startDate: '2026-08-19T06:00:00', + data: { count: 1 }, +}) + +// Serves `total` rows PAGE_SIZE.SERIES at a time, recording the params of every call so the walk +// itself can be asserted rather than only its result. +const pagedFetcher = (total: number) => { + const calls: GetMetricEntriesParams[] = [] + + const fetcher = (params: GetMetricEntriesParams): Promise => { + calls.push(params) + const start = params.startIndex ?? 0 + const limit = params.limit ?? PAGE_SIZE.SERIES + const entries = Array.from( + { length: Math.max(0, Math.min(limit, total - start)) }, + (_, offset) => row(start + offset), + ) + return Promise.resolve({ + start, + totalEntriesCount: total, + entriesCount: entries.length, + entries, + }) + } + + return { fetcher, calls } +} + +describe('fetchAllMetricEntries', () => { + it('returns a single page whole when it already covers the total', async () => { + const { fetcher, calls } = pagedFetcher(12) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toHaveLength(12) + expect(result.isTruncated).toBe(false) + expect(calls).toHaveLength(1) + }) + + // The bug this replaced: a seven-day window holds roughly 2,000 five-minute rows against a + // 500-row page, so one request charted a fraction of the range and every KPI total undercounted. + it('walks every page so a multi-page window is counted in full', async () => { + const total = PAGE_SIZE.SERIES * 3 + 17 + const { fetcher, calls } = pagedFetcher(total) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toHaveLength(total) + expect(result.totalCount).toBe(total) + expect(result.isTruncated).toBe(false) + expect(calls).toHaveLength(4) + expect(calls.map((call) => call.startIndex)).toEqual([ + 0, + PAGE_SIZE.SERIES, + PAGE_SIZE.SERIES * 2, + PAGE_SIZE.SERIES * 3, + ]) + }) + + // Ascending order keeps paging stable: rows written mid-walk append instead of shifting + // everything already read, which a descending sort would do. + it('pages in ascending start-date order', async () => { + const { fetcher, calls } = pagedFetcher(5) + + await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls[0]).toMatchObject({ sortBy: 'jansStartDate', sortOrder: 'ascending' }) + }) + + it('reports truncation instead of silently returning a short series', async () => { + const total = PAGE_SIZE.SERIES * (MAX_ENTRY_PAGES + 5) + const { fetcher, calls } = pagedFetcher(total) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls).toHaveLength(MAX_ENTRY_PAGES) + expect(result.isTruncated).toBe(true) + expect(result.totalCount).toBe(total) + }) + + // A server that over-reports its total would otherwise keep the walk running to the ceiling. + it('stops on an empty page even when the reported total is larger', async () => { + const calls: GetMetricEntriesParams[] = [] + const fetcher = (params: GetMetricEntriesParams): Promise => { + calls.push(params) + const entries = (params.startIndex ?? 0) === 0 ? [row(0)] : [] + return Promise.resolve({ + start: params.startIndex ?? 0, + totalEntriesCount: 9999, + entriesCount: entries.length, + entries, + }) + } + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls).toHaveLength(2) + expect(result.entries).toHaveLength(1) + expect(result.isTruncated).toBe(false) + }) + + it('treats a response with no entries array as the end of the walk', async () => { + const fetcher = () => Promise.resolve({} as MetricEntryPagedResult) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toEqual([]) + expect(result.isTruncated).toBe(false) + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts new file mode 100644 index 0000000000..e5cbf78fb7 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts @@ -0,0 +1,374 @@ +import type { MetricAggregationEntry, MetricDataEntry } from 'JansConfigApi' +import { AXIS_KEYS } from 'Plugins/fido/components/AuthMetrics/constants' +import { createDate } from '@/utils/dayjsUtils' +import { + buildChartRows, + endOfDay, + formatDateForApi, + granularitiesForRange, + groupBySubType, + parseMetricData, + plainPoints, + resolveGranularity, + startOfDay, + subTypePoints, + sumCounts, + toMetricPoints, +} from 'Plugins/fido/components/AuthMetrics/utils' + +// Buckets are measured from the start of the window, so every buildChartRows call needs one. +// Fixed at a UTC midnight, which is where a snapped range always begins. +const ANCHOR = Date.UTC(2026, 7, 20, 0, 0, 0) + +describe('formatDateForApi', () => { + // Regression: toISOString() converted to UTC, so an admin at UTC+5 who picked the 20th had the + // window start at 19:00 on the 19th. The wall-clock time the user chose has to survive intact. + it('sends the selected wall-clock time, with no UTC conversion', () => { + const picked = new Date(2026, 7, 20, 0, 0, 0) + + expect(formatDateForApi(picked)).toBe('2026-08-20T00:00:00') + }) + + it('emits no Z suffix, which would make the server read the value as UTC', () => { + expect(formatDateForApi(new Date(2026, 7, 20, 23, 59, 0))).toBe('2026-08-20T23:59:00') + }) +}) + +describe('UTC handling of server timestamps', () => { + // MetricEntry.startDate is a plain java.util.Date with no @JsonFormat, so whether a Z reaches us + // is decided by the server's Jackson config. MetricDateUtil reads a bare value as UTC and + // normalizes an offset to UTC, so all three shapes have to land on the same instant here. + const shapes = [ + ['bare, no offset', '2026-08-20T06:50:00'], + ['Z suffix', '2026-08-20T06:50:00.000Z'], + ['explicit +00:00', '2026-08-20T06:50:00+00:00'], + ] as const + + it.each(shapes)('reads a %s timestamp as UTC', (_name, startDate) => { + const [point] = toMetricPoints([{ startDate, data: { count: 1 } } as MetricDataEntry], 'HH:mm') + + expect(point.label).toBe('06:50') + }) + + it('converts a non-UTC offset rather than dropping it, matching the server', () => { + const [point] = toMetricPoints( + [{ startDate: '2026-08-20T06:50:00+05:00', data: { count: 1 } } as MetricDataEntry], + 'HH:mm', + ) + + expect(point.label).toBe('01:50') + }) + + // Daily buckets have to break on the server's midnight. Bucketing in the viewer's zone would put + // these two rows in different bars for anyone east or west of UTC. + it('cuts daily buckets at UTC midnight, not the viewer local midnight', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: toMetricPoints( + [ + { startDate: '2026-08-20T00:30:00Z', data: { count: 1 } } as MetricDataEntry, + { startDate: '2026-08-20T23:30:00Z', data: { count: 2 } } as MetricDataEntry, + ], + 'HH:mm', + ), + }, + ], + 'DAILY', + ANCHOR, + ) + + expect(rows).toHaveLength(1) + expect(rows[0].success).toBe(3) + expect(rows[0][AXIS_KEYS.LABEL]).toBe('Aug-20') + }) +}) + +describe('granularitiesForRange', () => { + const spanOf = (days: number): [Date, Date] => [ + new Date(2026, 7, 20 - days, 0, 0, 0), + new Date(2026, 7, 20, 23, 59, 0), + ] + + // Keyed off the span, not off which preset was clicked, so a hand-picked range of the same + // length is offered exactly the same choices. + it.each([ + ['a single day', 0, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['the 24 Hours preset', 1, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['the 7 Days preset', 7, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['a fortnight', 14, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['the 30 Days preset', 30, ['DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_15', 'DAYS_21', 'DAYS_30']], + ])('offers %s the right tier', (_name, days, expected) => { + expect(granularitiesForRange(...spanOf(days))).toEqual(expected) + }) + + // A reversed range is transient while the user edits the second date; it must not empty the + // toggle, which would leave nothing selectable. + it('falls back to the finest tier when the range is reversed', () => { + const [start, end] = spanOf(7) + + expect(granularitiesForRange(end, start)).toEqual(['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']) + }) +}) + +describe('multi-unit bucket widths', () => { + // One row per hour across two days, so every bucket width below has something to fold. + const hourlyRows = Array.from({ length: 48 }, (_, hour) => ({ + startDate: new Date(ANCHOR + hour * 3_600_000).toISOString(), + data: { count: 1 }, + })) as MetricDataEntry[] + + const rowsAt = (granularity: string) => + buildChartRows( + [{ key: 'success', points: toMetricPoints(hourlyRows, 'HH:mm') }], + granularity as never, + ANCHOR, + ) + + it.each([ + ['HOURLY', 48, 1], + ['HOURS_3', 16, 3], + ['HOURS_12', 4, 12], + ['HOURS_24', 2, 24], + ['DAILY', 2, 24], + ['DAYS_3', 1, 48], + ['DAYS_7', 1, 48], + ])('folds 48 hourly rows into %s buckets', (granularity, expectedRows, expectedPerBucket) => { + const rows = rowsAt(granularity) + + expect(rows).toHaveLength(expectedRows) + expect(rows[0].success).toBe(expectedPerBucket) + }) + + // Whatever the width, nothing may be dropped or counted twice on the way into the buckets. + it.each(['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24', 'DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_30'])( + 'preserves the total at %s', + (granularity) => { + const total = rowsAt(granularity).reduce((sum, row) => sum + Number(row.success), 0) + + expect(total).toBe(48) + }, + ) + + // Buckets run from the start of the window, not from the epoch, so a range opening mid-month + // still gets its first bucket at its own first row. + it('measures buckets from the range start rather than from the epoch', () => { + const [first] = rowsAt('DAYS_3') + + expect(first[AXIS_KEYS.TIMESTAMP]).toBe(ANCHOR) + }) +}) + +describe('resolveGranularity', () => { + it('keeps the choice when the range still allows it', () => { + expect(resolveGranularity('DAILY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') + }) + + // Picking 30 Days while on an hourly bucket has to land somewhere valid, not on an empty chart. + it('falls back to the finest allowed when the choice is out of range', () => { + expect(resolveGranularity('HOURLY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') + }) +}) + +describe('startOfDay and endOfDay', () => { + // The date picker hands back the day the user clicked carrying a time of day they never chose, + // so a single-day range silently skipped the hours before it and after the end. + it('widens a mid-afternoon pick to cover the whole day', () => { + const picked = createDate(new Date(2026, 7, 20, 14, 37, 12)) + + expect(formatDateForApi(startOfDay(picked).toDate())).toBe('2026-08-20T00:00:00') + expect(formatDateForApi(endOfDay(picked).toDate())).toBe('2026-08-20T23:59:00') + }) + + it('keeps both ends on the date that was picked', () => { + const picked = createDate(new Date(2026, 7, 20, 0, 0, 0)) + + expect(startOfDay(picked).date()).toBe(20) + expect(endOfDay(picked).date()).toBe(20) + }) +}) + +describe('parseMetricData', () => { + // Aggregations deliver `data` as a JSON string, entries as an object; both must work. + it('parses a JSON string payload', () => { + expect(parseMetricData('{"success":10,"failure":2}')).toEqual({ success: 10, failure: 2 }) + }) + + it('reads an object payload directly', () => { + expect(parseMetricData({ success: 10 })).toEqual({ success: 10 }) + }) + + it('addresses nested values by dotted path', () => { + expect(parseMetricData({ auth: { success: 3, nested: { latency: 12.5 } } })).toEqual({ + 'auth.success': 3, + 'auth.nested.latency': 12.5, + }) + }) + + it('coerces quoted numbers, since counters often arrive as strings', () => { + expect(parseMetricData('{"success":"42"}')).toEqual({ success: 42 }) + }) + + it('skips non-numeric and non-finite values instead of emitting NaN', () => { + expect(parseMetricData({ label: 'basic', ok: true, bad: 'abc', empty: '' })).toEqual({}) + }) + + // The shape is undocumented, so bad input must not throw during a render. + it.each([['{not json'], [null], [undefined], ['']])('returns no values for %p', (input) => { + expect(parseMetricData(input)).toEqual({}) + }) +}) + +describe('toMetricPoints', () => { + // Typed as the aggregation entry, whose data is a JSON string, matching what the endpoint sends. + const entry = (startDate: string, data: string): MetricAggregationEntry => ({ + startDate, + applicationType: 'jans_auth', + metricType: 'user_authentication_success', + metricSubType: 'basic', + data, + }) + + it('sorts chronologically regardless of response order', () => { + const points = toMetricPoints( + [entry('2026-08-03T00:00:00Z', '{"n":3}'), entry('2026-08-01T00:00:00Z', '{"n":1}')], + 'MMM-DD', + ) + + expect(points.map((p) => p.values.n)).toEqual([1, 3]) + }) + + it('carries the identifying fields and retains the raw payload', () => { + const [point] = toMetricPoints([entry('2026-08-01T00:00:00Z', '{"n":1}')], 'MMM-DD') + + expect(point).toMatchObject({ + appType: 'jans_auth', + metricType: 'user_authentication_success', + subType: 'basic', + values: { n: 1 }, + raw: '{"n":1}', + }) + }) + + it('keeps an unparseable entry rather than dropping it silently', () => { + const points = toMetricPoints([entry('2026-08-01T00:00:00Z', 'not-json')], 'MMM-DD') + + expect(points).toHaveLength(1) + expect(points[0]!.values).toEqual({}) + expect(points[0]!.raw).toBe('not-json') + }) + + it('returns nothing for a missing entry list', () => { + expect(toMetricPoints(undefined, 'MMM-DD')).toEqual([]) + }) +}) + +// Built through a typed factory rather than cast: the repo bans the top type, and naming the +// three fields the parser actually reads keeps the fixtures honest. +const entry = (startDate: string, count: number, metricSubType?: string): MetricDataEntry => ({ + startDate, + metricSubType, + data: { count }, +}) + +describe('plainPoints and subTypePoints', () => { + // /metric/entries returns a plain row and a per-subtype row for the same window when subType is + // omitted. Adding both together is the one mistake that silently doubles every total. + const points = toMetricPoints( + [entry('2026-08-19T06:00:00Z', 6), entry('2026-08-19T06:00:00Z', 6, 'basic')], + 'MMM-DD', + ) + + it('keeps only the untagged rows for a total', () => { + expect(sumCounts(plainPoints(points))).toBe(6) + }) + + it('keeps only the tagged rows for a breakdown', () => { + expect(subTypePoints(points).map((point) => point.subType)).toEqual(['basic']) + }) +}) + +describe('buildChartRows', () => { + const at = (iso: string, count: number) => toMetricPoints([entry(iso, count)], 'MMM-DD')[0] + + it('sums five-minute rows into the requested bucket', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: [at('2026-08-19T06:05:00Z', 2), at('2026-08-19T06:55:00Z', 3)], + }, + ], + 'HOURLY', + ANCHOR, + ) + + expect(rows).toHaveLength(1) + expect(rows[0].success).toBe(5) + }) + + it('zero-fills a series that has no row in a bucket, so its line stays connected', () => { + const rows = buildChartRows( + [ + { key: 'success', points: [at('2026-08-19T06:00:00Z', 4)] }, + { key: 'failure', points: [] }, + ], + 'HOURLY', + ANCHOR, + ) + + expect(rows[0].failure).toBe(0) + }) + + it('orders buckets chronologically regardless of input order', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: [at('2026-08-20T06:00:00Z', 1), at('2026-08-19T06:00:00Z', 2)], + }, + ], + 'DAILY', + ANCHOR, + ) + + expect(rows.map((row) => row.success)).toEqual([2, 1]) + }) + + // Series keys are acr names straight from the API. Underscored axis fields are what stop a + // subtype called "label" from overwriting the x-axis value. + it('keeps the axis fields intact when a series is named after one', () => { + const rows = buildChartRows( + [ + { key: 'label', points: [at('2026-08-19T06:00:00Z', 7)] }, + { key: 'timestamp', points: [at('2026-08-19T06:00:00Z', 9)] }, + ], + 'DAILY', + ANCHOR, + ) + + expect(typeof rows[0]![AXIS_KEYS.LABEL]).toBe('string') + expect(rows[0]![AXIS_KEYS.TIMESTAMP]).toBeGreaterThan(0) + expect(rows[0]!.label).toBe(7) + expect(rows[0]!.timestamp).toBe(9) + }) +}) + +describe('groupBySubType', () => { + it('splits each acr into its own series, sorted so colours stay stable', () => { + const points = toMetricPoints( + [ + entry('2026-08-19T06:00:00Z', 1, 'simple_password_auth'), + entry('2026-08-19T06:00:00Z', 2, 'basic'), + entry('2026-08-19T06:00:00Z', 3), + ], + 'MMM-DD', + ) + + expect(groupBySubType(points).map((series) => series.key)).toEqual([ + 'basic', + 'simple_password_auth', + ]) + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx new file mode 100644 index 0000000000..23cebbbd45 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx @@ -0,0 +1,120 @@ +import React, { useMemo } from 'react' +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { acrColorAt } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow, NamedSeries } from '../types' + +const AREA_FILL_OPACITY = 0.35 + +type AcrBreakdownChartProps = { + rows: MetricChartRow[] + // Carries both the prefixed dataKey and the acr's own name, so the legend shows the acr while + // the row key stays collision-proof. + series: NamedSeries[] +} + +// Stacked because the parts sum to total successful authentications; the interesting question is +// which acr carried the traffic, not how each one moved in isolation. +const AcrBreakdownChart: React.FC = ({ rows, series }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + + const legend = useMemo( + () => + series.map((entry, index) => ({ + label: entry.label ?? entry.key, + color: acrColorAt(themeColors, index), + })), + [series, themeColors], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => + Math.max( + max, + series.reduce((total, entry) => total + Number(row[entry.key] ?? 0), 0), + ), + 0, + ), + ), + [rows, series], + ) + + const isEmpty = rows.length === 0 || series.length === 0 + + // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone + // point draws nothing at all, so markers come back once the series is sparse enough to need them. + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + +
+ + + + + + {isEmpty ? null : } + {series.map((entry, index) => { + const color = acrColorAt(themeColors, index) + return ( + + ) + })} + + +
+
+ ) +} + +export default React.memo(AcrBreakdownChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx new file mode 100644 index 0000000000..469ccd6141 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react' +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { SERIES_KEYS } from '../hooks' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow } from '../types' + +type AuthActivityChartProps = { + rows: MetricChartRow[] +} + +const AuthActivityChart: React.FC = ({ rows }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + const legend = useMemo( + () => [ + { label: t('fields.auth_success'), color: palette.success }, + { label: t('fields.auth_failure'), color: palette.failure }, + ], + [t, palette], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => + Math.max(max, Number(row[SERIES_KEYS.SUCCESS]), Number(row[SERIES_KEYS.FAILURE])), + 0, + ), + ), + [rows], + ) + + const isEmpty = rows.length === 0 + + // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone + // point draws nothing at all, so markers come back once the series is sparse enough to need them. + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + +
+ + + + + + {isEmpty ? null : } + + + + +
+
+ ) +} + +export default React.memo(AuthActivityChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx new file mode 100644 index 0000000000..1a1fe1c797 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' + +type AuthMetricsKpiStripProps = { + totals: { + attempts: number + success: number + failure: number + successRate: number | null + acrCount: number + } +} + +const AuthMetricsKpiStrip: React.FC = ({ totals }) => { + const { t } = useTranslation() + const { themeColors, isDark } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + // An unknown rate is shown as a dash: printing 0% with no attempts recorded would read as + // every authentication having failed. + const successRateLabel = totals.successRate === null ? '—' : `${totals.successRate.toFixed(1)}%` + + const cards = [ + { label: t('fields.auth_attempts'), value: totals.attempts.toLocaleString() }, + { + label: t('fields.auth_success'), + value: totals.success.toLocaleString(), + color: palette.success, + }, + { + label: t('fields.auth_failure'), + value: totals.failure.toLocaleString(), + color: totals.failure > 0 ? palette.failure : undefined, + }, + { label: t('fields.auth_success_rate'), value: successRateLabel }, + { label: t('fields.acr_in_use'), value: totals.acrCount.toLocaleString() }, + ] + + return ( +
+ {cards.map((card) => ( +
+

{card.label}

+

+ {card.value} +

+
+ ))} +
+ ) +} + +export default React.memo(AuthMetricsKpiStrip) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts new file mode 100644 index 0000000000..81886d560d --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts @@ -0,0 +1,66 @@ +import { makeStyles } from 'tss-react/mui' +import customColors, { hexToRgb } from '@/customColors' +import { + SHARED_DROPDOWN_STYLES, + createBaseOptionStyles, +} from '@/components/GluuDropdown/sharedDropdownStyles' + +// Built from the shared dropdown tokens rather than a second set defined here, so this menu is the +// same object as the Theme and language dropdowns in the header. GluuDropdown itself is not used: +// it renders its own trigger, and here the trigger is the date preset button, which lives inside +// the shared DateRangeSelector. +const OPTION_PADDING = '12px 16px' + +export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_theme, { isDark }) => { + const dropdownBg = isDark ? customColors.darkDropdownBg : customColors.white + + return { + menu: { + backgroundColor: dropdownBg, + border: 'none', + borderRadius: SHARED_DROPDOWN_STYLES.borderRadius, + boxShadow: `0px 4px 11px 0px rgba(${hexToRgb(customColors.black)}, 0.05)`, + padding: 0, + // Grows to the longest label rather than forcing it to wrap, with the shared minimum as a + // floor so a short set like Daily/All still reads as a menu and not a tooltip. + width: 'max-content', + minWidth: SHARED_DROPDOWN_STYLES.minWidth, + maxHeight: SHARED_DROPDOWN_STYLES.maxHeight, + // Visible rather than hidden so the arrow, which sits outside the panel, is not clipped. + overflow: 'visible', + position: 'relative', + }, + content: { + padding: SHARED_DROPDOWN_STYLES.padding, + maxHeight: SHARED_DROPDOWN_STYLES.maxHeight, + overflowY: 'auto', + overflowX: 'hidden', + }, + // Points back at the preset that opened the menu, which is why the panel is centred on it. + arrow: { + 'position': 'absolute', + 'top': '-15px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'width': SHARED_DROPDOWN_STYLES.arrowWidth, + 'height': SHARED_DROPDOWN_STYLES.arrowHeight, + 'zIndex': SHARED_DROPDOWN_STYLES.arrowZIndex, + 'pointerEvents': 'none', + '& svg': { + width: '100%', + height: '100%', + fill: dropdownBg, + color: dropdownBg, + filter: `drop-shadow(0px -1px 2px rgba(${hexToRgb(customColors.black)}, 0.1))`, + }, + }, + // Carries the shared hover and `.selected` treatment, so the highlighted row reads exactly as + // the selected theme does in the header dropdown. The shared right padding reserves room for a + // trailing icon this menu does not use, and against two-word labels like "3 Weeks" it forced a + // line break, so the padding is evened up and wrapping is ruled out outright. + option: { + ...createBaseOptionStyles({ isDark, optionPadding: OPTION_PADDING }), + whiteSpace: 'nowrap' as const, + }, + } +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx new file mode 100644 index 0000000000..abaeca80ca --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect } from 'react' +import Box from '@mui/material/Box' +import ClickAwayListener from '@mui/material/ClickAwayListener' +import ArrowIcon from '@/components/SVG/Arrow' +import { useSecurityTheme } from '../../SecurityMonitor/hooks' +import { useGranularityMenuStyles } from './GranularityMenu.style' +import type { Granularity, GranularityMenuProps } from '../types' + +// Hangs under the date preset that opened it, so the granularities on offer read as belonging to +// the range just chosen. Deliberately not a standalone control: which buckets make sense is a +// property of the range, and a separate always-visible picker invited combinations that produce an +// unreadable chart. +const GranularityMenu: React.FC = ({ + options, + value, + onSelect, + onDismiss, + ariaLabel, +}) => { + const { isDark } = useSecurityTheme() + const { classes, cx } = useGranularityMenuStyles({ isDark }) + + // Escape closes as well as an outside click; a menu that only the mouse can dismiss strands + // anyone who opened it from the keyboard. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onDismiss() + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [onDismiss]) + + const handleSelect = useCallback( + (next: Granularity) => { + onSelect(next) + onDismiss() + }, + [onSelect, onDismiss], + ) + + return ( + + +
+ +
+
+ {options.map((option) => { + const isSelected = option.value === value + return ( + handleSelect(option.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleSelect(option.value) + } + }} + > + {option.label} + + ) + })} +
+
+
+ ) +} + +export default React.memo(GranularityMenu) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx new file mode 100644 index 0000000000..a555d36b1d --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx @@ -0,0 +1,118 @@ +import React, { useMemo } from 'react' +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { SERIES_KEYS } from '../hooks' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow } from '../types' + +type TokenIssuanceChartProps = { + rows: MetricChartRow[] +} + +const TokenIssuanceChart: React.FC = ({ rows }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + const series = useMemo( + () => [ + { + key: SERIES_KEYS.ACCESS_TOKEN, + label: t('fields.access_tokens'), + color: palette.accessToken, + }, + { key: SERIES_KEYS.ID_TOKEN, label: t('fields.id_tokens'), color: palette.idToken }, + { + key: SERIES_KEYS.REFRESH_TOKEN, + label: t('fields.refresh_tokens'), + color: palette.refreshToken, + }, + { + key: SERIES_KEYS.AUTHORIZATION_CODE, + label: t('fields.authorization_codes'), + color: palette.authorizationCode, + }, + ], + [t, palette], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => Math.max(max, ...series.map((entry) => Number(row[entry.key] ?? 0))), + 0, + ), + ), + [rows, series], + ) + + const isEmpty = rows.length === 0 + + // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone + // point draws nothing at all, so markers come back once the series is sparse enough to need them. + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + ({ label: entry.label, color: entry.color }))} + isEmpty={isEmpty} + emptyLabel={t('fields.no_data')} + > +
+ + + + + + {isEmpty ? null : } + {series.map((entry) => ( + + ))} + + +
+
+ ) +} + +export default React.memo(TokenIssuanceChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts new file mode 100644 index 0000000000..4d9790bde9 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts @@ -0,0 +1,5 @@ +export { default as AcrBreakdownChart } from './AcrBreakdownChart' +export { default as AuthActivityChart } from './AuthActivityChart' +export { default as AuthMetricsKpiStrip } from './AuthMetricsKpiStrip' +export { default as GranularityMenu } from './GranularityMenu' +export { default as TokenIssuanceChart } from './TokenIssuanceChart' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/constants.ts b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts new file mode 100644 index 0000000000..98ca59e4c8 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts @@ -0,0 +1,142 @@ +export const AUTH_METRICS_CACHE_CONFIG = { + STALE_TIME: 5 * 60 * 1000, + GC_TIME: 10 * 60 * 1000, +} as const + +// Rows per request. The spec states no maximum, so this stays conservative and the caller pages. +export const PAGE_SIZE = { + SERIES: 500, +} as const + +// Ceiling on the page walk, so a server that keeps reporting a larger total cannot loop forever. +// 200 pages covers roughly two months of five-minute rows, well past the retention window. +export const MAX_ENTRY_PAGES = 200 + +// Names taken verbatim from the auth server's MetricType enum rather than from /metric/types, +// which only lists types that already hold rows. A type absent from discovery is idle, not +// unsupported, so charting against the enum keeps a series present once its events start. +export const METRIC_TYPES = { + AUTH_SUCCESS: 'user_authentication_success', + AUTH_FAILURE: 'user_authentication_failure', + AUTH_RATE: 'user_authentication_rate', + DCR_RATE: 'dynamic_client_registration_rate', + ACCESS_TOKEN: 'tkn_access_token_count', + ID_TOKEN: 'tkn_id_token_count', + REFRESH_TOKEN: 'tkn_refresh_token_count', + AUTHORIZATION_CODE: 'tkn_authorization_code_count', + LOGOUT_STATUS_JWT: 'tkn_logout_status_jwt_count', + LONG_LIVED_ACCESS_TOKEN: 'tkn_long_lived_access_token_count', +} as const + +// Every metric payload observed so far carries exactly one numeric leaf. +export const COUNT_KEY = 'count' + +// Underscored so a metric subtype can never collide with them: series keys are acr names taken +// straight from the API, and one literally called "label" would otherwise overwrite the axis. +export const AXIS_KEYS = { + TIMESTAMP: '__timestamp', + LABEL: '__label', +} as const + +// Buckets are folded client-side because /metric/aggregations stays empty until its producer task +// is deployed. Rows arrive at the auth server's metricReporterInterval, five minutes on the test +// deployment, so an hour is the finest bucket offered here. +export const GRANULARITIES = { + HOURLY: 'HOURLY', + HOURS_3: 'HOURS_3', + HOURS_12: 'HOURS_12', + HOURS_24: 'HOURS_24', + DAILY: 'DAILY', + DAYS_3: 'DAYS_3', + DAYS_7: 'DAYS_7', + DAYS_15: 'DAYS_15', + DAYS_21: 'DAYS_21', + DAYS_30: 'DAYS_30', +} as const + +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +// Bucket width. Every tier ends on a bucket as wide as its own range, which is the total for that +// range in a single point, so no separate "total" option is needed. +export const GRANULARITY_STEP_MS = { + [GRANULARITIES.HOURLY]: HOUR_MS, + [GRANULARITIES.HOURS_3]: 3 * HOUR_MS, + [GRANULARITIES.HOURS_12]: 12 * HOUR_MS, + // Same width as DAILY, kept separate only so the hour tier can end on "24 Hours" instead of + // switching units mid-list. The day tiers still read "Daily". + [GRANULARITIES.HOURS_24]: DAY_MS, + [GRANULARITIES.DAILY]: DAY_MS, + [GRANULARITIES.DAYS_3]: 3 * DAY_MS, + [GRANULARITIES.DAYS_7]: 7 * DAY_MS, + [GRANULARITIES.DAYS_15]: 15 * DAY_MS, + [GRANULARITIES.DAYS_21]: 21 * DAY_MS, + [GRANULARITIES.DAYS_30]: 30 * DAY_MS, +} + +export const GRANULARITY_LABEL_FORMATS = { + [GRANULARITIES.HOURLY]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_3]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_12]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_24]: 'MMM-DD', + [GRANULARITIES.DAILY]: 'MMM-DD', + [GRANULARITIES.DAYS_3]: 'MMM-DD', + [GRANULARITIES.DAYS_7]: 'MMM-DD', + [GRANULARITIES.DAYS_15]: 'MMM-DD', + [GRANULARITIES.DAYS_21]: 'MMM-DD', + [GRANULARITIES.DAYS_30]: 'MMM-DD', +} + +// What each range may be viewed at, keyed by span in days rather than by which preset was clicked, +// so a hand-picked range of the same length is offered the same buckets. Ordered coarsest-last, and +// the first entry is what an out-of-range selection falls back to. +export const GRANULARITY_TIERS = [ + { + maxSpanDays: 2, + granularities: [ + GRANULARITIES.HOURLY, + GRANULARITIES.HOURS_3, + GRANULARITIES.HOURS_12, + GRANULARITIES.HOURS_24, + ], + }, + { + maxSpanDays: 14, + granularities: [GRANULARITIES.DAILY, GRANULARITIES.DAYS_3, GRANULARITIES.DAYS_7], + }, + { + maxSpanDays: Number.POSITIVE_INFINITY, + granularities: [ + GRANULARITIES.DAILY, + GRANULARITIES.DAYS_3, + GRANULARITIES.DAYS_7, + GRANULARITIES.DAYS_15, + GRANULARITIES.DAYS_21, + GRANULARITIES.DAYS_30, + ], + }, +] as const + +// The default range is a week, whose tier opens on daily. +export const DEFAULT_GRANULARITY = GRANULARITIES.DAILY + +// A raw row's own timestamp, at the reporter's own resolution. Independent of the chart bucket: +// this labels the entry that came back, not the bucket it later folds into. +export const POINT_LABEL_FORMAT = 'MMM-DD HH:mm' + +// Below this many points a line is drawn with its markers shown. Hiding them keeps a dense series +// clean, but a coarse bucket can leave two points or one, and a lone point with no marker draws +// nothing at all. +export const SPARSE_SERIES_MAX_POINTS = 40 + +// Opening window for the date filter. Retention is driven by metricReporterKeepDataDays, 15 days +// on the test deployment, so a much longer default would open on a mostly empty chart. +export const DEFAULT_SELECTED_RANGE_DAYS = 7 + +// Days rather than the MAU dashboard's months: auth rows expire on metricReporterKeepDataDays, +// so a quarter-length preset would ask for history the store has already dropped. +export const DATE_PRESETS = [ + { labelKey: 'fields.date_preset_24h', value: 1 }, + { labelKey: 'fields.date_preset_7d', value: 7 }, + { labelKey: 'fields.date_preset_30d', value: 30 }, +] as const diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts new file mode 100644 index 0000000000..be4f7d8c17 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts @@ -0,0 +1,2 @@ +export { fetchAllMetricEntries, useAllMetricEntries } from './useMetricSeries' +export { useAuthMetricsCharts, SERIES_KEYS } from './useAuthMetricsCharts' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts new file mode 100644 index 0000000000..47c6c59c4c --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import { METRIC_TYPES } from '../constants' +import { buildChartRows, groupBySubType, plainPoints, sumCounts, toUtcWallClockMs } from '../utils' +import { useAllMetricEntries } from './useMetricSeries' +import type { Granularity, MetricRange, NamedSeries } from '../types' + +// Series keys are the recharts dataKeys, kept separate from the metric type names so a chart +// legend never has to render a raw jansMetricTyp string. +const SERIES_KEYS = { + SUCCESS: 'success', + FAILURE: 'failure', + ACCESS_TOKEN: 'accessToken', + ID_TOKEN: 'idToken', + REFRESH_TOKEN: 'refreshToken', + AUTHORIZATION_CODE: 'authorizationCode', +} as const + +// Prefixed so an acr named after one of the fixed series keys, or after an axis field, cannot +// collide with it once the values share a row. +const ACR_KEY_PREFIX = 'acr__' + +type UseAuthMetricsChartsArgs = { + range: MetricRange + granularity: Granularity +} + +// Every chart on this page reads /metric/entries rather than /metric/aggregations: the raw rows +// arrive in five-minute buckets, finer than any aggregation period, and they are available now +// whereas the aggregation producer is not deployed. +export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChartsArgs) => { + // Listed one call per type because hooks cannot run inside a loop or callback. + const success = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_SUCCESS }) + const failure = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_FAILURE }) + const accessToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.ACCESS_TOKEN }) + const idToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.ID_TOKEN }) + const refreshToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.REFRESH_TOKEN }) + const authorizationCode = useAllMetricEntries({ + range, + metricType: METRIC_TYPES.AUTHORIZATION_CODE, + }) + + // Multi-hour and multi-day buckets are measured from the start of the window, so every chart + // has to fold against the same anchor or their x-axes would not line up. + const anchorMs = useMemo(() => toUtcWallClockMs(range.startDate), [range.startDate]) + + // Only the plain rows: the endpoint also returns a per-subtype copy of the same window, so + // charting both together would double every total. + const successTotals = useMemo(() => plainPoints(success.points), [success.points]) + const failureTotals = useMemo(() => plainPoints(failure.points), [failure.points]) + + const authRows = useMemo( + () => + buildChartRows( + [ + { key: SERIES_KEYS.SUCCESS, points: successTotals }, + { key: SERIES_KEYS.FAILURE, points: failureTotals }, + ], + granularity, + anchorMs, + ), + [successTotals, failureTotals, granularity, anchorMs], + ) + + // The ACR breakdown the security team asked for: jansMetricSubTyp carries the acr each + // successful authentication ran under. + const acrSeries = useMemo( + () => + groupBySubType(success.points).map((series) => ({ + ...series, + key: `${ACR_KEY_PREFIX}${series.key}`, + label: series.key, + })), + [success.points], + ) + + const acrRows = useMemo( + () => buildChartRows(acrSeries, granularity, anchorMs), + [acrSeries, granularity, anchorMs], + ) + + const tokenRows = useMemo( + () => + buildChartRows( + [ + { key: SERIES_KEYS.ACCESS_TOKEN, points: plainPoints(accessToken.points) }, + { key: SERIES_KEYS.ID_TOKEN, points: plainPoints(idToken.points) }, + { key: SERIES_KEYS.REFRESH_TOKEN, points: plainPoints(refreshToken.points) }, + { key: SERIES_KEYS.AUTHORIZATION_CODE, points: plainPoints(authorizationCode.points) }, + ], + granularity, + anchorMs, + ), + [ + anchorMs, + accessToken.points, + idToken.points, + refreshToken.points, + authorizationCode.points, + granularity, + ], + ) + + const totals = useMemo(() => { + const successCount = sumCounts(successTotals) + const failureCount = sumCounts(failureTotals) + const attempts = successCount + failureCount + + return { + success: successCount, + failure: failureCount, + attempts, + // Guarded rather than reported as zero: no attempts means the rate is unknown, and a flat + // 0% would read as total failure. + successRate: attempts > 0 ? (successCount / attempts) * 100 : null, + acrCount: acrSeries.length, + } + }, [successTotals, failureTotals, acrSeries]) + + const queries = [success, failure, accessToken, idToken, refreshToken, authorizationCode] + + return { + authRows, + acrRows, + acrSeries, + tokenRows, + totals, + // Covers the refetch too, not just the first load: keepPreviousData holds the old series on + // screen while a new range loads, so a first-load-only flag would let View look inert. + isBusy: queries.some((query) => query.isLoading || query.isFetching), + // Every query has to fail before the page calls itself unavailable; one idle metric type + // erroring should not hide the five that returned data. + isError: queries.every((query) => query.isError), + // Surfaced rather than swallowed: a truncated walk means the totals below are incomplete. + isTruncated: queries.some((query) => query.isTruncated), + refetch: () => queries.forEach((query) => void query.refetch()), + } +} + +export { SERIES_KEYS, ACR_KEY_PREFIX } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts new file mode 100644 index 0000000000..34c95f0c03 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts @@ -0,0 +1,129 @@ +import { useMemo } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useAppSelector } from '@/redux/hooks' +import { + getMetricEntries, + type GetMetricEntriesParams, + type MetricDataEntry, + type MetricEntryPagedResult, +} from 'JansConfigApi' +import { + AUTH_METRICS_CACHE_CONFIG, + POINT_LABEL_FORMAT, + MAX_ENTRY_PAGES, + PAGE_SIZE, +} from '../constants' +import { formatDateForApi, toMetricPoints } from '../utils' +import type { MetricPoint, MetricQueryOptions, MetricRange } from '../types' + +type EntriesFetcher = ( + params: GetMetricEntriesParams, + signal?: AbortSignal, +) => Promise + +type AllEntriesResult = { + entries: MetricDataEntry[] + totalCount: number + // True when the page ceiling was hit before the server's total was reached. Callers must say so + // rather than render a total that silently under-reports. + isTruncated: boolean +} + +const defaultFetcher: EntriesFetcher = (params, signal) => + getMetricEntries(params, undefined, signal) + +// One page holds far fewer rows than a multi-day window at the reporter's five-minute interval — +// seven days is roughly 2,000 rows against a 500-row page — so every page is walked. Without this +// the newest page alone was charted and every KPI total under-reported. +const fetchAllMetricEntries = async ( + baseParams: GetMetricEntriesParams, + { fetcher = defaultFetcher, signal }: { fetcher?: EntriesFetcher; signal?: AbortSignal } = {}, +): Promise => { + const collected: MetricDataEntry[] = [] + let totalCount = 0 + + for (let page = 0; page < MAX_ENTRY_PAGES; page += 1) { + const result = await fetcher( + { + ...baseParams, + startIndex: page * PAGE_SIZE.SERIES, + limit: PAGE_SIZE.SERIES, + // Ascending keeps paging stable: rows written while we walk the pages land at the end + // instead of shifting everything we have already read, as a descending sort would. + sortBy: 'jansStartDate', + sortOrder: 'ascending', + }, + signal, + ) + + const batch = result?.entries ?? [] + collected.push(...batch) + totalCount = result?.totalEntriesCount ?? collected.length + + // An empty page also ends the walk, so a server that misreports its total cannot spin here. + if (batch.length === 0 || collected.length >= totalCount) { + return { entries: collected, totalCount, isTruncated: false } + } + } + + return { entries: collected, totalCount, isTruncated: collected.length < totalCount } +} + +const useIsEnabled = (range: MetricRange, options?: MetricQueryOptions) => { + const hasSession = useAppSelector((state) => state.authReducer?.hasSession) + return (options?.enabled ?? true) && hasSession === true && !!range.startDate && !!range.endDate +} + +const sharedQueryConfig = { + staleTime: AUTH_METRICS_CACHE_CONFIG.STALE_TIME, + gcTime: AUTH_METRICS_CACHE_CONFIG.GC_TIME, + placeholderData: keepPreviousData, +} + +// Every row for one metric type across the window. /metric/aggregations is not used: it stays +// empty until its producer task is deployed, and raw rows are finer than any aggregation period. +const useAllMetricEntries = ( + args: { + range: MetricRange + metricType: string + appType?: string + subType?: string + }, + options?: MetricQueryOptions, +) => { + const { range, metricType, appType, subType } = args + const isEnabled = useIsEnabled(range, options) && !!metricType + + const params: GetMetricEntriesParams = useMemo( + () => ({ + metricType, + start_date: formatDateForApi(range.startDate), + end_date: formatDateForApi(range.endDate), + ...(appType ? { appType } : {}), + ...(subType ? { subType } : {}), + }), + [metricType, range.startDate, range.endDate, appType, subType], + ) + + const query = useQuery({ + queryKey: ['metric-entries-all', params], + queryFn: ({ signal }) => fetchAllMetricEntries(params, { signal }), + enabled: isEnabled, + ...sharedQueryConfig, + }) + + const points: MetricPoint[] = useMemo( + () => toMetricPoints(query.data?.entries, POINT_LABEL_FORMAT), + [query.data], + ) + + return { + ...query, + points, + totalCount: query.data?.totalCount ?? 0, + isTruncated: query.data?.isTruncated ?? false, + } +} + +export { fetchAllMetricEntries, useAllMetricEntries } +export type { AllEntriesResult, EntriesFetcher } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/index.ts new file mode 100644 index 0000000000..354b279c03 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/index.ts @@ -0,0 +1,4 @@ +export { default } from './AuthMetricsPage' +export { useAllMetricEntries, useAuthMetricsCharts } from './hooks' +export * from './constants' +export type * from './types' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts new file mode 100644 index 0000000000..215fc15342 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts @@ -0,0 +1,67 @@ +import type { MetricRawData } from './JsonTypes' +import type { GRANULARITIES } from '../constants' + +type Granularity = (typeof GRANULARITIES)[keyof typeof GRANULARITIES] + +type MetricRange = { + startDate: Date + endDate: Date +} + +// The spec leaves `data` opaque: a JSON string on aggregations, an untyped JsonNode on entries. +// Every numeric leaf is captured by path so a chart can pick the key it needs once the real +// shape is known, rather than the parser guessing at field names up front. +type MetricDataValues = Record + +type MetricPoint = { + timestamp: number + label: string + appType?: string + metricType?: string + subType?: string + values: MetricDataValues + // Retained so an unrecognised payload can be inspected instead of silently dropped. + raw: MetricRawData +} + +type MetricQueryOptions = { + enabled?: boolean +} + +// One series ready to be folded onto a shared time axis; `key` becomes the recharts dataKey and +// `label` is what a legend shows, so an acr name never has to double as a safe object key. +type NamedSeries = { + key: string + label?: string + points: MetricPoint[] +} + +// A recharts row: the underscored axis fields from AXIS_KEYS plus one numeric entry per series +// sharing the bucket. Addressed through an index signature because series keys are only known at +// runtime. +type MetricChartRow = Record + +export type { + Granularity, + GranularityMenuOption, + GranularityMenuProps, + MetricChartRow, + MetricDataValues, + MetricPoint, + MetricQueryOptions, + MetricRange, + NamedSeries, +} + +type GranularityMenuOption = { + value: Granularity + label: string +} + +type GranularityMenuProps = { + options: readonly GranularityMenuOption[] + value: Granularity + onSelect: (value: Granularity) => void + onDismiss: () => void + ariaLabel: string +} diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts new file mode 100644 index 0000000000..1d8d3ac899 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts @@ -0,0 +1,12 @@ +// The metric plugin leaves `data` undescribed, so it is modelled as arbitrary JSON rather than +// `unknown`: concrete enough to recurse over safely, honest about being unvalidated. +type JsonPrimitive = string | number | boolean | null + +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } + +type JsonObject = { [key: string]: JsonValue } + +// What either endpoint can put in `data`: a JSON string on aggregations, an object on entries. +type MetricRawData = JsonValue | undefined + +export type { JsonObject, JsonPrimitive, JsonValue, MetricRawData } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts new file mode 100644 index 0000000000..efd2683fde --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts @@ -0,0 +1,2 @@ +export type * from './AuthMetricsTypes' +export type * from './JsonTypes' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/utils.ts b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts new file mode 100644 index 0000000000..61efb98395 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts @@ -0,0 +1,246 @@ +import { createDate, createUtcDate, toApiDatetime, type Dayjs } from '@/utils/dayjsUtils' +import type { ThemeConfig } from '@/context/theme/config' +import { getSecurityPalette } from '../SecurityMonitor/utils' +import type { MetricAggregationEntry, MetricDataEntry } from 'JansConfigApi' +import { + AXIS_KEYS, + COUNT_KEY, + GRANULARITY_LABEL_FORMATS, + GRANULARITY_STEP_MS, + GRANULARITY_TIERS, +} from './constants' +import type { + Granularity, + JsonObject, + JsonValue, + MetricChartRow, + MetricDataValues, + MetricPoint, + MetricRawData, + NamedSeries, +} from './types' + +// Sent without an offset, which MetricDateUtil on the server reads as UTC verbatim. toISOString() +// would append Z after converting, so a UTC+5 admin picking the 20th had the window start at 19:00 +// on the 19th. The picked wall-clock is the UTC wall-clock, and the axis is rendered in UTC to +// match, so both directions stay on one clock. +const formatDateForApi = (date: Date): string => toApiDatetime(createDate(date)) + +// Both ends snap to the day they name, so a hand-picked date covers that whole day rather than +// starting at whatever time the picker happened to carry over. The presets already did this; the +// manual pickers did not, which made the two paths disagree for the same visible date. +const startOfDay = (date: Dayjs): Dayjs => date.startOf('day') + +// 23:59 rather than 23:59:59, because toApiDatetime truncates to the minute anyway. +const endOfDay = (date: Dayjs): Dayjs => date.hour(23).minute(59).second(0).millisecond(0) + +// The granularities a range may be viewed at. Driven by the span in days, so a hand-picked range +// behaves the same as a preset of the same length. A reversed range yields the finest tier rather +// than an empty list, leaving the toggle usable while the user is still mid-edit. +const granularitiesForRange = (startDate: Date, endDate: Date): readonly Granularity[] => { + const spanDays = createDate(endDate).diff(createDate(startDate), 'day') + const tier = + GRANULARITY_TIERS.find((candidate) => spanDays <= candidate.maxSpanDays) ?? + GRANULARITY_TIERS[GRANULARITY_TIERS.length - 1] + + return tier.granularities +} + +// The user's pick stands while the range allows it and is only overridden when it does not, so +// returning to a shorter range restores what they last chose rather than a reset default. +const resolveGranularity = ( + granularity: Granularity, + allowed: readonly Granularity[], +): Granularity => (allowed.includes(granularity) ? granularity : allowed[0]) + +const isRecord = (value: JsonValue | undefined): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +// Aggregations carry `data` as a JSON string, entries as an object. Neither is described by the +// spec, so a malformed payload yields no values rather than throwing mid-render. +const toDataObject = (data: MetricRawData): JsonValue | undefined => { + if (typeof data !== 'string') return data + try { + return JSON.parse(data) + } catch { + return null + } +} + +// Numeric leaves are collected by dotted path so nested payloads stay addressable. Numeric +// strings are included because counters frequently arrive quoted. +const collectNumericValues = ( + value: JsonValue | undefined, + prefix = '', + acc: MetricDataValues = {}, +): MetricDataValues => { + if (!isRecord(value)) return acc + + for (const [key, leaf] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key + + if (typeof leaf === 'number' && Number.isFinite(leaf)) { + acc[path] = leaf + } else if (typeof leaf === 'string' && leaf.trim() !== '' && Number.isFinite(Number(leaf))) { + acc[path] = Number(leaf) + } else if (isRecord(leaf)) { + collectNumericValues(leaf, path, acc) + } + } + + return acc +} + +const parseMetricData = (data: MetricRawData): MetricDataValues => + collectNumericValues(toDataObject(data)) + +// MetricEntry.startDate is a bare java.util.Date with no @JsonFormat, so whether the wire value +// carries a Z is decided by the server's Jackson config rather than by the API contract. Read as +// UTC either way: that is the one clock the metric endpoints store and filter in. +const toTimestamp = (value?: string): number => { + if (!value) return 0 + const parsed = createUtcDate(value) + return parsed.isValid() ? parsed.valueOf() : 0 +} + +const toMetricPoint = ( + entry: MetricAggregationEntry | MetricDataEntry, + labelFormat: string, +): MetricPoint => { + const timestamp = toTimestamp(entry.startDate) + + return { + timestamp, + label: timestamp ? createUtcDate(entry.startDate).format(labelFormat) : '', + appType: entry.applicationType, + metricType: entry.metricType, + subType: entry.metricSubType, + // The generated JsonNode is an index signature of unknown; this is the one place the + // untyped payload crosses into the typed domain, so the cast is made explicitly here. + values: parseMetricData(entry.data as MetricRawData), + raw: entry.data as MetricRawData, + } +} + +const toMetricPoints = ( + entries: readonly (MetricAggregationEntry | MetricDataEntry)[] | undefined, + labelFormat: string, +): MetricPoint[] => + (entries ?? []) + .map((entry) => toMetricPoint(entry, labelFormat)) + .sort((a, b) => a.timestamp - b.timestamp) + +// Series colours come from the Security Monitor palette rather than a second set defined here, so +// success and failure read the same across both FIDO dashboards in either theme. +const getSeriesColors = (themeColors: ThemeConfig) => { + const { chart } = getSecurityPalette(themeColors) + + return { + success: chart.success, + failure: chart.failures, + accessToken: themeColors.chart.blue, + idToken: themeColors.chart.lightBlue, + refreshToken: themeColors.chart.purple, + authorizationCode: themeColors.chart.cyan, + } +} + +// ACR names are discovered at runtime, which is exactly what errorCategories exists for: a themed +// list long enough for arbitrary categories. Cycled so an unexpected count repeats a colour rather +// than rendering an invisible series. +const acrColorAt = (themeColors: ThemeConfig, index: number): string => { + const { errorCategories } = getSecurityPalette(themeColors) + return errorCategories[index % errorCategories.length] +} + +// With subType omitted the endpoint returns both plain and per-subtype rows for the same window. +// Summing them together double counts, so every caller has to pick a side deliberately. +const plainPoints = (points: readonly MetricPoint[]): MetricPoint[] => + points.filter((point) => !point.subType) + +const subTypePoints = (points: readonly MetricPoint[]): MetricPoint[] => + points.filter((point) => !!point.subType) + +const countOf = (point: MetricPoint): number => point.values[COUNT_KEY] ?? 0 + +const sumCounts = (points: readonly MetricPoint[]): number => + points.reduce((total, point) => total + countOf(point), 0) + +// The request carries local wall-clock that the server reads as UTC, and rows come back on that +// same clock, so the bucket anchor has to be expressed in it too. Anchoring off a local Date would +// phase every multi-hour bucket by the viewer's offset. +const toUtcWallClockMs = (date: Date): number => createUtcDate(formatDateForApi(date)).valueOf() + +// Buckets are measured from the start of the range rather than from the epoch, so "3 Days" means +// three days into the window the user asked for instead of an arbitrary offset inherited from 1970. +// The widest bucket a tier offers spans its whole range, so it lands everything on the anchor and +// gives the range total in one point. +const bucketStart = (timestamp: number, granularity: Granularity, anchorMs: number): number => { + const step = GRANULARITY_STEP_MS[granularity] + + return anchorMs + Math.floor((timestamp - anchorMs) / step) * step +} + +// Folds several named series onto a shared time axis: one row per bucket, one key per series. +// Absent keys are zero-filled so a line never breaks where a neighbouring series has data. +const buildChartRows = ( + series: readonly NamedSeries[], + granularity: Granularity, + anchorMs: number, +): MetricChartRow[] => { + const labelFormat = GRANULARITY_LABEL_FORMATS[granularity] + const rows = new Map() + + for (const { key, points } of series) { + for (const point of points) { + if (!point.timestamp) continue + const bucket = bucketStart(point.timestamp, granularity, anchorMs) + const row = rows.get(bucket) ?? { + [AXIS_KEYS.TIMESTAMP]: bucket, + [AXIS_KEYS.LABEL]: createUtcDate(bucket).format(labelFormat), + } + row[key] = (typeof row[key] === 'number' ? row[key] : 0) + countOf(point) + rows.set(bucket, row) + } + } + + const zeroed = Object.fromEntries(series.map(({ key }) => [key, 0])) + + return [...rows.values()] + .sort((a, b) => Number(a[AXIS_KEYS.TIMESTAMP]) - Number(b[AXIS_KEYS.TIMESTAMP])) + .map((row) => ({ ...zeroed, ...row })) +} + +// Each distinct subtype becomes its own series. Sorted so colour assignment stays stable between +// renders rather than following whatever order the API happened to return. +const groupBySubType = (points: readonly MetricPoint[]): NamedSeries[] => { + const groups = new Map() + + for (const point of subTypePoints(points)) { + const key = point.subType as string + groups.set(key, [...(groups.get(key) ?? []), point]) + } + + return [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, grouped]) => ({ key, points: grouped })) +} + +export { + acrColorAt, + buildChartRows, + countOf, + endOfDay, + formatDateForApi, + getSeriesColors, + granularitiesForRange, + toUtcWallClockMs, + groupBySubType, + parseMetricData, + plainPoints, + resolveGranularity, + startOfDay, + subTypePoints, + sumCounts, + toMetricPoints, +} diff --git a/admin-ui/plugins/fido/plugin-metadata.ts b/admin-ui/plugins/fido/plugin-metadata.ts index 3ba4c36f5d..8308c4dd8a 100644 --- a/admin-ui/plugins/fido/plugin-metadata.ts +++ b/admin-ui/plugins/fido/plugin-metadata.ts @@ -6,6 +6,7 @@ import { createLazyRoute } from '@/utils/RouteLoader' const Fido = createLazyRoute(() => import('./components/Configuration')) const MetricsPage = createLazyRoute(() => import('./components/Metrics')) const SecurityMonitorPage = createLazyRoute(() => import('./components/SecurityMonitor')) +const AuthMetricsPage = createLazyRoute(() => import('./components/AuthMetrics')) const pluginMetadata = { menus: [ @@ -32,6 +33,12 @@ const pluginMetadata = { action: CEDAR_ACTIONS.READ, resourceKey: ADMIN_UI_RESOURCES.FIDO, }, + { + title: 'menus.auth_metrics', + path: ROUTES.FIDO_AUTH_METRICS, + action: CEDAR_ACTIONS.READ, + resourceKey: ADMIN_UI_RESOURCES.FIDO, + }, ], }, ], @@ -54,6 +61,12 @@ const pluginMetadata = { action: CEDAR_ACTIONS.READ, resourceKey: ADMIN_UI_RESOURCES.FIDO, }, + { + component: AuthMetricsPage, + path: ROUTES.FIDO_AUTH_METRICS, + action: CEDAR_ACTIONS.READ, + resourceKey: ADMIN_UI_RESOURCES.FIDO, + }, ], reducers: [], } From fccbc706b344aebe7cb9671a785fdf547c741640 Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Thu, 20 Aug 2026 18:19:03 +0500 Subject: [PATCH 2/2] coderabbit fixes Signed-off-by: faisalsiddique4400 --- .../GluuDatePicker/GluuDatePicker.style.ts | 6 +- .../GluuDatePicker/GluuDatePicker.tsx | 1 + .../app/components/GluuDatePicker/types.ts | 4 -- admin-ui/app/constants/ui.ts | 2 +- admin-ui/app/locales/en/translation.json | 1 + admin-ui/app/locales/es/translation.json | 1 + admin-ui/app/locales/fr/translation.json | 1 + admin-ui/app/locales/pt/translation.json | 1 + admin-ui/app/utils/dayjsUtils.ts | 4 -- .../MAU/components/DateRangeSelector.style.ts | 16 +---- .../MAU/components/DateRangeSelector.tsx | 2 - .../admin/components/MAU/types/MauTypes.ts | 6 -- .../AuthMetrics/AuthMetricsPage.style.ts | 6 -- .../AuthMetrics/AuthMetricsPage.tsx | 68 +++++++++---------- .../__tests__/AuthMetricsKpiStrip.test.tsx | 26 ++++++- .../__tests__/fetchAllMetricEntries.test.ts | 7 -- .../AuthMetrics/__tests__/utils.test.ts | 57 ++++++++-------- .../components/AcrBreakdownChart.tsx | 6 -- .../components/AuthActivityChart.tsx | 2 - .../components/AuthMetricsKpiStrip.tsx | 17 +++-- .../components/GranularityMenu.style.ts | 12 ---- .../components/GranularityMenu.tsx | 8 --- .../components/TokenIssuanceChart.tsx | 2 - .../fido/components/AuthMetrics/constants.ts | 29 -------- .../AuthMetrics/hooks/useAuthMetricsCharts.ts | 22 +----- .../AuthMetrics/hooks/useMetricSeries.ts | 10 --- .../AuthMetrics/types/AuthMetricsTypes.ts | 9 --- .../components/AuthMetrics/types/JsonTypes.ts | 3 - .../fido/components/AuthMetrics/utils.ts | 40 ----------- 29 files changed, 107 insertions(+), 262 deletions(-) diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts index 7f6ad61930..05ab6a0804 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts @@ -114,6 +114,7 @@ const buildTextFieldSx = ( '& .MuiInputBase-root, & .MuiPickersInputBase-root': { color: tc.inputTextColor, backgroundColor: tc.inputBackground, + borderRadius: `${BORDER_RADIUS.SMALL}px`, ...(inputHeight != null ? { height: inputHeight, minHeight: inputHeight } : {}), // With the calendar icon hidden on mobile, center the date in the freed space. ...(forceIcon ? {} : { [HIDE_ICON_QUERY]: { justifyContent: 'center' } }), @@ -133,7 +134,10 @@ const buildTextFieldSx = ( color: tc.inputTextColor, }, '& .MuiOutlinedInput-root, & .MuiPickersOutlinedInput-root': { - '& fieldset, & .MuiPickersOutlinedInput-notchedOutline': { borderColor: tc.borderColor }, + '& fieldset, & .MuiPickersOutlinedInput-notchedOutline': { + borderColor: tc.borderColor, + borderRadius: `${BORDER_RADIUS.SMALL}px`, + }, '&:hover fieldset, &:hover .MuiPickersOutlinedInput-notchedOutline': { borderColor: tc.borderColor, }, diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx index 63bfccf593..fa908c62e0 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx @@ -137,6 +137,7 @@ const GluuDatePicker = memo( prev.inputHeight === next.inputHeight && prev.textColor === next.textColor && prev.backgroundColor === next.backgroundColor && + prev.inputBackgroundColor === next.inputBackgroundColor && prev.minDate === next.minDate && prev.maxDate === next.maxDate && prev.disabled === next.disabled diff --git a/admin-ui/app/components/GluuDatePicker/types.ts b/admin-ui/app/components/GluuDatePicker/types.ts index 4fce1d77d1..2d8d741e46 100644 --- a/admin-ui/app/components/GluuDatePicker/types.ts +++ b/admin-ui/app/components/GluuDatePicker/types.ts @@ -34,11 +34,7 @@ type GluuDatePickerBase = { format?: string dateFormat?: string textColor?: string - // Sits behind the floating label so it does not collide with the outline. Named for what it - // backs, not for the field: use inputBackgroundColor to fill the field itself. backgroundColor?: string - // Fills the input. Left unset the field takes themeColors.inputBackground, which is right on a - // plain form; a field sitting in a toolbar may need to match the controls beside it instead. inputBackgroundColor?: string inputHeight?: number showTime?: boolean diff --git a/admin-ui/app/constants/ui.ts b/admin-ui/app/constants/ui.ts index 4ba9cbc323..ad0522e65f 100644 --- a/admin-ui/app/constants/ui.ts +++ b/admin-ui/app/constants/ui.ts @@ -310,7 +310,7 @@ export const SUMMARY_CARD = { } as const export const getSegmentedButtonStyle = (isFirst: boolean, isLast: boolean) => { - const radius = BORDER_RADIUS.SMALL_MEDIUM + const radius = BORDER_RADIUS.SMALL return { borderRadius: isFirst ? `${radius}px 0 0 ${radius}px` diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 83a029ca98..ff97a4a5fd 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -834,6 +834,7 @@ "secondary_value": "Secondary value", "suspicious_ips": "Suspicious IP addresses", "auth_metrics_unavailable": "Metrics are unavailable. Check that the metric plugin is deployed and your token carries the metric.readonly scope.", + "auth_metrics_partial": "Some metric types could not be loaded. The figures below are incomplete.", "auth_activity_subtitle": "Successful and failed authentications over time.", "auth_by_acr_subtitle": "Successful authentications split by authentication context.", "token_issuance_subtitle": "Tokens and authorization codes issued over time.", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 141ae0b160..231d8a022c 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -834,6 +834,7 @@ "secondary_value": "Valor secundario", "suspicious_ips": "Direcciones IP sospechosas", "auth_metrics_unavailable": "Las métricas no están disponibles. Compruebe que el plugin de métricas esté desplegado y que su token tenga el permiso metric.readonly.", + "auth_metrics_partial": "No se pudieron cargar algunos tipos de métrica. Las cifras siguientes están incompletas.", "auth_activity_subtitle": "Autenticaciones correctas y fallidas a lo largo del tiempo.", "auth_by_acr_subtitle": "Autenticaciones correctas divididas por contexto de autenticación.", "token_issuance_subtitle": "Tokens y códigos de autorización emitidos a lo largo del tiempo.", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 805e00df9c..2c5cb2059f 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -948,6 +948,7 @@ "secondary_value": "Valeur secondaire", "suspicious_ips": "Adresses IP suspectes", "auth_metrics_unavailable": "Les métriques sont indisponibles. Vérifiez que le plugin de métriques est déployé et que votre jeton possède la portée metric.readonly.", + "auth_metrics_partial": "Certains types de métrique n'ont pas pu être chargés. Les chiffres ci-dessous sont incomplets.", "auth_activity_subtitle": "Authentifications réussies et échouées au fil du temps.", "auth_by_acr_subtitle": "Authentifications réussies réparties par contexte d'authentification.", "token_issuance_subtitle": "Jetons et codes d'autorisation émis au fil du temps.", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index c8da8e6681..0c45bc2c23 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -944,6 +944,7 @@ "secondary_value": "Valor secundário", "suspicious_ips": "Endereços IP suspeitos", "auth_metrics_unavailable": "As métricas não estão disponíveis. Verifique se o plugin de métricas está implementado e se o seu token tem o âmbito metric.readonly.", + "auth_metrics_partial": "Não foi possível carregar alguns tipos de métrica. Os números abaixo estão incompletos.", "auth_activity_subtitle": "Autenticações bem-sucedidas e falhadas ao longo do tempo.", "auth_by_acr_subtitle": "Autenticações bem-sucedidas divididas por contexto de autenticação.", "token_issuance_subtitle": "Tokens e códigos de autorização emitidos ao longo do tempo.", diff --git a/admin-ui/app/utils/dayjsUtils.ts b/admin-ui/app/utils/dayjsUtils.ts index 138e0f0349..f70259a0cf 100644 --- a/admin-ui/app/utils/dayjsUtils.ts +++ b/admin-ui/app/utils/dayjsUtils.ts @@ -75,10 +75,6 @@ export const createDate = ( return dayjs(date) } -// For APIs that work wholly in UTC. A value carrying an offset is converted to UTC; one without an -// offset is read as UTC rather than as the viewer's local time. That matches how such a server -// parses the dates it is sent, so a range requested and the timestamps returned stay on one clock. -// Use createDate instead wherever the API speaks the viewer's local time. export const createUtcDate = (date?: string | number | Date | Dayjs | null): Dayjs => { if (date == null) { return dayjs.utc() diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts index 64ec5b5f13..af4c74cab9 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts @@ -6,14 +6,12 @@ import { SHARED_DROPDOWN_STYLES } from '@/components/GluuDropdown/sharedDropdown const PRESET_BUTTON_MIN_WIDTH = SEGMENTED_CONTROL.BUTTON_MIN_WIDTH const VIEW_BUTTON_MIN_WIDTH = 96 -// Above the cards the menu overhangs, and matching the shared dropdown keeps the two consistent -// where both could be open on one screen. const PRESET_MENU_Z_INDEX = SHARED_DROPDOWN_STYLES.menuZIndex const PRESET_MENU_GAP = SHARED_DROPDOWN_STYLES.margin export const VIEW_BUTTON_STYLE = { minWidth: VIEW_BUTTON_MIN_WIDTH, - borderRadius: BORDER_RADIUS.SMALL_MEDIUM, + borderRadius: BORDER_RADIUS.SMALL, fontFamily, fontStyle: 'normal' as const, lineHeight: lineHeights.normal, @@ -21,9 +19,6 @@ export const VIEW_BUTTON_STYLE = { } export const getPresetButtonStyle = (isFirst: boolean, isLast: boolean) => { - // The border overlap moves to the slot: buttons sit inside positioned wrappers now, and a - // negative margin on the button would shift it within its own slot rather than pull neighbouring - // slots together, leaving a one-pixel seam and a doubled border between segments. const segmented = getSegmentedButtonStyle(isFirst, isLast) return { minWidth: PRESET_BUTTON_MIN_WIDTH, ...segmented, marginLeft: 0 } @@ -68,9 +63,6 @@ const useStyles = makeStyles()((theme) => ({ justifyContent: 'flex-end', }, }, - // Inside the controls column and left-aligned, which puts it on the same edge as the preset - // group: the column shrinks to its contents at md and up, so that edge tracks the controls - // however wide the heading gets. secondaryRow: { display: 'flex', alignItems: 'center', @@ -88,8 +80,6 @@ const useStyles = makeStyles()((theme) => ({ 'display': 'flex', 'gap': 0, 'width': '100%', - // Sizing sits on the slot rather than the button, so a menu can be anchored to one segment - // without the button losing the flex behaviour it had when it was a direct child. '& > *': { flex: 1, minWidth: 0, @@ -97,7 +87,6 @@ const useStyles = makeStyles()((theme) => ({ '& > * > button': { width: '100%', }, - // Carries the overlap that used to live on the button, collapsing adjacent borders. '& > * + *': { marginLeft: SEGMENTED_CONTROL.BORDER_OVERLAP, }, @@ -112,9 +101,6 @@ const useStyles = makeStyles()((theme) => ({ presetSlot: { position: 'relative', }, - // Hangs off the segment that was clicked, which is why the slot exists at all. Centred on that - // segment so the menu's arrow points back at the button that opened it, the same relationship the - // header dropdowns have with their triggers. presetMenu: { position: 'absolute', top: '100%', diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx index ad799d63ae..7e82126ea9 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx @@ -35,8 +35,6 @@ const DateRangeSelector: React.FC = ({ const themeColors = getThemeColor(selectedTheme) const { classes } = useStyles() - // The unselected preset fill doubles as the date field fill, so the whole filter row reads as one - // surface with only the active preset lifted out of it. const unselectedBg = themeColors.dashboard.supportCard ?? themeColors.menu.background const presetButtonBg = (isSelected: boolean) => isSelected ? themeColors.inputBackground : unselectedBg diff --git a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts index a3f22652ba..b646fbfafb 100644 --- a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts +++ b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts @@ -55,15 +55,9 @@ export type DateRangeSelectorProps = { onPresetSelect: (value: number) => void onApply: () => void isLoading?: boolean - // Heading and presets are overridable so other dashboards can mount the same control surface. - // `value` is deliberately unitless: MAU reads it as months, callers with shorter retention read - // it as days, and the selector itself never needs to know which. headingKey?: string presets?: readonly DateRangePreset[] applyLabelKey?: string - // Hung under one preset button rather than beside the group, so a control that qualifies the - // chosen range appears against the segment that set it. Anchored by preset value; nothing is - // rendered when the anchor matches no preset, which is how the menu stays closed. presetMenu?: ReactNode presetMenuAnchor?: number | null } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts index 612199b062..0b3eef46c6 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts @@ -17,8 +17,6 @@ interface StylesParams { themeColors: ThemeConfig } -// Five cards rather than the Security Monitor's four, so the widest breakpoint carries an extra -// column; every step below reuses that page's breakpoints so the two dashboards reflow together. const KPI_COLUMNS = 5 export const useAuthMetricsStyles = makeStyles()(( @@ -35,8 +33,6 @@ export const useAuthMetricsStyles = makeStyles()(( color: themeColors.fontColor, marginBottom: SPACING.CARD_GAP, }, - // The MAU DateRangeSelector brings its own grid and now hosts the granularity menu, so this - // only carries the gap down to the KPI strip. filterRow: { width: '100%', marginBottom: SPACING.CARD_GAP, @@ -93,8 +89,6 @@ export const useAuthMetricsStyles = makeStyles()(( width: '100%', marginBottom: SPACING.CARD_GAP, }, - // ResponsiveContainer measures its parent, so the canvas needs a height of its own. Shared - // with the Security Monitor charts so the cards line up when both pages are open. chartCanvas: { width: '100%', height: SECURITY_CHART_HEIGHT, diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx index 5b9ec7c56a..8be2d031c5 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx @@ -25,7 +25,7 @@ import type { Granularity, MetricRange } from './types' const AUTH_METRICS_RESOURCE_ID = ADMIN_UI_RESOURCES.FIDO -const startOfWindow = (days: number) => startOfDay(dayjs().subtract(days, 'day')) +const startOfWindow = (days: number) => startOfDay(dayjs().subtract(days - 1, 'day')) const endOfToday = () => endOfDay(dayjs()) @@ -43,22 +43,16 @@ const AuthMetricsPage: React.FC = () => { const [endDate, setEndDate] = useState(endOfToday) const [selectedPreset, setSelectedPreset] = useState(DEFAULT_SELECTED_RANGE_DAYS) const [granularity, setGranularity] = useState(DEFAULT_GRANULARITY) - // Held here rather than inside the dropdown so picking a preset can open it. Closed on load. const [isGranularityMenuOpen, setIsGranularityMenuOpen] = useState(false) - // Draft dates are held separately from the applied range so the charts only refetch on View, - // the same contract the MAU dashboard uses. const [appliedRange, setAppliedRange] = useState(() => ({ startDate: startOfWindow(DEFAULT_SELECTED_RANGE_DAYS).toDate(), endDate: endOfToday().toDate(), })) - // Snapped to day boundaries so picking a single date covers that whole day. The picker carries a - // time of day the user never chose, and left as-is it cut the first hours off the start date. const handleStartDateChange = useCallback((date: Dayjs | null) => { if (!date) return setStartDate(startOfDay(date)) - // Any hand-picked date leaves the presets, so none of them should read as active. setSelectedPreset(null) }, []) @@ -68,14 +62,10 @@ const AuthMetricsPage: React.FC = () => { setSelectedPreset(null) }, []) - // Presets carry days here rather than the MAU dashboard's months, since auth rows expire on - // metricReporterKeepDataDays. const handlePresetSelect = useCallback((days: number) => { setSelectedPreset(days) setStartDate(startOfWindow(days)) setEndDate(endOfToday()) - // A new range changes which granularities apply, so the menu opens on the new set instead of - // leaving the user to notice for themselves that the options moved under the collapsed label. setIsGranularityMenuOpen(true) }, []) @@ -83,21 +73,24 @@ const AuthMetricsPage: React.FC = () => { setAppliedRange({ startDate: startDate.toDate(), endDate: endDate.toDate() }) }, [startDate, endDate]) - // Follows the dates being edited rather than the applied range, so the toggle shows what the - // pending selection allows before View is pressed. Granularity is a client-side fold, so - // narrowing it re-buckets what is already loaded without waiting for a refetch. const allowedGranularities = useMemo( () => granularitiesForRange(startDate.toDate(), endDate.toDate()), [startDate, endDate], ) - // Resolved during render rather than corrected through an effect, which keeps `granularity` as a - // record of what the user last asked for: pick 5 min on 24 Hours, move to 30 Days and back, and - // 5 min returns instead of a reset default. const effectiveGranularity = resolveGranularity(granularity, allowedGranularities) - const { authRows, acrRows, acrSeries, tokenRows, totals, isBusy, isError, isTruncated } = - useAuthMetricsCharts({ range: appliedRange, granularity: effectiveGranularity }) + const { + authRows, + acrRows, + acrSeries, + tokenRows, + totals, + isBusy, + isError, + isPartial, + isTruncated, + } = useAuthMetricsCharts({ range: appliedRange, granularity: effectiveGranularity }) const granularityLabelOf = useCallback( (value: Granularity) => t(`fields.granularity_${value.toLowerCase()}`), @@ -129,9 +122,6 @@ const AuthMetricsPage: React.FC = () => { onPresetSelect={handlePresetSelect} onApply={handleApply} isLoading={isBusy} - // Hangs under whichever preset was clicked rather than standing as its own control: - // which buckets are available is a property of the range, so the choice belongs - // against the button that set it. presetMenuAnchor={isGranularityMenuOpen ? selectedPreset : null} presetMenu={ { ) : null} - {/* Stated outright: a capped page walk means the totals below undercount, and a silent - short chart is exactly the failure this replaced. */} + {!isError && isPartial ? ( + + {t('fields.auth_metrics_partial')} + + ) : null} + {!isError && isTruncated ? ( {t('fields.auth_metrics_truncated')} ) : null} - - -
- -
-
- -
-
- -
+ {isError ? null : ( + <> + + +
+ +
+
+ +
+
+ +
+ + )} diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx index 273c8b37f3..efe65f4267 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx @@ -13,7 +13,6 @@ const totals: Totals = { acrCount: 2, } -// The strip reads theme colours through useChartTheme, so it needs the provider the app supplies. const renderStrip = (props: Totals) => render( @@ -37,8 +36,6 @@ describe('AuthMetricsKpiStrip', () => { expect(screen.getByText('97.2%')).toBeInTheDocument() }) - // A flat 0% with nothing recorded would read as every authentication having failed, which is a - // very different claim from having no data. it('shows a dash rather than 0% when no attempts were recorded', () => { renderStrip({ attempts: 0, success: 0, failure: 0, successRate: null, acrCount: 0 }) @@ -46,3 +43,26 @@ describe('AuthMetricsKpiStrip', () => { expect(screen.queryByText('0.0%')).not.toBeInTheDocument() }) }) + +describe('AuthMetricsKpiStrip number locale', () => { + const formatIn = (language: string, value: number) => + new Intl.NumberFormat(language).format(value) + + it('groups digits differently in Spanish than in English', () => { + expect(formatIn('en', 12345)).toBe('12,345') + expect(formatIn('es', 12345)).toBe('12.345') + }) + + it('renders totals using the resolved app language, not the browser default', async () => { + const i18n = (await import('@/i18n')).default + await i18n.changeLanguage('es') + + try { + renderStrip({ ...totals, attempts: 12345 }) + + expect(screen.getByText(formatIn(i18n.resolvedLanguage as string, 12345))).toBeInTheDocument() + } finally { + await i18n.changeLanguage('en') + } + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts index f87bee9498..a348cb0764 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts @@ -14,8 +14,6 @@ const row = (index: number): MetricDataEntry => ({ data: { count: 1 }, }) -// Serves `total` rows PAGE_SIZE.SERIES at a time, recording the params of every call so the walk -// itself can be asserted rather than only its result. const pagedFetcher = (total: number) => { const calls: GetMetricEntriesParams[] = [] @@ -49,8 +47,6 @@ describe('fetchAllMetricEntries', () => { expect(calls).toHaveLength(1) }) - // The bug this replaced: a seven-day window holds roughly 2,000 five-minute rows against a - // 500-row page, so one request charted a fraction of the range and every KPI total undercounted. it('walks every page so a multi-page window is counted in full', async () => { const total = PAGE_SIZE.SERIES * 3 + 17 const { fetcher, calls } = pagedFetcher(total) @@ -69,8 +65,6 @@ describe('fetchAllMetricEntries', () => { ]) }) - // Ascending order keeps paging stable: rows written mid-walk append instead of shifting - // everything already read, which a descending sort would do. it('pages in ascending start-date order', async () => { const { fetcher, calls } = pagedFetcher(5) @@ -90,7 +84,6 @@ describe('fetchAllMetricEntries', () => { expect(result.totalCount).toBe(total) }) - // A server that over-reports its total would otherwise keep the walk running to the ceiling. it('stops on an empty page even when the reported total is larger', async () => { const calls: GetMetricEntriesParams[] = [] const fetcher = (params: GetMetricEntriesParams): Promise => { diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts index e5cbf78fb7..dfc57cce1e 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts @@ -16,13 +16,9 @@ import { toMetricPoints, } from 'Plugins/fido/components/AuthMetrics/utils' -// Buckets are measured from the start of the window, so every buildChartRows call needs one. -// Fixed at a UTC midnight, which is where a snapped range always begins. const ANCHOR = Date.UTC(2026, 7, 20, 0, 0, 0) describe('formatDateForApi', () => { - // Regression: toISOString() converted to UTC, so an admin at UTC+5 who picked the 20th had the - // window start at 19:00 on the 19th. The wall-clock time the user chose has to survive intact. it('sends the selected wall-clock time, with no UTC conversion', () => { const picked = new Date(2026, 7, 20, 0, 0, 0) @@ -35,9 +31,6 @@ describe('formatDateForApi', () => { }) describe('UTC handling of server timestamps', () => { - // MetricEntry.startDate is a plain java.util.Date with no @JsonFormat, so whether a Z reaches us - // is decided by the server's Jackson config. MetricDateUtil reads a bare value as UTC and - // normalizes an offset to UTC, so all three shapes have to land on the same instant here. const shapes = [ ['bare, no offset', '2026-08-20T06:50:00'], ['Z suffix', '2026-08-20T06:50:00.000Z'], @@ -59,8 +52,6 @@ describe('UTC handling of server timestamps', () => { expect(point.label).toBe('01:50') }) - // Daily buckets have to break on the server's midnight. Bucketing in the viewer's zone would put - // these two rows in different bars for anyone east or west of UTC. it('cuts daily buckets at UTC midnight, not the viewer local midnight', () => { const rows = buildChartRows( [ @@ -85,14 +76,40 @@ describe('UTC handling of server timestamps', () => { }) }) +describe('preset window boundaries', () => { + const startOfWindow = (days: number) => startOfDay(createDate().subtract(days - 1, 'day')) + + it.each([ + ['24 Hours', 1], + ['7 Days', 7], + ['30 Days', 30], + ])('has the %s preset cover exactly that many calendar dates', (_name, days) => { + const start = startOfWindow(days) + const end = endOfDay(createDate()) + + expect(end.diff(start, 'day') + 1).toBe(days) + }) + + it.each([ + ['24 Hours', 1, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['7 Days', 7, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['30 Days', 30, ['DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_15', 'DAYS_21', 'DAYS_30']], + ])('keeps the %s preset in its own granularity tier', (_name, days, expected) => { + const granularities = granularitiesForRange( + startOfWindow(days).toDate(), + endOfDay(createDate()).toDate(), + ) + + expect(granularities).toEqual(expected) + }) +}) + describe('granularitiesForRange', () => { const spanOf = (days: number): [Date, Date] => [ new Date(2026, 7, 20 - days, 0, 0, 0), new Date(2026, 7, 20, 23, 59, 0), ] - // Keyed off the span, not off which preset was clicked, so a hand-picked range of the same - // length is offered exactly the same choices. it.each([ ['a single day', 0, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], ['the 24 Hours preset', 1, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], @@ -103,8 +120,6 @@ describe('granularitiesForRange', () => { expect(granularitiesForRange(...spanOf(days))).toEqual(expected) }) - // A reversed range is transient while the user edits the second date; it must not empty the - // toggle, which would leave nothing selectable. it('falls back to the finest tier when the range is reversed', () => { const [start, end] = spanOf(7) @@ -113,7 +128,6 @@ describe('granularitiesForRange', () => { }) describe('multi-unit bucket widths', () => { - // One row per hour across two days, so every bucket width below has something to fold. const hourlyRows = Array.from({ length: 48 }, (_, hour) => ({ startDate: new Date(ANCHOR + hour * 3_600_000).toISOString(), data: { count: 1 }, @@ -141,7 +155,6 @@ describe('multi-unit bucket widths', () => { expect(rows[0].success).toBe(expectedPerBucket) }) - // Whatever the width, nothing may be dropped or counted twice on the way into the buckets. it.each(['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24', 'DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_30'])( 'preserves the total at %s', (granularity) => { @@ -151,8 +164,6 @@ describe('multi-unit bucket widths', () => { }, ) - // Buckets run from the start of the window, not from the epoch, so a range opening mid-month - // still gets its first bucket at its own first row. it('measures buckets from the range start rather than from the epoch', () => { const [first] = rowsAt('DAYS_3') @@ -165,15 +176,12 @@ describe('resolveGranularity', () => { expect(resolveGranularity('DAILY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') }) - // Picking 30 Days while on an hourly bucket has to land somewhere valid, not on an empty chart. it('falls back to the finest allowed when the choice is out of range', () => { expect(resolveGranularity('HOURLY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') }) }) describe('startOfDay and endOfDay', () => { - // The date picker hands back the day the user clicked carrying a time of day they never chose, - // so a single-day range silently skipped the hours before it and after the end. it('widens a mid-afternoon pick to cover the whole day', () => { const picked = createDate(new Date(2026, 7, 20, 14, 37, 12)) @@ -190,7 +198,6 @@ describe('startOfDay and endOfDay', () => { }) describe('parseMetricData', () => { - // Aggregations deliver `data` as a JSON string, entries as an object; both must work. it('parses a JSON string payload', () => { expect(parseMetricData('{"success":10,"failure":2}')).toEqual({ success: 10, failure: 2 }) }) @@ -214,14 +221,12 @@ describe('parseMetricData', () => { expect(parseMetricData({ label: 'basic', ok: true, bad: 'abc', empty: '' })).toEqual({}) }) - // The shape is undocumented, so bad input must not throw during a render. it.each([['{not json'], [null], [undefined], ['']])('returns no values for %p', (input) => { expect(parseMetricData(input)).toEqual({}) }) }) describe('toMetricPoints', () => { - // Typed as the aggregation entry, whose data is a JSON string, matching what the endpoint sends. const entry = (startDate: string, data: string): MetricAggregationEntry => ({ startDate, applicationType: 'jans_auth', @@ -264,8 +269,6 @@ describe('toMetricPoints', () => { }) }) -// Built through a typed factory rather than cast: the repo bans the top type, and naming the -// three fields the parser actually reads keeps the fixtures honest. const entry = (startDate: string, count: number, metricSubType?: string): MetricDataEntry => ({ startDate, metricSubType, @@ -273,8 +276,6 @@ const entry = (startDate: string, count: number, metricSubType?: string): Metric }) describe('plainPoints and subTypePoints', () => { - // /metric/entries returns a plain row and a per-subtype row for the same window when subType is - // omitted. Adding both together is the one mistake that silently doubles every total. const points = toMetricPoints( [entry('2026-08-19T06:00:00Z', 6), entry('2026-08-19T06:00:00Z', 6, 'basic')], 'MMM-DD', @@ -336,8 +337,6 @@ describe('buildChartRows', () => { expect(rows.map((row) => row.success)).toEqual([2, 1]) }) - // Series keys are acr names straight from the API. Underscored axis fields are what stop a - // subtype called "label" from overwriting the x-axis value. it('keeps the axis fields intact when a series is named after one', () => { const rows = buildChartRows( [ diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx index 23cebbbd45..b7e48189a2 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx @@ -22,13 +22,9 @@ const AREA_FILL_OPACITY = 0.35 type AcrBreakdownChartProps = { rows: MetricChartRow[] - // Carries both the prefixed dataKey and the acr's own name, so the legend shows the acr while - // the row key stays collision-proof. series: NamedSeries[] } -// Stacked because the parts sum to total successful authentications; the interesting question is -// which acr carried the traffic, not how each one moved in isolation. const AcrBreakdownChart: React.FC = ({ rows, series }) => { const { t } = useTranslation() const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() @@ -60,8 +56,6 @@ const AcrBreakdownChart: React.FC = ({ rows, series }) = const isEmpty = rows.length === 0 || series.length === 0 - // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone - // point draws nothing at all, so markers come back once the series is sparse enough to need them. const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } return ( diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx index 469ccd6141..ac5df6adc9 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx @@ -51,8 +51,6 @@ const AuthActivityChart: React.FC = ({ rows }) => { const isEmpty = rows.length === 0 - // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone - // point draws nothing at all, so markers come back once the series is sparse enough to need them. const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } return ( diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx index 1a1fe1c797..c3aa92644f 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx @@ -15,29 +15,32 @@ type AuthMetricsKpiStripProps = { } const AuthMetricsKpiStrip: React.FC = ({ totals }) => { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { themeColors, isDark } = useChartTheme() const { classes } = useAuthMetricsStyles({ isDark, themeColors }) const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) - // An unknown rate is shown as a dash: printing 0% with no attempts recorded would read as - // every authentication having failed. + const formatCount = useMemo(() => { + const formatter = new Intl.NumberFormat(i18n.resolvedLanguage) + return (value: number) => formatter.format(value) + }, [i18n.resolvedLanguage]) + const successRateLabel = totals.successRate === null ? '—' : `${totals.successRate.toFixed(1)}%` const cards = [ - { label: t('fields.auth_attempts'), value: totals.attempts.toLocaleString() }, + { label: t('fields.auth_attempts'), value: formatCount(totals.attempts) }, { label: t('fields.auth_success'), - value: totals.success.toLocaleString(), + value: formatCount(totals.success), color: palette.success, }, { label: t('fields.auth_failure'), - value: totals.failure.toLocaleString(), + value: formatCount(totals.failure), color: totals.failure > 0 ? palette.failure : undefined, }, { label: t('fields.auth_success_rate'), value: successRateLabel }, - { label: t('fields.acr_in_use'), value: totals.acrCount.toLocaleString() }, + { label: t('fields.acr_in_use'), value: formatCount(totals.acrCount) }, ] return ( diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts index 81886d560d..c81fedfbc2 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts @@ -5,10 +5,6 @@ import { createBaseOptionStyles, } from '@/components/GluuDropdown/sharedDropdownStyles' -// Built from the shared dropdown tokens rather than a second set defined here, so this menu is the -// same object as the Theme and language dropdowns in the header. GluuDropdown itself is not used: -// it renders its own trigger, and here the trigger is the date preset button, which lives inside -// the shared DateRangeSelector. const OPTION_PADDING = '12px 16px' export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_theme, { isDark }) => { @@ -21,12 +17,9 @@ export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_them borderRadius: SHARED_DROPDOWN_STYLES.borderRadius, boxShadow: `0px 4px 11px 0px rgba(${hexToRgb(customColors.black)}, 0.05)`, padding: 0, - // Grows to the longest label rather than forcing it to wrap, with the shared minimum as a - // floor so a short set like Daily/All still reads as a menu and not a tooltip. width: 'max-content', minWidth: SHARED_DROPDOWN_STYLES.minWidth, maxHeight: SHARED_DROPDOWN_STYLES.maxHeight, - // Visible rather than hidden so the arrow, which sits outside the panel, is not clipped. overflow: 'visible', position: 'relative', }, @@ -36,7 +29,6 @@ export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_them overflowY: 'auto', overflowX: 'hidden', }, - // Points back at the preset that opened the menu, which is why the panel is centred on it. arrow: { 'position': 'absolute', 'top': '-15px', @@ -54,10 +46,6 @@ export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_them filter: `drop-shadow(0px -1px 2px rgba(${hexToRgb(customColors.black)}, 0.1))`, }, }, - // Carries the shared hover and `.selected` treatment, so the highlighted row reads exactly as - // the selected theme does in the header dropdown. The shared right padding reserves room for a - // trailing icon this menu does not use, and against two-word labels like "3 Weeks" it forced a - // line break, so the padding is evened up and wrapping is ruled out outright. option: { ...createBaseOptionStyles({ isDark, optionPadding: OPTION_PADDING }), whiteSpace: 'nowrap' as const, diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx index abaeca80ca..484d51b813 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx @@ -6,10 +6,6 @@ import { useSecurityTheme } from '../../SecurityMonitor/hooks' import { useGranularityMenuStyles } from './GranularityMenu.style' import type { Granularity, GranularityMenuProps } from '../types' -// Hangs under the date preset that opened it, so the granularities on offer read as belonging to -// the range just chosen. Deliberately not a standalone control: which buckets make sense is a -// property of the range, and a separate always-visible picker invited combinations that produce an -// unreadable chart. const GranularityMenu: React.FC = ({ options, value, @@ -20,8 +16,6 @@ const GranularityMenu: React.FC = ({ const { isDark } = useSecurityTheme() const { classes, cx } = useGranularityMenuStyles({ isDark }) - // Escape closes as well as an outside click; a menu that only the mouse can dismiss strands - // anyone who opened it from the keyboard. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onDismiss() @@ -54,8 +48,6 @@ const GranularityMenu: React.FC = ({ role="option" aria-selected={isSelected} tabIndex={0} - // `selected` is a plain class name because the shared option styles target - // `&.selected`, the same hook GluuDropdown uses for its own rows. className={cx(classes.option, isSelected && 'selected')} onClick={() => handleSelect(option.value)} onKeyDown={(event) => { diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx index a555d36b1d..134fb7ffde 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx @@ -64,8 +64,6 @@ const TokenIssuanceChart: React.FC = ({ rows }) => { const isEmpty = rows.length === 0 - // A coarse bucket can leave a handful of points, and ALL leaves exactly one. An unmarked lone - // point draws nothing at all, so markers come back once the series is sparse enough to need them. const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } return ( diff --git a/admin-ui/plugins/fido/components/AuthMetrics/constants.ts b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts index 98ca59e4c8..60af274044 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/constants.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts @@ -3,18 +3,12 @@ export const AUTH_METRICS_CACHE_CONFIG = { GC_TIME: 10 * 60 * 1000, } as const -// Rows per request. The spec states no maximum, so this stays conservative and the caller pages. export const PAGE_SIZE = { SERIES: 500, } as const -// Ceiling on the page walk, so a server that keeps reporting a larger total cannot loop forever. -// 200 pages covers roughly two months of five-minute rows, well past the retention window. export const MAX_ENTRY_PAGES = 200 -// Names taken verbatim from the auth server's MetricType enum rather than from /metric/types, -// which only lists types that already hold rows. A type absent from discovery is idle, not -// unsupported, so charting against the enum keeps a series present once its events start. export const METRIC_TYPES = { AUTH_SUCCESS: 'user_authentication_success', AUTH_FAILURE: 'user_authentication_failure', @@ -28,19 +22,13 @@ export const METRIC_TYPES = { LONG_LIVED_ACCESS_TOKEN: 'tkn_long_lived_access_token_count', } as const -// Every metric payload observed so far carries exactly one numeric leaf. export const COUNT_KEY = 'count' -// Underscored so a metric subtype can never collide with them: series keys are acr names taken -// straight from the API, and one literally called "label" would otherwise overwrite the axis. export const AXIS_KEYS = { TIMESTAMP: '__timestamp', LABEL: '__label', } as const -// Buckets are folded client-side because /metric/aggregations stays empty until its producer task -// is deployed. Rows arrive at the auth server's metricReporterInterval, five minutes on the test -// deployment, so an hour is the finest bucket offered here. export const GRANULARITIES = { HOURLY: 'HOURLY', HOURS_3: 'HOURS_3', @@ -57,14 +45,10 @@ export const GRANULARITIES = { const HOUR_MS = 3_600_000 const DAY_MS = 86_400_000 -// Bucket width. Every tier ends on a bucket as wide as its own range, which is the total for that -// range in a single point, so no separate "total" option is needed. export const GRANULARITY_STEP_MS = { [GRANULARITIES.HOURLY]: HOUR_MS, [GRANULARITIES.HOURS_3]: 3 * HOUR_MS, [GRANULARITIES.HOURS_12]: 12 * HOUR_MS, - // Same width as DAILY, kept separate only so the hour tier can end on "24 Hours" instead of - // switching units mid-list. The day tiers still read "Daily". [GRANULARITIES.HOURS_24]: DAY_MS, [GRANULARITIES.DAILY]: DAY_MS, [GRANULARITIES.DAYS_3]: 3 * DAY_MS, @@ -87,9 +71,6 @@ export const GRANULARITY_LABEL_FORMATS = { [GRANULARITIES.DAYS_30]: 'MMM-DD', } -// What each range may be viewed at, keyed by span in days rather than by which preset was clicked, -// so a hand-picked range of the same length is offered the same buckets. Ordered coarsest-last, and -// the first entry is what an out-of-range selection falls back to. export const GRANULARITY_TIERS = [ { maxSpanDays: 2, @@ -117,24 +98,14 @@ export const GRANULARITY_TIERS = [ }, ] as const -// The default range is a week, whose tier opens on daily. export const DEFAULT_GRANULARITY = GRANULARITIES.DAILY -// A raw row's own timestamp, at the reporter's own resolution. Independent of the chart bucket: -// this labels the entry that came back, not the bucket it later folds into. export const POINT_LABEL_FORMAT = 'MMM-DD HH:mm' -// Below this many points a line is drawn with its markers shown. Hiding them keeps a dense series -// clean, but a coarse bucket can leave two points or one, and a lone point with no marker draws -// nothing at all. export const SPARSE_SERIES_MAX_POINTS = 40 -// Opening window for the date filter. Retention is driven by metricReporterKeepDataDays, 15 days -// on the test deployment, so a much longer default would open on a mostly empty chart. export const DEFAULT_SELECTED_RANGE_DAYS = 7 -// Days rather than the MAU dashboard's months: auth rows expire on metricReporterKeepDataDays, -// so a quarter-length preset would ask for history the store has already dropped. export const DATE_PRESETS = [ { labelKey: 'fields.date_preset_24h', value: 1 }, { labelKey: 'fields.date_preset_7d', value: 7 }, diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts index 47c6c59c4c..e9410c3ef4 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts @@ -4,8 +4,6 @@ import { buildChartRows, groupBySubType, plainPoints, sumCounts, toUtcWallClockM import { useAllMetricEntries } from './useMetricSeries' import type { Granularity, MetricRange, NamedSeries } from '../types' -// Series keys are the recharts dataKeys, kept separate from the metric type names so a chart -// legend never has to render a raw jansMetricTyp string. const SERIES_KEYS = { SUCCESS: 'success', FAILURE: 'failure', @@ -15,8 +13,6 @@ const SERIES_KEYS = { AUTHORIZATION_CODE: 'authorizationCode', } as const -// Prefixed so an acr named after one of the fixed series keys, or after an axis field, cannot -// collide with it once the values share a row. const ACR_KEY_PREFIX = 'acr__' type UseAuthMetricsChartsArgs = { @@ -24,11 +20,7 @@ type UseAuthMetricsChartsArgs = { granularity: Granularity } -// Every chart on this page reads /metric/entries rather than /metric/aggregations: the raw rows -// arrive in five-minute buckets, finer than any aggregation period, and they are available now -// whereas the aggregation producer is not deployed. export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChartsArgs) => { - // Listed one call per type because hooks cannot run inside a loop or callback. const success = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_SUCCESS }) const failure = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_FAILURE }) const accessToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.ACCESS_TOKEN }) @@ -39,12 +31,8 @@ export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChart metricType: METRIC_TYPES.AUTHORIZATION_CODE, }) - // Multi-hour and multi-day buckets are measured from the start of the window, so every chart - // has to fold against the same anchor or their x-axes would not line up. const anchorMs = useMemo(() => toUtcWallClockMs(range.startDate), [range.startDate]) - // Only the plain rows: the endpoint also returns a per-subtype copy of the same window, so - // charting both together would double every total. const successTotals = useMemo(() => plainPoints(success.points), [success.points]) const failureTotals = useMemo(() => plainPoints(failure.points), [failure.points]) @@ -61,8 +49,6 @@ export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChart [successTotals, failureTotals, granularity, anchorMs], ) - // The ACR breakdown the security team asked for: jansMetricSubTyp carries the acr each - // successful authentication ran under. const acrSeries = useMemo( () => groupBySubType(success.points).map((series) => ({ @@ -109,8 +95,6 @@ export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChart success: successCount, failure: failureCount, attempts, - // Guarded rather than reported as zero: no attempts means the rate is unknown, and a flat - // 0% would read as total failure. successRate: attempts > 0 ? (successCount / attempts) * 100 : null, acrCount: acrSeries.length, } @@ -124,13 +108,9 @@ export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChart acrSeries, tokenRows, totals, - // Covers the refetch too, not just the first load: keepPreviousData holds the old series on - // screen while a new range loads, so a first-load-only flag would let View look inert. isBusy: queries.some((query) => query.isLoading || query.isFetching), - // Every query has to fail before the page calls itself unavailable; one idle metric type - // erroring should not hide the five that returned data. isError: queries.every((query) => query.isError), - // Surfaced rather than swallowed: a truncated walk means the totals below are incomplete. + isPartial: queries.some((query) => query.isError) && !queries.every((query) => query.isError), isTruncated: queries.some((query) => query.isTruncated), refetch: () => queries.forEach((query) => void query.refetch()), } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts index 34c95f0c03..502201d714 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts @@ -24,17 +24,12 @@ type EntriesFetcher = ( type AllEntriesResult = { entries: MetricDataEntry[] totalCount: number - // True when the page ceiling was hit before the server's total was reached. Callers must say so - // rather than render a total that silently under-reports. isTruncated: boolean } const defaultFetcher: EntriesFetcher = (params, signal) => getMetricEntries(params, undefined, signal) -// One page holds far fewer rows than a multi-day window at the reporter's five-minute interval — -// seven days is roughly 2,000 rows against a 500-row page — so every page is walked. Without this -// the newest page alone was charted and every KPI total under-reported. const fetchAllMetricEntries = async ( baseParams: GetMetricEntriesParams, { fetcher = defaultFetcher, signal }: { fetcher?: EntriesFetcher; signal?: AbortSignal } = {}, @@ -48,8 +43,6 @@ const fetchAllMetricEntries = async ( ...baseParams, startIndex: page * PAGE_SIZE.SERIES, limit: PAGE_SIZE.SERIES, - // Ascending keeps paging stable: rows written while we walk the pages land at the end - // instead of shifting everything we have already read, as a descending sort would. sortBy: 'jansStartDate', sortOrder: 'ascending', }, @@ -60,7 +53,6 @@ const fetchAllMetricEntries = async ( collected.push(...batch) totalCount = result?.totalEntriesCount ?? collected.length - // An empty page also ends the walk, so a server that misreports its total cannot spin here. if (batch.length === 0 || collected.length >= totalCount) { return { entries: collected, totalCount, isTruncated: false } } @@ -80,8 +72,6 @@ const sharedQueryConfig = { placeholderData: keepPreviousData, } -// Every row for one metric type across the window. /metric/aggregations is not used: it stays -// empty until its producer task is deployed, and raw rows are finer than any aggregation period. const useAllMetricEntries = ( args: { range: MetricRange diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts index 215fc15342..90e583065c 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts @@ -8,9 +8,6 @@ type MetricRange = { endDate: Date } -// The spec leaves `data` opaque: a JSON string on aggregations, an untyped JsonNode on entries. -// Every numeric leaf is captured by path so a chart can pick the key it needs once the real -// shape is known, rather than the parser guessing at field names up front. type MetricDataValues = Record type MetricPoint = { @@ -20,7 +17,6 @@ type MetricPoint = { metricType?: string subType?: string values: MetricDataValues - // Retained so an unrecognised payload can be inspected instead of silently dropped. raw: MetricRawData } @@ -28,17 +24,12 @@ type MetricQueryOptions = { enabled?: boolean } -// One series ready to be folded onto a shared time axis; `key` becomes the recharts dataKey and -// `label` is what a legend shows, so an acr name never has to double as a safe object key. type NamedSeries = { key: string label?: string points: MetricPoint[] } -// A recharts row: the underscored axis fields from AXIS_KEYS plus one numeric entry per series -// sharing the bucket. Addressed through an index signature because series keys are only known at -// runtime. type MetricChartRow = Record export type { diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts index 1d8d3ac899..6e63cc38f4 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts @@ -1,12 +1,9 @@ -// The metric plugin leaves `data` undescribed, so it is modelled as arbitrary JSON rather than -// `unknown`: concrete enough to recurse over safely, honest about being unvalidated. type JsonPrimitive = string | number | boolean | null type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } type JsonObject = { [key: string]: JsonValue } -// What either endpoint can put in `data`: a JSON string on aggregations, an object on entries. type MetricRawData = JsonValue | undefined export type { JsonObject, JsonPrimitive, JsonValue, MetricRawData } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/utils.ts b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts index 61efb98395..bead902456 100644 --- a/admin-ui/plugins/fido/components/AuthMetrics/utils.ts +++ b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts @@ -20,23 +20,12 @@ import type { NamedSeries, } from './types' -// Sent without an offset, which MetricDateUtil on the server reads as UTC verbatim. toISOString() -// would append Z after converting, so a UTC+5 admin picking the 20th had the window start at 19:00 -// on the 19th. The picked wall-clock is the UTC wall-clock, and the axis is rendered in UTC to -// match, so both directions stay on one clock. const formatDateForApi = (date: Date): string => toApiDatetime(createDate(date)) -// Both ends snap to the day they name, so a hand-picked date covers that whole day rather than -// starting at whatever time the picker happened to carry over. The presets already did this; the -// manual pickers did not, which made the two paths disagree for the same visible date. const startOfDay = (date: Dayjs): Dayjs => date.startOf('day') -// 23:59 rather than 23:59:59, because toApiDatetime truncates to the minute anyway. const endOfDay = (date: Dayjs): Dayjs => date.hour(23).minute(59).second(0).millisecond(0) -// The granularities a range may be viewed at. Driven by the span in days, so a hand-picked range -// behaves the same as a preset of the same length. A reversed range yields the finest tier rather -// than an empty list, leaving the toggle usable while the user is still mid-edit. const granularitiesForRange = (startDate: Date, endDate: Date): readonly Granularity[] => { const spanDays = createDate(endDate).diff(createDate(startDate), 'day') const tier = @@ -46,8 +35,6 @@ const granularitiesForRange = (startDate: Date, endDate: Date): readonly Granula return tier.granularities } -// The user's pick stands while the range allows it and is only overridden when it does not, so -// returning to a shorter range restores what they last chose rather than a reset default. const resolveGranularity = ( granularity: Granularity, allowed: readonly Granularity[], @@ -56,8 +43,6 @@ const resolveGranularity = ( const isRecord = (value: JsonValue | undefined): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value) -// Aggregations carry `data` as a JSON string, entries as an object. Neither is described by the -// spec, so a malformed payload yields no values rather than throwing mid-render. const toDataObject = (data: MetricRawData): JsonValue | undefined => { if (typeof data !== 'string') return data try { @@ -67,8 +52,6 @@ const toDataObject = (data: MetricRawData): JsonValue | undefined => { } } -// Numeric leaves are collected by dotted path so nested payloads stay addressable. Numeric -// strings are included because counters frequently arrive quoted. const collectNumericValues = ( value: JsonValue | undefined, prefix = '', @@ -94,9 +77,6 @@ const collectNumericValues = ( const parseMetricData = (data: MetricRawData): MetricDataValues => collectNumericValues(toDataObject(data)) -// MetricEntry.startDate is a bare java.util.Date with no @JsonFormat, so whether the wire value -// carries a Z is decided by the server's Jackson config rather than by the API contract. Read as -// UTC either way: that is the one clock the metric endpoints store and filter in. const toTimestamp = (value?: string): number => { if (!value) return 0 const parsed = createUtcDate(value) @@ -115,8 +95,6 @@ const toMetricPoint = ( appType: entry.applicationType, metricType: entry.metricType, subType: entry.metricSubType, - // The generated JsonNode is an index signature of unknown; this is the one place the - // untyped payload crosses into the typed domain, so the cast is made explicitly here. values: parseMetricData(entry.data as MetricRawData), raw: entry.data as MetricRawData, } @@ -130,8 +108,6 @@ const toMetricPoints = ( .map((entry) => toMetricPoint(entry, labelFormat)) .sort((a, b) => a.timestamp - b.timestamp) -// Series colours come from the Security Monitor palette rather than a second set defined here, so -// success and failure read the same across both FIDO dashboards in either theme. const getSeriesColors = (themeColors: ThemeConfig) => { const { chart } = getSecurityPalette(themeColors) @@ -145,16 +121,11 @@ const getSeriesColors = (themeColors: ThemeConfig) => { } } -// ACR names are discovered at runtime, which is exactly what errorCategories exists for: a themed -// list long enough for arbitrary categories. Cycled so an unexpected count repeats a colour rather -// than rendering an invisible series. const acrColorAt = (themeColors: ThemeConfig, index: number): string => { const { errorCategories } = getSecurityPalette(themeColors) return errorCategories[index % errorCategories.length] } -// With subType omitted the endpoint returns both plain and per-subtype rows for the same window. -// Summing them together double counts, so every caller has to pick a side deliberately. const plainPoints = (points: readonly MetricPoint[]): MetricPoint[] => points.filter((point) => !point.subType) @@ -166,23 +137,14 @@ const countOf = (point: MetricPoint): number => point.values[COUNT_KEY] ?? 0 const sumCounts = (points: readonly MetricPoint[]): number => points.reduce((total, point) => total + countOf(point), 0) -// The request carries local wall-clock that the server reads as UTC, and rows come back on that -// same clock, so the bucket anchor has to be expressed in it too. Anchoring off a local Date would -// phase every multi-hour bucket by the viewer's offset. const toUtcWallClockMs = (date: Date): number => createUtcDate(formatDateForApi(date)).valueOf() -// Buckets are measured from the start of the range rather than from the epoch, so "3 Days" means -// three days into the window the user asked for instead of an arbitrary offset inherited from 1970. -// The widest bucket a tier offers spans its whole range, so it lands everything on the anchor and -// gives the range total in one point. const bucketStart = (timestamp: number, granularity: Granularity, anchorMs: number): number => { const step = GRANULARITY_STEP_MS[granularity] return anchorMs + Math.floor((timestamp - anchorMs) / step) * step } -// Folds several named series onto a shared time axis: one row per bucket, one key per series. -// Absent keys are zero-filled so a line never breaks where a neighbouring series has data. const buildChartRows = ( series: readonly NamedSeries[], granularity: Granularity, @@ -211,8 +173,6 @@ const buildChartRows = ( .map((row) => ({ ...zeroed, ...row })) } -// Each distinct subtype becomes its own series. Sorted so colour assignment stays stable between -// renders rather than following whatever order the API happened to return. const groupBySubType = (points: readonly MetricPoint[]): NamedSeries[] => { const groups = new Map()