Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ 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: () =>
Expand Down
18 changes: 18 additions & 0 deletions core-web/libs/portlets/dot-cdn/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.base.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
13 changes: 13 additions & 0 deletions core-web/libs/portlets/dot-cdn/project.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
1 change: 1 addition & 0 deletions core-web/libs/portlets/dot-cdn/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/lib.routes';
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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: `
<div class="flex items-center gap-3">
<p-selectbutton
[options]="presets"
[(ngModel)]="$selectedPreset"
optionLabel="label"
optionValue="value"
[allowEmpty]="false"
(onChange)="onPresetChange($event)"
data-testid="cdn-period-buttons" />

@if ($showDatePicker()) {
<p-datepicker
[(ngModel)]="$customRange"
selectionMode="range"
[readonlyInput]="false"
[showIcon]="true"
[iconDisplay]="'input'"
placeholder="Pick start and end date"
dateFormat="M dd, yy"
[maxDate]="$today()"
[showButtonBar]="true"
[style]="{ minWidth: '280px' }"
(onSelect)="onDateSelect($event)"
(onClose)="onCalendarClosed()"
data-testid="cdn-custom-date-range">
<ng-template #buttonbar let-api>
<p-button
label="Clear"
[text]="true"
size="small"
(onClick)="clearDateRange(); api.onClearButtonClick($event)"
data-testid="cdn-date-clear-btn" />
</ng-template>
</p-datepicker>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DotCdnFiltersComponent {
readonly presets = CDN_TIME_PRESETS;
readonly $today = signal<Date>(startOfDay(new Date()));
readonly $selectedPreset = model<string>('last30d');
readonly $customRange = model<Date[] | null>(null);
readonly $rangeStart = signal<Date | null>(null);

readonly $showDatePicker = computed(() => this.$selectedPreset() === 'custom');

filterChange = output<CdnDateFilter>();

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
};
}
}
}
183 changes: 183 additions & 0 deletions core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
@if (vm$ | async; as VM) {
<div class="flex flex-col">
<p-tabs value="overview">
<p-tablist>
<p-tab value="overview">Overview</p-tab>
<p-tab value="flush">Flush Cache</p-tab>
</p-tablist>
<p-tabpanels>
<p-tabpanel value="overview">
<!-- Toolbar -->
<div
class="flex justify-between items-center bg-gray-100 border border-gray-200 rounded-lg px-4 py-3">
<dot-cdn-filters (filterChange)="onFilterChange($event)" />
@if (VM.cdnDomain) {
<span class="text-sm text-color-secondary">
CDN:
<strong>{{ VM.cdnDomain }}</strong>
</span>
}
</div>

<div class="p-4">
<!-- Stats cards -->
<div class="flex gap-4 mb-6">
@for (stats of VM.statsData; track stats) {
<div
class="flex items-center gap-3 flex-1 p-4 border border-gray-200 rounded-lg bg-white">
<dot-icon [name]="stats.icon" size="28" />
<div>
<span
class="text-xs uppercase text-color-secondary font-medium">
{{ stats.label }}
</span>
@if (VM.isChartLoading) {
<p-skeleton width="6rem" height="1.5rem" />
} @else {
<div class="text-xl font-semibold text-primary">
{{ stats.value }}
</div>
}
</div>
</div>
}
</div>

<!-- Charts: 2-column grid -->
<div class="grid grid-cols-2 gap-4">
<div class="p-4 border border-gray-200 rounded-lg bg-white">
@if (VM.isChartLoading) {
<dot-spinner
[size]="'3rem'"
[borderSize]="'3px'"
[ngStyle]="{ height: chartHeight }" />
} @else {
<h3 class="text-base font-semibold mt-0 mb-3">
Bandwidth Served
</h3>
<p-chart
[data]="VM.chartBandwidthData"
[height]="chartHeight"
[options]="options['bandwidthUsedChart']"
type="line"
width="100%" />
}
</div>

<div class="p-4 border border-gray-200 rounded-lg bg-white">
@if (VM.isChartLoading) {
<dot-spinner
[size]="'3rem'"
[borderSize]="'3px'"
[ngStyle]="{ height: chartHeight }" />
} @else {
<h3 class="text-base font-semibold mt-0 mb-3">
Requests Served
</h3>
<p-chart
[data]="VM.chartRequestsData"
[height]="chartHeight"
[options]="options['requestsServedChart']"
type="line"
width="100%" />
}
</div>

<div class="p-4 border border-gray-200 rounded-lg bg-white">
@if (VM.isChartLoading) {
<dot-spinner
[size]="'3rem'"
[borderSize]="'3px'"
[ngStyle]="{ height: chartHeight }" />
} @else {
<h3 class="text-base font-semibold mt-0 mb-3">
Cache Hit Rate
</h3>
<p-chart
[data]="VM.chartCacheHitRateData"
[height]="chartHeight"
[options]="options['cacheHitRateChart']"
type="line"
width="100%" />
}
</div>

<div class="p-4 border border-gray-200 rounded-lg bg-white">
@if (VM.isChartLoading) {
<dot-spinner
[size]="'3rem'"
[borderSize]="'3px'"
[ngStyle]="{ height: chartHeight }" />
} @else {
<h3 class="text-base font-semibold mt-0 mb-3">Errors</h3>
<p-chart
[data]="VM.chartErrorData"
[height]="chartHeight"
[options]="options['errorChart']"
type="line"
width="100%" />
}
</div>
</div>
</div>
</p-tabpanel>

@if (vmPurgeLoaders$ | async; as VMPurgeLoaders) {
<p-tabpanel value="flush">
<div class="p-4 max-w-3xl">
<div class="mb-8">
<h3 class="text-base font-semibold mt-0 mb-2">Purge URL List</h3>
<p class="text-sm text-color-secondary leading-relaxed mb-3">
Purging removes files from CDN cache and re-downloads from your
origin. Enter exact CDN URLs, one per line. Use
<code>*</code>
for wildcards.
</p>
<form [formGroup]="purgeZoneForm">
<textarea
[rows]="5"
pInputTextarea
class="w-full mb-3"
formControlName="purgeUrlsTextArea"
placeholder="https://cdn.example.com/path&#10;https://cdn.example.com/images/*"></textarea>
<button
(click)="purgeUrls()"
[label]="
VMPurgeLoaders.isPurgeUrlsLoading
? 'Purging...'
: 'Purge URLs'
"
[disabled]="
purgeZoneForm.get('purgeUrlsTextArea').value.length ===
0
"
pButton></button>
</form>
</div>

<div>
<h3 class="text-base font-semibold mt-0 mb-2">
Purge Entire Cache
</h3>
<p class="text-sm text-color-secondary leading-relaxed mb-3">
Forces everything to be re-downloaded from your origin. May
cause a traffic spike if your CDN domain is heavily trafficked.
</p>
<button
(click)="purgePullZone()"
[label]="
VMPurgeLoaders.isPurgeZoneLoading
? 'Purging...'
: 'Purge All'
"
pButton
severity="danger"
[outlined]="true"></button>
</div>
</div>
</p-tabpanel>
}
</p-tabpanels>
</p-tabs>
</div>
}
Loading
Loading