diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 839dfc9e7ec8..e3622fa8adfa 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2500,6 +2500,7 @@ export default { pollingEvery10: 'Polling every 10s', pollingEvery20: 'Polling every 20s', pollingEvery30: 'Polling every 30s', + logTypesChartDescription: 'In the chosen date range, you have this number of log messages grouped by type:', }, clipboard: { labelForCopyAllEntries: 'Copy %0%', diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts index 96872fa203a9..d45cf85c6a18 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts @@ -43,15 +43,15 @@ class UmbLogViewerMessagesData extends UmbMockDBBase { return this.data.slice(skip, take); } - getLevelCount() { - const levels = this.data.map((log) => log.level?.toLowerCase() ?? 'unknown'); - const counts = {}; - levels.forEach((level: string) => { - //eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - counts[level ?? 'unknown'] = (counts[level] || 0) + 1; - }); - return counts; + getLevelCount(): Record { + return this.data.reduce( + (counts, log) => { + const level = log.level ?? 'unknown'; + counts[level] = (counts[level] || 0) + 1; + return counts; + }, + {} as Record, + ); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/anchor.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/anchor.ts index b0fff6303cd8..d61dfe07acf2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/anchor.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/anchor.ts @@ -1,5 +1,5 @@ /** - * Hook up a click listener to the window that, for all anchor tags + * Hook up a click listener to the window that, for all anchor tags (HTML or SVG) * that has a relative HREF, uses the history API instead. */ export function ensureAnchorHistory() { @@ -10,37 +10,50 @@ export function ensureAnchorHistory() { if ((isWindows && e.ctrlKey) || (!isWindows && e.metaKey)) return; // Find the target by using the composed path to get the element through the shadow boundaries. + // Support both HTML anchor tags and SVG anchor tags const $anchor = (('composedPath' in e) as any) - ? e.composedPath().find(($elem) => $elem instanceof HTMLAnchorElement) + ? e.composedPath().find(($elem) => $elem instanceof HTMLAnchorElement || $elem instanceof SVGAElement) : e.target; - // Abort if the event is not about the anchor tag - if ($anchor == null || !($anchor instanceof HTMLAnchorElement)) { + // Abort if the event is not about an anchor tag (HTML or SVG) + if ($anchor == null || !($anchor instanceof HTMLAnchorElement || $anchor instanceof SVGAElement)) { return; } // Get the HREF value from the anchor tag - const href = $anchor.href; + // SVGAElement.href returns SVGAnimatedString, so we need to access .baseVal + const href = $anchor instanceof SVGAElement ? $anchor.href.baseVal : $anchor.href; + const target = $anchor instanceof SVGAElement ? $anchor.target.baseVal : $anchor.target; + + // For SVG anchors, we need to construct a full URL to extract pathname, search, and hash + // For HTML anchors, these properties are directly available + let fullUrl: URL; + try { + // Use the current document location as the base to resolve relative URLs + // This respects the tag and works the same as HTML anchors + // Note: This may resolve into an external URL, but we validate that later + fullUrl = new URL(href, document.location.origin); + } catch { + // Invalid URL, skip + return; + } // Only handle the anchor tag if the follow holds true: // - The HREF is relative to the origin of the current location. // - The target is targeting the current frame. // - The anchor doesn't have the attribute [data-router-slot]="disabled" if ( - !href.startsWith(location.origin) || - ($anchor.target !== '' && $anchor.target !== '_self') || + fullUrl.origin !== location.origin || + (target !== '' && target !== '_self') || $anchor.dataset['routerSlot'] === 'disabled' ) { return; } - // Remove the origin from the start of the HREF to get the path - const path = $anchor.pathname + $anchor.search + $anchor.hash; - // Prevent the default behavior e.preventDefault(); // Change the history! - history.pushState(null, '', path); + history.pushState(null, '', fullUrl); }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-chart.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-chart.element.ts index 095b60c80edc..9118c316a7e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-chart.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-chart.element.ts @@ -19,6 +19,7 @@ interface Circle { percent: number; kind: string; number: number; + href: string; } interface CircleWithCommands extends Circle { @@ -72,6 +73,20 @@ export class UmbDonutChartElement extends LitElement { @property({ type: Boolean }) hideDetailBox = false; + /** + * Shows numbers inside each slice of the donut chart + * @memberof UmbDonutChartElement + */ + @property({ type: Boolean, attribute: 'show-inline-numbers' }) + showInlineNumbers = false; + + /** + * Shows the description text below the chart + * @memberof UmbDonutChartElement + */ + @property({ type: Boolean, attribute: 'show-description' }) + showDescription = false; + @queryAssignedElements({ selector: 'umb-donut-slice' }) private _slices!: UmbDonutSliceElement[]; @@ -141,6 +156,7 @@ export class UmbDonutChartElement extends LitElement { color: slice.color, name: slice.name, kind: slice.kind, + href: slice.href, }; }), ); @@ -180,11 +196,28 @@ export class UmbDonutChartElement extends LitElement { return [coordX, coordY].join(' '); } + #getTextPosition(circle: CircleWithCommands): { x: number; y: number } { + // Calculate the middle angle of the slice + const startAngle = -circle.offset; + const sliceDegrees = UmbDonutChartElement.percentToDegrees(circle.percent); + const middleAngle = startAngle + sliceDegrees / 2; + + // Position the text at the middle of the donut ring + const textRadius = this.radius - this.borderSize / 2; + const angleRad = (middleAngle * Math.PI) / 180; + const x = Math.cos(angleRad) * textRadius + this.svgSize / 2; + const y = -Math.sin(angleRad) * textRadius + this.svgSize / 2; + + return { x, y }; + } + #calculateDetailsBoxPosition = (event: MouseEvent) => { + // Recalculate bounds on each mouse move to handle window resize + this.#containerBounds = this._container.getBoundingClientRect(); const x = this.#containerBounds ? event.clientX - this.#containerBounds?.left : 0; const y = this.#containerBounds ? event.clientY - this.#containerBounds?.top : 0; - this._posX = x - 10; - this._posY = y - 70; + this._posX = x + 10; + this._posY = y + 10; }; #setDetailsBoxData(event: MouseEvent) { @@ -231,11 +264,10 @@ export class UmbDonutChartElement extends LitElement { ${this.description} - ${this._circles.map( - (circle, i) => svg` + ${this._circles.map((circle, i) => { + const content = svg` - `, - )} + + ${ + this.showInlineNumbers + ? svg`${circle.number}` + : '' + }`; + + return circle.href ? svg`${content}` : content; + })} `; } override render() { return html`
- ${this.#renderCircles()} + + ${this.#renderCircles()} +
@@ -267,6 +317,7 @@ export class UmbDonutChartElement extends LitElement { ${this._detailAmount} ${this._detailKind}
+ ${this.showDescription && this.description ? html`

${this.description}

` : ''} `; } @@ -292,7 +343,9 @@ export class UmbDonutChartElement extends LitElement { #container { position: relative; - width: 200px; + width: 100%; + max-width: 200px; + aspect-ratio: 1; } #details-box { @@ -311,6 +364,7 @@ export class UmbDonutChartElement extends LitElement { transform: translate3d(var(--pos-x), var(--pos-y), 0); transition: transform 0.2s cubic-bezier(0.02, 1.23, 0.79, 1.08); transition: opacity 150ms linear; + pointer-events: none; } #details-box.show { @@ -328,6 +382,17 @@ export class UmbDonutChartElement extends LitElement { display: flex; align-items: center; } + + .slice-number { + user-select: none; + } + + .description { + text-align: center; + font-size: var(--uui-type-small-size); + color: var(--uui-color-text-alt); + margin: var(--uui-size-space-2) 0 0 0; + } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-slice.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-slice.element.ts index 36a2de1f8b98..960ba0775323 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-slice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/donut-chart/donut-slice.element.ts @@ -32,6 +32,13 @@ export class UmbDonutSliceElement extends LitElement { @property() kind = ''; + /** + * Optional href to make the slice clickable + * @memberof UmbDonutSliceElement + */ + @property() + href = ''; + override willUpdate() { this.dispatchEvent(new CustomEvent('slice-update', { composed: true, bubbles: true })); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts index c314fa23e6c2..201a3f1964bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts @@ -1,5 +1,5 @@ import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js'; -import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { LogLevelCountsReponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { consumeContext } from '@umbraco-cms/backoffice/context-api'; @@ -18,6 +18,9 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement { return this.#logViewerContext; } + @state() + private _dateRange = { startDate: '', endDate: '' }; + @state() private _logLevelCountResponse: LogLevelCountsReponseModel | null = null; @@ -27,8 +30,11 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement { @state() private _logLevelCountFilter: string[] = []; + @state() + private _logLevelKeys: string[] = []; + protected override willUpdate(_changedProperties: Map): void { - if (_changedProperties.has('_logLevelCountFilter')) { + if (_changedProperties.has('_logLevelCountFilter') || _changedProperties.has('_logLevelCountResponse')) { this.setLogLevelCount(); } } @@ -43,9 +49,15 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement { } setLogLevelCount() { - this._logLevelCount = this._logLevelCountResponse - ? Object.entries(this._logLevelCountResponse).filter(([level]) => !this._logLevelCountFilter.includes(level)) - : []; + if (this._logLevelCountResponse) { + this._logLevelKeys = Object.keys(this._logLevelCountResponse); + this._logLevelCount = Object.entries(this._logLevelCountResponse).filter( + ([level]) => !this._logLevelCountFilter.includes(level), + ); + } else { + this._logLevelKeys = []; + this._logLevelCount = []; + } } #observeStuff() { @@ -53,46 +65,69 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement { this._logLevelCountResponse = logLevel ?? null; this.setLogLevelCount(); }); + + this.observe(this._logViewerContext?.dateRange, (dateRange) => { + if (dateRange) { + this._dateRange = dateRange; + } + }); + } + + #buildSearchUrl(level: string): string { + const params = new URLSearchParams(); + params.set('loglevels', level); + if (this._dateRange.startDate) { + params.set('startDate', this._dateRange.startDate); + } + if (this._dateRange.endDate) { + params.set('endDate', this._dateRange.endDate); + } + return `section/settings/workspace/logviewer/view/search/?${params.toString()}`; } - // TODO: Stop using this complex code in render methods, instead changes to _logLevelCount should trigger a state prop containing the keys. And then try to make use of the repeat LIT method: override render() { return html` +

+ + In the chosen date range, you have this number of log messages grouped by type: + +

+ + ${repeat( + this._logLevelCount, + ([level]) => level, + ([level, number]) => + html``, + )} +
    - ${this._logLevelCountResponse - ? Object.keys(this._logLevelCountResponse).map( - (level) => - html`
  • - -
  • `, - ) - : ''} + ${repeat( + this._logLevelKeys, + (level) => level, + (level) => + html`
  • + +
  • `, + )}
- - ${this._logLevelCountResponse - ? this._logLevelCount.map( - ([level, number]) => - html` `, - ) - : ''} -
`; @@ -100,12 +135,49 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement { static override styles = [ css` + uui-box { + container-type: inline-size; + } + + #description { + text-align: center; + font-size: var(--uui-type-small-size); + color: var(--uui-color-text-alt); + margin: 0 0 var(--uui-size-space-4) 0; + } + #log-types-container { - display: flex; + display: grid; gap: var(--uui-size-space-4); - flex-direction: column-reverse; - align-items: center; - justify-content: space-between; + grid-template-columns: 1fr; + place-items: center; + } + + umb-donut-chart { + width: 100%; + max-width: 200px; + } + + #legend { + width: 100%; + display: flex; + justify-content: center; + } + + @container (min-width: 312px) { + #log-types-container { + grid-template-columns: auto 1fr; + place-items: start; + } + + umb-donut-chart { + max-width: 200px; + } + + #legend { + width: auto; + justify-content: flex-start; + } } button {