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:
@@ -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%',
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LogLevelModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
export type UmbLogLevelCounts = {
|
||||
[level in LogLevelModel]: number;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class UmbLogViewerOverviewViewElement extends UmbLitElement {
|
||||
|
||||
#observeErrorCount() {
|
||||
this.observe(this._logViewerContext?.logCount, (logLevelCount) => {
|
||||
this._errorCount = logLevelCount?.error;
|
||||
this._errorCount = logLevelCount?.Error;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user