Log Viewer: Enhances the donut chart to be responsive, link to log search, and show numbers directly (#20928)

* Log Viewer: Refactor log types chart to use Lit repeat directive

- Import and use repeat directive for better performance
- Add _logLevelKeys state property to track log level keys
- Update setLogLevelCount() to populate _logLevelKeys
- Replace .map() with repeat() in render method for legend and donut slices
- Update willUpdate to observe both filter and response changes
- Resolves TODO comment about using repeat directive

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Donut Chart: Add inline numbers and fix tooltip positioning

- Add showInlineNumbers property to optionally display numbers inside slices
- Implement #getTextPosition() method to calculate text position at slice center
- Render SVG text elements when showInlineNumbers is enabled
- Fix tooltip positioning to appear near cursor (changed from x-10, y-70 to x+10, y+10)
- Recalculate container bounds on each mouse move to handle window resize
- Add pointer-events: none to tooltip to prevent mouse interference
- Add CSS styling for slice numbers (user-select: none)
- Enable inline numbers by default in log viewer log types chart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Donut Chart: Add clickable slices and visible description

- Add href property to donut-slice element for clickable slices
- Wrap SVG paths in <a> tags when href is provided
- Update Circle interface to include href property
- Add showDescription property to optionally display description text
- Render description as visible text below the chart
- Add CSS styling for description text
- Update log-types-chart to build search URLs with log level and date range
- Observe dateRange from context to build accurate search URLs
- Enable clickable slices and visible description in log-types-chart

Now clicking on a donut slice navigates to the search view filtered by that log level and the current date range.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: uses whole link

* Log Viewer: Fix log types chart layout for larger screens

Add media query to switch from column to row layout on screens wider than 768px. This ensures the legend and donut chart are displayed side by side on desktop resolutions instead of stacked vertically.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: improves mock function

* chore: formatting

* fix: ensures the donut chart works responsively

* feat: adds support for SVGAElement in the router

* adds key for description

* chore: adds test data

* feat: displays numbers in the legend instead of the chart

* chore: restores functionality with lower-cased keys

* fix: adds translation to 'log messages'

* chore: removes unused method

* feat: ensures that the log levels follow the generated LogLevelModel enum from the server, which requires to map the keys as JSON camelCase's the keys

* fix: uses correct property

* fix: reverts back to the original behavior to calculate a relative URL (rather than the automatic .toString() that gets a qualified URL)

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: uses fullUrl for router

* fix: properly translates new aria-label

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jacob Overgaard
2025-11-26 10:28:44 +01:00
committed by GitHub
parent 69e2f8df74
commit ce98184178
13 changed files with 322 additions and 112 deletions

View File

@@ -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',
@@ -2441,6 +2442,7 @@ export default {
totalUniqueMessageTypes: 'Samlet antal unikke beskedtyper: %0%',
logTypes: 'Log typer',
logTypesChartDescription: 'I det valgte datointerval har du dette antal logbeskeder af typen:',
viewLogsLabel: 'Vis %0% logs',
},
clipboard: {
labelForCopyAllEntries: 'Kopier %0%',

View File

@@ -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',
@@ -2511,7 +2512,8 @@ 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:',
viewLogsLabel: 'View %0% logs',
},
clipboard: {
labelForCopyAllEntries: 'Copy %0%',

View File

@@ -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;
});
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;
}
}

View File

@@ -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 base URI 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.baseURI);
} 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);
});
}

View File

@@ -3,7 +3,6 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import {
css,
html,
LitElement,
svg,
customElement,
property,
@@ -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 {
@@ -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
@@ -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[];
@@ -97,7 +112,7 @@ export class UmbDonutChartElement extends LitElement {
private _detailName = '';
@state()
private _detailAmount = 0;
private _detailAmount = '0';
@state()
private _detailColor = 'black';
@@ -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) => {
// First pass: calculate actual percentages
const circles = this._slices.map((slice) => {
const percent = this.#calculatePercentage(slice.amount);
return {
percent: this.#calculatePercentage(slice.amount),
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[] {
@@ -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;
@@ -181,10 +221,12 @@ 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) {
@@ -192,7 +234,7 @@ export class UmbDonutChartElement extends LitElement {
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;
}
@@ -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"
@@ -251,15 +292,21 @@ 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}" aria-label=${this.localize.term('logViewer_viewLogsLabel', circle.name)}>${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}">
@@ -267,6 +314,7 @@ export class UmbDonutChartElement extends LitElement {
<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>`;
}
@@ -292,7 +340,9 @@ export class UmbDonutChartElement extends LitElement {
#container {
position: relative;
width: 200px;
width: 100%;
max-width: 200px;
aspect-ratio: 1;
}
#details-box {
@@ -311,6 +361,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 +379,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;
}
`,
];
}

View File

@@ -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 }));
}

View File

@@ -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';

View File

@@ -1,6 +1,6 @@
import type { UmbLogLevelCounts } from '../../types.js';
import type {
DirectionModel,
LogLevelCountsReponseModel,
LogLevelModel,
PagedLoggerResponseModel,
PagedLogMessageResponseModel,
@@ -37,7 +37,7 @@ export interface UmbLogMessagesDataSource {
}: {
startDate?: string;
endDate?: string;
}): Promise<UmbDataSourceResponse<LogLevelCountsReponseModel>>;
}): Promise<UmbDataSourceResponse<UmbLogLevelCounts>>;
getLogViewerLogs({
skip,
take,

View File

@@ -1,12 +1,14 @@
import type { UmbLogMessagesDataSource, UmbLogSearchDataSource } from './index.js';
import type {
DirectionModel,
import {
LogLevelModel,
SavedLogSearchResponseModel,
type DirectionModel,
type SavedLogSearchResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { LogViewerService } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';
import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository';
import type { UmbLogLevelCounts } from '../../types.js';
/**
* A data source for the log saved searches
@@ -87,7 +89,7 @@ export class UmbLogMessagesServerDataSource implements UmbLogMessagesDataSource
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerLevel({ skip = 0, take = 100 }: { skip?: number; take?: number }) {
return await tryExecute(this.#host, LogViewerService.getLogViewerLevel({ query: { skip, take } }));
return tryExecute(this.#host, LogViewerService.getLogViewerLevel({ query: { skip, take } }));
}
/**
@@ -96,13 +98,45 @@ export class UmbLogMessagesServerDataSource implements UmbLogMessagesDataSource
* @returns {*}
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerLevelCount({ startDate, endDate }: { startDate?: string; endDate?: string }) {
return await tryExecute(
async getLogViewerLevelCount({
startDate,
endDate,
}: {
startDate?: string;
endDate?: string;
}): Promise<UmbDataSourceResponse<UmbLogLevelCounts>> {
const data = await tryExecute(
this.#host,
LogViewerService.getLogViewerLevelCount({
query: { startDate, endDate },
}),
);
if (data?.data) {
const normalizedData: UmbLogLevelCounts = {
[LogLevelModel.VERBOSE]: 0,
[LogLevelModel.DEBUG]: 0,
[LogLevelModel.INFORMATION]: 0,
[LogLevelModel.WARNING]: 0,
[LogLevelModel.ERROR]: 0,
[LogLevelModel.FATAL]: 0,
};
// Helper to normalize log level keys to PascalCase
const normalizeLogLevel = (level: string): LogLevelModel => {
const normalized = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase();
return normalized as LogLevelModel;
};
// Normalize keys to match LogLevelModel
for (const [level, count] of Object.entries(data.data)) {
normalizedData[normalizeLogLevel(level)] = count;
}
return { data: normalizedData };
}
return {};
}
/**
* Grabs all the log messages from the server
@@ -143,7 +177,7 @@ export class UmbLogMessagesServerDataSource implements UmbLogMessagesDataSource
startDate?: string;
endDate?: string;
}) {
return await tryExecute(
return tryExecute(
this.#host,
LogViewerService.getLogViewerLog({
query: {
@@ -185,7 +219,7 @@ export class UmbLogMessagesServerDataSource implements UmbLogMessagesDataSource
startDate?: string;
endDate?: string;
}) {
return await tryExecute(
return tryExecute(
this.#host,
LogViewerService.getLogViewerMessageTemplate({
query: { skip, take, startDate, endDate },
@@ -194,7 +228,7 @@ export class UmbLogMessagesServerDataSource implements UmbLogMessagesDataSource
}
async getLogViewerValidateLogsSize({ startDate, endDate }: { startDate?: string; endDate?: string }) {
return await tryExecute(
return tryExecute(
this.#host,
LogViewerService.getLogViewerValidateLogsSize({
query: { startDate, endDate },

View File

@@ -0,0 +1,5 @@
import type { LogLevelModel } from '@umbraco-cms/backoffice/external/backend-api';
export type UmbLogLevelCounts = {
[level in LogLevelModel]: number;
};

View File

@@ -1,8 +1,8 @@
import { UmbLogViewerRepository } from '../repository/log-viewer.repository.js';
import type { UmbLogLevelCounts } from '../types.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from './logviewer-workspace.context-token.js';
import { UmbBasicState, UmbArrayState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type {
LogLevelCountsReponseModel,
PagedLoggerResponseModel,
PagedLogMessageResponseModel,
PagedLogTemplateResponseModel,
@@ -38,10 +38,6 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW
return 'log-viewer';
}
getEntityName() {
return 'Log Viewer';
}
get today() {
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
@@ -68,7 +64,7 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW
#savedSearches = new UmbObjectState<PagedSavedLogSearchResponseModel | undefined>(undefined);
savedSearches = this.#savedSearches.asObservablePart((data) => data);
#logCount = new UmbObjectState<LogLevelCountsReponseModel | null>(null);
#logCount = new UmbObjectState<UmbLogLevelCounts | null>(null);
logCount = this.#logCount.asObservable();
#dateRange = new UmbObjectState<UmbLogViewerDateRange>(this.defaultDateRange);
@@ -236,10 +232,7 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW
async getLogCount() {
const { data } = await this.#repository.getLogCount({ ...this.#dateRange.getValue() });
if (data) {
this.#logCount.setValue(data);
}
this.#logCount.setValue(data ?? null);
}
async getMessageTemplates(skip: number, take: number) {

View File

@@ -1,7 +1,7 @@
import type { UmbLogLevelCounts } from '../../../../../log-viewer/types.js';
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';
@customElement('umb-log-viewer-log-types-chart')
@@ -19,7 +19,10 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
}
@state()
private _logLevelCountResponse: LogLevelCountsReponseModel | null = null;
private _dateRange = { startDate: '', endDate: '' };
@state()
private _logLevelCounts: UmbLogLevelCounts | null = null;
@state()
private _logLevelCount: [string, number][] = [];
@@ -27,8 +30,11 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
@state()
private _logLevelCountFilter: string[] = [];
@state()
private _logLevelKeys: [string, number][] = [];
protected override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
if (_changedProperties.has('_logLevelCountFilter')) {
if (_changedProperties.has('_logLevelCountFilter') || _changedProperties.has('_logLevelCounts')) {
this.setLogLevelCount();
}
}
@@ -43,28 +49,69 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
}
setLogLevelCount() {
this._logLevelCount = this._logLevelCountResponse
? Object.entries(this._logLevelCountResponse).filter(([level]) => !this._logLevelCountFilter.includes(level))
: [];
if (this._logLevelCounts) {
const nonZeroEntries = Object.entries(this._logLevelCounts).filter(([, count]) => count > 0);
this._logLevelKeys = nonZeroEntries;
this._logLevelCount = nonZeroEntries.filter(([level]) => !this._logLevelCountFilter.includes(level));
} else {
this._logLevelKeys = [];
this._logLevelCount = [];
}
}
#observeStuff() {
this.observe(this._logViewerContext?.logCount, (logLevel) => {
this._logLevelCountResponse = logLevel ?? null;
this._logLevelCounts = 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`
<uui-box id="types" headline=${this.localize.term('logViewer_logTypes')}>
<p id="description">
<umb-localize key="logViewer_logTypesChartDescription">
In the chosen date range, you have this number of log messages grouped by type:
</umb-localize>
</p>
<div id="log-types-container">
<umb-donut-chart>
${repeat(
this._logLevelCount,
([level]) => level,
([level, number]) =>
html`<umb-donut-slice
.name=${level}
.amount=${number}
.kind=${this.localize.term('logViewer_messagesCount')}
.href=${this.#buildSearchUrl(level)}
.color="${`var(--umb-log-viewer-${level.toLowerCase()}-color)`}"></umb-donut-slice>`,
)}
</umb-donut-chart>
<div id="legend">
<ul>
${this._logLevelCountResponse
? Object.keys(this._logLevelCountResponse).map(
(level) =>
${repeat(
this._logLevelKeys,
([level]) => level,
([level, count]) =>
html`<li>
<button
@click=${(e: Event) => {
@@ -73,26 +120,20 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
}}>
<uui-icon
name="icon-record"
style="color: var(--umb-log-viewer-${level.toLowerCase()}-color);"></uui-icon
>${level}
style="color: var(--umb-log-viewer-${level.toLowerCase()}-color);"></uui-icon>
${level}
<span class="count">
(${this.localize.number(count, {
notation: 'compact',
minimumFractionDigits: count > 1000 ? 1 : 0,
maximumFractionDigits: 2,
})})
</span>
</button>
</li>`,
)
: ''}
)}
</ul>
</div>
<umb-donut-chart .description=${this.localize.term('logViewer_logTypesChartDescription')}>
${this._logLevelCountResponse
? this._logLevelCount.map(
([level, number]) =>
html`<umb-donut-slice
.name=${level}
.amount=${number}
.kind=${'messages'}
.color="${`var(--umb-log-viewer-${level.toLowerCase()}-color)`}"></umb-donut-slice> `,
)
: ''}
</umb-donut-chart>
</div>
</uui-box>
`;
@@ -100,12 +141,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 {
@@ -158,6 +236,11 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
li uui-icon {
margin-right: 1em;
}
.count {
margin-left: 0.3em;
color: var(--uui-color-text-alt);
}
`,
];
}

View File

@@ -29,7 +29,7 @@ export class UmbLogViewerOverviewViewElement extends UmbLitElement {
#observeErrorCount() {
this.observe(this._logViewerContext?.logCount, (logLevelCount) => {
this._errorCount = logLevelCount?.error;
this._errorCount = logLevelCount?.Error;
});
}