diff --git a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts index 675439ccda03..4ea298b3d860 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts @@ -291,7 +291,9 @@ export const CONFIGURATION_CONFIRM_DIALOG_KEY = 'confirmDialog'; export enum HealthStatusTypes { OK = 'OK', NOT_CONFIGURED = 'NOT_CONFIGURED', - CONFIGURATION_ERROR = 'CONFIGURATION_ERROR' + CONFIGURATION_ERROR = 'CONFIGURATION_ERROR', + AVAILABLE = 'AVAILABLE', + NOT_AVAILABLE = 'NOT_AVAILABLE' } export const RUNNING_UNTIL_DATE_FORMAT = 'EEE, LLL dd'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts index cf9752eb3e33..21fe4a9a5409 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts @@ -10,6 +10,12 @@ export const TIME_RANGE_CUBEJS_MAPPING = { last30days: 'from 30 days ago to now' } as const; +/** Maps internal time range options to the new analytics event API `range` param */ +export const TIME_RANGE_API_MAPPING: Record = { + [TIME_RANGE_OPTIONS.last7days]: 'last_7_days', + [TIME_RANGE_OPTIONS.last30days]: 'last_30_days' +} as const; + /** Maps time range options to comparison label days count */ export const TIME_RANGE_DAYS_MAP: Record = { [TIME_RANGE_OPTIONS.last7days]: 7, diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts index 62b1c1657654..886a00b741af 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts @@ -1,17 +1,28 @@ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { catchError, map, shareReplay } from 'rxjs/operators'; -import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; +import { DotCMSResponse, HealthStatusTypes } from '@dotcms/dotcms-models'; + +import { + AnalyticsApiResponse, + AnalyticsEventResponse, + ApiGranularity, + ApiRangeParams, + CubeJSQuery, + DeviceBrowserData, + TopContentData, + TotalEventsByDayData, + TotalEventsData, + UniqueVisitorsByDayData, + UniqueVisitorsData +} from '../../index'; /** - * Generic analytics service for CubeJS queries. - * - * This service provides a single method to execute any CubeJS query. - * Query construction is handled by the store using the CubeQueryBuilder. + * Generic analytics service for CubeJS queries and health checks. * * @example * ```typescript @@ -22,7 +33,7 @@ import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; * .siteId(siteId) * .build(); * - * analyticsService.query(query).pipe( + * analyticsService.cubeQuery(query).pipe( * map(entities => entities[0]) * ); * ``` @@ -32,8 +43,156 @@ import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; }) export class DotAnalyticsService { readonly #BASE_URL = '/api/v1/analytics/content/_query/cube'; + readonly #EVENT_URL = '/api/v1/analytics/event'; + readonly #HEALTH_URL = '/api/v1/analytics/check'; readonly #http = inject(HttpClient); + #healthCache$: Observable | null = null; + + /** + * Checks analytics availability via the health endpoint. + * Always makes a fresh HTTP request. + * + * @returns Observable of HealthStatusTypes (AVAILABLE or NOT_AVAILABLE) + */ + healthCheck(): Observable { + return this.#http.get<{ available: string }>(this.#HEALTH_URL).pipe( + map((response) => + response.available === 'true' + ? HealthStatusTypes.AVAILABLE + : HealthStatusTypes.NOT_AVAILABLE + ), + catchError(() => of(HealthStatusTypes.AVAILABLE)) + ); + } + + /** + * Cached version of healthCheck. Uses shareReplay to avoid + * multiple HTTP calls across guards/components in the same navigation. + * + * @returns Observable of HealthStatusTypes (cached) + */ + healthCheckWithCache(): Observable { + if (!this.#healthCache$) { + this.#healthCache$ = this.healthCheck().pipe(shareReplay(1)); + } + + return this.#healthCache$; + } + + /** + * Clears the cached health check result, forcing a fresh request on next call. + */ + clearHealthCache(): void { + this.#healthCache$ = null; + } + + /** + * Fetches total events from the new analytics event endpoint. + * Supports predefined ranges (`?range=last_7_days`) or custom dates (`?from=...&to=...`). + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @param granularity - Optional granularity (e.g. 'day', 'hour') + * @returns Observable of TotalEventsData (single object) or TotalEventsByDayData[] (array with granularity) + */ + getTotalEvents(rangeParams: ApiRangeParams): Observable; + getTotalEvents( + rangeParams: ApiRangeParams, + granularity: ApiGranularity + ): Observable; + getTotalEvents( + rangeParams: ApiRangeParams, + granularity?: ApiGranularity + ): Observable { + let params = this.#buildRangeParams(rangeParams); + if (granularity) { + params = params.set('granularity', granularity); + } + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/total-events`, { params }) + .pipe(map((response) => response.entity.data)); + } + + /** + * Fetches unique visitors from the new analytics event endpoint. + * Supports predefined ranges (`?range=last_7_days`) or custom dates (`?from=...&to=...`). + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @param granularity - Optional granularity (e.g. 'day', 'hour') + * @returns Observable of UniqueVisitorsData (single object) or UniqueVisitorsByDayData[] (array with granularity) + */ + getUniqueVisitors(rangeParams: ApiRangeParams): Observable; + getUniqueVisitors( + rangeParams: ApiRangeParams, + granularity: ApiGranularity + ): Observable; + getUniqueVisitors( + rangeParams: ApiRangeParams, + granularity?: ApiGranularity + ): Observable { + let params = this.#buildRangeParams(rangeParams); + if (granularity) { + params = params.set('granularity', granularity); + } + + return this.#http + .get< + DotCMSResponse< + AnalyticsEventResponse + > + >(`${this.#EVENT_URL}/unique-visitors`, { params }) + .pipe(map((response) => response.entity.data)); + } + + /** + * Fetches top content from the new analytics event endpoint. + * Returns an array of content items ordered by total events descending. + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @returns Observable of TopContentData[] + */ + getTopContent(rangeParams: ApiRangeParams): Observable { + const params = this.#buildRangeParams(rangeParams); + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/top-content`, { params }) + .pipe(map((response) => response.entity.data)); + } + + /** + * Fetches pageviews by device and browser from the new analytics event endpoint. + * Returns an array of items with browser, device, and total count. + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @returns Observable of DeviceBrowserData[] + */ + getPageviewsByDeviceBrowser(rangeParams: ApiRangeParams): Observable { + const params = this.#buildRangeParams(rangeParams); + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/pageviews-by-device-browser`, { params }) + .pipe(map((response) => response.entity.data)); + } + + #buildRangeParams(rangeParams: ApiRangeParams): HttpParams { + let params = new HttpParams(); + if ('range' in rangeParams) { + params = params.set('range', rangeParams.range); + } else { + params = params.set('from', rangeParams.from); + params = params.set('to', rangeParams.to); + } + + return params; + } + /** * Executes a CubeJS query and returns the entity array. * diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts index 9c50e8506a2e..7af9bad8f29b 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts @@ -1,6 +1,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { format } from 'date-fns'; import { pipe } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; @@ -16,23 +17,22 @@ import { FiltersState } from './with-filters.feature'; import { DotAnalyticsService } from '../../services/dot-analytics.service'; import { - DEFAULT_COUNT_LIMIT, - DEFAULT_GRANULARITY, + DeviceBrowserData, PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, RequestState, TimeRangeInput, + TopContentData, + TotalEventsByDayData, + TotalEventsData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalPageViewsEntity, + UniqueVisitorsData, UniqueVisitorsEntity } from '../../types'; -import { createCubeQuery } from '../../utils/cube/cube-query-builder.util'; import { - createEmptyAnalyticsEntity, createInitialRequestState, - fillMissingDates, - toTimeRangeCubeJS + fillMissingApiDates, + toApiRangeParams } from '../../utils/data/analytics-data.utils'; /** @@ -43,9 +43,9 @@ export interface PageviewState { totalPageViews: RequestState; uniqueVisitors: RequestState; topPagePerformance: RequestState; - pageViewTimeLine: RequestState; + pageViewTimeLine: RequestState; pageViewDeviceBrowsers: RequestState; - topPagesTable: RequestState; + topPagesTable: RequestState; } /** @@ -92,17 +92,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['totalEvents']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return analyticsService.getTotalEvents(rangeParams).pipe( + map( + (data: TotalEventsData): TotalPageViewsEntity => ({ + totalEvents: data.totalEvents + }) + ), tapResponse({ next: (data) => { patchState(store, { @@ -145,17 +143,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['uniqueVisitors']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return analyticsService.getUniqueVisitors(rangeParams).pipe( + map( + (data: UniqueVisitorsData): UniqueVisitorsEntity => ({ + uniqueVisitors: data.uniqueVisitors + }) + ), tapResponse({ next: (data) => { patchState(store, { @@ -201,20 +197,23 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .dimensions(['identifier', 'title']) - .measures(['totalEvents']) - .siteId(currentSiteId) - .orderBy('totalEvents', 'desc') - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .limit(1) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); + + return analyticsService.getTopContent(rangeParams).pipe( + map((items: TopContentData[]): TopPagePerformanceEntity | null => { + if (!items?.length) { + return null; + } + + const top = items[0]; - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return { + identifier: top.identifier, + title: top.title, + totalEvents: top.totalEvents + }; + }), tapResponse({ next: (data) => { patchState(store, { @@ -260,23 +259,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['totalEvents']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange), DEFAULT_GRANULARITY) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => - fillMissingDates( - entities, - timeRange, - DEFAULT_GRANULARITY, - createEmptyAnalyticsEntity - ) + return analyticsService.getTotalEvents(rangeParams, 'day').pipe( + map((items) => + fillMissingApiDates(items, timeRange, 'day', (date) => ({ + day: format(date, 'yyyy-MM-dd'), + totalEvents: 0 + })) ), tapResponse({ next: (data) => { @@ -322,47 +313,43 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('request') - .pageviews() - .dimensions(['userAgent']) - .measures(['count']) - .siteId(currentSiteId) - .orderBy('totalRequest', 'desc') - .timeRange('createdAt', toTimeRangeCubeJS(timeRange)) - .limit(DEFAULT_COUNT_LIMIT) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService - .cubeQuery(query) - .pipe( - tapResponse({ - next: (data) => { - patchState(store, { - pageViewDeviceBrowsers: { - status: ComponentStatus.LOADED, - data, - error: null - } - }); - }, - error: (error: HttpErrorResponse) => { - const errorMessage = - error.message || - dotMessageService.get( - 'analytics.error.loading.device-breakdown' - ); - patchState(store, { - pageViewDeviceBrowsers: { - status: ComponentStatus.ERROR, - data: null, - error: errorMessage - } - }); - } - }) - ); + return analyticsService.getPageviewsByDeviceBrowser(rangeParams).pipe( + map((items: DeviceBrowserData[]): PageViewDeviceBrowsersEntity[] => + items.map((item) => ({ + browser: item.browser, + device: item.device, + total: item.total + })) + ), + tapResponse({ + next: (data) => { + patchState(store, { + pageViewDeviceBrowsers: { + status: ComponentStatus.LOADED, + data, + error: null + } + }); + }, + error: (error: HttpErrorResponse) => { + const errorMessage = + error.message || + dotMessageService.get( + 'analytics.error.loading.device-breakdown' + ); + patchState(store, { + pageViewDeviceBrowsers: { + status: ComponentStatus.ERROR, + data: null, + error: errorMessage + } + }); + } + }) + ); }) ) ), @@ -379,47 +366,36 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .dimensions(['identifier', 'title']) - .measures(['totalEvents']) - .siteId(currentSiteId) - .orderBy('totalEvents', 'desc') - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .limit(DEFAULT_COUNT_LIMIT) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService - .cubeQuery(query) - .pipe( - tapResponse({ - next: (data) => { - patchState(store, { - topPagesTable: { - status: ComponentStatus.LOADED, - data, - error: null - } - }); - }, - error: (error: HttpErrorResponse) => { - const errorMessage = - error.message || - dotMessageService.get( - 'analytics.error.loading.top-pages-table' - ); - patchState(store, { - topPagesTable: { - status: ComponentStatus.ERROR, - data: null, - error: errorMessage - } - }); - } - }) - ); + return analyticsService.getTopContent(rangeParams).pipe( + tapResponse({ + next: (data) => { + patchState(store, { + topPagesTable: { + status: ComponentStatus.LOADED, + data, + error: null + } + }); + }, + error: (error: HttpErrorResponse) => { + const errorMessage = + error.message || + dotMessageService.get( + 'analytics.error.loading.top-pages-table' + ); + patchState(store, { + topPagesTable: { + status: ComponentStatus.ERROR, + data: null, + error: errorMessage + } + }); + } + }) + ); }) ) ) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts new file mode 100644 index 000000000000..e8dc3fed9177 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts @@ -0,0 +1,57 @@ +/** + * Types for the new Analytics Event API (microservice). + * Separate from CubeJS types which will be removed in the future. + */ + +/** Granularity options for the new analytics event API */ +export type ApiGranularity = 'hour' | 'day' | 'week' | 'month'; + +/** + * Query params for the analytics event API. + * Either a predefined `range` OR both `from` + `to` (never partial). + * The API returns 400 if only one of from/to is provided. + */ +export type ApiRangeParams = { range: string } | { from: string; to: string }; + +/** Response wrapper from analytics event endpoints (entity.data field) */ +export interface AnalyticsEventResponse { + data: T; + params?: Record; + query?: Record; +} + +/** Total events (no granularity) */ +export interface TotalEventsData { + totalEvents: number; +} + +/** Total events by day (with granularity) */ +export interface TotalEventsByDayData { + day: string; + totalEvents: number; +} + +/** Unique visitors (no granularity) */ +export interface UniqueVisitorsData { + uniqueVisitors: number; +} + +/** Unique visitors by day (with granularity) */ +export interface UniqueVisitorsByDayData { + day: string; + uniqueVisitors: number; +} + +/** Top content item from /api/v1/analytics/event/top-content */ +export interface TopContentData { + identifier: string; + title: string; + totalEvents: number; +} + +/** Device browser item from /api/v1/analytics/event/pageviews-by-device-browser */ +export interface DeviceBrowserData { + browser: string; + device: string; + total: number; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index da7341ac3c19..5aac1f398a73 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -4,52 +4,35 @@ */ /** - * Total page views entity response + * Total page views entity response from the new analytics event API. */ export interface TotalPageViewsEntity { - 'EventSummary.totalEvents': string; + totalEvents: number; } /** - * Unique visitors entity response + * Unique visitors entity response from the new analytics event API. */ export interface UniqueVisitorsEntity { - 'EventSummary.uniqueVisitors': string; + uniqueVisitors: number; } /** - * Top page performance entity response + * Top page performance entity response from the new analytics event API. */ export interface TopPagePerformanceEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.title': string; - 'EventSummary.identifier': string; -} - -/** - * Top performance table entity response - */ -export interface TopPerformanceTableEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.title': string; - 'EventSummary.identifier': string; -} - -/** - * Page view timeline entity response - */ -export interface PageViewTimeLineEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.day': string; - 'EventSummary.day.day': string; + identifier: string; + title: string; + totalEvents: number; } /** - * Page view device browsers entity response + * Page view device browsers entity response from the new analytics event API. */ export interface PageViewDeviceBrowsersEntity { - 'request.count': string; - 'request.userAgent': string; + browser: string; + device: string; + total: number; } /** diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts index 5242aefd2687..5d7f12f22264 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts @@ -10,6 +10,9 @@ export * from './common.types'; // CubeJS query types export * from './cubequery.types'; +// New Analytics Event API types (microservice) +export * from './analytics-api.types'; + // API entity types export * from './engagement.types'; export * from './entities.types'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index a3b2c5130759..9c5f71001eb8 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -1,4 +1,4 @@ -import { addHours, endOfDay, format, startOfDay } from 'date-fns'; +import { format } from 'date-fns'; import { ComponentStatus } from '@dotcms/dotcms-models'; @@ -22,14 +22,16 @@ import { import { AnalyticsChartColors } from '../../constants'; import { Granularity } from '../../types'; +// eslint-disable-next-line no-duplicate-imports +import type { ConversionTrendEntity } from './analytics-data.utils'; // eslint-disable-next-line no-duplicate-imports import type { PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, TablePageData, + TopContentData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalConversionsEntity, + TotalEventsByDayData, TotalPageViewsEntity, UniqueVisitorsEntity } from '../../types'; @@ -64,7 +66,7 @@ describe('Analytics Data Utils', () => { describe('extractPageViews', () => { it('should extract page views from valid data', () => { const mockData: TotalPageViewsEntity = { - 'EventSummary.totalEvents': '1250' + totalEvents: 1250 }; const result = extractPageViews(mockData); @@ -76,16 +78,18 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return null when totalRequest is missing', () => { - const mockData: Partial = {}; + it('should return null when totalEvents is zero', () => { + const mockData: TotalPageViewsEntity = { + totalEvents: 0 + }; - const result = extractPageViews(mockData as TotalPageViewsEntity); + const result = extractPageViews(mockData); expect(result).toBeNull(); }); - it('should handle string numbers correctly', () => { + it('should handle numeric values correctly', () => { const mockData: TotalPageViewsEntity = { - 'EventSummary.totalEvents': '5000' + totalEvents: 5000 }; const result = extractPageViews(mockData); @@ -96,7 +100,7 @@ describe('Analytics Data Utils', () => { describe('extractSessions', () => { it('should extract sessions from valid data', () => { const mockData: UniqueVisitorsEntity = { - 'EventSummary.uniqueVisitors': '342' + uniqueVisitors: 342 }; const result = extractSessions(mockData); @@ -108,20 +112,22 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return NaN when totalUsers is missing', () => { - const mockData: Partial = {}; + it('should return null when uniqueVisitors is zero', () => { + const mockData: UniqueVisitorsEntity = { + uniqueVisitors: 0 + }; const result = extractSessions(mockData as UniqueVisitorsEntity); - expect(result).toBeNaN(); + expect(result).toBeNull(); }); }); describe('extractTopPageValue', () => { it('should extract top page value from valid data', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '890', - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + totalEvents: 890, + title: 'Home Page', + identifier: '/home' }; const result = extractTopPageValue(mockData); @@ -133,23 +139,24 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return NaN when totalRequest is missing', () => { - const mockData: Partial = { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + it('should return null when totalEvents is zero', () => { + const mockData: TopPagePerformanceEntity = { + totalEvents: 0, + title: 'Home Page', + identifier: '/home' }; - const result = extractTopPageValue(mockData as TopPagePerformanceEntity); - expect(result).toBeNaN(); + const result = extractTopPageValue(mockData); + expect(result).toBeNull(); }); }); describe('extractPageTitle', () => { it('should extract page title from valid data', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '100', - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + totalEvents: 100, + title: 'Home Page', + identifier: '/home' }; const result = extractPageTitle(mockData); @@ -161,21 +168,21 @@ describe('Analytics Data Utils', () => { expect(result).toBe('analytics.metrics.pageTitle.not-available'); }); - it('should return default message when pageTitle is missing', () => { + it('should return default message when title is missing', () => { const mockData: Partial = { - 'EventSummary.totalEvents': '100', - 'EventSummary.identifier': '/home' + totalEvents: 100, + identifier: '/home' }; const result = extractPageTitle(mockData as TopPagePerformanceEntity); expect(result).toBe('analytics.metrics.pageTitle.not-available'); }); - it('should return default message when pageTitle is empty', () => { + it('should return default message when title is empty', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '100', - 'EventSummary.title': '', - 'EventSummary.identifier': '/home' + totalEvents: 100, + title: '', + identifier: '/home' }; const result = extractPageTitle(mockData); @@ -272,31 +279,15 @@ describe('Analytics Data Utils', () => { describe('Transformation Functions', () => { describe('transformTopPagesTableData', () => { it('should transform valid table data correctly', () => { - const mockData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home', - 'EventSummary.totalEvents': '1250' - }, - { - 'EventSummary.title': 'About Us', - 'EventSummary.identifier': '/about', - 'EventSummary.totalEvents': '890' - } + const mockData: TopContentData[] = [ + { title: 'Home Page', identifier: '/home', totalEvents: 1250 }, + { title: 'About Us', identifier: '/about', totalEvents: 890 } ]; const result = transformTopPagesTableData(mockData); const expected: TablePageData[] = [ - { - pageTitle: 'Home Page', - path: '/home', - views: 1250 - }, - { - pageTitle: 'About Us', - path: '/about', - views: 890 - } + { pageTitle: 'Home Page', path: '/home', views: 1250 }, + { pageTitle: 'About Us', path: '/about', views: 890 } ]; expect(result).toEqual(expected); @@ -308,20 +299,14 @@ describe('Analytics Data Utils', () => { }); it('should return empty array when data is not an array', () => { - const result = transformTopPagesTableData( - {} as unknown as TopPerformanceTableEntity[] - ); + const result = transformTopPagesTableData({} as unknown as TopContentData[]); expect(result).toEqual([]); }); it('should handle missing fields with defaults', () => { - const mockData: Partial[] = [ - { - 'EventSummary.totalEvents': '500' - } - ]; + const mockData: Partial[] = [{ totalEvents: 500 }]; - const result = transformTopPagesTableData(mockData as TopPerformanceTableEntity[]); + const result = transformTopPagesTableData(mockData as TopContentData[]); const expected: TablePageData[] = [ { pageTitle: 'analytics.table.data.not-available', @@ -341,17 +326,9 @@ describe('Analytics Data Utils', () => { describe('transformPageViewTimeLineData', () => { it('should transform valid timeline data correctly', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T00:00:00Z', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); @@ -385,284 +362,80 @@ describe('Analytics Data Utils', () => { }); it('should sort data by date correctly', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-03T00:00:00Z', - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '200' - }, - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T00:00:00Z', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-03', totalEvents: 200 }, + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); - // Should be sorted chronologically expect(result.datasets[0].data).toEqual([100, 150, 200]); }); - it('should handle missing totalRequest fields', () => { - const mockData: Partial[] = [ - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01' - } - ]; + it('should handle zero totalEvents', () => { + const mockData: TotalEventsByDayData[] = [{ day: '2023-12-01', totalEvents: 0 }]; - const result = transformPageViewTimeLineData(mockData as PageViewTimeLineEntity[]); + const result = transformPageViewTimeLineData(mockData); expect(result.datasets[0].data).toEqual([0]); }); describe('Date and Time Formatting', () => { - it('should format labels as hours when all data is from the same day', () => { - // Use local dates to ensure same day detection works properly - const baseDate = new Date('2023-12-01T12:00:00'); // Local time, midday - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': new Date( - baseDate.getTime() - 3 * 60 * 60 * 1000 - ).toISOString(), // 9 AM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': new Date( - baseDate.getTime() + 2 * 60 * 60 * 1000 - ).toISOString(), // 2 PM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - }, - { - 'EventSummary.day': new Date( - baseDate.getTime() + 6 * 60 * 60 * 1000 - ).toISOString(), // 6 PM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '200' - } - ]; - - const result = transformPageViewTimeLineData(mockData); - - // Should format as hours (HH:mm format) when all data is from same day - expect(result.labels).toHaveLength(3); - // Check that labels contain time format with HH:mm (24-hour format) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - it('should format labels as short date when data spans multiple days', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T12:00:00', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T12:00:00', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - }, - { - 'EventSummary.day': '2023-12-03T12:00:00', - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '200' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 }, + { day: '2023-12-03', totalEvents: 200 } ]; const result = transformPageViewTimeLineData(mockData); - // Should format as day + month when data spans multiple days expect(result.labels).toHaveLength(3); - // Check that labels contain date format (MMM dd) result.labels?.forEach((label) => { expect(typeof label).toBe('string'); expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); }); }); - it('should handle same day detection correctly for edge cases', () => { - // Test data with same date but different times - use local time - const baseDate = new Date('2023-12-01T12:00:00'); - - const sameDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': startOfDay(baseDate).toISOString(), // startOfDay - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '50' - }, - { - 'EventSummary.day': endOfDay(baseDate).toISOString(), // endOfDay - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '75' - } - ]; - - const result = transformPageViewTimeLineData(sameDayData); - - // Should still format as hours since it's the same day - expect(result.labels).toHaveLength(2); - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - - it('should handle data spanning just two different days', () => { - // Use dates that will definitely be different days even after timezone conversion - const twoDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T12:00:00.000', // Noon UTC - safe for most timezones - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-03T12:00:00.000', // Two days later at noon UTC - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '120' - } - ]; - - const result = transformPageViewTimeLineData(twoDayData); - - // Should format as dates since data spans multiple days in any timezone - expect(result.labels).toHaveLength(2); - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - // Should use date format (MMM dd) - expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); - }); - }); - - it('should maintain chronological order when formatting hours', () => { - const baseDate = startOfDay(new Date('2023-12-01T12:00:00')); - const unorderedSameDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': addHours(baseDate, 7).toISOString(), // 7am - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '200' - }, - { - 'EventSummary.day': addHours(baseDate, 1).toISOString(), // 1am - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': addHours(baseDate, 13).toISOString(), // 3pm - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - } - ]; - - const result = transformPageViewTimeLineData(unorderedSameDayData); - - // Should be sorted chronologically: 1am, 7am, 3pm - expect(result.datasets[0].data).toEqual([100, 200, 150]); - expect(result.labels).toHaveLength(3); - - // Verify hour format is used (HH:mm) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - - it('should convert UTC dates to user local timezone for labels', () => { - // Mock UTC dates in the format that comes from the endpoint (without Z) - // These should be converted to user's local timezone - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T14:00:00.000', // 2 PM UTC (from endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-01T18:30:00.000', // 6:30 PM UTC (from endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - } + it('should format labels as hours when all data is from the same day', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 } ]; const result = transformPageViewTimeLineData(mockData); - // The dates should be formatted using user's locale and timezone - // We can't predict the exact output since it depends on user's timezone, - // but we can verify the format is correct for local time - expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Check that labels are formatted as local time (HH:mm format) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); + expect(result.labels).toHaveLength(1); + expect(result.datasets[0].data).toEqual([100]); }); - it('should handle dates across different days in local timezone', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T22:00:00.000', // 10 PM UTC (endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T02:00:00.000', // 2 AM UTC next day (endpoint format) - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + it('should handle data spanning two different days', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-03', totalEvents: 120 } ]; const result = transformPageViewTimeLineData(mockData); expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Should have date format since they're different days in local time + expect(result.datasets[0].data).toEqual([100, 120]); result.labels?.forEach((label) => { expect(typeof label).toBe('string'); - // Either time format (HH:mm) or date format (MMM dd) depending on timezone - expect(label as string).toMatch( - /^(\d{1,2}:\d{2})|([A-Za-z]{3}\s+\d{1,2})$/ - ); + expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); }); }); - it('should handle endpoint date format without Z suffix', () => { - // Test with the exact format that comes from the endpoint - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2025-08-05T16:00:00.000', // Endpoint format (no Z) - 'EventSummary.day.day': '2025-08-05', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2025-08-05T17:00:00.000', // Endpoint format (no Z) - 'EventSummary.day.day': '2025-08-05', - 'EventSummary.totalEvents': '150' - } + it('should maintain chronological order', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-07', totalEvents: 200 }, + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-04', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); - // Should parse correctly and convert to local timezone - expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Should format as time (same day) - HH:mm format - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); + expect(result.datasets[0].data).toEqual([100, 150, 200]); + expect(result.labels).toHaveLength(3); }); }); }); @@ -670,16 +443,8 @@ describe('Analytics Data Utils', () => { describe('transformDeviceBrowsersData', () => { it('should transform valid device browsers data correctly', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '500' - }, - { - 'request.userAgent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', - 'request.count': '300' - } + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Safari', device: 'Mobile', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); @@ -709,84 +474,47 @@ describe('Analytics Data Utils', () => { expect(result.datasets[0].data).toEqual([]); }); - it('should return "No Data" when no valid entries found', () => { + it('should format labels as "browser (device)"', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': 'Some browser', - 'request.count': '0' - } + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Safari', device: 'Mobile', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); - expect(result.labels).toEqual(['No Data']); - expect(result.datasets[0].data).toEqual([1]); - expect(result.datasets[0].backgroundColor).toEqual([ - AnalyticsChartColors.neutral.line - ]); + expect(result.labels[0]).toBe('Chrome (Desktop)'); + expect(result.labels[1]).toBe('Safari (Mobile)'); }); - it('should group by browser and device type correctly', () => { + it('should sort results by total descending', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '200' - }, - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '300' - } + { browser: 'Safari', device: 'Mobile', total: 100 }, + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Firefox', device: 'Desktop', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); - // Should combine the two Chrome Desktop entries - expect(result.labels).toHaveLength(1); - expect(result.labels[0]).toContain('Chrome (Desktop)'); - expect(result.datasets[0].data).toEqual([500]); // 200 + 300 + expect(result.labels[0]).toBe('Chrome (Desktop)'); + expect(result.labels[1]).toBe('Firefox (Desktop)'); + expect(result.labels[2]).toBe('Safari (Mobile)'); + expect(result.datasets[0].data).toEqual([500, 300, 100]); }); - it('should sort results by usage descending', () => { - const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', - 'request.count': '100' // Less usage - }, - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '500' // More usage - } - ]; + it('should limit results to top 10', () => { + const mockData: PageViewDeviceBrowsersEntity[] = Array.from( + { length: 15 }, + (_, i) => ({ + browser: `Browser${i}`, + device: 'Desktop', + total: 100 - i + }) + ); const result = transformDeviceBrowsersData(mockData); - // Chrome Desktop should come first (higher usage) - expect(result.labels[0]).toContain('Chrome (Desktop)'); - expect(result.labels[1]).toContain('Safari (Mobile)'); - expect(result.datasets[0].data).toEqual([500, 100]); - }); - - it('should handle invalid user agents gracefully', () => { - const mockData: Partial[] = [ - { - 'request.userAgent': '', - 'request.count': '100' - }, - { - 'request.count': '200' - } - ]; - - const result = transformDeviceBrowsersData( - mockData as PageViewDeviceBrowsersEntity[] - ); - - expect(result.labels).toEqual(['No Data']); - expect(result.datasets[0].data).toEqual([1]); + expect(result.labels).toHaveLength(10); + expect(result.datasets[0].data).toHaveLength(10); }); }); }); @@ -859,10 +587,10 @@ describe('Analytics Data Utils', () => { }); describe('fillMissingDates', () => { - describe('with PageViewTimeLineEntity', () => { + describe('with ConversionTrendEntity', () => { it('should return empty array when data is null', () => { const result = fillMissingDates( - null as unknown as PageViewTimeLineEntity[], + null as unknown as ConversionTrendEntity[], ['2024-01-01', '2024-01-03'], Granularity.DAY, createEmptyAnalyticsEntity @@ -873,7 +601,7 @@ describe('Analytics Data Utils', () => { it('should return empty array when data is not an array', () => { const result = fillMissingDates( - {} as unknown as PageViewTimeLineEntity[], + {} as unknown as ConversionTrendEntity[], ['2024-01-01', '2024-01-03'], Granularity.DAY, createEmptyAnalyticsEntity @@ -883,7 +611,7 @@ describe('Analytics Data Utils', () => { }); it('should fill all dates in range when data is empty', () => { - const result = fillMissingDates( + const result = fillMissingDates( [], ['2024-01-01', '2024-01-03'], Granularity.DAY, @@ -898,7 +626,7 @@ describe('Analytics Data Utils', () => { }); it('should return correct number of entries for date range', () => { - const result = fillMissingDates( + const result = fillMissingDates( [], ['2024-01-01', '2024-01-05'], Granularity.DAY, @@ -960,7 +688,7 @@ describe('Analytics Data Utils', () => { describe('createEmptyAnalyticsEntity', () => { it('should create entity with correct structure', () => { - const result = createEmptyAnalyticsEntity( + const result = createEmptyAnalyticsEntity( testDate, testDateKey ); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index bfe506d89c5e..cf1b332f2965 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -15,27 +15,29 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { AnalyticsChartColors, BAR_CHART_STYLE, + TIME_RANGE_API_MAPPING, TIME_RANGE_CUBEJS_MAPPING, TIME_RANGE_OPTIONS } from '../../constants'; import { + ApiGranularity, + ApiRangeParams, ChartData, ChartDataset, ContentAttributionEntity, Granularity, PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, RequestState, TablePageData, TimeRangeCubeJS, TimeRangeInput, + TopContentData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalConversionsEntity, + TotalEventsByDayData, TotalPageViewsEntity, UniqueVisitorsEntity } from '../../types'; -import { parseUserAgent } from '../browser/userAgentParser'; /** * Time formats for different chart types @@ -76,12 +78,32 @@ export function toTimeRangeCubeJS(timeRange: TimeRangeInput): TimeRangeCubeJS { ); } +/** + * Converts TimeRangeInput to the new analytics event API query params. + * For predefined ranges returns `{ range: 'last_7_days' }`. + * For custom date arrays returns `{ from: '2026-03-30', to: '2026-04-29' }`. + * + * @param timeRange - The time range input (predefined option or custom date array) + * @returns Object with either `range` or `from`+`to` params + */ +export function toApiRangeParams(timeRange: TimeRangeInput): ApiRangeParams { + if (Array.isArray(timeRange)) { + return { from: timeRange[0], to: timeRange[1] }; + } + + return { + range: + TIME_RANGE_API_MAPPING[timeRange as keyof typeof TIME_RANGE_API_MAPPING] || + TIME_RANGE_API_MAPPING[TIME_RANGE_OPTIONS.last7days] + }; +} + /** * Extracts page views count from TotalPageViewsEntity */ export const extractPageViews = (data: TotalPageViewsEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.totalEvents'] ?? 0); + const value = data.totalEvents ?? 0; return value === 0 ? null : value; }; @@ -91,7 +113,7 @@ export const extractPageViews = (data: TotalPageViewsEntity | null): number | nu */ export const extractSessions = (data: UniqueVisitorsEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.uniqueVisitors']); + const value = data.uniqueVisitors ?? 0; return value === 0 ? null : value; }; @@ -101,7 +123,7 @@ export const extractSessions = (data: UniqueVisitorsEntity | null): number | nul */ export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.totalEvents']); + const value = data.totalEvents ?? 0; return value === 0 ? null : value; }; @@ -110,7 +132,7 @@ export const extractTopPageValue = (data: TopPagePerformanceEntity | null): numb * Extracts page title from TopPagePerformanceEntity */ export const extractPageTitle = (data: TopPagePerformanceEntity | null): string => - data?.['EventSummary.title'] || 'analytics.metrics.pageTitle.not-available'; + data?.title || 'analytics.metrics.pageTitle.not-available'; /** * Aggregates total conversions from an array of TotalConversionsEntity. @@ -138,26 +160,24 @@ export const aggregateTotalConversions = ( }; /** - * Transforms TopPerformanceTableEntity array to table-friendly format + * Transforms TopContentData array to table-friendly format */ -export const transformTopPagesTableData = ( - data: TopPerformanceTableEntity[] | null -): TablePageData[] => { +export const transformTopPagesTableData = (data: TopContentData[] | null): TablePageData[] => { if (!data || !Array.isArray(data)) { return []; } return data.map((item) => ({ - pageTitle: item['EventSummary.title'] || 'analytics.table.data.not-available', - path: item['EventSummary.identifier'] || 'analytics.table.data.not-available', - views: Number(item['EventSummary.totalEvents']) || 0 + pageTitle: item.title || 'analytics.table.data.not-available', + path: item.identifier || 'analytics.table.data.not-available', + views: item.totalEvents })); }; /** - * Transforms PageViewTimeLineEntity array to Chart.js compatible format + * Transforms TotalEventsByDayData array to Chart.js compatible format */ -export const transformPageViewTimeLineData = (data: PageViewTimeLineEntity[] | null): ChartData => { +export const transformPageViewTimeLineData = (data: TotalEventsByDayData[] | null): ChartData => { if (!data || !Array.isArray(data)) { return { labels: [], @@ -177,8 +197,8 @@ export const transformPageViewTimeLineData = (data: PageViewTimeLineEntity[] | n const transformedData = data .map((item) => ({ - date: new Date(item['EventSummary.day']), - value: Number(item['EventSummary.totalEvents'] || '0') + date: new Date(item.day), + value: item.totalEvents })) .sort((a, b) => a.date.getTime() - b.date.getTime()); @@ -437,7 +457,8 @@ export const transformContentConversionsData = ( }; /** - * Transforms PageViewDeviceBrowsersEntity array to pie chart ChartData format + * Transforms PageViewDeviceBrowsersEntity array to pie chart ChartData format. + * The new API returns browser and device already parsed, no user-agent parsing needed. */ export const transformDeviceBrowsersData = ( data: PageViewDeviceBrowsersEntity[] | null @@ -455,67 +476,22 @@ export const transformDeviceBrowsersData = ( }; } - // Group data by browser + device type combination - const browserDeviceGroups = new Map(); + const sorted = [...data].sort((a, b) => b.total - a.total).slice(0, 10); - data.forEach((item) => { - const userAgent = item['request.userAgent']; - const totalRequests = parseInt(item['request.count'] || '0', 10); - - if (userAgent && totalRequests > 0) { - const parsed = parseUserAgent(userAgent); - const browserName = parsed.browser.name; - const deviceType = parsed.device.type; - - // Create combined label: "Chrome (Mobile)", "Safari (Desktop)", etc. - // Note: Device labels are hardcoded as they go directly to chart library - const deviceLabel = - deviceType === 'mobile' ? 'Mobile' : deviceType === 'tablet' ? 'Tablet' : 'Desktop'; - const combinedLabel = `${browserName} (${deviceLabel})`; - - const currentTotal = browserDeviceGroups.get(combinedLabel) || 0; - browserDeviceGroups.set(combinedLabel, currentTotal + totalRequests); - } - }); - - // Convert map to arrays and sort by usage - const sortedBrowserDevices = Array.from(browserDeviceGroups.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10); // Increase limit to 10 for more device combinations + const labels = sorted.map((item) => `${item.browser} (${item.device})`); + const chartData = sorted.map((item) => item.total); - if (sortedBrowserDevices.length === 0) { - return { - labels: ['No Data'], - datasets: [ - { - label: 'analytics.charts.device-breakdown.dataset-label', - data: [1], - backgroundColor: [AnalyticsChartColors.neutral.line] - } - ] - }; - } - - const labels = sortedBrowserDevices.map(([browserDevice]) => browserDevice); - const chartData = sortedBrowserDevices.map(([, count]) => count); - - // Enhanced color palette for browser + device combinations const colorPalette = [ - AnalyticsChartColors.primary.line, // Chrome Desktop - Blue - '#1E40AF', // Chrome Mobile - Dark Blue - '#60A5FA', // Chrome Tablet - Light Blue - '#8B5CF6', // Safari Desktop - Purple - '#6D28D9', // Safari Mobile - Dark Purple - '#A78BFA', // Safari Tablet - Light Purple - AnalyticsChartColors.secondary.line, // Firefox Desktop - Green - '#047857', // Firefox Mobile - Dark Green - '#34D399', // Firefox Tablet - Light Green - '#F59E0B', // Edge Desktop - Orange - '#D97706', // Edge Mobile - Dark Orange - '#FBBF24', // Edge Tablet - Light Orange - '#EF4444', // Others Desktop - Red - '#DC2626', // Others Mobile - Dark Red - '#F87171' // Others Tablet - Light Red + AnalyticsChartColors.primary.line, + '#1E40AF', + '#60A5FA', + '#8B5CF6', + '#6D28D9', + '#A78BFA', + AnalyticsChartColors.secondary.line, + '#047857', + '#34D399', + '#F59E0B' ]; return { @@ -622,6 +598,47 @@ export const fillMissingDates = ( return filledData; }; +/** Base type for new API timeline entities */ +type ApiTimelineEntity = { day: string }; + +/** + * Fills missing dates for new API timeline data (shape: { day: string, ... }). + * The API returns sparse data (only days with events), this fills gaps with zeros. + */ +export const fillMissingApiDates = ( + data: T[], + timeRange: TimeRangeInput, + granularity: ApiGranularity, + createEmptyEntity: (date: Date) => T +): T[] => { + if (!data || !Array.isArray(data)) { + return []; + } + + const [startDate, endDate] = getDateRange(timeRange); + + const dataMap = new Map(); + data.forEach((item) => { + dataMap.set(item.day, item); + }); + + const filledData: T[] = []; + let currentDate = startDate; + while (currentDate <= endDate) { + const currentDateKey = format(currentDate, 'yyyy-MM-dd'); + + const existing = dataMap.get(currentDateKey); + if (existing) { + filledData.push(existing); + } else { + filledData.push(createEmptyEntity(currentDate)); + } + currentDate = granularity === 'hour' ? addHours(currentDate, 1) : addDays(currentDate, 1); + } + + return filledData; +}; + /** * Get the date range for the given time range * @param timeRange - The time range to get the date range for diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts index f330244078ab..ec6ccbf08c04 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts @@ -6,38 +6,23 @@ import { TableModule } from 'primeng/table'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { - RequestState, - TopPerformanceTableEntity -} from '@dotcms/portlets/dot-analytics/data-access'; +import { RequestState, TopContentData } from '@dotcms/portlets/dot-analytics/data-access'; import { DotAnalyticsTopPagesTableComponent } from './dot-analytics-top-pages-table.component'; describe('DotAnalyticsTopPagesTableComponent', () => { let spectator: Spectator; - const mockTableData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home', - 'EventSummary.totalEvents': '1250' - }, - { - 'EventSummary.title': 'About Us', - 'EventSummary.identifier': '/about', - 'EventSummary.totalEvents': '890' - }, - { - 'EventSummary.title': 'Contact', - 'EventSummary.identifier': '/contact', - 'EventSummary.totalEvents': '567' - } + const mockTableData: TopContentData[] = [ + { title: 'Home Page', identifier: '/home', totalEvents: 1250 }, + { title: 'About Us', identifier: '/about', totalEvents: 890 }, + { title: 'Contact', identifier: '/contact', totalEvents: 567 } ]; const createMockTableState = ( - data: TopPerformanceTableEntity[] | null = mockTableData, + data: TopContentData[] | null = mockTableData, status: ComponentStatus = ComponentStatus.LOADED - ): RequestState => ({ + ): RequestState => ({ data, status, error: null @@ -90,12 +75,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { describe('Data Handling', () => { it('should handle data changes', () => { - const newData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'New Page', - 'EventSummary.identifier': '/new', - 'EventSummary.totalEvents': '100' - } + const newData: TopContentData[] = [ + { title: 'New Page', identifier: '/new', totalEvents: 100 } ]; spectator.setInput('tableState', createMockTableState(newData, ComponentStatus.LOADED)); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts index 6f7c39979d21..438b505581c1 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts @@ -7,7 +7,7 @@ import { TableModule } from 'primeng/table'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { RequestState, - TopPerformanceTableEntity, + TopContentData, transformTopPagesTableData } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; @@ -46,7 +46,7 @@ const SKELETON_WIDTH_MAP = { }) export class DotAnalyticsTopPagesTableComponent { /** Complete table state from analytics store */ - readonly $tableState = input.required>({ + readonly $tableState = input.required>({ alias: 'tableState' }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html index 09595d81fa8a..f24d3b64c1dc 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html @@ -1,3 +1,7 @@
- +
diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts index 7adf828689f5..8a51462f33d7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts @@ -1,8 +1,9 @@ import { Component, computed, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { DotMessageService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; import { DotEmptyContainerComponent, PrincipalConfiguration } from '@dotcms/ui'; /** @@ -16,7 +17,9 @@ import { DotEmptyContainerComponent, PrincipalConfiguration } from '@dotcms/ui'; }) export default class DotAnalyticsErrorComponent { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly dotMessageService = inject(DotMessageService); + private readonly analyticsService = inject(DotAnalyticsService); /** * Computed configuration for the empty state component based on route parameters @@ -29,6 +32,16 @@ export default class DotAnalyticsErrorComponent { return this.getErrorConfig(status, isEnterprise); }); + protected readonly $retryLabel = this.dotMessageService.get('analytics.error.retry'); + + /** + * Clears the health check cache and navigates back to analytics dashboard. + */ + onRetry(): void { + this.analyticsService.clearHealthCache(); + this.router.navigate(['/analytics']); + } + /** * Gets the appropriate error configuration based on health status and enterprise license */ @@ -36,7 +49,6 @@ export default class DotAnalyticsErrorComponent { status: HealthStatusTypes, isEnterprise: boolean ): PrincipalConfiguration { - // If not enterprise, show license error regardless of health status if (!isEnterprise) { return { title: this.dotMessageService.get('analytics.search.no.license'), @@ -45,8 +57,14 @@ export default class DotAnalyticsErrorComponent { }; } - // Enterprise license configurations based on health status - const enterpriseConfigs: Record = { + const defaultConfig: PrincipalConfiguration = { + title: this.dotMessageService.get('analytics.error.not.available'), + subtitle: this.dotMessageService.get('analytics.error.not.available.subtitle'), + icon: 'pi-exclamation-triangle' + }; + + const enterpriseConfigs: Partial> = { + [HealthStatusTypes.NOT_AVAILABLE]: defaultConfig, [HealthStatusTypes.NOT_CONFIGURED]: { title: this.dotMessageService.get('analytics.search.no.configured'), subtitle: this.dotMessageService.get('analytics.search.no.configured.subtitle'), @@ -64,8 +82,6 @@ export default class DotAnalyticsErrorComponent { } }; - return ( - enterpriseConfigs[status] || enterpriseConfigs[HealthStatusTypes.CONFIGURATION_ERROR] - ); + return enterpriseConfigs[status] ?? defaultConfig; } } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts index a0c6698eb018..60b8ef99e0f7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts @@ -3,23 +3,20 @@ import { of } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Route, Router } from '@angular/router'; -import { DotExperimentsService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; -import { analyticsHealthGuard, clearAnalyticsHealthCache } from './analytics-health.guard'; +import { analyticsHealthGuard } from './analytics-health.guard'; describe('analyticsHealthGuard', () => { let mockRouter: Router; let mockActivatedRoute: ActivatedRoute; - let mockDotExperimentsService: DotExperimentsService; + let mockAnalyticsService: DotAnalyticsService; const mockRoute = {} as Route; const mockSegments = []; beforeEach(() => { - // Clear cache before each test to ensure isolation - clearAnalyticsHealthCache(); - mockRouter = { navigate: jest.fn() } as unknown as Router; @@ -30,22 +27,24 @@ describe('analyticsHealthGuard', () => { } } as unknown as ActivatedRoute; - mockDotExperimentsService = { - healthCheck: jest.fn() - } as unknown as DotExperimentsService; + mockAnalyticsService = { + healthCheck: jest.fn(), + healthCheckWithCache: jest.fn(), + clearHealthCache: jest.fn() + } as unknown as DotAnalyticsService; TestBed.configureTestingModule({ providers: [ { provide: Router, useValue: mockRouter }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: DotExperimentsService, useValue: mockDotExperimentsService } + { provide: DotAnalyticsService, useValue: mockAnalyticsService } ] }); }); - it('should allow access when health status is OK', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.OK) + it('should allow access when health status is AVAILABLE', (done) => { + (mockAnalyticsService.healthCheckWithCache as jest.Mock).mockReturnValue( + of(HealthStatusTypes.AVAILABLE) ); TestBed.runInInjectionContext(() => { @@ -61,9 +60,9 @@ describe('analyticsHealthGuard', () => { }); }); - it('should redirect to error page when health status is NOT_CONFIGURED', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + it('should redirect to error page when health status is NOT_AVAILABLE', (done) => { + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); TestBed.runInInjectionContext(() => { @@ -74,30 +73,7 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, - isEnterprise: true - } - }); - done(); - }); - } - }); - }); - - it('should redirect to error page when health status is CONFIGURATION_ERROR', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.CONFIGURATION_ERROR) - ); - - TestBed.runInInjectionContext(() => { - const result = analyticsHealthGuard(mockRoute, mockSegments); - - if (result && typeof result === 'object' && 'subscribe' in result) { - result.subscribe((canActivate) => { - expect(canActivate).toBe(false); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { - queryParams: { - status: HealthStatusTypes.CONFIGURATION_ERROR, + status: HealthStatusTypes.NOT_AVAILABLE, isEnterprise: true } }); @@ -108,10 +84,10 @@ describe('analyticsHealthGuard', () => { }); it('should handle missing isEnterprise data by defaulting to true', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); - mockActivatedRoute.snapshot.data = {}; // No isEnterprise data + mockActivatedRoute.snapshot.data = {}; TestBed.runInInjectionContext(() => { const result = analyticsHealthGuard(mockRoute, mockSegments); @@ -121,8 +97,8 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, - isEnterprise: true // Should default to true + status: HealthStatusTypes.NOT_AVAILABLE, + isEnterprise: true } }); done(); @@ -132,8 +108,8 @@ describe('analyticsHealthGuard', () => { }); it('should pass isEnterprise false when it is set to false', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); mockActivatedRoute.snapshot.data = { isEnterprise: false }; @@ -145,7 +121,7 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, + status: HealthStatusTypes.NOT_AVAILABLE, isEnterprise: false } }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts index 289fa684d0a9..267ce14eda10 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts @@ -1,43 +1,28 @@ -import { Observable } from 'rxjs'; - import { inject } from '@angular/core'; import { ActivatedRoute, CanMatchFn, Router } from '@angular/router'; -import { map, shareReplay } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; -import { DotExperimentsService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; - -// Cache global para el health check - compartido entre todas las ejecuciones del guard -let healthCheckCache$: Observable | null = null; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; /** - * Guard optimizado que protege las rutas de analytics. - * Usa shareReplay para evitar múltiples llamadas al health check. + * Guard that protects analytics routes by checking service availability. + * Cache is managed internally by DotAnalyticsService. */ export const analyticsHealthGuard: CanMatchFn = (_route, _segments) => { - const dotExperimentsService = inject(DotExperimentsService); + const analyticsService = inject(DotAnalyticsService); const router = inject(Router); const activatedRoute = inject(ActivatedRoute); - // Si no hay cache, crear uno con shareReplay - if (!healthCheckCache$) { - healthCheckCache$ = dotExperimentsService.healthCheck().pipe( - shareReplay(1) // ← CLAVE: Comparte el último resultado entre múltiples suscriptores - ); - } - - // Usar el observable cacheado - return healthCheckCache$.pipe( + return analyticsService.healthCheckWithCache().pipe( map((healthStatus) => { - if (healthStatus === HealthStatusTypes.OK) { - return true; // Allow access to the route + if (healthStatus === HealthStatusTypes.AVAILABLE) { + return true; } - // Get isEnterprise from route data (resolved at parent level) const isEnterprise = activatedRoute.snapshot.data?.['isEnterprise'] ?? true; - // Redirect to error page with status information router.navigate(['/analytics/error'], { queryParams: { status: healthStatus, @@ -45,14 +30,7 @@ export const analyticsHealthGuard: CanMatchFn = (_route, _segments) => { } }); - return false; // Block access to the route + return false; }) ); }; - -/** - * Función para limpiar el cache del health check (útil para testing o forzar revalidación) - */ -export function clearAnalyticsHealthCache(): void { - healthCheckCache$ = null; -} diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts index cf312ae77b16..59a08742483a 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts @@ -1,26 +1,23 @@ import { Route } from '@angular/router'; -import DotAnalyticsDashboardComponent from './dot-analytics-dashboard/dot-analytics-dashboard.component'; import { analyticsHealthGuard } from './guards/analytics-health.guard'; export const dotAnalyticsRoutes: Route[] = [ { path: 'error', title: 'analytics.error.title', - loadComponent: () => - import('./dot-analytics-error/dot-analytics-error.component').then((m) => m.default) + loadComponent: () => import('./dot-analytics-error/dot-analytics-error.component') }, { path: 'search', title: 'analytics.search.title', canMatch: [analyticsHealthGuard], - loadComponent: () => - import('./dot-analytics-search/dot-analytics-search.component').then((m) => m.default) + loadComponent: () => import('./dot-analytics-search/dot-analytics-search.component') }, { path: 'dashboard', canMatch: [analyticsHealthGuard], - component: DotAnalyticsDashboardComponent + loadComponent: () => import('./dot-analytics-dashboard/dot-analytics-dashboard.component') }, { path: '', diff --git a/docker/docker-compose-examples/single-node/docker-compose.yml b/docker/docker-compose-examples/single-node/docker-compose.yml index 1e16d62e3c1c..bda1567e2541 100644 --- a/docker/docker-compose-examples/single-node/docker-compose.yml +++ b/docker/docker-compose-examples/single-node/docker-compose.yml @@ -61,7 +61,15 @@ services: GLOWROOT_WEB_UI_ENABLED: 'true' # Enable glowroot web ui on localhost. do not use in production #CMS_SSL_CERTIFICATE_FILE: '/certs/localhost.pem' # Can create cert with mkcert tool #CMS_SSL_CERTIFICATE_KEY_FILE: '/certs/localhost-key.pem' - #CUSTOM_STARTER_URL: 'https://repo.dotcms.com/artifactory/libs-release-local/com/dotcms/starter/20260409/starter-20260409.zip' + #CUSTOM_STARTER_URL: 'https://repo.dotcms.com/artifactory/libs-release-local/com/dotcms/starter/20260211/starter-20260211.zip' + + DOT_ANALYTICS_BASE_URL: 'http://host.docker.internal:8080' + DOT_ANALYTICS_CUSTOMER_ID: 'cust-001' + DOT_ANALYTICS_PASSWORD: 'abc' + DOT_ANALYTICS_ENVIRONMENT: 'dev' + + DOT_ALLOW_ACCESS_TO_PRIVATE_SUBNETS: 'true' + DOT_FEATURE_FLAG_CONTENT_ANALYTICS_AUTO_INJECT: 'true' depends_on: - db - opensearch @@ -73,6 +81,7 @@ services: - db_net - opensearch-net ports: + - "8000:8000" - "8082:8082" - "8443:8443" - "4000:4000" # Glowroot web ui if enabled