Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
49219aa
Log Viewer: Refactor log types chart to use Lit repeat directive
iOvergaard Nov 21, 2025
4de030a
Donut Chart: Add inline numbers and fix tooltip positioning
iOvergaard Nov 21, 2025
a471c72
Donut Chart: Add clickable slices and visible description
iOvergaard Nov 21, 2025
a2eec64
fix: uses whole link
iOvergaard Nov 21, 2025
f27ebb5
Log Viewer: Fix log types chart layout for larger screens
iOvergaard Nov 21, 2025
f6c66ec
chore: improves mock function
iOvergaard Nov 21, 2025
5b46335
chore: formatting
iOvergaard Nov 21, 2025
52d36ca
fix: ensures the donut chart works responsively
iOvergaard Nov 21, 2025
72d7715
feat: adds support for SVGAElement in the router
iOvergaard Nov 21, 2025
3526bf1
Merge branch 'main' into v17/feature/logviewer-donut-chart
iOvergaard Nov 24, 2025
fb4e8e4
adds key for description
iOvergaard Nov 24, 2025
7cabeee
Merge remote-tracking branch 'origin/main' into v17/feature/logviewer…
iOvergaard Nov 25, 2025
3bd3042
chore: adds test data
iOvergaard Nov 25, 2025
9e1911e
feat: displays numbers in the legend instead of the chart
iOvergaard Nov 25, 2025
73b981a
Merge branch 'main' into v17/feature/logviewer-donut-chart
iOvergaard Nov 25, 2025
39b9faf
chore: restores functionality with lower-cased keys
iOvergaard Nov 25, 2025
bb01c67
fix: adds translation to 'log messages'
iOvergaard Nov 25, 2025
252f16f
chore: removes unused method
iOvergaard Nov 25, 2025
80f10b3
feat: ensures that the log levels follow the generated LogLevelModel …
iOvergaard Nov 25, 2025
e9ac69d
Merge remote-tracking branch 'origin/main' into v17/feature/logviewer…
iOvergaard Nov 25, 2025
7bc9b9a
fix: uses correct property
iOvergaard Nov 25, 2025
a505041
fix: reverts back to the original behavior to calculate a relative UR…
iOvergaard Nov 25, 2025
9dff41e
Apply suggestions from code review
iOvergaard Nov 26, 2025
6e514df
fix: uses fullUrl for router
iOvergaard Nov 26, 2025
1505466
fix: properly translates new aria-label
iOvergaard Nov 26, 2025
f3081e8
Merge branch 'main' into v17/feature/logviewer-donut-chart
iOvergaard Nov 26, 2025
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
1 change: 1 addition & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,7 @@ export default {
level: 'Type',
machine: 'Maskine',
message: 'Besked',
messagesCount: 'logbeskeder',
searchWithGoogle: 'Søg med Google',
searchThisMessageWithGoogle: 'Søg efter denne besked på Google',
searchWithBing: 'Søg med Bing',
Expand Down
3 changes: 2 additions & 1 deletion src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,7 @@ export default {
level: 'Level',
machine: 'Machine',
message: 'Message',
messagesCount: 'log messages',
searchWithGoogle: 'Search With Google',
searchThisMessageWithGoogle: 'Search this message with Google',
searchWithBing: 'Search With Bing',
Expand Down Expand Up @@ -2511,7 +2512,7 @@ export default {
commonLogMessages: 'Common Log Messages',
totalUniqueMessageTypes: 'Total Unique Message types: %0%',
logTypes: 'Log types',
logTypesChartDescription: 'In chosen date range you have this number of log message of type:',
logTypesChartDescription: 'In the chosen date range, you have this number of log messages grouped by type:',
},
clipboard: {
labelForCopyAllEntries: 'Copy %0%',
Expand Down
26 changes: 17 additions & 9 deletions src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ class UmbLogViewerMessagesData extends UmbMockDBBase<LogMessageResponseModel> {
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<string, number> {
const levels = this.data.reduce(
(counts, log) => {
const level = log.level?.toLocaleLowerCase() ?? 'unknown';
counts[level] = (counts[level] || 0) + 1;
return counts;
},
{} as Record<string, number>,
);

// Test 1k logs for the first level
levels[Object.keys(levels)[0]] += 1000;

// Test 1m logs for the second level
levels[Object.keys(levels)[1]] += 1000000;

return levels;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -10,36 +10,52 @@
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 <base> 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') ||

Check warning on line 47 in src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/anchor.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Conditional

ensureAnchorHistory.'click' increases from 2 complex conditionals with 6 branches to 3 complex conditionals with 8 branches, threshold = 2. A complex conditional is an expression inside a branch (e.g. if, for, while) which consists of multiple, logical operators such as AND/OR. The more logical operators in an expression, the more severe the code smell.
$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();

// Remove the origin from the start of the HREF to get the path
const path = fullUrl.pathname + fullUrl.search + fullUrl.hash;

// Change the history!
history.pushState(null, '', path);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import {
css,
html,
LitElement,
svg,
customElement,
property,
Expand All @@ -12,13 +11,16 @@ import {
state,
} from '@umbraco-cms/backoffice/external/lit';
import { clamp } from '@umbraco-cms/backoffice/utils';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

interface Circle {
color: string;
name: string;
percent: number;
visualPercent: number;
kind: string;
number: number;
href: string;
}

interface CircleWithCommands extends Circle {
Expand All @@ -32,11 +34,17 @@ interface CircleWithCommands extends Circle {
* @augments {LitElement}
*/
@customElement('umb-donut-chart')
export class UmbDonutChartElement extends LitElement {
export class UmbDonutChartElement extends UmbLitElement {
static percentToDegrees(percent: number): number {
return percent * 3.6;
}

/**
* Minimum visual percentage for rendering a slice.
* Slices below this percentage will be visually expanded to this size to remain visible.
*/
static MIN_SLICE_PERCENT = 5;

/**
* Circle radius in pixels
* @memberof UmbDonutChartElement
Expand Down Expand Up @@ -72,6 +80,13 @@ export class UmbDonutChartElement extends LitElement {
@property({ type: Boolean })
hideDetailBox = 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[];

Expand All @@ -97,7 +112,7 @@ export class UmbDonutChartElement extends LitElement {
private _detailName = '';

@state()
private _detailAmount = 0;
private _detailAmount = '0';

@state()
private _detailColor = 'black';
Expand Down Expand Up @@ -126,24 +141,49 @@ export class UmbDonutChartElement extends LitElement {

#calculatePercentage(partialValue: number) {
if (this._totalAmount === 0) return 0;
const percent = Math.round((100 * partialValue) / this._totalAmount);
const percent = (100 * partialValue) / this._totalAmount;
return clamp(percent, 0, 99);
}

#printCircles(event: Event | null = null) {
this._totalAmount = this._slices.reduce((acc, slice) => acc + slice.amount, 0);
event?.stopPropagation();
this._circles = this.#addCommands(
this._slices.map((slice) => {
return {
percent: this.#calculatePercentage(slice.amount),
number: slice.amount,
color: slice.color,
name: slice.name,
kind: slice.kind,
};
}),
);

// First pass: calculate actual percentages
const circles = this._slices.map((slice) => {
const percent = this.#calculatePercentage(slice.amount);
return {
percent,
visualPercent: percent,
number: slice.amount,
color: slice.color,
name: slice.name,
kind: slice.kind,
href: slice.href,
};
});

// Second pass: apply minimum visual percentage and normalize to 100%
const totalActualPercent = circles.reduce((acc, c) => acc + c.percent, 0);
if (totalActualPercent > 0) {
const smallSlices = circles.filter((c) => c.percent > 0 && c.percent < UmbDonutChartElement.MIN_SLICE_PERCENT);

// Expand small slices to minimum
smallSlices.forEach((c) => {
c.visualPercent = UmbDonutChartElement.MIN_SLICE_PERCENT;
});

// Calculate total and normalize to 100%
const totalVisualPercent = circles.reduce((acc, c) => acc + c.visualPercent, 0);
if (totalVisualPercent > 0 && totalVisualPercent !== 100) {
const scale = 100 / totalVisualPercent;
circles.forEach((c) => {
c.visualPercent = c.visualPercent * scale;
});
}
}

this._circles = this.#addCommands(circles);
}

#addCommands(Circles: Circle[]): CircleWithCommands[] {
Expand All @@ -154,13 +194,13 @@ export class UmbDonutChartElement extends LitElement {
commands: this.#getSliceCommands(slice, this.radius, this.svgSize, this.borderSize),
offset: previousPercent * 3.6 * -1,
};
previousPercent += slice.percent;
previousPercent += slice.visualPercent;
return sliceWithCommands;
});
}

#getSliceCommands(Circle: Circle, radius: number, svgSize: number, borderSize: number): string {
const degrees = UmbDonutChartElement.percentToDegrees(Circle.percent);
const degrees = UmbDonutChartElement.percentToDegrees(Circle.visualPercent);
const longPathFlag = degrees > 180 ? 1 : 0;
const innerRadius = radius - borderSize;

Expand All @@ -181,18 +221,20 @@ export class UmbDonutChartElement extends LitElement {
}

#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) {
const target = event.target as SVGPathElement;
const index = target.dataset.index as unknown as number;
const circle = this._circles[index];
this._detailName = circle.name;
this._detailAmount = circle.number;
this._detailAmount = this.localize.number(circle.number);
this._detailColor = circle.color;
this._detailKind = circle.kind;
}
Expand Down Expand Up @@ -231,11 +273,10 @@ export class UmbDonutChartElement extends LitElement {
<feDropShadow stdDeviation="1 1" in="merge1" dx="0" dy="0" flood-color="#000" flood-opacity="0.8" x="0%" y="0%" width="100%" height="100%" result="dropShadow1"/>
</filter>
<desc>${this.description}</desc>
${this._circles.map(
(circle, i) => svg`
${this._circles.map((circle, i) => {
const content = svg`
<path
class="circle"

data-index="${i}"
fill="${circle.color}"
role="listitem"
Expand All @@ -251,22 +292,27 @@ export class UmbDonutChartElement extends LitElement {
role="listitem"
d="${circle.commands}"
transform="rotate(${circle.offset} ${this._viewBox / 2} ${this._viewBox / 2})">
</path>`,
)}
</path>`;

return circle.href ? svg`<a href="${circle.href}">${content}</a>` : content;
})}

`;
}

override render() {
return html` <div id="container" @mousemove=${this.#calculateDetailsBoxPosition}>
<svg viewBox="0 0 ${this._viewBox} ${this._viewBox}" role="list">${this.#renderCircles()}</svg>
<svg width="100%" height="100%" viewBox="0 0 ${this._viewBox} ${this._viewBox}" role="list">
${this.#renderCircles()}
</svg>
<div
id="details-box"
style="--pos-y: ${this._posY}px; --pos-x: ${this._posX}px; --umb-donut-detail-color: ${this._detailColor}">
<div id="details-title"><uui-icon name="icon-record"></uui-icon>${this._detailName}</div>
<span>${this._detailAmount} ${this._detailKind}</span>
</div>
</div>
${this.showDescription && this.description ? html`<p class="description">${this.description}</p>` : ''}
<slot @slotchange=${this.#printCircles} @slice-update=${this.#printCircles}></slot>`;
}

Expand All @@ -292,7 +338,9 @@ export class UmbDonutChartElement extends LitElement {

#container {
position: relative;
width: 200px;
width: 100%;
max-width: 200px;
aspect-ratio: 1;
}

#details-box {
Expand All @@ -311,6 +359,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 {
Expand All @@ -328,6 +377,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;
}
`,
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
1 change: 1 addition & 0 deletions src/Umbraco.Web.UI.Client/src/packages/log-viewer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './constants.js';
export * from './repository/index.js';
export * from './components/donut-chart/donut-chart.element.js';
export * from './components/donut-chart/donut-slice.element.js';
export type * from './types.js';
Loading
Loading