From 671024981e8d5cc2b14766e9f0e42b2e5df72f4d Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:31:11 -0400 Subject: [PATCH 01/10] feat(dotcdn): migrate dotCDN from OSGI plugin to core app (#35320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP checkpoint — no real testing has been done yet. This is a structural migration before improving the UI (date range controls, chart formatting, hourly granularity). Backend migration: - Moved all Java classes from com.dotcms.cloud.* to com.dotcms.cdn.* - Registered REST resource, workflow actionlet, ViewTool, event subscriber, web interceptor, and portlet in core - Added app descriptor YAML (auto-discovered by AppsAPI) - Dropped OSGI Activator, AppUtil, and unused BCDNStorage client - Includes #35320 fixes: empty response handling, Integer→Double cast Frontend migration: - Moved Angular app from apps/dotcdn/ to libs/portlets/dot-cdn/ - Converted to standalone component (no NgModule) - Updated PrimeNG tabs to new p-tablist/p-tabpanel API - Registered as lazy-loaded Angular portlet route (no JSP/iframe) - Added smart bandwidth unit selection (B/KB/MB/GB) - Added human-readable number formatting for requests - Fixed duplicate y-axis on charts - Temporary mock data options for visual testing (to be removed) Unit tests: 7/7 passing (DotCDNAPITest) --- core-web/apps/dotcms-ui/src/app/app.routes.ts | 7 + core-web/libs/portlets/dot-cdn/project.json | 13 + core-web/libs/portlets/dot-cdn/src/index.ts | 1 + .../dot-cdn/src/lib/dot-cdn.component.html | 116 ++++++ .../dot-cdn/src/lib/dot-cdn.component.scss | 127 ++++++ .../dot-cdn/src/lib/dot-cdn.component.ts | 156 ++++++++ .../dot-cdn/src/lib/dot-cdn.models.ts | 81 ++++ .../dot-cdn/src/lib/dot-cdn.service.ts | 68 ++++ .../portlets/dot-cdn/src/lib/dot-cdn.store.ts | 329 ++++++++++++++++ .../portlets/dot-cdn/src/lib/lib.routes.ts | 10 + core-web/libs/portlets/dot-cdn/tsconfig.json | 24 ++ .../libs/portlets/dot-cdn/tsconfig.lib.json | 12 + core-web/tsconfig.base.json | 1 + .../java/com/dotcms/cdn/CDNConstants.java | 13 + .../java/com/dotcms/cdn/CDNInterceptor.java | 34 ++ .../java/com/dotcms/cdn/api/DotCDNAPI.java | 57 +++ .../com/dotcms/cdn/api/DotCDNAPIImpl.java | 370 ++++++++++++++++++ .../java/com/dotcms/cdn/api/DotCDNStats.java | 172 ++++++++ .../PushPublishOnReceiverEndSubscriber.java | 118 ++++++ .../com/dotcms/cdn/rest/DotCDNResource.java | 113 ++++++ .../com/dotcms/cdn/rest/InvalidationForm.java | 63 +++ .../com/dotcms/cdn/viewtool/DotCDNTool.java | 45 +++ .../dotcms/cdn/viewtool/DotCDNToolInfo.java | 30 ++ .../workflow/DotCDNInvalidateActionlet.java | 99 +++++ .../rest/config/DotRestApplication.java | 1 + ...ocalSystemEventSubscribersInitializer.java | 3 + .../filters/InterceptorFilter.java | 2 + .../workflows/business/WorkflowAPIImpl.java | 4 +- dotCMS/src/main/resources/apps/dotCDN.yml | 30 ++ dotCMS/src/main/webapp/WEB-INF/portlet.xml | 6 + dotCMS/src/main/webapp/WEB-INF/toolbox.xml | 5 + .../com/dotcms/cdn/api/DotCDNAPITest.java | 326 +++++++++++++++ .../test/resources/test-stats-response.json | 63 +++ 33 files changed, 2498 insertions(+), 1 deletion(-) create mode 100644 core-web/libs/portlets/dot-cdn/project.json create mode 100644 core-web/libs/portlets/dot-cdn/src/index.ts create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts create mode 100644 core-web/libs/portlets/dot-cdn/tsconfig.json create mode 100644 core-web/libs/portlets/dot-cdn/tsconfig.lib.json create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java create mode 100644 dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java create mode 100644 dotCMS/src/main/resources/apps/dotCDN.yml create mode 100644 dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java create mode 100644 dotCMS/src/test/resources/test-stats-response.json diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index 2ba39163544b..af53bc311e8e 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -22,6 +22,13 @@ import { MainCoreLegacyComponent } from './view/components/main-core-legacy/main import { MainComponentLegacyComponent } from './view/components/main-legacy/main-legacy.component'; const PORTLETS_ANGULAR: Route[] = [ + { + path: 'dotCDN', + canActivate: [MenuGuardService], + canActivateChild: [MenuGuardService], + loadChildren: () => + import('@dotcms/portlets/dot-cdn/portlet').then((m) => m.dotCdnRoutes) + }, { path: 'containers', loadChildren: () => diff --git a/core-web/libs/portlets/dot-cdn/project.json b/core-web/libs/portlets/dot-cdn/project.json new file mode 100644 index 000000000000..19d8a93794bd --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/project.json @@ -0,0 +1,13 @@ +{ + "name": "portlets-dot-cdn-portlet", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portlets/dot-cdn/src", + "prefix": "dot", + "projectType": "library", + "tags": ["type:feature", "scope:dotcms-ui", "portlet:cdn"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/index.ts b/core-web/libs/portlets/dot-cdn/src/index.ts new file mode 100644 index 000000000000..44c9365302f3 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/index.ts @@ -0,0 +1 @@ +export * from './lib/lib.routes'; diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html new file mode 100644 index 000000000000..98d29977dd14 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html @@ -0,0 +1,116 @@ +@if (vm$ | async; as VM) { + + + Overview + Flush Cache + + + +
+ +
+ @for (stats of VM.statsData; track stats) { +
+ {{ stats.label }} + @if (VM.isChartLoading) { + + } @else { +

{{ stats.value }}

+ } + +
+ } +
+
+ @if (VM.isChartLoading) { + + } @else { + + + } +
+
+ @if (VM.isChartLoading) { + + } @else { + + + } +
+
+
+ @if (vmPurgeLoaders$ | async; as VMPurgeLoaders) { + +
+
+
+ +

+ Purging an URL list will remove the file from the CDN cache and + re-download it from your server. Please enter the exact CDN URL of each + file. You can also purge folders or wildcards files using * inside the + URL path. +

+ + +
+
+
+ +

+ "Purge All" will remove files from your CDN domain and force everything to + be re-downloaded from your origin site. If your CDN domain is highly + trafficked and/or caches a lot of resources, this might send a large amount + of traffic back to your origin sites, so use with caution. +

+ +
+
+
+ } +
+
+} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss new file mode 100644 index 000000000000..e511e14b973b --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss @@ -0,0 +1,127 @@ +@use "variables" as *; + +p-skeleton { + display: flex; + flex-direction: column; + justify-content: center; +} + +dot-spinner { + margin: 0 auto; + + width: max-content; + display: flex; + align-items: center; + justify-content: center; +} +h1 { + margin: 0; +} + +.dot-cdn__container { + background: $white; + display: block; +} + +.dot-cdn__tab-content { + padding: $spacing-4; +} + +.dot-cdn__tab-content--contained { + padding: $spacing-4; + textarea { + width: 100%; + margin-bottom: $spacing-4; + } +} + +.dot-cdn__stats { + padding-top: $spacing-4; + display: flex; + flex-direction: column; + + h3 { + margin: 0; + } +} + +.dot-cdn__col { + color: $color-palette-primary; + margin-bottom: $spacing-3; + display: grid; + column-gap: $spacing-3; + grid-template-columns: repeat(2, 7rem); + dot-icon { + align-self: center; + } +} + +.dot-cdn__stats-label { + text-transform: uppercase; + color: gray; + font-size: $font-size-sm; + grid-column: 1/3; + display: inline-block; +} + +.dot-cdn__chart { + margin-bottom: $spacing-6; +} + +.dot-cdn__tab-content-meta { + display: flex; + justify-content: space-between; + margin-bottom: 0; +} +.dot-cdn__tab-domain small { + color: $color-palette-gray-700; +} + +.dot-cdn__tab-domain p { + margin-top: $spacing-1; +} + +.dot-cdn__tab-content-label { + margin: 0; + font-size: $font-size-lmd; +} + +.dot-cdn__tab-content__row:first-child { + margin-bottom: $spacing-8; +} + +.dot-cdn__tab-content__row { + button { + min-width: 12.71rem; + } + button:focus { + color: white; + } +} + +@media (min-width: $screen-md-min) { + .dot-cdn__tab-content { + padding: $spacing-4; + } + + .dot-cdn__tab-content--contained { + padding: $spacing-4; + max-width: 70%; + + p { + line-height: 1.5; + margin-bottom: $spacing-4; + } + } + + .dot-cdn__stats { + flex-direction: row; + justify-content: space-between; + margin: $spacing-2 0 $spacing-5 0; + } + + .dot-cdn__col { + grid-template-columns: repeat(2, 1fr); + margin-bottom: 0; + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts new file mode 100644 index 000000000000..7e12c3796ff9 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts @@ -0,0 +1,156 @@ +import { ChartOptions } from 'chart.js'; +import { Observable } from 'rxjs'; + +import { AsyncPipe, NgStyle } from '@angular/common'; +import { Component, inject, OnInit, ViewChild } from '@angular/core'; +import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; + +import { SelectItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { UIChart, ChartModule } from 'primeng/chart'; +import { SelectModule } from 'primeng/select'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TabsModule } from 'primeng/tabs'; +import { TextareaModule } from 'primeng/textarea'; + +import { take } from 'rxjs/operators'; + +import { DotIconComponent, DotSpinnerComponent } from '@dotcms/ui'; + +import { CdnChartOptions, ChartPeriod, DotCDNState } from './dot-cdn.models'; +import { DotCDNStore } from './dot-cdn.store'; + +@Component({ + selector: 'dot-cdn', + templateUrl: './dot-cdn.component.html', + styleUrls: ['./dot-cdn.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + NgStyle, + FormsModule, + ReactiveFormsModule, + TabsModule, + ChartModule, + SelectModule, + ButtonModule, + TextareaModule, + SkeletonModule, + DotIconComponent, + DotSpinnerComponent + ], + providers: [DotCDNStore] +}) +export class DotCDNComponent implements OnInit { + private fb = inject(UntypedFormBuilder); + private dotCdnStore = inject(DotCDNStore); + + @ViewChild('chart', { static: true }) chart: UIChart; + purgeZoneForm: UntypedFormGroup; + periodValues: SelectItem[] = [ + { label: 'Last 15 days', value: ChartPeriod.Last15Days }, + { label: 'Last 30 days', value: ChartPeriod.Last30Days }, + { label: 'Last 60 days', value: ChartPeriod.Last60Days }, + // TODO: Remove mock options before merging + { label: '[MOCK] High traffic (GB)', value: 'mock-high' }, + { label: '[MOCK] Medium traffic (MB)', value: 'mock-medium' }, + { label: '[MOCK] Low traffic (KB)', value: 'mock-low' } + ]; + selectedPeriod: SelectItem = { value: ChartPeriod.Last15Days }; + vm$: Observable> = + this.dotCdnStore.vm$; + vmPurgeLoaders$: Observable> = + this.dotCdnStore.vmPurgeLoaders$; + chartHeight = '25rem'; + options: CdnChartOptions; + + ngOnInit(): void { + this.setChartOptions(); + + this.purgeZoneForm = this.fb.group({ + purgeUrlsTextArea: '' + }); + } + + changePeriod(element: HTMLTextAreaElement): void { + this.dotCdnStore.getChartStats(element.value); + } + + purgePullZone(): void { + this.dotCdnStore.purgeCDNCacheAll(); + } + + purgeUrls(): void { + const urls: string[] = this.purgeZoneForm + .get('purgeUrlsTextArea') + .value.split('\n') + .map((url) => url.trim()); + + this.dotCdnStore + .purgeCDNCache(urls) + .pipe(take(1)) + .subscribe(() => { + this.purgeZoneForm.setValue({ purgeUrlsTextArea: '' }); + }); + } + + private setChartOptions(): void { + const bandwidthOptions: ChartOptions = { + responsive: true, + plugins: { + tooltip: { + callbacks: { + label: function (context) { + return `${context.dataset.label}: ${context.formattedValue}`; + } + } + } + }, + scales: { + x: { + ticks: { + maxTicksLimit: 15 + } + }, + y: { + beginAtZero: true, + ticks: { + callback: function (value: number): string { + return value.toLocaleString('en-US'); + } + } + } + } + }; + + const requestOptions: ChartOptions = { + responsive: true, + plugins: { + tooltip: { + callbacks: { + label: function (context) { + return `${context.dataset.label}: ${Number(context.raw).toLocaleString('en-US')}`; + } + } + } + }, + scales: { + x: { + ticks: { + maxTicksLimit: 15 + } + }, + y: { + beginAtZero: true, + ticks: { + callback: function (value: number): string { + return value.toLocaleString('en-US'); + } + } + } + } + }; + + this.options = { bandwidthUsedChart: bandwidthOptions, requestsServedChart: requestOptions }; + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts new file mode 100644 index 000000000000..6d3878057211 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts @@ -0,0 +1,81 @@ +import { ChartOptions } from 'chart.js'; + +export interface ChartDataSet { + label: string; + data: string[]; + borderColor?: string; + fill?: boolean; +} + +export interface DotCDNStats { + stats: { + bandwidthPretty: string; + bandwidthUsedChart: { [key: string]: number }; + requestsServedChart: { [key: string]: number }; + cacheHitRate: number; + dateFrom: string; + dateTo: string; + geographicDistribution: unknown; + totalBandwidthUsed: number; + totalRequestsServed: number; + cdnDomain: string; + }; +} + +export interface ChartData { + labels: string[]; + datasets: ChartDataSet[]; +} + +export interface DotChartStats { + label: string; + value: string; + icon: string; +} + +export interface PurgeUrlOptions { + hostId: string; + invalidateAll: boolean; + urls?: string[]; +} + +export interface DotCDNState { + chartBandwidthData: ChartData; + chartRequestsData: ChartData; + statsData: DotChartStats[]; + isChartLoading: boolean; + cdnDomain: string; + isPurgeUrlsLoading: boolean; + isPurgeZoneLoading: boolean; +} + +export type CdnChartOptions = { + bandwidthUsedChart: ChartOptions; + requestsServedChart: ChartOptions; +}; + +export interface PurgeReturnData { + entity: { [key: string]: string | boolean }; + errors: string[]; + messages: string[]; + permissions: string[]; + i18nMessagesMap: { [key: string]: string }; +} + +export const enum ChartPeriod { + Last15Days = '15', + Last30Days = '30', + Last60Days = '60' +} + +export const enum LoadingState { + IDLE = 'IDLE', + LOADING = 'LOADING', + LOADED = 'LOADED' +} + +export const enum Loader { + CHART = 'CHART', + PURGE_URLS = 'PURGE_URLS', + PURGE_PULL_ZONE = 'PURGE_PULL_ZONE' +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts new file mode 100644 index 000000000000..b2d3c55184da --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts @@ -0,0 +1,68 @@ +import { format, subDays } from 'date-fns'; +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { map, mergeMap } from 'rxjs/operators'; + +import { SiteService } from '@dotcms/dotcms-js'; +import { DotCMSResponse } from '@dotcms/dotcms-models'; + +import { DotCDNStats, PurgeReturnData, PurgeUrlOptions } from './dot-cdn.models'; + +@Injectable({ + providedIn: 'root' +}) +export class DotCDNService { + private http = inject(HttpClient); + private siteService = inject(SiteService); + + requestStats(period: string): Observable { + return this.siteService.getCurrentSite().pipe( + map((site) => site.identifier), + mergeMap((hostId: string) => { + const dateTo = format(new Date(), 'yyyy-MM-dd'); + const dateFrom = format(subDays(new Date(), parseInt(period, 10)), 'yyyy-MM-dd'); + + return this.http + .get< + DotCMSResponse + >(`/api/v1/dotcdn/stats?hostId=${hostId}&dateFrom=${dateFrom}&dateTo=${dateTo}`) + .pipe(map((response) => response.entity)); + }) + ); + } + + purgeCache(urls?: string[]): Observable { + return this.siteService.getCurrentSite().pipe( + map((site) => site.identifier), + mergeMap((hostId: string) => { + return this.purgeUrlRequest({ hostId, invalidateAll: false, urls }); + }), + map((response) => response.entity) + ); + } + + purgeCacheAll(): Observable { + return this.siteService.getCurrentSite().pipe( + map((site) => site.identifier), + mergeMap((hostId: string) => this.purgeUrlRequest({ hostId, invalidateAll: true })), + map((response) => response.entity) + ); + } + + private purgeUrlRequest({ + urls = [], + invalidateAll, + hostId + }: PurgeUrlOptions): Observable> { + return this.http.request>('DELETE', '/api/v1/dotcdn', { + body: { + urls, + invalidateAll, + hostId + } + }); + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts new file mode 100644 index 000000000000..4a4b35ee47e6 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts @@ -0,0 +1,329 @@ +import { ComponentStore } from '@ngrx/component-store'; +import { tapResponse } from '@ngrx/operators'; +import { Observable, of } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { SelectItem } from 'primeng/api'; + +import { mergeMap, switchMap, tap } from 'rxjs/operators'; + +import { + ChartData, + ChartPeriod, + DotCDNState, + DotCDNStats, + DotChartStats, + Loader, + LoadingState, + PurgeReturnData +} from './dot-cdn.models'; +import { DotCDNService } from './dot-cdn.service'; + +@Injectable() +export class DotCDNStore extends ComponentStore { + private readonly dotCdnService = inject(DotCDNService); + selectedPeriod: SelectItem = { value: ChartPeriod.Last15Days }; + + constructor() { + super({ + chartBandwidthData: { + labels: [], + datasets: [] + }, + chartRequestsData: { + labels: [], + datasets: [] + }, + cdnDomain: '', + statsData: [], + isChartLoading: false, + isPurgeUrlsLoading: false, + isPurgeZoneLoading: false + }); + this.getChartStats(this.selectedPeriod.value); + } + + readonly vm$ = this.select( + ({ isChartLoading, chartBandwidthData, chartRequestsData, statsData, cdnDomain }) => ({ + chartBandwidthData, + chartRequestsData, + statsData, + isChartLoading, + cdnDomain + }) + ); + + readonly vmPurgeLoaders$ = this.select(({ isPurgeUrlsLoading, isPurgeZoneLoading }) => ({ + isPurgeUrlsLoading, + isPurgeZoneLoading + })); + + readonly updateChartState = this.updater( + ( + state, + chartData: Omit< + DotCDNState, + 'isChartLoading' | 'isPurgeUrlsLoading' | 'isPurgeZoneLoading' + > + ) => { + return { + ...state, + chartBandwidthData: chartData.chartBandwidthData, + chartRequestsData: chartData.chartRequestsData, + cdnDomain: chartData.cdnDomain, + statsData: chartData.statsData + }; + } + ); + + getChartStats = this.effect((period$: Observable): Observable => { + return period$.pipe( + mergeMap((period: string) => { + // TODO: Remove mock handling before merging + if (period.startsWith('mock-')) { + const mockData = DotCDNStore.generateMockData(period); + this.dispatchLoading({ loadingState: LoadingState.LOADED, loader: Loader.CHART }); + const { + statsData, + chartData: [chartBandwidthData, chartRequestsData], + cdnDomain + } = this.getChartStatsData(mockData); + this.updateChartState({ chartBandwidthData, chartRequestsData, statsData, cdnDomain }); + + return of(mockData); + } + + this.dispatchLoading({ + loadingState: LoadingState.LOADING, + loader: Loader.CHART + }); + + return this.dotCdnService.requestStats(period).pipe( + tapResponse({ + next: (data: DotCDNStats) => { + this.dispatchLoading({ + loadingState: LoadingState.LOADED, + loader: Loader.CHART + }); + const { + statsData, + chartData: [chartBandwidthData, chartRequestsData], + cdnDomain + } = this.getChartStatsData(data); + this.updateChartState({ + chartBandwidthData, + chartRequestsData, + statsData, + cdnDomain + }); + }, + error: (_error) => { + // TODO: Handle error + } + }) + ); + }) + ); + }); + + readonly dispatchLoading = this.updater( + (state, action: { loadingState: string; loader: string }) => { + switch (action.loader) { + case Loader.CHART: + return { + ...state, + isChartLoading: action.loadingState === LoadingState.LOADING + }; + + case Loader.PURGE_URLS: + return { + ...state, + isPurgeUrlsLoading: action.loadingState === LoadingState.LOADING + }; + + case Loader.PURGE_PULL_ZONE: + return { + ...state, + isPurgeZoneLoading: action.loadingState === LoadingState.LOADING + }; + } + } + ); + + purgeCDNCache(urls: string[]): Observable { + const loading$ = of( + this.dispatchLoading({ + loadingState: LoadingState.LOADING, + loader: Loader.PURGE_URLS + }) + ); + + return loading$.pipe( + switchMap(() => + this.dotCdnService.purgeCache(urls).pipe( + tap(() => { + this.dispatchLoading({ + loadingState: LoadingState.LOADED, + loader: Loader.PURGE_URLS + }); + }) + ) + ) + ); + } + + purgeCDNCacheAll(): void { + const $loading = of( + this.dispatchLoading({ + loadingState: LoadingState.LOADING, + loader: Loader.PURGE_PULL_ZONE + }) + ); + + $loading.pipe(switchMap(() => this.dotCdnService.purgeCacheAll())).subscribe(() => { + this.dispatchLoading({ + loadingState: LoadingState.LOADED, + loader: Loader.PURGE_PULL_ZONE + }); + }); + } + + private getChartStatsData({ stats }: DotCDNStats) { + const bandwidthValues = Object.values(stats.bandwidthUsedChart); + const { divisor, unit } = DotCDNStore.pickBandwidthUnit(bandwidthValues); + + const chartData: ChartData[] = [ + { + labels: this.getLabels(stats.bandwidthUsedChart), + datasets: [ + { + label: `Bandwidth (${unit})`, + data: bandwidthValues.map((v) => + (v / divisor).toFixed(2).toString() + ), + borderColor: '#6f5fa3', + fill: false + } + ] + }, + { + labels: this.getLabels(stats.requestsServedChart), + datasets: [ + { + label: 'Requests Served', + data: Object.values(stats.requestsServedChart).map( + (value: number): string => value.toString() + ), + borderColor: '#FFA726', + fill: false + } + ] + } + ]; + + const statsData: DotChartStats[] = [ + { + label: 'Bandwidth Used', + value: stats.bandwidthPretty, + icon: 'insert_chart_outlined' + }, + { + label: 'Requests Served', + value: DotCDNStore.formatNumber(stats.totalRequestsServed), + icon: 'file_download' + }, + { + label: 'Cache Hit Rate', + value: `${stats.cacheHitRate.toFixed(2)}%`, + icon: 'file_download' + } + ]; + + return { chartData, statsData, cdnDomain: stats.cdnDomain }; + } + + /** + * Pick the best bandwidth unit based on the max value in the dataset. + */ + private static pickBandwidthUnit(values: number[]): { divisor: number; unit: string } { + const max = Math.max(...values, 0); + if (max >= 1e9) { + return { divisor: 1e9, unit: 'GB' }; + } else if (max >= 1e6) { + return { divisor: 1e6, unit: 'MB' }; + } else if (max >= 1e3) { + return { divisor: 1e3, unit: 'KB' }; + } + + return { divisor: 1, unit: 'B' }; + } + + /** + * Format large numbers with commas (e.g. 1,234,567). + */ + private static formatNumber(value: number): string { + return value.toLocaleString('en-US'); + } + + private formatDate(date: string): string { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + } + + private getLabels(data: { [key: string]: number }): string[] { + return Object.keys(data).map((label) => { + return this.formatDate(label.split('T')[0]); + }); + } + + // TODO: Remove before merging — mock data for visual testing + private static generateMockData(mode: string): DotCDNStats { + const multipliers: Record = { + 'mock-high': { bw: 5e9, req: 500000 }, + 'mock-medium': { bw: 80e6, req: 25000 }, + 'mock-low': { bw: 500e3, req: 200 } + }; + const { bw, req } = multipliers[mode] || multipliers['mock-medium']; + + const bandwidthUsedChart: Record = {}; + const requestsServedChart: Record = {}; + let totalBw = 0; + let totalReq = 0; + + for (let i = 14; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const key = date.toISOString().split('T')[0] + 'T00:00:00'; + const bwVal = Math.round(bw * (0.5 + Math.random())); + const reqVal = Math.round(req * (0.5 + Math.random())); + bandwidthUsedChart[key] = bwVal; + requestsServedChart[key] = reqVal; + totalBw += bwVal; + totalReq += reqVal; + } + + const prettyBw = totalBw >= 1e9 + ? (totalBw / 1e9).toFixed(2) + ' GB' + : totalBw >= 1e6 + ? (totalBw / 1e6).toFixed(2) + ' MB' + : (totalBw / 1e3).toFixed(2) + ' KB'; + + return { + stats: { + bandwidthPretty: prettyBw, + bandwidthUsedChart, + requestsServedChart, + cacheHitRate: 72.5 + Math.random() * 20, + dateFrom: Object.keys(bandwidthUsedChart)[0], + dateTo: Object.keys(bandwidthUsedChart)[14], + geographicDistribution: {}, + totalBandwidthUsed: totalBw, + totalRequestsServed: totalReq, + cdnDomain: 'https://mock-cdn.example.com' + } + }; + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts new file mode 100644 index 000000000000..bb5d80961c2e --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts @@ -0,0 +1,10 @@ +import { Route } from '@angular/router'; + +import { DotCDNComponent } from './dot-cdn.component'; + +export const dotCdnRoutes: Route[] = [ + { + path: '', + component: DotCDNComponent + } +]; diff --git a/core-web/libs/portlets/dot-cdn/tsconfig.json b/core-web/libs/portlets/dot-cdn/tsconfig.json new file mode 100644 index 000000000000..8a932ccbe168 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/core-web/libs/portlets/dot-cdn/tsconfig.lib.json b/core-web/libs/portlets/dot-cdn/tsconfig.lib.json new file mode 100644 index 000000000000..d62a8082de0b --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 28336d565c77..bb8c83dfde96 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -69,6 +69,7 @@ "@dotcms/portlets/dot-locales/portlet/data-access": [ "libs/portlets/dot-locales/data-access/src/index.ts" ], + "@dotcms/portlets/dot-cdn/portlet": ["libs/portlets/dot-cdn/src/index.ts"], "@dotcms/portlets/dot-plugins/portlet": ["libs/portlets/dot-plugins/src/index.ts"], "@dotcms/portlets/dot-tags/portlet": ["libs/portlets/dot-tags/src/index.ts"], "@dotcms/portlets/dot-usage": ["libs/portlets/dot-usage/src/index.ts"], diff --git a/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java b/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java new file mode 100644 index 000000000000..b50015f09648 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java @@ -0,0 +1,13 @@ +package com.dotcms.cdn; + +public class CDNConstants { + + public static final String DOT_CDN_APP_KEY = "dotCDN"; + public static final String DOT_CDN_DOMAIN = "cdnDomain"; + public static final String DOT_CDN_ZONEID = "cdnZoneId"; + public static final String DOT_CDN_API_KEY = "cdnApiKey"; + + private CDNConstants() { + // constants class + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java b/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java new file mode 100644 index 000000000000..175fa3a22a52 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java @@ -0,0 +1,34 @@ +package com.dotcms.cdn; + +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import com.dotcms.filters.interceptor.Result; +import com.dotcms.filters.interceptor.WebInterceptor; +import com.dotmarketing.util.Logger; + +public class CDNInterceptor implements WebInterceptor { + + private static final long serialVersionUID = 1L; + + @Override + public String[] getFilters() { + return new String[] {"/*"}; + } + + @Override + public Result intercept(final HttpServletRequest request, final HttpServletResponse response) { + + final boolean recordDotHeaders = request.getParameter("recordDotHeaders") != null; + if (recordDotHeaders) { + final List headers = Collections.list(request.getHeaderNames()); + for (final String header : headers) { + Logger.info(this.getClass().getName(), + " CDN : " + header + " : " + request.getHeader(header)); + } + } + + return Result.NEXT; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java new file mode 100644 index 000000000000..7164ab4819cf --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java @@ -0,0 +1,57 @@ +package com.dotcms.cdn.api; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.contentlet.model.Contentlet; + +import java.util.List; + +public interface DotCDNAPI { + + static DotCDNAPI api(Host host) { + return new DotCDNAPIImpl(host); + } + + /** + * Logic to get and parse the Stats from bunny to {@link DotCDNStats} + * @param dateFromStr The start date of the statistics. + * @param dateToStr The end date of the statistics + * @return {@link DotCDNStats} + */ + DotCDNStats getStats(final String dateFromStr, final String dateToStr); + + /** + * Logic to invalidate the List of urls. + * @param urls List of url to invalidate + * @return true if all the urls were purged successfully, false if one or more failed. + */ + boolean invalidate(final List urls); + + /** + * Invalidate all urls related to the contentlet + * @param contentlet {@link Contentlet} + * @return boolean + */ + boolean invalidateContentlet(final Contentlet contentlet); + + /** + * Invalidate all urls related to the contentlet, in addition can pass extra urls to purge + * @param contentlet {@link Contentlet} + * @param urlsToPurge {@link List} + * @return boolean + */ + boolean invalidateContentlet(final Contentlet contentlet, final List urlsToPurge); + + /** + * Invalidate all pages urls related to the contentlet, in addition can pass extra urls to purge + * @param contentlet {@link Contentlet} + * @param urlsToPurge {@link List} + * @return boolean + */ + boolean invalidateRelatedPages(final Contentlet contentlet, final List urlsToPurge); + + /** + * Logic to invalidate the entire cache. + * @return true if the entire cache was invalidated successfully. + */ + boolean invalidateAll(); +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java new file mode 100644 index 000000000000..6ed145b7d38c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java @@ -0,0 +1,370 @@ +package com.dotcms.cdn.api; + +import com.dotcms.cdn.CDNConstants; +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.http.CircuitBreakerUrl.Method; +import com.dotcms.http.CircuitBreakerUrlBuilder; +import com.dotcms.repackage.org.apache.commons.httpclient.HttpStatus; +import com.dotcms.rest.RestClientBuilder; +import com.dotcms.rest.exception.BadRequestException; +import com.dotcms.rest.exception.NotFoundException; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.uuid.shorty.ShortyIdAPI; +import com.dotmarketing.beans.Host; +import com.dotmarketing.beans.Identifier; +import com.dotmarketing.beans.MultiTree; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.json.JSONObject; +import com.liferay.util.StringPool; +import io.vavr.control.Try; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DotCDNAPIImpl implements DotCDNAPI { + + private final String accessKey; + private final long pullZoneId; + private final String cdnDomain; + + /** + * Method to load the app secrets into the variables. + * @param host if is not sent will throw IllegalArgumentException, if sent will try to + * find the secrets for it, if there is no secrets for the host will use the ones for the System_Host + */ + DotCDNAPIImpl(final Host host) { + if (host == null || UtilMethods.isNotSet(host.getIdentifier())) { + Logger.warn(DotCDNAPIImpl.class, "There is no host sent or found"); + throw new IllegalArgumentException("There is no host sent or found"); + } + final Optional appSecrets = Try.of(() -> APILocator.getAppsAPI() + .getSecrets(CDNConstants.DOT_CDN_APP_KEY, true, host, APILocator.systemUser())) + .getOrElse(Optional.empty()); + if (!appSecrets.isPresent()) { + Logger.warn(DotCDNAPIImpl.class, "There is no config set, please set it via Apps Tool"); + throw new NotFoundException("There is no config set, please set it via Apps Tool"); + } + this.accessKey = appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_API_KEY).getString(); + this.cdnDomain = appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_DOMAIN).getString(); + this.pullZoneId = Long.parseLong( + appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_ZONEID).getString()); + } + + /** + * Url from bunny api to get the stats + * @param from The start date of the statistics. + * @param to The end date of the statistics + * @return url from bunny api with the passed dates and the pullzone + */ + private String statsUrl(final Instant from, final Instant to) { + final DateTimeFormatter formatter = + DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault()); + final String url = "https://api.bunny.net/statistics?dateFrom=" + formatter.format(from) + + "&dateTo=" + formatter.format(to) + "&pullZone=" + pullZoneId; + Logger.debug(DotCDNAPIImpl.class, "Sending URL:" + url); + return url; + } + + CircuitBreakerUrlBuilder urlBuilder(final String url) { + return new CircuitBreakerUrlBuilder() + .setHeaders(Map.of("AccessKey", accessKey, "accept", "application/json")) + .setTimeout(10000) + .setUrl(url) + .setMethod(Method.GET); + } + + /** + * Method to get the response from the url that was hit. + * @return if the response was successful the object is returned, if not FAILURE is returned. + */ + private Map getData(CircuitBreakerUrlBuilder url) { + final String response = Try.of(() -> url.build().doString()) + .getOrElse("{\"response\":\"FAILURE\"}"); + JSONObject jsonObject = new JSONObject(response); + return jsonObject; + } + + /** + * Logic to get and parse the Stats from bunny to {@link DotCDNStats} + * @param dateFromStr The start date of the statistics. + * @param dateToStr The end date of the statistics + * @return {@link DotCDNStats} + */ + @Override + public DotCDNStats getStats(final String dateFromStr, final String dateToStr) { + final LocalDateTime now = LocalDateTime.now(); + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + final Date from = Try.of(() -> sdf.parse(dateFromStr)).getOrNull(); + final Date to = Try.of(() -> sdf.parse(dateToStr)).getOrNull(); + if (from == null) { + Logger.info(this.getClass().getName(), + "dateFrom is not sent or does not comply with the format yyyy-MM-dd, using date of 15 days ago"); + } + final Instant dateFrom = from == null + ? now.atZone(ZoneId.of("UTC")).withSecond(0).withMinute(0).withHour(0) + .minusDays(15).toInstant() + : from.toInstant(); + if (to == null) { + Logger.info(this.getClass().getName(), + "dateTo is not sent or does not comply with the format yyyy-MM-dd, using today's date instead"); + } + final Instant dateTo = to == null + ? now.atZone(ZoneId.of("UTC")).withSecond(0).withMinute(0).withHour(0).toInstant() + : to.toInstant(); + + final String statsUrl = statsUrl(dateFrom, dateTo); + final Map data = getData(urlBuilder(statsUrl)); + if ("FAILURE".equals(data.get("response"))) { + final String message = "Failed to get the Stats using the url: " + statsUrl + + " , please check the App Configuration"; + Logger.info(this.getClass().getName(), message); + throw new BadRequestException(message); + } + final DotCDNStats.DotStatsBuilder builder = DotCDNStats.builder() + .withCDNDomain(cdnDomain) + .withDateFrom(dateFrom.toString()) + .withDateTo(dateTo.toString()) + .withCacheHitRate(((Number) data.get("CacheHitRate")).doubleValue()) + .withTotalRequestsServed(Long.parseLong(data.get("TotalRequestsServed") + "")) + .withTotalBandwidthUsed(Long.parseLong(data.get("TotalBandwidthUsed") + "")); + + @SuppressWarnings("unchecked") + final Map incomingGeo = + (Map) data.get("GeoTrafficDistribution"); + + @SuppressWarnings("unchecked") + final Map bandwidthUsedChart = + (Map) data.get("BandwidthUsedChart"); + builder.withBandwidthUsedChart(bandwidthUsedChart); + + @SuppressWarnings("unchecked") + final Map requestsServedChart = + (Map) data.get("RequestsServedChart"); + builder.withRequestsServedChart(requestsServedChart); + + final Map> geoMap = new LinkedHashMap<>(); + for (final String key : incomingGeo.keySet()) { + final String[] keySplit = key.split(":"); + final long traffic = Long.parseLong(incomingGeo.get(key) + ""); + final Map geoEntry = geoMap.computeIfAbsent(keySplit[0], + e -> new HashMap<>()); + geoEntry.put(keySplit[1], traffic); + geoMap.put(keySplit[0], geoEntry); + } + builder.withGeographicDistribution(geoMap); + + return builder.build(); + } + + @Override + public boolean invalidateContentlet(final Contentlet contentlet) { + return this.invalidateContentlet(contentlet, Collections.emptyList()); + } + + @Override + public boolean invalidateContentlet(final Contentlet contentlet, + final List urlsToPurgeParam) { + + final List urlsToPurge = new ArrayList<>(); + final List contentletList = new ArrayList<>(); + contentletList.add(contentlet); + + if (!UtilMethods.isSet(urlsToPurgeParam)) { + urlsToPurge.addAll(urlsToPurgeParam); + } + + if (contentlet.isHTMLPage()) { + contentletList.addAll(findPageContent(contentlet)); + } else { + contentletList.addAll(findPagesUsingContent(contentlet)); + } + + contentletList.forEach(contentletItem -> { + Try.of(() -> urlsToPurge.addAll(createUrlsToPurgeForContentlet(contentletItem))) + .onFailure(e -> Logger.warn(this.getClass().getName(), + "Unable to create urls to purge based on the contentlet: " + + e.getMessage())); + }); + + return this.invalidate(urlsToPurge); + } + + @Override + public boolean invalidateRelatedPages(final Contentlet contentlet, + final List urlsToPurgeParam) { + + final List urlsToPurge = new ArrayList<>(); + + if (!UtilMethods.isSet(urlsToPurgeParam)) { + urlsToPurge.addAll(urlsToPurgeParam); + } + + findPagesUsingContent(contentlet).forEach(contentletItem -> { + Try.of(() -> urlsToPurge.addAll(createUrlsToPurgeForContentlet(contentletItem))) + .onFailure(e -> Logger.warn(this.getClass().getName(), + "Unable to create urls to purge based on the contentlet: " + + e.getMessage())); + }); + + return this.invalidate(urlsToPurge); + } + + /** + * Creates a List of strings (urls) based on the contentlet properties (path, identifier, inode). + * @param contentlet contentlet which the actionlet is being fired + * @return list of strings urls + */ + private List createUrlsToPurgeForContentlet(final Contentlet contentlet) { + + final List urlsForContentlet = new ArrayList<>(); + + final Identifier identifier = Try.of( + () -> APILocator.getIdentifierAPI().find(contentlet.getIdentifier())).getOrNull(); + + final String path = identifier.getPath(); + urlsForContentlet.add(path); + + if (path.endsWith("/index")) { + urlsForContentlet.add(path.substring(0, path.length() - 5)); + urlsForContentlet.add(path.substring(0, path.length() - 6)); + } + + final ShortyIdAPI shorty = APILocator.getShortyAPI(); + final String inode = contentlet.getInode(); + final String id = identifier.getId(); + + urlsForContentlet.add(buildUrl(id, "*")); + urlsForContentlet.add(buildUrl(inode, "*")); + + urlsForContentlet.add(buildUrl(shorty.shortify(id), "*")); + urlsForContentlet.add(buildUrl(shorty.shortify(inode), "*")); + + final String urlMap = Try.of(() -> APILocator.getContentletAPI() + .getUrlMapForContentlet(contentlet, APILocator.systemUser(), false)).getOrNull(); + if (urlMap != null) { + urlsForContentlet.add(urlMap); + } + + urlsForContentlet.removeIf(UtilMethods::isEmpty); + + Logger.info(this, urlsForContentlet.toString()); + + return urlsForContentlet; + } + + private List findPagesUsingContent(final Contentlet contentlet) { + final List trees = Try.of( + () -> APILocator.getMultiTreeAPI().getMultiTreesByChild(contentlet.getIdentifier())) + .getOrElse(new ArrayList<>()); + + return trees.stream() + .map(tree -> Try.of(() -> APILocator.getContentletAPI() + .findContentletByIdentifierAnyLanguage(tree.getHtmlPage())).getOrNull()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private List findPageContent(final Contentlet contentlet) { + final List trees = Try.of( + () -> APILocator.getMultiTreeAPI().getMultiTreesByPage(contentlet.getIdentifier())) + .getOrElse(new ArrayList<>()); + + return trees.stream() + .map(tree -> Try.of(() -> APILocator.getContentletAPI() + .findContentletByIdentifierAnyLanguage(tree.getContentlet())).getOrNull()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private String buildUrl(final String... concat) { + return "/dA/" + String.join(StringPool.SLASH, concat); + } + + /** + * Logic to invalidate the List of urls. + * @param urls List of url to invalidate + * @return true if all the urls were purged successfully, false if one or more failed. + */ + @Override + public boolean invalidate(final List urls) { + boolean results = true; + for (final String url : urls) { + + final String urlToPurge = url.startsWith(cdnDomain) ? url : + url.startsWith("/") ? cdnDomain + url : + cdnDomain + "/" + url; + + Logger.info(this.getClass().getName(), "Purging URL: " + urlToPurge); + + final CircuitBreakerUrl.Response response = Try.of(() -> + this.urlBuilder(invalidateUrl(urlToPurge)) + .setMethod(Method.POST) + .setThrowWhenError(false) + .build() + .doResponse() + ).getOrNull(); + + if (response == null) { + Logger.warn(this.getClass().getName(), + "Purge failed (no response) for: " + urlToPurge); + results = false; + } else if (!CircuitBreakerUrl.isSuccessResponse(response.getStatusCode())) { + Logger.warn(this.getClass().getName(), + "Purge failed for: " + urlToPurge + + " - HTTP " + response.getStatusCode() + + " - " + response.getResponse()); + results = false; + } else { + Logger.info(this.getClass().getName(), "Purge successful for: " + urlToPurge); + } + } + return results; + } + + /** + * Logic to invalidate the entire cache. + * @return true if the entire cache was invalidated successfully. + */ + @Override + public boolean invalidateAll() { + final Response response = RestClientBuilder.newClient().target(invalidateAllUrl()) + .request(MediaType.APPLICATION_JSON_TYPE) + .header("AccessKey", accessKey) + .post(Entity.entity("", MediaType.TEXT_PLAIN)); + Logger.debug(this.getClass().getName(), "Response: " + response); + if (response.getStatus() != HttpStatus.SC_NO_CONTENT) { + final String message = "Failed to Purge the entire Cache: " + invalidateAllUrl() + + " , please check the App Configuration"; + Logger.info(this.getClass().getName(), message); + throw new BadRequestException(message); + } + return true; + } + + private String invalidateAllUrl() { + return "https://api.bunny.net/pullzone/" + this.pullZoneId + "/purgeCache"; + } + + private String invalidateUrl(String url) { + return "https://api.bunny.net/purge?url=" + url; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java new file mode 100644 index 000000000000..5f1e62204406 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java @@ -0,0 +1,172 @@ +package com.dotcms.cdn.api; + +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Collections; +import java.util.Map; +import javax.annotation.Nonnull; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +@JsonDeserialize(builder = DotCDNStats.DotStatsBuilder.class) +public class DotCDNStats { + + @JsonSerialize + final String cdnDomain; + + @JsonSerialize + final String dateFrom; + + @JsonSerialize + final String dateTo; + + @JsonSerialize + final double cacheHitRate; + + @JsonSerialize + final long totalBandwidthUsed; + + @JsonSerialize + final long totalRequestsServed; + + @JsonSerialize + final String bandwidthPretty; + + @JsonSerialize + final Map> geographicDistribution; + + @JsonSerialize + final Map bandwidthUsedChart; + + @JsonSerialize + final Map requestsServedChart; + + private DotCDNStats(DotStatsBuilder builder) { + this.cdnDomain = builder.cdnDomain; + this.dateFrom = builder.dateFrom.toString(); + this.dateTo = builder.dateTo.toString(); + this.cacheHitRate = builder.cacheHitRate; + this.totalBandwidthUsed = builder.totalBandwidthUsed; + this.totalRequestsServed = builder.totalRequestsServed; + this.geographicDistribution = builder.geographicDistribution; + this.bandwidthPretty = prettyByteify(builder.totalBandwidthUsed); + this.bandwidthUsedChart = builder.bandwidthUsedChart; + this.requestsServedChart = builder.requestsServedChart; + } + + /** + * Creates builder to build {@link DotCDNStats}. + * @return created builder + */ + public static DotStatsBuilder builder() { + return new DotStatsBuilder(); + } + + /** + * Creates a builder to build {@link DotCDNStats} and initialize it with the given object. + * @param dotCDNStats to initialize the builder with + * @return created builder + */ + public static DotStatsBuilder from(DotCDNStats dotCDNStats) { + return new DotStatsBuilder(dotCDNStats); + } + + /** + * Builder to build {@link DotCDNStats}. + */ + public static final class DotStatsBuilder { + + private String cdnDomain; + private String dateFrom; + private String dateTo; + private double cacheHitRate; + private long totalBandwidthUsed; + private long totalRequestsServed; + private Map> geographicDistribution = Collections.emptyMap(); + private Map bandwidthUsedChart; + private Map requestsServedChart; + + private DotStatsBuilder() {} + + private DotStatsBuilder(DotCDNStats dotCDNStats) { + this.cdnDomain = dotCDNStats.cdnDomain; + this.dateFrom = dotCDNStats.dateFrom; + this.dateTo = dotCDNStats.dateTo; + this.cacheHitRate = dotCDNStats.cacheHitRate; + this.totalBandwidthUsed = dotCDNStats.totalBandwidthUsed; + this.totalRequestsServed = dotCDNStats.totalRequestsServed; + this.geographicDistribution = dotCDNStats.geographicDistribution; + this.bandwidthUsedChart = dotCDNStats.bandwidthUsedChart; + this.requestsServedChart = dotCDNStats.requestsServedChart; + } + + public DotStatsBuilder withCDNDomain(@Nonnull String cdnDomain) { + this.cdnDomain = cdnDomain; + return this; + } + + public DotStatsBuilder withDateFrom(@Nonnull String dateFrom) { + this.dateFrom = dateFrom; + return this; + } + + public DotStatsBuilder withDateTo(@Nonnull String dateTo) { + this.dateTo = dateTo; + return this; + } + + public DotStatsBuilder withCacheHitRate(@Nonnull double cacheHitRate) { + this.cacheHitRate = cacheHitRate; + return this; + } + + public DotStatsBuilder withTotalBandwidthUsed(@Nonnull long totalBandwidthUsed) { + this.totalBandwidthUsed = totalBandwidthUsed; + return this; + } + + public DotStatsBuilder withTotalRequestsServed(@Nonnull long totalRequestsServed) { + this.totalRequestsServed = totalRequestsServed; + return this; + } + + public DotStatsBuilder withGeographicDistribution( + @Nonnull Map> geographicDistribution) { + this.geographicDistribution = geographicDistribution; + return this; + } + + public DotStatsBuilder withBandwidthUsedChart(@Nonnull Map bandWidthUsed) { + this.bandwidthUsedChart = bandWidthUsed; + return this; + } + + public DotStatsBuilder withRequestsServedChart( + @Nonnull Map requestsServedChart) { + this.requestsServedChart = requestsServedChart; + return this; + } + + public DotCDNStats build() { + return new DotCDNStats(this); + } + } + + private static final NumberFormat NF = new DecimalFormat("#.00"); + private static final int DIVIDE_BY = 1000; + + private String prettyByteify(long memory) { + double x = memory; + if (x > (DIVIDE_BY * DIVIDE_BY * DIVIDE_BY)) { + return NF.format(x / (DIVIDE_BY * DIVIDE_BY * DIVIDE_BY)) + " GB"; + } else if (x > (DIVIDE_BY * DIVIDE_BY)) { + return NF.format(x / (DIVIDE_BY * DIVIDE_BY)) + " MB"; + } else if (x > DIVIDE_BY) { + return NF.format(x / DIVIDE_BY) + " KB"; + } else if (x > 1) { + return NF.format(x) + " B"; + } else { + return "0 b"; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java b/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java new file mode 100644 index 000000000000..eb205e238b08 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java @@ -0,0 +1,118 @@ +package com.dotcms.cdn.pushpublish.receiver.event; + +import com.dotcms.cdn.api.DotCDNAPI; +import com.dotcms.publisher.business.PublishQueueElement; +import com.dotcms.system.event.local.model.Subscriber; +import com.dotcms.system.event.local.type.pushpublish.receiver.PushPublishEndOnReceiverEvent; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.business.ContentletAPI; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import io.vavr.control.Try; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * This subscriber is in charge of invalidating CDN cache when push-publish completes on receiver. + */ +public class PushPublishOnReceiverEndSubscriber { + + private final boolean live = false; + private final User user = APILocator.systemUser(); + private final boolean respectFrontendRoles = false; + + @Subscriber + public void notify(PushPublishEndOnReceiverEvent event) { + + final ContentletAPI contentletAPI = APILocator.getContentletAPI(); + final List publishQueueElementsUnFiltered = + event.getPublishQueueElements(); + final List publishQueueElements = + publishQueueElementsUnFiltered.stream() + .filter(pqe -> "contentlet".equals(pqe.getType())) + .collect(Collectors.toList()); + final int minimumNumberToPurgeAll = + Config.getIntProperty("DOT_CDN_MIN_NUM_TO_PURGE_ALL", 250); + + if (null != publishQueueElements) { + if (publishQueueElements.size() > minimumNumberToPurgeAll) { + this.invalidateAllSites(contentletAPI, publishQueueElements); + } else { + invalidateContentlets(contentletAPI, publishQueueElements); + } + } + } + + private void invalidateContentlets(final ContentletAPI contentletAPI, + final List publishQueueElements) { + + final Map dotCDNAPIMap = new HashMap<>(); + Logger.info(this, "Purging selective Contentlets on CDN"); + + for (final PublishQueueElement publishQueueElement : publishQueueElements) { + + final String identifier = publishQueueElement.getAsset(); + final long languageId = publishQueueElement.getLanguageId(); + final Contentlet contentlet = Try.of(() -> contentletAPI + .findContentletByIdentifier(identifier, live, languageId, user, + respectFrontendRoles)).getOrNull(); + + if (null != contentlet) { + final String hostId = contentlet.getHost(); + final Host site = Try.of( + () -> APILocator.getHostAPI().find(hostId, user, respectFrontendRoles)) + .getOrNull(); + if (null != site) { + final DotCDNAPI cdnApi = + dotCDNAPIMap.computeIfAbsent(hostId, k -> DotCDNAPI.api(site)); + Logger.debug(this, () -> "Invalidating the contentlet: " + + contentlet.getIdentifier()); + cdnApi.invalidateContentlet(contentlet); + } else { + Logger.debug(this, () -> "Can not Invalidating the contentlet: " + + identifier + " b/c could not the host: " + hostId); + } + } else { + Logger.debug(this, () -> "Can not Invalidating the contentlet: " + + identifier + " b/c could not find it"); + } + } + } + + private void invalidateAllSites(final ContentletAPI contentletAPI, + final List publishQueueElements) { + + final Map hostMap = new HashMap<>(); + for (final PublishQueueElement publishQueueElement : publishQueueElements) { + + final String identifier = publishQueueElement.getAsset(); + final long languageId = publishQueueElement.getLanguageId(); + final Contentlet contentlet = Try.of(() -> contentletAPI + .findContentletByIdentifier(identifier, live, languageId, user, + respectFrontendRoles)).getOrNull(); + if (null != contentlet) { + final Host site = Try.of(() -> APILocator.getHostAPI() + .find(contentlet.getHost(), user, respectFrontendRoles)).getOrNull(); + if (null != site) { + hostMap.put(contentlet.getHost(), site); + } + } + } + + final Collection sites = hostMap.values(); + for (final Host site : sites) { + final DotCDNAPI cdnApi = DotCDNAPI.api(site); + Logger.info(this, "Purging all CDN, host: " + site.getHostname()); + final boolean resultInvalidate = cdnApi.invalidateAll(); + Logger.info(this, "Purge result: " + resultInvalidate + + " host: " + site.getHostname()); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java b/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java new file mode 100644 index 000000000000..6423064f481e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java @@ -0,0 +1,113 @@ +package com.dotcms.cdn.rest; + +import com.dotcms.cdn.api.DotCDNAPI; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.WebKeys; +import com.liferay.portal.model.User; +import io.vavr.Lazy; +import io.vavr.control.Try; + +import java.io.Serializable; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +@Path("/v1/dotcdn") +public class DotCDNResource implements Serializable { + + private static final long serialVersionUID = 204840922704940654L; + + /** + * This endpoint is to get the statistics from the CDN. + * We get the stats of a range of dates. + * + * The hostId property is to get the AppSecret Configuration. If the hostId is not send will + * try to get it from the session. + * + * @param dateFromStr date from we should get the stats, format yyyy-MM-dd + * @param dateToStr date until we should get the stats, format: yyyy-MM-dd + * @param hostId Id of the host which App config we should get. + * @return stats response + */ + @GET + @Path("/stats") + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public final Response getStats( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("dateFrom") final String dateFromStr, + @QueryParam("dateTo") final String dateToStr, + @QueryParam("hostId") final String hostId) { + + final User user = new WebResource.InitBuilder(request, response) + .rejectWhenNoUser(true).requiredBackendUser(true) + .requiredPortlet("dotCDN").init().getUser(); + + final Lazy lazyCurrentHost = Lazy.of(() -> Try.of( + () -> Host.class.cast(request.getSession().getAttribute(WebKeys.CURRENT_HOST))) + .getOrNull()); + final Host host = Try.of(() -> APILocator.getHostAPI() + .find(hostId, user, false)).getOrElse(lazyCurrentHost.get()); + + return Response.ok(new ResponseEntityView( + Map.of("stats", DotCDNAPI.api(host).getStats(dateFromStr, dateToStr)))).build(); + } + + /** + * This endpoint is to purgeCache of the CDN. + * You can purge the entire cache by setting the invalidateAll to true + * Or you can purge specific urls by sending them in a Array. + * + * The hostId property is to get the AppSecret Configuration. If the hostId is not send will + * try to get it from the session. + * + * @param invalidationForm request body + * @return a message if the purged was successful. + */ + @DELETE + @Path("/") + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public final Response purgeCache(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final InvalidationForm invalidationForm) { + + final User user = new WebResource.InitBuilder(request, response) + .rejectWhenNoUser(true).requiredBackendUser(true) + .requiredPortlet("dotCDN").init().getUser(); + + final Lazy lazyCurrentHost = Lazy.of(() -> Try.of( + () -> Host.class.cast(request.getSession().getAttribute(WebKeys.CURRENT_HOST))) + .getOrNull()); + final Host host = Try.of(() -> APILocator.getHostAPI() + .find(invalidationForm.getHostId(), user, false)) + .getOrElse(lazyCurrentHost.get()); + + final DotCDNAPI cdnApi = DotCDNAPI.api(host); + + if (invalidationForm.isInvalidateAll()) { + Logger.info(this.getClass().getName(), + "User:" + user.getUserId() + " purging entire cache"); + return Response.ok(new ResponseEntityView( + Map.of("Entire Cache Purged: ", cdnApi.invalidateAll()))).build(); + } + + return Response.ok(new ResponseEntityView( + Map.of("All Urls Sent Purged: ", + cdnApi.invalidate(invalidationForm.getUrls())))).build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java b/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java new file mode 100644 index 000000000000..deb1dbfd9854 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java @@ -0,0 +1,63 @@ +package com.dotcms.cdn.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.ArrayList; +import java.util.List; + +@JsonDeserialize(builder = InvalidationForm.Builder.class) +public class InvalidationForm { + + private final List urls; + private final boolean invalidateAll; + private final String hostId; + + private InvalidationForm(Builder builder) { + urls = builder.urls; + invalidateAll = builder.invalidateAll; + hostId = builder.hostId; + } + + public boolean isInvalidateAll() { + return invalidateAll; + } + + public String getHostId() { + return hostId; + } + + public List getUrls() { + return urls; + } + + public static final class Builder { + + @JsonProperty + private List urls = new ArrayList<>(); + + @JsonProperty + private String hostId; + + @JsonProperty + private boolean invalidateAll = false; + + public Builder urls(final List urls) { + this.urls = urls; + return this; + } + + public Builder invalidateAll(final boolean invalidateAll) { + this.invalidateAll = invalidateAll; + return this; + } + + public Builder hostId(final String hostId) { + this.hostId = hostId; + return this; + } + + public InvalidationForm build() { + return new InvalidationForm(this); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java new file mode 100644 index 000000000000..36953029d0fb --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java @@ -0,0 +1,45 @@ +package com.dotcms.cdn.viewtool; + +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.tools.ViewTool; +import com.dotcms.cdn.CDNConstants; +import com.dotcms.security.apps.AppSecrets; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.web.WebAPILocator; +import io.vavr.Lazy; +import io.vavr.control.Try; + +public class DotCDNTool implements ViewTool { + + private HttpServletRequest request; + private Host host; + + private final Lazy> appSecrets = Lazy.of(() -> + Try.of(() -> APILocator.getAppsAPI() + .getSecrets(CDNConstants.DOT_CDN_APP_KEY, true, host, APILocator.systemUser())) + .getOrElse(Optional.empty())); + + @Override + public void init(Object initData) { + this.request = ((ViewContext) initData).getRequest(); + this.host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(this.request); + } + + /** + * Creates the full url where the file lives in the cdn. + * + * @return the cdn domain + url, that is where the file lives + */ + public String cdnify(final String fileUrl) { + if (!appSecrets.get().isPresent() || fileUrl == null + || fileUrl.contains("//") || !fileUrl.startsWith("/")) { + return fileUrl; + } + + return appSecrets.get().get().getSecrets() + .get(CDNConstants.DOT_CDN_DOMAIN).getString() + fileUrl; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java new file mode 100644 index 000000000000..7cbb72ccb4e2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java @@ -0,0 +1,30 @@ +package com.dotcms.cdn.viewtool; + +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.servlet.ServletToolInfo; + +public class DotCDNToolInfo extends ServletToolInfo { + + @Override + public String getKey() { + return "dotcdn"; + } + + @Override + public String getScope() { + return ViewContext.REQUEST; + } + + @Override + public String getClassname() { + return DotCDNTool.class.getName(); + } + + @Override + public Object getInstance(Object initData) { + DotCDNTool viewTool = new DotCDNTool(); + viewTool.init(initData); + setScope(ViewContext.REQUEST); + return viewTool; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java b/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java new file mode 100644 index 000000000000..94f29d732237 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java @@ -0,0 +1,99 @@ +package com.dotcms.cdn.workflow; + +import com.dotcms.cdn.api.DotCDNAPI; +import com.dotcms.concurrent.DotConcurrentFactory; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.workflows.actionlet.WorkFlowActionlet; +import com.dotmarketing.portlets.workflows.model.WorkflowActionClassParameter; +import com.dotmarketing.portlets.workflows.model.WorkflowActionFailureException; +import com.dotmarketing.portlets.workflows.model.WorkflowActionletParameter; +import com.dotmarketing.portlets.workflows.model.WorkflowProcessor; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import io.vavr.control.Try; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; + +public class DotCDNInvalidateActionlet extends WorkFlowActionlet { + + private static final long serialVersionUID = 1L; + private static final String PURGE_CONTENTLET_PARAM = "purgeContentlet"; + private static final String PURGE_URL_PARAM = "purgeUrl"; + + @Override + public List getParameters() { + final List params = new ArrayList<>(); + params.add(new WorkflowActionletParameter(PURGE_CONTENTLET_PARAM, + "Purge Contentlet", "true", false)); + params.add(new WorkflowActionletParameter(PURGE_URL_PARAM, + "Additional Url(s) to Purge", "", false)); + return params; + } + + @Override + public String getName() { + return "dotCDN Purge"; + } + + @Override + public String getHowTo() { + return "URL(s) to purge: can be set to specific url(s) to be purged. " + + "Use comma (,) as a delimiter.
" + + "Purge Contentlet: It creates a list of patterns that should " + + "be purged using the $contentlet object." + + "e.g. /dA/$contentlet.identifier/* or /dA/$contentlet.shortyInode/*"; + } + + @Override + public void executeAction(WorkflowProcessor processor, + Map params) + throws WorkflowActionFailureException { + + final Contentlet contentlet = processor.getContentlet(); + final String purgeUrls = params.get(PURGE_URL_PARAM).getValue(); + final boolean isPurgeContentlet = Try.of( + () -> Boolean.parseBoolean( + params.get(PURGE_CONTENTLET_PARAM).getValue().trim())) + .getOrElse(true); + final Host host = Try.of(() -> APILocator.getHostAPI() + .find(contentlet.getHost(), APILocator.systemUser(), false)) + .getOrNull(); + if (host == null) { + Logger.warn(this.getClass().getName(), "Contentlet Host is Null"); + return; + } + final DotCDNAPI cdnApi = DotCDNAPI.api(host); + final List urlsToPurge = new ArrayList<>(); + + if (!UtilMethods.isEmpty(purgeUrls)) { + urlsToPurge.addAll(parseUrlsParam(purgeUrls)); + } + + Logger.info(this, "PurgeContentlet: " + isPurgeContentlet); + if (isPurgeContentlet) { + DotConcurrentFactory.getInstance().getSubmitter().submit( + () -> cdnApi.invalidateContentlet(contentlet, urlsToPurge)); + } else { + DotConcurrentFactory.getInstance().getSubmitter().submit( + () -> cdnApi.invalidateRelatedPages(contentlet, urlsToPurge)); + } + } + + private List parseUrlsParam(final String urlsParam) { + final List urls = new ArrayList<>(); + if (urlsParam == null) { + return urls; + } + final StringTokenizer st = new StringTokenizer(urlsParam, ","); + while (st.hasMoreTokens()) { + urls.add(st.nextToken().trim()); + } + urls.removeIf(UtilMethods::isEmpty); + return urls; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java index f44ca0470f7f..2f41373d6d57 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java @@ -143,6 +143,7 @@ private void configureApplication() { "com.dotcms.contenttype.model.field", "com.dotcms.rendering.js", "com.dotcms.ai.rest", + "com.dotcms.cdn.rest", "com.dotcms.health", "io.swagger.v3.jaxrs2")); diff --git a/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java b/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java index 1e27f3f8436a..31d3b6a75ebf 100644 --- a/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java +++ b/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java @@ -1,5 +1,6 @@ package com.dotcms.system.event.local.business; +import com.dotcms.cdn.pushpublish.receiver.event.PushPublishOnReceiverEndSubscriber; import com.dotcms.ai.listener.AIAppListener; import com.dotcms.analytics.listener.AnalyticsAppListener; import com.dotcms.config.DotInitializer; @@ -72,6 +73,8 @@ public void notify(final ChangeLoggerLevelEvent event) { APILocator.getLocalSystemEventsAPI().subscribe(AppSecretSavedEvent.class, AnalyticsAppListener.Instance.get()); APILocator.getLocalSystemEventsAPI().subscribe(AppSecretSavedEvent.class, AIAppListener.Instance.get()); + APILocator.getLocalSystemEventsAPI().subscribe(new PushPublishOnReceiverEndSubscriber()); + this.initDotVelocityMacrosVtlFiles(); } diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java index 700a3cdbf4ba..e88b41731e8c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java @@ -1,6 +1,7 @@ package com.dotmarketing.filters; import com.dotcms.analytics.track.AnalyticsTrackWebInterceptor; +import com.dotcms.cdn.CDNInterceptor; import com.dotcms.business.SystemTableUpdatedKeyEvent; import com.dotcms.ema.EMAWebInterceptor; import com.dotcms.filters.interceptor.AbstractWebInterceptorSupportFilter; @@ -56,6 +57,7 @@ private void addInterceptors(final FilterConfig config) { delegate.add(new EventLogWebInterceptor()); delegate.add(new CurrentVariantWebInterceptor()); delegate.add(analyticsTrackWebInterceptor); + delegate.add(new CDNInterceptor()); APILocator.getLocalSystemEventsAPI().subscribe(SystemTableUpdatedKeyEvent.class, analyticsTrackWebInterceptor); } // addInterceptors. diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java index bbd8876e840b..1797d359f42a 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java @@ -22,6 +22,7 @@ import com.dotcms.exception.ExceptionUtil; import com.dotcms.notifications.bean.NotificationLevel; import com.dotcms.notifications.bean.NotificationType; +import com.dotcms.cdn.workflow.DotCDNInvalidateActionlet; import com.dotcms.rekognition.actionlet.RekognitionActionlet; import com.dotcms.rendering.js.JsScriptActionlet; import com.google.common.annotations.VisibleForTesting; @@ -295,7 +296,8 @@ public WorkflowAPIImpl() { OpenAIAutoTagActionlet.class, AnalyticsFireUserEventActionlet.class, OpenAIVisionAutoTagActionlet.class, - OpenAITranslationActionlet.class + OpenAITranslationActionlet.class, + DotCDNInvalidateActionlet.class )); refreshWorkFlowActionletMap(); diff --git a/dotCMS/src/main/resources/apps/dotCDN.yml b/dotCMS/src/main/resources/apps/dotCDN.yml new file mode 100644 index 000000000000..3e09a1b257b8 --- /dev/null +++ b/dotCMS/src/main/resources/apps/dotCDN.yml @@ -0,0 +1,30 @@ +--- +name: "dotCDN" +description: "This app connects your dotCMS instance with dotCMS's CDN for faster asset delivery. If you are interested in using this, please contact your dotCMS sales representative." +iconUrl: "https://static.dotcms.com/assets/icons/apps/dotCDN.png" +allowExtraParameters: false +params: + + cdnDomain: + label: "CDN domain" + value: "" + hidden: false + type: "STRING" + hint: "The base domain for your CDN, e.g. https://cdn.clientname.com" + required: true + + cdnApiKey: + label: "CDN API/Client Key" + value: "" + hidden: true + type: "STRING" + hint: "CDN API Key (dotCMS will provide)" + required: true + + cdnZoneId: + label: "CDN Zone ID" + value: "" + hidden: false + type: "STRING" + hint: "The machine ID for your CDN (dotCMS will provide)" + required: true diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index 859040355de0..9083c2320470 100644 --- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml +++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml @@ -379,6 +379,12 @@ com.dotcms.rest.JSPPortlet + + dotCDN + dotCDN + com.dotcms.spring.portlet.PortletController + + diff --git a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml index 23f8793935fc..991904a8fc36 100644 --- a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml +++ b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml @@ -74,6 +74,11 @@ request com.dotcms.rendering.velocity.viewtools.DotRenderTool + + dotcdn + request + com.dotcms.cdn.viewtool.DotCDNTool + import request diff --git a/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java b/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java new file mode 100644 index 000000000000..dd49df83d99b --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java @@ -0,0 +1,326 @@ +package com.dotcms.cdn.api; + +import com.dotcms.cdn.CDNConstants; +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.http.CircuitBreakerUrlBuilder; +import com.dotcms.rest.RestClientBuilder; +import com.dotcms.rest.exception.BadRequestException; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.AppsAPI; +import com.dotcms.security.apps.Secret; +import com.dotcms.security.apps.Type; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import org.apache.http.conn.ConnectTimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.Invocation; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the DotCDNAPI class. These tests verify the behavior of the API methods + * related to fetching CDN stats and invalidating URLs using a mock setup. + */ +class DotCDNAPITest { + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Mock + private AppsAPI appsAPI; + + @Mock + private AppSecrets appSecrets; + + @Mock + private CircuitBreakerUrl circuitBreakerUrl; + + @Mock + private Host host; + + @Mock + private Client restClient; + + @Mock + private WebTarget webTarget; + + @Mock + private Invocation.Builder invocationBuilder; + + @Mock + private Response restResponse; + + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_CDN_DOMAIN = "demo.dotcms.com"; + private static final String TEST_ZONE_ID = "1234567890"; + private static final String TEST_HOST_IDENTIFIER = "1234567890"; + + @Test + void test_getStats_Should_Return_Stats_For_Valid_Date_Range() + throws DotDataException, DotSecurityException, IOException { + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + try (MockedConstruction ignoredUrlBuilder = + mockConstruction(CircuitBreakerUrlBuilder.class, + (urlBuilder, context) -> + setCircuitBreakerUrlBuilderMock(urlBuilder))) { + + final String statsResponse = Files.readString(Paths.get( + "src/test/resources/test-stats-response.json"), + StandardCharsets.UTF_8); + when(circuitBreakerUrl.doString()).thenReturn(statsResponse); + + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + final DotCDNStats dotCDNStats = dotCDNAPI.getStats("2023-10-01", "2023-10-02"); + assertNotNull(dotCDNStats); + } + } + } + + @Test + void test_getStats_Should_Throw_BadRequestException_For_Invalid_Dates() + throws DotDataException, DotSecurityException, IOException { + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + try (MockedConstruction ignoredUrlBuilder = + mockConstruction(CircuitBreakerUrlBuilder.class, + (urlBuilder, context) -> + setCircuitBreakerUrlBuilderMock(urlBuilder))) { + + when(circuitBreakerUrl.doString()).thenThrow(new BadRequestException("Invalid date range")); + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + + assertThrows(BadRequestException.class, () -> + dotCDNAPI.getStats("2023-10-01", "2023-10-02") + ); + } + } + } + + @Test + void test_getStats_Should_Throw_Bad_Request_For_Timeout() + throws DotDataException, DotSecurityException, IOException { + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + try (MockedConstruction ignoredUrlBuilder = + mockConstruction(CircuitBreakerUrlBuilder.class, + (urlBuilder, context) -> + setCircuitBreakerUrlBuilderMock(urlBuilder))) { + + when(circuitBreakerUrl.doString()).thenThrow(new ConnectTimeoutException("Request timed out")); + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + + assertThrows(BadRequestException.class, () -> + dotCDNAPI.getStats("2023-10-01", "2023-10-02") + ); + } + } + } + + @Test + void test_invalidate_Should_Return_True_For_Valid_Urls() + throws DotDataException, DotSecurityException, IOException { + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + @SuppressWarnings("unchecked") + final CircuitBreakerUrl.Response mockResponse = + org.mockito.Mockito.mock(CircuitBreakerUrl.Response.class); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getResponse()).thenReturn(""); + + try (MockedConstruction ignoredUrlBuilder = + mockConstruction(CircuitBreakerUrlBuilder.class, + (urlBuilder, context) -> + setCircuitBreakerUrlBuilderMock(urlBuilder))) { + + when(circuitBreakerUrl.doResponse()).thenReturn(mockResponse); + + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + final boolean result = dotCDNAPI.invalidate(List.of( + "demo.dotcms.com/page1", "demo.dotcms.com/page2")); + + assertTrue(result); + } + } + } + + @Test + void test_invalidate_Should_Return_False_For_Bad_Request_Response() + throws DotDataException, DotSecurityException, IOException { + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + @SuppressWarnings("unchecked") + final CircuitBreakerUrl.Response mockResponse = + org.mockito.Mockito.mock(CircuitBreakerUrl.Response.class); + when(mockResponse.getStatusCode()).thenReturn(400); + when(mockResponse.getResponse()).thenReturn("Bad Request"); + + try (MockedConstruction ignoredUrlBuilder = + mockConstruction(CircuitBreakerUrlBuilder.class, + (urlBuilder, context) -> + setCircuitBreakerUrlBuilderMock(urlBuilder))) { + + when(circuitBreakerUrl.doResponse()).thenReturn(mockResponse); + + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + final boolean result = dotCDNAPI.invalidate(List.of( + "demo.dotcms.com/invalid-url-1" + )); + + assertFalse(result); + } + } + } + + @Test + void test_invalidateAll_Should_Return_True_For_Success() + throws DotDataException, DotSecurityException { + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + try (MockedStatic restClientBuilder = mockStatic(RestClientBuilder.class)) { + + setupRestClientMock(restClientBuilder); + when(restResponse.getStatus()).thenReturn(Response.Status.NO_CONTENT.getStatusCode()); + + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + final boolean result = dotCDNAPI.invalidateAll(); + + assertTrue(result); + } + } + } + + @Test + void test_invalidateAll_Should_Throw_Bad_Request_For_Invalid_Request() + throws DotDataException, DotSecurityException { + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + + setupAppsAPIMock(apiLocator); + setupCDNSecrets(); + when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER); + + try (MockedStatic restClientBuilder = mockStatic(RestClientBuilder.class)) { + + setupRestClientMock(restClientBuilder); + when(restResponse.getStatus()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode()); + + final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host); + + assertThrows(BadRequestException.class, dotCDNAPI::invalidateAll); + } + } + } + + private void setCircuitBreakerUrlBuilderMock(CircuitBreakerUrlBuilder urlBuilder) { + when(urlBuilder.setHeaders(anyMap())).thenReturn(urlBuilder); + when(urlBuilder.setTimeout(anyLong())).thenReturn(urlBuilder); + when(urlBuilder.setUrl(anyString())).thenReturn(urlBuilder); + when(urlBuilder.setMethod(any())).thenReturn(urlBuilder); + when(urlBuilder.setThrowWhenError(anyBoolean())).thenReturn(urlBuilder); + when(urlBuilder.build()).thenReturn(circuitBreakerUrl); + } + + private void setupAppsAPIMock(final MockedStatic apiLocator) + throws DotDataException, DotSecurityException { + apiLocator.when(APILocator::getAppsAPI).thenReturn(appsAPI); + final Optional appSecretsResult = Optional.of(appSecrets); + when(appsAPI.getSecrets(eq(CDNConstants.DOT_CDN_APP_KEY), + anyBoolean(), any(), any())).thenReturn(appSecretsResult); + } + + private void setupCDNSecrets() { + final Secret apiKeySecret = createSecret(TEST_API_KEY); + final Secret cdnDomainSecret = createSecret(TEST_CDN_DOMAIN); + final Secret zoneIdSecret = createSecret(TEST_ZONE_ID); + + when(appSecrets.getSecrets()).thenReturn(Map.of( + CDNConstants.DOT_CDN_API_KEY, apiKeySecret, + CDNConstants.DOT_CDN_DOMAIN, cdnDomainSecret, + CDNConstants.DOT_CDN_ZONEID, zoneIdSecret + )); + } + + private Secret createSecret(String value) { + return Secret.builder() + .withValue(value) + .withHidden(false) + .withType(Type.STRING) + .build(); + } + + private void setupRestClientMock(MockedStatic restClientBuilder) { + restClientBuilder.when(RestClientBuilder::newClient).thenReturn(restClient); + when(restClient.target(anyString())).thenReturn(webTarget); + when(webTarget.request(any(MediaType.class))).thenReturn(invocationBuilder); + when(invocationBuilder.header(anyString(), anyString())).thenReturn(invocationBuilder); + when(invocationBuilder.post(any())).thenReturn(restResponse); + } +} diff --git a/dotCMS/src/test/resources/test-stats-response.json b/dotCMS/src/test/resources/test-stats-response.json new file mode 100644 index 000000000000..a234534abfe3 --- /dev/null +++ b/dotCMS/src/test/resources/test-stats-response.json @@ -0,0 +1,63 @@ +{ + "TotalBandwidthUsed": 4358868943127, + "TotalOriginTraffic": 201443350067, + "AverageOriginResponseTime": 107, + "OriginResponseTimeChart": { + "2025-04-21T00:00:00Z": 119.97915496496852, + "2025-04-22T00:00:00Z": 123.14402618107543 + }, + "TotalRequestsServed": 59506919, + "CacheHitRate": 92.00229640522979, + "BandwidthUsedChart": { + "2025-04-21T00:00:00Z": 323843276876, + "2025-04-22T00:00:00Z": 140979625395 + }, + "BandwidthCachedChart": { + "2025-04-21T00:00:00Z": 319176563428, + "2025-04-22T00:00:00Z": 139213074644 + }, + "CacheHitRateChart": { + "2025-04-21T00:00:00Z": 87.98853939047213, + "2025-04-22T00:00:00Z": 89.02157708669782 + }, + "RequestsServedChart": { + "2025-04-21T00:00:00Z": 4513198, + "2025-04-22T00:00:00Z": 1997582 + }, + "PullRequestsPulledChart": { + "2025-04-21T00:00:00Z": 542101, + "2025-04-22T00:00:00Z": 219303 + }, + "OriginShieldBandwidthUsedChart": { + "2025-04-21T00:00:00Z": 2655412083, + "2025-04-22T00:00:00Z": 1383082792 + }, + "OriginShieldInternalBandwidthUsedChart": { + "2025-04-21T00:00:00Z": 6216959163, + "2025-04-22T00:00:00Z": 3061534513 + }, + "OriginTrafficChart": { + "2025-04-21T00:00:00Z": 14447333558, + "2025-04-22T00:00:00Z": 7375473616 + }, + "UserBalanceHistoryChart": { + "2025-04-21T01:26:36": -657.07, + "2025-04-22T01:21:46": -688.27 + }, + "GeoTrafficDistribution": { + "NA: New York City, NY": 411752804365, + "NA: Miami, FL": 250155831412 + }, + "Error3xxChart": { + "2025-04-21T00:00:00Z": 0, + "2025-04-22T00:00:00Z": 0 + }, + "Error4xxChart": { + "2025-04-21T00:00:00Z": 0, + "2025-04-22T00:00:00Z": 0 + }, + "Error5xxChart": { + "2025-04-21T00:00:00Z": 0, + "2025-04-22T00:00:00Z": 0 + } +} From 3a851a34c783a7dfb47333631f64d539998fafc4 Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:27:55 -0400 Subject: [PATCH 02/10] feat(dotcdn): add new metrics, date range controls, and UI improvements - Added origin response time, cache hit rate chart, and error rate (4xx/5xx) charts from Bunny API (loadErrors + loadOriginResponseTimes) - Replaced dropdown with segmented button bar (Today|24h|7d|30d|90d|Custom) plus inline date range picker for custom selection - Backend passes hourly=true to Bunny for short ranges (<=2 days) - 2x2 chart grid layout with stat cards (4 metrics) - Smart date labels: hourly ("Apr 1 2pm") vs daily ("Apr 1") - Integer-only ticks on request/error charts - Cache hit rate chart with 0-100% scale - Toolbar with filters scoped to Overview tab only - Null-safe handling for origin response time --- .../dot-cdn-filters.component.ts | 174 +++++++++++ .../dot-cdn/src/lib/dot-cdn.component.html | 242 ++++++++------ .../dot-cdn/src/lib/dot-cdn.component.ts | 94 ++++-- .../dot-cdn/src/lib/dot-cdn.models.ts | 15 +- .../dot-cdn/src/lib/dot-cdn.service.ts | 21 +- .../portlets/dot-cdn/src/lib/dot-cdn.store.ts | 295 +++++++++--------- .../java/com/dotcms/cdn/api/DotCDNAPI.java | 13 +- .../com/dotcms/cdn/api/DotCDNAPIImpl.java | 47 ++- .../java/com/dotcms/cdn/api/DotCDNStats.java | 69 +++- .../com/dotcms/cdn/rest/DotCDNResource.java | 5 +- 10 files changed, 664 insertions(+), 311 deletions(-) create mode 100644 core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts new file mode 100644 index 000000000000..4de22248e762 --- /dev/null +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts @@ -0,0 +1,174 @@ +import { format, isBefore, startOfDay, subDays } from 'date-fns'; + +import { + ChangeDetectionStrategy, + Component, + computed, + model, + output, + signal +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DatePickerModule } from 'primeng/datepicker'; +import { SelectButtonModule, SelectButtonChangeEvent } from 'primeng/selectbutton'; + +export interface CdnDateFilter { + dateFrom: string; + dateTo: string; + hourly: boolean; +} + +export interface CdnFilterOption { + label: string; + value: string; +} + +export const CDN_TIME_PRESETS: CdnFilterOption[] = [ + { label: 'Today', value: 'today' }, + { label: '24h', value: 'last24h' }, + { label: '7d', value: 'last7d' }, + { label: '30d', value: 'last30d' }, + { label: '90d', value: 'last90d' }, + { label: 'Custom', value: 'custom' } +]; + +@Component({ + selector: 'dot-cdn-filters', + standalone: true, + imports: [FormsModule, SelectButtonModule, DatePickerModule, ButtonModule], + template: ` +
+ + + @if ($showDatePicker()) { + + + + + + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotCdnFiltersComponent { + readonly presets = CDN_TIME_PRESETS; + readonly $today = signal(startOfDay(new Date())); + readonly $selectedPreset = model('last30d'); + readonly $customRange = model(null); + readonly $rangeStart = signal(null); + + readonly $showDatePicker = computed(() => this.$selectedPreset() === 'custom'); + + filterChange = output(); + + onPresetChange(event: SelectButtonChangeEvent): void { + const preset = event.value as string; + if (preset === 'custom') { + return; + } + + this.$customRange.set(null); + this.$rangeStart.set(null); + this.filterChange.emit(this.resolvePreset(preset)); + } + + onDateSelect(date: Date): void { + const start = this.$rangeStart(); + + if (start === null || isBefore(date, start)) { + this.$rangeStart.set(startOfDay(date)); + } else { + this.$rangeStart.set(null); + const dateFrom = format(start, 'yyyy-MM-dd'); + const dateTo = format(date, 'yyyy-MM-dd'); + const diffDays = Math.ceil( + (date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + ); + + this.filterChange.emit({ + dateFrom, + dateTo, + hourly: diffDays <= 2 + }); + } + } + + clearDateRange(): void { + this.$rangeStart.set(null); + this.$customRange.set(null); + } + + onCalendarClosed(): void { + const range = this.$customRange(); + if (!range || range.length !== 2) { + this.$rangeStart.set(null); + } + } + + private resolvePreset(preset: string): CdnDateFilter { + const today = format(new Date(), 'yyyy-MM-dd'); + + switch (preset) { + case 'today': + return { dateFrom: today, dateTo: today, hourly: true }; + case 'last24h': + return { + dateFrom: format(subDays(new Date(), 1), 'yyyy-MM-dd'), + dateTo: today, + hourly: true + }; + case 'last7d': + return { + dateFrom: format(subDays(new Date(), 7), 'yyyy-MM-dd'), + dateTo: today, + hourly: false + }; + case 'last30d': + return { + dateFrom: format(subDays(new Date(), 30), 'yyyy-MM-dd'), + dateTo: today, + hourly: false + }; + case 'last90d': + return { + dateFrom: format(subDays(new Date(), 90), 'yyyy-MM-dd'), + dateTo: today, + hourly: false + }; + default: + return { + dateFrom: format(subDays(new Date(), 30), 'yyyy-MM-dd'), + dateTo: today, + hourly: false + }; + } + } +} diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html index 98d29977dd14..736340f71eaf 100644 --- a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html +++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html @@ -1,116 +1,154 @@ @if (vm$ | async; as VM) { - - - Overview - Flush Cache - - - -
-