+ "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 @@
requestcom.dotcms.rendering.velocity.viewtools.DotRenderTool
+
+ dotcdn
+ request
+ com.dotcms.cdn.viewtool.DotCDNTool
+ importrequest
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: `
+