Merge pull request #577 from umbraco/feature/log-viewer

Looks good, there is still a few things for Warren to get around. and something about the button-with-dropdown. I have added Azure board tasks and notified Warren.
This commit is contained in:
Niels Lyngsø
2023-03-09 10:52:57 +01:00
committed by GitHub
42 changed files with 11054 additions and 15 deletions

View File

@@ -1,4 +1,6 @@
{
"cssVariables.lookupFiles": ["node_modules/@umbraco-ui/uui-css/dist/custom-properties.css"],
"cSpell.words": ["combobox", "variantable"]
"cSpell.words": ["combobox", "variantable"],
"exportall.config.folderListener": [],
"exportall.config.relExclusion": []
}

View File

@@ -10,7 +10,6 @@ import { pushToUniqueArray } from './push-to-unique-array.function';
*
* The ArrayState provides methods to append data when the data is an Object.
*/
export class ArrayState<T> extends DeepState<T[]> {
constructor(initialData: T[], private _getUnique?: (entry: T) => unknown) {
super(initialData);

View File

@@ -8,7 +8,7 @@ const menuItem: ManifestMenuItem = {
meta: {
label: 'Log Viewer',
icon: 'umb:box-alt',
entityType: 'logviewer-root',
entityType: 'logviewer',
menus: ['Umb.Menu.Settings'],
},
};

View File

@@ -0,0 +1,101 @@
import { UmbLogMessagesServerDataSource, UmbLogSearchesServerDataSource } from './sources/log-viewer.server.data';
import { UmbContextConsumerController } from '@umbraco-cms/context-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { UmbNotificationContext, UMB_NOTIFICATION_CONTEXT_TOKEN } from '@umbraco-cms/notification';
import { DirectionModel, LogLevelModel } from '@umbraco-cms/backend-api';
// Move to documentation / JSdoc
/* We need to create a new instance of the repository from within the element context. We want the notifications to be displayed in the right context. */
// element -> context -> repository -> (store) -> data source
// All methods should be async and return a promise. Some methods might return an observable as part of the promise response.
export class UmbLogViewerRepository {
#host: UmbControllerHostInterface;
#searchDataSource: UmbLogSearchesServerDataSource;
#messagesDataSource: UmbLogMessagesServerDataSource;
#notificationService?: UmbNotificationContext;
#initResolver?: () => void;
#initialized = false;
constructor(host: UmbControllerHostInterface) {
this.#host = host;
this.#searchDataSource = new UmbLogSearchesServerDataSource(this.#host);
this.#messagesDataSource = new UmbLogMessagesServerDataSource(this.#host);
new UmbContextConsumerController(this.#host, UMB_NOTIFICATION_CONTEXT_TOKEN, (instance) => {
this.#notificationService = instance;
this.#checkIfInitialized();
});
}
#init() {
// TODO: This would only works with one user of this method. If two, the first one would be forgotten, but maybe its alright for now as I guess this is temporary.
return new Promise<void>((resolve) => {
this.#initialized ? resolve() : (this.#initResolver = resolve);
});
}
#checkIfInitialized() {
if (this.#notificationService) {
this.#initialized = true;
this.#initResolver?.();
}
}
async getSavedSearches({ skip, take }: { skip: number; take: number }) {
await this.#init();
return this.#searchDataSource.getAllSavedSearches({ skip, take });
}
async getMessageTemplates({ skip, take }: { skip: number; take: number }) {
await this.#init();
return this.#messagesDataSource.getLogViewerMessageTemplate({ skip, take });
}
async getLogCount({ startDate, endDate }: { startDate?: string; endDate?: string }) {
await this.#init();
return this.#messagesDataSource.getLogViewerLevelCount({ startDate, endDate });
}
async getLogs({
skip = 0,
take = 100,
orderDirection,
filterExpression,
logLevel,
startDate,
endDate,
}: {
skip?: number;
take?: number;
orderDirection?: DirectionModel;
filterExpression?: string;
logLevel?: Array<LogLevelModel>;
startDate?: string;
endDate?: string;
}) {
await this.#init();
return this.#messagesDataSource.getLogViewerLogs({
skip,
take,
orderDirection,
filterExpression,
logLevel,
startDate,
endDate,
});
}
async getLogLevels({ skip = 0, take = 100 }: { skip: number; take: number }) {
await this.#init();
return this.#messagesDataSource.getLogViewerLevel({ skip, take });
}
async getLogViewerValidateLogsSize({ startDate, endDate }: { startDate?: string; endDate?: string }) {
await this.#init();
return this.#messagesDataSource.getLogViewerValidateLogsSize({ startDate, endDate });
}
}

View File

@@ -0,0 +1,69 @@
import {
DirectionModel,
LogLevelCountsModel,
LogLevelModel,
PagedLoggerModel,
PagedLogMessageModel,
PagedLogTemplateModel,
PagedSavedLogSearchModel,
SavedLogSearchModel,
} from '@umbraco-cms/backend-api';
import type { DataSourceResponse } from '@umbraco-cms/models';
export interface LogSearchDataSource {
getAllSavedSearches({
skip,
take,
}: {
skip?: number;
take?: number;
}): Promise<DataSourceResponse<PagedSavedLogSearchModel>>;
getSavedSearchByName({ name }: { name: string }): Promise<DataSourceResponse<SavedLogSearchModel>>;
deleteSavedSearchByName({ name }: { name: string }): Promise<DataSourceResponse<unknown>>;
postLogViewerSavedSearch({
requestBody,
}: {
requestBody?: SavedLogSearchModel;
}): Promise<DataSourceResponse<unknown>>;
}
export interface LogMessagesDataSource {
getLogViewerLevel({ skip, take }: { skip?: number; take?: number }): Promise<DataSourceResponse<PagedLoggerModel>>;
getLogViewerLevelCount({
startDate,
endDate,
}: {
startDate?: string;
endDate?: string;
}): Promise<DataSourceResponse<LogLevelCountsModel>>;
getLogViewerLogs({
skip,
take = 100,
orderDirection,
filterExpression,
logLevel,
startDate,
endDate,
}: {
skip?: number;
take?: number;
orderDirection?: DirectionModel;
filterExpression?: string;
logLevel?: Array<LogLevelModel>;
startDate?: string;
endDate?: string;
}): Promise<DataSourceResponse<PagedLogMessageModel>>;
getLogViewerMessageTemplate({
skip,
take = 100,
startDate,
endDate,
}: {
skip?: number;
take?: number;
startDate?: string;
endDate?: string;
}): Promise<DataSourceResponse<PagedLogTemplateModel>>;
}

View File

@@ -0,0 +1,213 @@
import { LogMessagesDataSource, LogSearchDataSource } from '.';
import { DirectionModel, LogLevelModel, LogViewerResource, SavedLogSearchModel } from '@umbraco-cms/backend-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { tryExecuteAndNotify } from '@umbraco-cms/resources';
/**
* A data source for the log saved searches
* @export
* @class UmbLogSearchesServerDataSource
* @implements {TemplateDetailDataSource}
*/
export class UmbLogSearchesServerDataSource implements LogSearchDataSource {
#host: UmbControllerHostInterface;
/**
* Creates an instance of UmbLogSearchesServerDataSource.
* @param {UmbControllerHostInterface} host
* @memberof UmbLogSearchesServerDataSource
*/
constructor(host: UmbControllerHostInterface) {
this.#host = host;
}
/**
* Grabs all the log viewer saved searches from the server
*
* @param {{ skip?: number; take?: number }} { skip = 0, take = 100 }
* @return {*}
* @memberof UmbLogSearchesServerDataSource
*/
async getAllSavedSearches({ skip = 0, take = 100 }: { skip?: number; take?: number }) {
return await tryExecuteAndNotify(this.#host, LogViewerResource.getLogViewerSavedSearch({ skip, take }));
}
/**
* Get a log viewer saved search by name from the server
*
* @param {{ name: string }} { name }
* @return {*}
* @memberof UmbLogSearchesServerDataSource
*/
async getSavedSearchByName({ name }: { name: string }) {
return await tryExecuteAndNotify(this.#host, LogViewerResource.getLogViewerSavedSearchByName({ name }));
}
/**
* Post a new log viewer saved search to the server
*
* @param {{ requestBody?: SavedLogSearch }} { requestBody }
* @return {*}
* @memberof UmbLogSearchesServerDataSource
*/
async postLogViewerSavedSearch({ requestBody }: { requestBody?: SavedLogSearchModel }) {
return await tryExecuteAndNotify(this.#host, LogViewerResource.postLogViewerSavedSearch({ requestBody }));
}
/**
* Remove a log viewer saved search by name from the server
*
* @param {{ name: string }} { name }
* @return {*}
* @memberof UmbLogSearchesServerDataSource
*/
async deleteSavedSearchByName({ name }: { name: string }) {
return await tryExecuteAndNotify(this.#host, LogViewerResource.deleteLogViewerSavedSearchByName({ name }));
}
}
/**
* A data source for the log messages and levels
*
* @export
* @class UmbLogMessagesServerDataSource
* @implements {LogMessagesDataSource}
*/
export class UmbLogMessagesServerDataSource implements LogMessagesDataSource {
#host: UmbControllerHostInterface;
/**
* Creates an instance of UmbLogMessagesServerDataSource.
* @param {UmbControllerHostInterface} host
* @memberof UmbLogMessagesServerDataSource
*/
constructor(host: UmbControllerHostInterface) {
this.#host = host;
}
/**
* Grabs all the loggers from the server
*
* @param {{ skip?: number; take?: number }} { skip = 0, take = 100 }
* @return {*}
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerLevel({ skip = 0, take = 100 }: { skip?: number; take?: number }) {
return await tryExecuteAndNotify(this.#host, LogViewerResource.getLogViewerLevel({ skip, take }));
}
/**
* Grabs all the number of different log messages from the server
*
* @param {{ skip?: number; take?: number }} { skip = 0, take = 100 }
* @return {*}
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerLevelCount({ startDate, endDate }: { startDate?: string; endDate?: string }) {
return await tryExecuteAndNotify(
this.#host,
LogViewerResource.getLogViewerLevelCount({
startDate,
endDate,
})
);
}
/**
* Grabs all the log messages from the server
*
* @param {{
* skip?: number;
* take?: number;
* orderDirection?: DirectionModel;
* filterExpression?: string;
* logLevel?: Array<LogLevelModel>;
* startDate?: string;
* endDate?: string;
* }} {
* skip = 0,
* take = 100,
* orderDirection,
* filterExpression,
* logLevel,
* startDate,
* endDate,
* }
* @return {*}
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerLogs({
skip = 0,
take = 100,
orderDirection,
filterExpression,
logLevel,
startDate,
endDate,
}: {
skip?: number;
take?: number;
orderDirection?: DirectionModel;
filterExpression?: string;
logLevel?: Array<LogLevelModel>;
startDate?: string;
endDate?: string;
}) {
return await tryExecuteAndNotify(
this.#host,
LogViewerResource.getLogViewerLog({
skip,
take,
orderDirection,
filterExpression,
logLevel,
startDate,
endDate,
})
);
}
/**
* Grabs all the log message templates from the server
*
* @param {{
* skip?: number;
* take?: number;
* startDate?: string;
* endDate?: string;
* }} {
* skip,
* take = 100,
* startDate,
* endDate,
* }
* @return {*}
* @memberof UmbLogMessagesServerDataSource
*/
async getLogViewerMessageTemplate({
skip,
take = 100,
startDate,
endDate,
}: {
skip?: number;
take?: number;
startDate?: string;
endDate?: string;
}) {
return await tryExecuteAndNotify(
this.#host,
LogViewerResource.getLogViewerMessageTemplate({
skip,
take,
startDate,
endDate,
})
);
}
async getLogViewerValidateLogsSize({ startDate, endDate }: { startDate?: string; endDate?: string }) {
return await tryExecuteAndNotify(
this.#host,
LogViewerResource.getLogViewerValidateLogsSize({
startDate,
endDate,
})
);
}
}

View File

@@ -0,0 +1,3 @@
export * from './log-viewer-date-range-selector.element';
export * from './log-viewer-level-tag.element';
export * from './log-viewer-to-many-logs-warning.element';

View File

@@ -0,0 +1,132 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, property, queryAll, state } from 'lit/decorators.js';
import {
LogViewerDateRange,
UmbLogViewerWorkspaceContext,
UMB_APP_LOG_VIEWER_CONTEXT_TOKEN,
} from '../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
import { query } from 'router-slot';
@customElement('umb-log-viewer-date-range-selector')
export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
:host {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-3);
}
input {
font-family: inherit;
padding: var(--uui-size-1) var(--uui-size-space-3);
font-size: inherit;
color: inherit;
border-radius: 0;
box-sizing: border-box;
border: none;
background: none;
width: 100%;
height: 100%;
outline: none;
position: relative;
border-bottom: 2px solid transparent;
}
/* find out better validation for that */
input:out-of-range {
border-color: var(--uui-color-danger);
}
:host([horizontal]) .input-container {
display: flex;
align-items: baseline;
}
`,
];
@state()
private _startDate = '';
@state()
private _endDate = '';
@queryAll('input')
private _inputs!: NodeListOf<HTMLInputElement>;
@property({ type: Boolean, reflect: true })
horizontal = false;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.addEventListener('input', this.#setDates);
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getMessageTemplates(0, 10);
this.#observeStuff();
});
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.dateRange, (dateRange: LogViewerDateRange) => {
this._startDate = dateRange?.startDate;
this._endDate = dateRange?.endDate;
});
}
#setDates() {
this._inputs.forEach((input) => {
if (input.id === 'start-date') {
this._startDate = input.value;
} else if (input.id === 'end-date') {
this._endDate = input.value;
}
});
const newDateRange: LogViewerDateRange = { startDate: this._startDate, endDate: this._endDate };
this.#logViewerContext?.setDateRange(newDateRange);
}
render() {
return html`
<div class="input-container">
<uui-label for="start-date">From:</uui-label>
<input
@click=${(e: Event) => {
(e.target as HTMLInputElement).showPicker();
}}
id="start-date"
type="date"
label="From"
.max=${this.#logViewerContext?.today ?? ''}
.value=${this._startDate}>
</input>
</div>
<div class="input-container">
<uui-label for="end-date">To: </uui-label>
<input
@click=${(e: Event) => {
(e.target as HTMLInputElement).showPicker();
}}
id="end-date"
type="date"
label="To"
.min=${this._startDate}
.max=${this.#logViewerContext?.today ?? ''}
.value=${this._endDate}>
</input>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-date-range-selector': UmbLogViewerDateRangeSelectorElement;
}
}

View File

@@ -0,0 +1,47 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { InterfaceColor, InterfaceLook } from '@umbraco-ui/uui-base/lib/types';
import { LogLevelModel } from '@umbraco-cms/backend-api';
interface LevelMapStyles {
look?: InterfaceLook;
color?: InterfaceColor;
style?: string;
}
@customElement('umb-log-viewer-level-tag')
export class UmbLogViewerLevelTagElement extends LitElement {
static styles = [UUITextStyles, css``];
@property()
level?: LogLevelModel;
levelMap: Record<LogLevelModel, LevelMapStyles> = {
Verbose: { look: 'secondary' },
Debug: {
look: 'default',
style: 'background-color: var(--umb-log-viewer-debug-color); color: var(--uui-color-surface)',
},
Information: { look: 'primary', color: 'positive' },
Warning: { look: 'primary', color: 'warning' },
Error: { look: 'primary', color: 'danger' },
Fatal: { look: 'primary' },
};
render() {
return html`<uui-tag
look=${ifDefined(this.level ? this.levelMap[this.level]?.look : undefined)}
color=${ifDefined(this.level ? this.levelMap[this.level]?.color : undefined)}
style="${ifDefined(this.level ? this.levelMap[this.level]?.style : undefined)}"
>${this.level}<slot></slot
></uui-tag>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-level-tag': UmbLogViewerLevelTagElement;
}
}

View File

@@ -0,0 +1,27 @@
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-log-viewer-to-many-logs-warning')
export class UmbLogViewerToManyLogsWarningElement extends LitElement {
static styles = [
css`
:host {
text-align: center;
}
`,
];
render() {
return html`<uui-box id="to-many-logs-warning">
<h3>Unable to view logs</h3>
<p>Today's log file is too large to be viewed and would cause performance problems.</p>
<p>If you need to view the log files, narrow your date range or try opening them manually.</p>
</uui-box>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-to-many-logs-warning': UmbLogViewerToManyLogsWarningElement;
}
}

View File

@@ -1,21 +1,190 @@
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import './components';
import { map } from 'rxjs';
import { css, html, nothing } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { IRoutingInfo } from 'router-slot';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { UmbLitElement } from '@umbraco-cms/element';
import { umbExtensionsRegistry, createExtensionElement } from '@umbraco-cms/extensions-api';
import { ManifestWorkspaceView, ManifestWorkspaceViewCollection } from '@umbraco-cms/extensions-registry';
import { UmbRouterSlotInitEvent, UmbRouterSlotChangeEvent } from '@umbraco-cms/router';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../logviewer.context';
import { repeat } from 'lit-html/directives/repeat.js';
//TODO make uui-input accept min and max values
@customElement('umb-logviewer-workspace')
export class UmbLogViewerWorkspaceElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
--umb-log-viewer-debug-color: var(--uui-color-default-emphasis);
--umb-log-viewer-information-color: var(--uui-color-positive);
--umb-log-viewer-warning-color: var(--uui-color-warning);
--umb-log-viewer-error-color: var(--uui-color-danger);
--umb-log-viewer-fatal-color: var(--uui-color-default);
--umb-log-viewer-verbose-color: var(--uui-color-current);
}
#header {
display: flex;
padding: 0 var(--uui-size-space-6);
gap: var(--uui-size-space-4);
align-items: center;
}
#router-slot {
height: 100%;
}
uui-tab-group {
--uui-tab-divider: var(--uui-color-border);
border-left: 1px solid var(--uui-color-border);
border-right: 1px solid var(--uui-color-border);
}
`,
];
private _alias = 'Umb.Workspace.LogviewerRoot';
@state()
private _workspaceViews: Array<ManifestWorkspaceView | ManifestWorkspaceViewCollection> = [];
@state()
private _routes: any[] = [];
@state()
private _activePath?: string;
@state()
private _routerPath?: string;
#logViewerContext = new UmbLogViewerWorkspaceContext(this);
constructor() {
super();
this.#logViewerContext.init();
}
connectedCallback() {
super.connectedCallback();
this._observeWorkspaceViews();
this.provideContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, this.#logViewerContext);
}
load(): void {
// Not relevant for this workspace -added to prevent the error from popping up
}
private _observeWorkspaceViews() {
this.observe(
umbExtensionsRegistry
.extensionsOfTypes<ManifestWorkspaceView>(['workspaceView'])
.pipe(map((extensions) => extensions.filter((extension) => extension.meta.workspaces.includes(this._alias)))),
(workspaceViews) => {
this._workspaceViews = workspaceViews;
this._createRoutes();
}
);
}
create(): void {
// Not relevant for this workspace
}
private _createRoutes() {
this._routes = [];
if (this._workspaceViews.length > 0) {
this._routes = this._workspaceViews.map((view) => {
return {
path: `${view.meta.pathname}`,
component: () => {
return createExtensionElement(view);
},
setup: (component: Promise<HTMLElement> | HTMLElement, info: IRoutingInfo) => {
// When its using import, we get an element, when using createExtensionElement we get a Promise.
if ((component as any).then) {
(component as any).then((el: any) => (el.manifest = view));
} else {
(component as any).manifest = view;
}
},
};
});
this._routes.push({
path: '**',
redirectTo: `${this._workspaceViews[0].meta.pathname}`,
});
}
}
#renderRoutes() {
return html`
${this._routes.length > 0
? html`
<umb-router-slot
id="router-slot"
.routes="${this._routes}"
@init=${(event: UmbRouterSlotInitEvent) => {
this._routerPath = event.target.absoluteRouterPath;
}}
@change=${(event: UmbRouterSlotChangeEvent) => {
this._activePath = event.target.localActiveViewPath;
}}></umb-router-slot>
`
: nothing}
`;
}
#renderViews() {
return html`
${this._workspaceViews.length > 1
? html`
<uui-tab-group slot="tabs">
${repeat(
this._workspaceViews,
(view) => view.alias,
(view) => html`
<uui-tab
.label="${view.meta.label || view.name}"
href="${this._routerPath}/${view.meta.pathname}"
?active="${view.meta.pathname === this._activePath}">
<uui-icon slot="icon" name="${view.meta.icon}"></uui-icon>
${view.meta.label || view.name}
</uui-tab>
`
)}
</uui-tab-group>
`
: nothing}
`;
}
@customElement('umb-logviewer-root-workspace')
export class UmbLogViewerRootWorkspaceElement extends LitElement {
render() {
return html`
<div>
<h1>LogViewer Root Workspace</h1>
</div>
<umb-body-layout>
<div id="header" slot="header">
<h3 id="headline">
${this._activePath === 'overview' ? 'Log Overview for Selected Time Period' : 'Log search'}
</h3>
</div>
${this.#renderViews()} ${this.#renderRoutes()}
<slot></slot>
</umb-body-layout>
`;
}
}
export default UmbLogViewerRootWorkspaceElement;
export default UmbLogViewerWorkspaceElement;
declare global {
interface HTMLElementTagNameMap {
'umb-logviewer-root-workspace': UmbLogViewerRootWorkspaceElement;
'umb-logviewer-workspace': UmbLogViewerWorkspaceElement;
}
}

View File

@@ -1,16 +1,45 @@
import type { ManifestWorkspace, ManifestWorkspaceAction, ManifestWorkspaceView } from '@umbraco-cms/models';
const workspaceAlias = 'Umb.Workspace.LogviewerRoot';
const workspace: ManifestWorkspace = {
type: 'workspace',
alias: 'Umb.Workspace.LogviewerRoot',
alias: workspaceAlias,
name: 'LogViewer Root Workspace',
loader: () => import('./logviewer-root-workspace.element'),
meta: {
entityType: 'logviewer-root',
entityType: 'logviewer',
},
};
const workspaceViews: Array<ManifestWorkspaceView> = [];
const workspaceViews: Array<ManifestWorkspaceView> = [
{
type: 'workspaceView',
alias: 'Umb.WorkspaceView.Logviewer.Overview',
name: 'LogViewer Root Workspace Overview View',
loader: () => import('../views/overview/index'),
weight: 300,
meta: {
workspaces: [workspaceAlias],
label: 'Overview',
pathname: 'overview',
icon: 'umb:box-alt',
},
},
{
type: 'workspaceView',
alias: 'Umb.WorkspaceView.Logviewer.Search',
name: 'LogViewer Root Workspace Search View',
loader: () => import('../views/search/index'),
weight: 200,
meta: {
workspaces: [workspaceAlias],
label: 'Search',
pathname: 'search',
icon: 'umb:search',
},
},
];
const workspaceActions: Array<ManifestWorkspaceAction> = [];

View File

@@ -0,0 +1,253 @@
import { UmbLogViewerRepository } from '../repository/log-viewer.repository';
import { ArrayState, createObservablePart, DeepState, ObjectState, StringState } from '@umbraco-cms/observable-api';
import {
DirectionModel,
LogLevelCountsModel,
LogLevelModel,
PagedLoggerModel,
PagedLogMessageModel,
PagedLogTemplateModel,
PagedSavedLogSearchModel,
} from '@umbraco-cms/backend-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { UmbContextToken } from '@umbraco-cms/context-api';
import { BasicState } from 'libs/observable-api/basic-state';
export type PoolingInterval = 0 | 2000 | 5000 | 10000 | 20000 | 30000;
export interface PoolingCOnfig {
enabled: boolean;
interval: PoolingInterval;
}
export interface LogViewerDateRange {
startDate: string;
endDate: string;
}
export class UmbLogViewerWorkspaceContext {
#host: UmbControllerHostInterface;
#repository: UmbLogViewerRepository;
get today() {
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
const mm = String(today.getMonth() + 1).padStart(2, '0');
const yyyy = today.getFullYear();
return yyyy + '-' + mm + '-' + dd;
}
get yesterday() {
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const dd = String(yesterday.getDate()).padStart(2, '0');
const mm = String(yesterday.getMonth() + 1).padStart(2, '0');
const yyyy = yesterday.getFullYear();
return yyyy + '-' + mm + '-' + dd;
}
defaultDateRange: LogViewerDateRange = {
startDate: this.yesterday,
endDate: this.today,
};
#savedSearches = new DeepState<PagedSavedLogSearchModel | undefined>(undefined);
savedSearches = createObservablePart(this.#savedSearches, (data) => data?.items);
#logCount = new DeepState<LogLevelCountsModel | null>(null);
logCount = createObservablePart(this.#logCount, (data) => data);
#dateRange = new DeepState<LogViewerDateRange>(this.defaultDateRange);
dateRange = createObservablePart(this.#dateRange, (data) => data);
#loggers = new DeepState<PagedLoggerModel | null>(null);
loggers = createObservablePart(this.#loggers, (data) => data?.items);
#canShowLogs = new BasicState<boolean | null>(null);
canShowLogs = createObservablePart(this.#canShowLogs, (data) => data);
#filterExpression = new StringState<string>('');
filterExpression = createObservablePart(this.#filterExpression, (data) => data);
#messageTemplates = new DeepState<PagedLogTemplateModel | null>(null);
messageTemplates = createObservablePart(this.#messageTemplates, (data) => data);
#logLevelsFilter = new ArrayState<LogLevelModel>([]);
logLevelsFilter = createObservablePart(this.#logLevelsFilter, (data) => data);
#logs = new DeepState<PagedLogMessageModel | null>(null);
logs = createObservablePart(this.#logs, (data) => data?.items);
logsTotal = createObservablePart(this.#logs, (data) => data?.total);
#polling = new ObjectState<PoolingCOnfig>({ enabled: false, interval: 2000 });
polling = createObservablePart(this.#polling, (data) => data);
#sortingDirection = new BasicState<DirectionModel>(DirectionModel.ASCENDING);
sortingDirection = createObservablePart(this.#sortingDirection, (data) => data);
#intervalID: number | null = null;
currentPage = 1;
constructor(host: UmbControllerHostInterface) {
this.#host = host;
this.#repository = new UmbLogViewerRepository(this.#host);
}
async init() {
this.validateLogSize();
}
setDateRange(dateRange: LogViewerDateRange) {
const { startDate, endDate } = dateRange;
const isAnyDateInTheFuture = new Date(startDate) > new Date() || new Date(endDate) > new Date();
const isStartDateBiggerThenEndDate = new Date(startDate) > new Date(endDate);
if (isAnyDateInTheFuture || isStartDateBiggerThenEndDate) {
return;
}
this.#dateRange.next(dateRange);
this.validateLogSize();
this.getLogCount();
}
async getSavedSearches() {
const { data } = await this.#repository.getSavedSearches({ skip: 0, take: 100 });
if (data) {
this.#savedSearches.next(data);
} else {
//falback to some default searches like in the old backoffice
this.#savedSearches.next({
items: [
{
name: 'Find all logs where the Level is NOT Verbose and NOT Debug',
query: "Not(@Level='Verbose') and Not(@Level='Debug')",
},
{
name: 'Find all logs that has an exception property (Warning, Error & Fatal with Exceptions)',
query: 'Has(@Exception)',
},
{
name: "Find all logs that have the property 'Duration'",
query: 'Has(Duration)',
},
{
name: "Find all logs that have the property 'Duration' and the duration is greater than 1000ms",
query: 'Has(Duration) and Duration > 1000',
},
{
name: "Find all logs that are from the namespace 'Umbraco.Core'",
query: "StartsWith(SourceContext, 'Umbraco.Core')",
},
{
name: 'Find all logs that use a specific log message template',
query: "@MessageTemplate = '[Timing {TimingId}] {EndMessage} ({TimingDuration}ms)'",
},
],
total: 6,
});
}
}
async getLogCount() {
const { data } = await this.#repository.getLogCount({ ...this.#dateRange.getValue() });
if (data) {
this.#logCount.next(data);
}
}
async getMessageTemplates(skip: number, take: number) {
const { data } = await this.#repository.getMessageTemplates({ skip, take });
if (data) {
this.#messageTemplates.next(data);
}
}
async getLogLevels(skip: number, take: number) {
const { data } = await this.#repository.getLogLevels({ skip, take });
if (data) {
this.#loggers.next(data);
}
}
async validateLogSize() {
const { data, error } = await this.#repository.getLogViewerValidateLogsSize({ ...this.#dateRange.getValue() });
if (error) {
this.#canShowLogs.next(false);
console.info('LogViewer: ', error);
return;
}
this.#canShowLogs.next(true);
console.info('LogViewer:showinfg logs');
}
setCurrentPage(page: number) {
this.currentPage = page;
}
getLogs = async () => {
if (!this.#canShowLogs.getValue()) {
return;
}
const skip = (this.currentPage - 1) * 100;
const take = 100;
const options = {
skip,
take,
orderDirection: this.#sortingDirection.getValue(),
filterExpression: this.#filterExpression.getValue(),
logLevel: this.#logLevelsFilter.getValue(),
...this.#dateRange.getValue(),
};
const { data } = await this.#repository.getLogs(options);
if (data) {
this.#logs.next(data);
}
};
setFilterExpression(query: string) {
this.#filterExpression.next(query);
}
setLogLevelsFilter(logLevels: LogLevelModel[]) {
this.#logLevelsFilter.next(logLevels);
}
togglePolling() {
const isEnabled = !this.#polling.getValue().enabled;
this.#polling.update({
enabled: isEnabled,
});
if (isEnabled) {
this.#intervalID = setInterval(() => {
this.currentPage = 1;
this.getLogs();
}, this.#polling.getValue().interval) as unknown as number;
return;
}
clearInterval(this.#intervalID as number);
}
setPollingInterval(interval: PoolingInterval) {
this.#polling.update({ interval, enabled: true });
}
toggleSortOrder() {
const direction = this.#sortingDirection.getValue();
const newDirection = direction === DirectionModel.ASCENDING ? DirectionModel.DESCENDING : DirectionModel.ASCENDING;
this.#sortingDirection.next(newDirection);
}
}
export const UMB_APP_LOG_VIEWER_CONTEXT_TOKEN = new UmbContextToken<UmbLogViewerWorkspaceContext>(
UmbLogViewerWorkspaceContext.name
);

View File

@@ -1,3 +1,4 @@
import { manifests as logviewerRootManifests } from './logviewer-root/manifests';
export const manifests = [...logviewerRootManifests];

View File

@@ -0,0 +1,4 @@
export * from './log-viewer-saved-searches-overview.element';
export * from './log-viewer-message-templates-overview.element';
export * from './log-viewer-log-types-chart.element';
export * from './log-viewer-log-level-overview.element';

View File

@@ -0,0 +1,48 @@
import { html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
import { LoggerModel } from '@umbraco-cms/backend-api';
//TODO: implement the saved searches pagination when the API total bug is fixed
@customElement('umb-log-viewer-log-level-overview')
export class UmbLogViewerLogLevelOverviewElement extends UmbLitElement {
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getSavedSearches();
this.#observeLogLevels();
});
}
@state()
private _loggers: LoggerModel[] = [];
/**
* The name of the logger to get the level for. Defaults to 'Global'.
*
* @memberof UmbLogViewerLogLevelOverviewElement
*/
@property()
loggerName = 'Global';
#observeLogLevels() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.loggers, (loggers) => {
this._loggers = loggers ?? [];
});
}
render() {
return html`${this._loggers.length > 0
? this._loggers.find((logger) => logger.name === this.loggerName)?.level
: ''}`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-log-level-overview': UmbLogViewerLogLevelOverviewElement;
}
}

View File

@@ -0,0 +1,167 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
import { LogLevelCountsModel } from '@umbraco-cms/backend-api';
@customElement('umb-log-viewer-log-types-chart')
export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
static styles = [
css`
#log-types-container {
display: flex;
gap: var(--uui-size-space-4);
flex-direction: column-reverse;
align-items: center;
justify-content: space-between;
}
button {
all: unset;
display: flex;
align-items: center;
cursor: pointer;
}
button:focus {
outline: 1px solid var(--uui-color-focus);
}
button.active {
text-decoration: line-through;
}
#chart {
width: 150px;
aspect-ratio: 1;
background: radial-gradient(white 40%, transparent 41%),
conic-gradient(
var(--umb-log-viewer-debug-color) 0% 20%,
var(--umb-log-viewer-information-color) 20% 40%,
var(--umb-log-viewer-warning-color) 40% 60%,
var(--umb-log-viewer-error-color) 60% 80%,
var(--umb-log-viewer-fatal-color) 80% 100%
);
margin: 10px;
display: inline-block;
border-radius: 50%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
li {
display: flex;
align-items: center;
}
li uui-icon {
margin-right: 1em;
}
`,
];
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getLogCount();
this.#observeStuff();
});
}
@state()
private _logLevelCount: LogLevelCountsModel | null = null;
@state()
private logLevelCount: [string, number][] = [];
@state()
private _logLevelCountFilter: string[] = [];
protected willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
if (_changedProperties.has('_logLevelCountFilter')) {
this.setLogLevelCount();
}
}
#setCountFilter(level: string) {
if (this._logLevelCountFilter.includes(level)) {
this._logLevelCountFilter = this._logLevelCountFilter.filter((item) => item !== level);
return;
}
this._logLevelCountFilter = [...this._logLevelCountFilter, level];
}
setLogLevelCount() {
this.logLevelCount = this._logLevelCount
? Object.entries(this._logLevelCount).filter(([level, number]) => !this._logLevelCountFilter.includes(level))
: [];
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logCount, (logLevel) => {
this._logLevelCount = logLevel ?? null;
this.setLogLevelCount();
});
}
render() {
return html`
<uui-box id="types" headline="Log types">
<div id="log-types-container">
<div id="legend">
<ul>
${this._logLevelCount
? Object.keys(this._logLevelCount).map(
(level) =>
html`<li>
<button
@click=${(e: Event) => {
(e.target as HTMLElement)?.classList.toggle('active');
this.#setCountFilter(level);
}}>
<uui-icon
name="umb:record"
style="color: var(--umb-log-viewer-${level.toLowerCase()}-color);"></uui-icon
>${level}
</button>
</li>`
)
: ''}
</ul>
</div>
<umb-donut-chart .description=${'In chosen date range you have this number of log message of type:'}>
${this._logLevelCount
? 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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-log-types-chart': UmbLogViewerLogTypesChartElement;
}
}

View File

@@ -0,0 +1,124 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
import { PagedLogTemplateModel, SavedLogSearchModel } from '@umbraco-cms/backend-api';
//TODO: fix pagination bug when API is fixed
@customElement('umb-log-viewer-message-templates-overview')
export class UmbLogViewerMessageTemplatesOverviewElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
#show-more-templates-btn {
margin-top: var(--uui-size-space-5);
}
a {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
color: inherit;
}
uui-table-cell {
padding: 10px 20px;
height: unset;
}
uui-table-row {
cursor: pointer;
}
uui-table-row:hover > uui-table-cell {
background-color: var(--uui-color-surface-alt);
}
`,
];
@state()
private _messageTemplates: PagedLogTemplateModel | null = null;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getMessageTemplates(0, 10);
this.#observeStuff();
});
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.messageTemplates, (templates) => {
this._messageTemplates = templates ?? null;
});
}
#getMessageTemplates() {
const take = this._messageTemplates?.items?.length ?? 0;
this.#logViewerContext?.getMessageTemplates(0, take + 10);
}
#renderSearchItem = (searchListItem: SavedLogSearchModel) => {
return html` <li>
<uui-button
@click=${() => {
this.#setCurrentQuery(searchListItem.query ?? '');
}}
label="${searchListItem.name ?? ''}"
title="${searchListItem.name ?? ''}"
href=${'/section/settings/logviewer/search?lq=' + searchListItem.query}
><uui-icon name="umb:search"></uui-icon>${searchListItem.name}</uui-button
>
</li>`;
};
#setCurrentQuery = (query: string) => {
this.#logViewerContext?.setFilterExpression(query);
};
render() {
return html`
<uui-box headline="Common Log Messages" id="saved-searches">
<p style="font-style: italic;">Total Unique Message types: ${this._messageTemplates?.total}</p>
<uui-table>
${this._messageTemplates
? this._messageTemplates.items.map(
(template) =>
html`<uui-table-row
><uui-table-cell>
<a
@click=${() => {
this.#setCurrentQuery(`@MessageTemplate='${template.messageTemplate}'` ?? '');
}}
href=${'/section/settings/logviewer/search?lg=@MessageTemplate%3D' + template.messageTemplate}>
<span>${template.messageTemplate}</span> <span>${template.count}</span>
</a>
</uui-table-cell>
</uui-table-row>`
)
: ''}
</uui-table>
<uui-button
id="show-more-templates-btn"
look="primary"
@click=${this.#getMessageTemplates}
label="Show more templates"
>Show more</uui-button
>
</uui-box>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-message-templates-overview': UmbLogViewerMessageTemplatesOverviewElement;
}
}

View File

@@ -0,0 +1,101 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
import { SavedLogSearchModel } from '@umbraco-cms/backend-api';
//TODO: implement the saved searches pagination when the API total bug is fixed
@customElement('umb-log-viewer-saved-searches-overview')
export class UmbLogViewerSavedSearchesOverviewElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
uui-box {
height: 100%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
li {
display: flex;
align-items: center;
}
li uui-icon {
margin-right: 1em;
}
`,
];
@state()
private _savedSearches: SavedLogSearchModel[] = [];
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getSavedSearches();
this.#observeStuff();
});
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.savedSearches, (savedSearches) => {
this._savedSearches = savedSearches ?? [];
});
}
#setCurrentQuery(query: string) {
this.#logViewerContext?.setFilterExpression(query);
}
#renderSearchItem = (searchListItem: SavedLogSearchModel) => {
return html` <li>
<uui-button
@click=${() => {
this.#setCurrentQuery(searchListItem.query ?? '');
}}
label="${searchListItem.name ?? ''}"
title="${searchListItem.name ?? ''}"
href=${'/section/settings/logviewer/search?lq=' + searchListItem.query}
><uui-icon name="umb:search"></uui-icon>${searchListItem.name}</uui-button
>
</li>`;
};
render() {
return html` <uui-box id="saved-searches" headline="Saved searches">
<ul>
<li>
<uui-button
@click=${() => {
this.#setCurrentQuery('');
}}
label="All logs"
title="All logs"
href="/section/settings/logviewer/search"
><uui-icon name="umb:search"></uui-icon>All logs</uui-button
>
</li>
${this._savedSearches.map(this.#renderSearchItem)}
</ul>
</uui-box>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-saved-searches-overview': UmbLogViewerSavedSearchesOverviewElement;
}
}

View File

@@ -0,0 +1,4 @@
import './components';
import { UmbLogViewerOverviewViewElement } from './log-overview-view.element';
export default UmbLogViewerOverviewViewElement;

View File

@@ -0,0 +1,159 @@
import { css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../logviewer.context';
import { LogLevelCountsModel } from '@umbraco-cms/backend-api';
import { UmbLitElement } from '@umbraco-cms/element';
//TODO: add a disabled attribute to the show more button when the total number of items is correctly returned from the endpoint
@customElement('umb-log-viewer-overview-view')
export class UmbLogViewerOverviewViewElement extends UmbLitElement {
static styles = [
css`
:host {
display: block;
}
#logviewer-layout {
margin: 20px;
height: calc(100vh - 160px);
display: grid;
grid-template-columns: 7fr 2fr;
grid-template-rows: 1fr 1fr;
gap: 20px 20px;
grid-auto-flow: row;
grid-template-areas:
'saved-searches info'
'common-messages info';
}
#info {
grid-area: info;
align-self: start;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 20px 20px;
}
#time-period {
grid-area: 1 / 1 / 2 / 3;
}
#errors {
grid-area: 2 / 1 / 3 / 2;
}
#level {
grid-area: 2 / 2 / 3 / 3;
}
#log-lever {
color: var(--uui-color-positive);
text-align: center;
}
#types {
grid-area: 3 / 1 / 5 / 3;
}
#saved-searches-container,
to-many-logs-warning {
grid-area: saved-searches;
}
#common-messages-container {
grid-area: common-messages;
--uui-box-default-padding: 0 var(--uui-size-space-5, 18px) var(--uui-size-space-5, 18px)
var(--uui-size-space-5, 18px);
}
#common-messages-container > uui-box {
height: 100%;
}
uui-label:nth-of-type(2) {
display: block;
margin-top: var(--uui-size-space-5);
}
#error-count {
font-size: 4rem;
text-align: center;
color: var(--uui-color-danger);
}
`,
];
@state()
private _errorCount = 0;
@state()
private _logLevelCount: LogLevelCountsModel | null = null;
@state()
private _canShowLogs = false;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observeErrorCount();
this.#observeCanShowLogs();
this.#logViewerContext?.getLogLevels(0, 100);
});
}
#observeErrorCount() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logCount, () => {
this._errorCount = this._logLevelCount?.error ?? 0;
});
}
#observeCanShowLogs() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.canShowLogs, (canShowLogs) => {
this._canShowLogs = canShowLogs ?? false;
});
}
render() {
return html`
<div id="logviewer-layout">
<div id="info">
<uui-box id="time-period" headline="Time Period">
<umb-log-viewer-date-range-selector></umb-log-viewer-date-range-selector>
</uui-box>
<uui-box id="errors" headline="Number of Errors">
<h1 id="error-count">${this._errorCount}</h1>
</uui-box>
<uui-box id="level" headline="Log level">
<h1 id="log-lever"><umb-log-viewer-log-level-overview></umb-log-viewer-log-level-overview></h1>
</uui-box>
<umb-log-viewer-log-types-chart id="types"></umb-log-viewer-log-types-chart>
</div>
${this._canShowLogs
? html`<div id="saved-searches-container">
<umb-log-viewer-saved-searches-overview></umb-log-viewer-saved-searches-overview>
</div>
<div id="common-messages-container">
<umb-log-viewer-message-templates-overview></umb-log-viewer-message-templates-overview>
</div>`
: html`<umb-log-viewer-to-many-logs-warning id="to-many-logs-warning"></umb-log-viewer-to-many-logs-warning>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-overview-view': UmbLogViewerOverviewViewElement;
}
}

View File

@@ -0,0 +1,5 @@
export * from './log-viewer-log-level-filter-menu.element';
export * from './log-viewer-message.element';
export * from './log-viewer-messages-list.element';
export * from './log-viewer-polling-button.element';
export * from './log-viewer-search-input.element';

View File

@@ -0,0 +1,118 @@
import { UUICheckboxElement } from '@umbraco-ui/uui';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, queryAll, state } from 'lit/decorators.js';
import _ from 'lodash';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { LogLevelModel } from '@umbraco-cms/backend-api';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('umb-log-viewer-log-level-filter-menu')
export class UmbLogViewerLogLevelFilterMenuElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
#log-level-selector {
padding: var(--uui-box-default-padding, var(--uui-size-space-5, 18px));
width: 150px;
background-color: var(--uui-color-surface);
box-shadow: var(--uui-shadow-depth-3);
display: flex;
flex-direction: column;
gap: var(--uui-size-space-3);
}
.log-level-button-indicator {
font-weight: 600;
}
.log-level-button-indicator:not(:last-of-type)::after {
content: ', ';
}
`,
];
@queryAll('#log-level-selector > uui-checkbox')
private _logLevelSelectorCheckboxes!: NodeListOf<UUICheckboxElement>;
@state()
private _logLevelFilter: LogLevelModel[] = [];
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observeLogLevelFilter();
});
}
#observeLogLevelFilter() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logLevelsFilter, (levelsFilter) => {
this._logLevelFilter = levelsFilter ?? [];
});
}
#setLogLevel() {
if (!this.#logViewerContext) return;
this.#logViewerContext?.setCurrentPage(1);
const logLevels = Array.from(this._logLevelSelectorCheckboxes)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.value as LogLevelModel);
this.#logViewerContext?.setLogLevelsFilter(logLevels);
this.#logViewerContext.getLogs();
}
setLogLevelDebounce = _.debounce(this.#setLogLevel, 300);
#selectAllLogLevels() {
this._logLevelSelectorCheckboxes.forEach((checkbox) => (checkbox.checked = true));
this.#setLogLevel();
}
#deselectAllLogLevels() {
this._logLevelSelectorCheckboxes.forEach((checkbox) => (checkbox.checked = false));
this.#setLogLevel();
}
#renderLogLevelSelector() {
return html`
<div slot="dropdown" id="log-level-selector" @change=${this.setLogLevelDebounce}>
${Object.values(LogLevelModel).map(
(logLevel) =>
html`<uui-checkbox class="log-level-menu-item" .value=${logLevel} label="${logLevel}"
><umb-log-viewer-level-tag .level=${logLevel}></umb-log-viewer-level-tag
></uui-checkbox>`
)}
<uui-button class="log-level-menu-item" @click=${this.#selectAllLogLevels} label="Select all"
>Select all</uui-button
>
<uui-button class="log-level-menu-item" @click=${this.#deselectAllLogLevels} label="Deselect all"
>Deselect all</uui-button
>
</div>
`;
}
render() {
return html`
<umb-button-with-dropdown label="Select log levels"
>Log Level:
${this._logLevelFilter.length > 0
? this._logLevelFilter.map((level) => html`<span class="log-level-button-indicator">${level}</span>`)
: 'All'}
${this.#renderLogLevelSelector()}
</umb-button-with-dropdown>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-log-level-filter-menu': UmbLogViewerLogLevelFilterMenuElement;
}
}

View File

@@ -0,0 +1,303 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, PropertyValueMap } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { LogLevelModel, LogMessagePropertyModel } from '@umbraco-cms/backend-api';
import { UmbLitElement } from '@umbraco-cms/element';
//TODO: check how to display EventId field in the message properties
@customElement('umb-log-viewer-message')
export class UmbLogViewerMessageElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
:host > details {
border-top: 1px solid var(--uui-color-border);
}
:host(:last-child) > details {
border-bottom: 1px solid var(--uui-color-border);
}
summary {
display: flex;
}
details[open] {
margin-bottom: var(--uui-size-space-3);
}
summary:hover,
#properties-list {
background-color: var(--uui-color-background);
}
#properties-list {
margin: 0;
padding: 0;
list-style: none;
margin-bottom: var(--uui-size-space-3);
}
.property {
padding: 10px 20px;
display: flex;
border-top: 1px solid var(--uui-color-border);
}
summary > div {
box-sizing: border-box;
padding: 10px 20px;
display: flex;
align-items: center;
}
#timestamp {
flex: 1 0 14ch;
}
#level,
#machine {
flex: 1 0 14ch;
}
#message {
flex: 6 0 14ch;
}
.property-name,
.property-value {
display: flex;
align-items: center;
}
.property-name {
font-weight: 600;
flex: 1 1 20ch;
}
.property-value {
flex: 3 0 20ch;
}
#search-menu {
margin: 0;
padding: 0;
margin-top: var(--uui-size-space-3);
background-color: var(--uui-color-surface);
box-shadow: var(--uui-shadow-depth-3);
max-width: 25%;
}
#search-menu > li {
padding: 0;
}
.search-item {
width: 100%;
}
pre {
background-color: var(--uui-color-background);
border-top: 1px solid #d8d7d9;
border-left: 4px solid #d42054;
color: #303033;
display: block;
font-family: Lato, Helvetica Neue, Helvetica, Arial, sans-serif;
line-height: 20px;
margin: 0;
overflow-x: auto;
padding: 9.5px;
white-space: pre-wrap;
}
`,
];
@query('details')
details!: HTMLDetailsElement;
@property()
timestamp = '';
@state()
date?: Date;
@property()
level: LogLevelModel | '' = '';
@property()
messageTemplate = '';
@property()
renderedMessage = '';
@property({ attribute: false })
properties: Array<LogMessagePropertyModel> = [];
@property({ type: Boolean })
open = false;
@property()
exception = '';
willUpdate(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('timestamp')) {
this.date = new Date(this.timestamp);
}
}
protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
if (_changedProperties.has('open')) {
this.open ? this.details.setAttribute('open', 'true') : this.details.removeAttribute('open');
}
}
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
});
}
private _searchMenuData: Array<{ label: string; href: () => string; icon: string; title: string }> = [
{
label: 'Search in Google',
title: '@logViewer_searchThisMessageWithGoogle',
href: () => `https://www.google.com/search?q=${this.renderedMessage}`,
icon: 'https://www.google.com/favicon.ico',
},
{
label: 'Search in Bing',
title: 'Search this message with Bing',
href: () => `https://www.bing.com/search?q=${this.renderedMessage}`,
icon: 'https://www.bing.com/favicon.ico',
},
{
label: 'Search in OurUmbraco',
title: 'Search this message on Our Umbraco forums and docs',
href: () => `https://our.umbraco.com/search?q=${this.renderedMessage}&content=wiki,forum,documentation`,
icon: 'https://our.umbraco.com/assets/images/app-icons/favicon.png',
},
{
label: 'Search in OurUmbraco with Google',
title: 'Search Our Umbraco forums using Google',
href: () =>
`https://www.google.co.uk/?q=site:our.umbraco.com ${this.renderedMessage}&safe=off#q=site:our.umbraco.com ${
this.renderedMessage
} ${this.properties.find((property) => property.name === 'SourceContext')?.value}&safe=off"`,
icon: 'https://www.google.com/favicon.ico',
},
{
label: 'Search Umbraco Source',
title: 'Search within Umbraco source code on Github',
href: () =>
`https://github.com/umbraco/Umbraco-CMS/search?q=${
this.properties.find((property) => property.name === 'SourceContext')?.value
}`,
icon: 'https://github.githubassets.com/favicon.ico',
},
{
label: 'Search Umbraco Issues',
title: 'Search Umbraco Issues on Github',
href: () =>
`https://github.com/umbraco/Umbraco-CMS/issues?q=${
this.properties.find((property) => property.name === 'SourceContext')?.value
}`,
icon: 'https://github.githubassets.com/favicon.ico',
},
];
private _propertiesWithSearchMenu: Array<string> = ['HttpRequestNumber', 'SourceContext', 'MachineName'];
private _findLogsWithProperty({ name, value }: LogMessagePropertyModel) {
let queryString = '';
if (isNaN(+(value ?? ''))) {
queryString = name + "='" + value + "'";
} else {
queryString = name + '=' + value;
}
this.#logViewerContext?.setFilterExpression(queryString);
this.#logViewerContext?.setCurrentPage(1);
this.details.removeAttribute('open');
this.#logViewerContext?.getLogs();
}
#setOpen(event: Event) {
this.open = (event.target as HTMLDetailsElement).open;
}
render() {
return html`
<details @open=${this.#setOpen}>
<summary>
<div id="timestamp">${this.date?.toLocaleString()}</div>
<div id="level">
<umb-log-viewer-level-tag .level=${this.level ? this.level : 'Information'}></umb-log-viewer-level-tag>
</div>
<div id="machine">${this.properties.find((property) => property.name === 'MachineName')?.value}</div>
<div id="message">${this.renderedMessage}</div>
</summary>
${this.exception ? html`<pre id="exception">${this.exception}</pre>` : ''}
<ul id="properties-list">
<li class="property">
<div class="property-name">Timestamp</div>
<div class="property-value">${this.date?.toLocaleString()}</div>
</li>
<li class="property">
<div class="property-name">@MessageTemplate</div>
<div class="property-value">${this.messageTemplate}</div>
</li>
${this.properties.map(
(property) =>
html`<li class="property">
<div class="property-name">${property.name}:</div>
<div class="property-value">
${property.value}
${this._propertiesWithSearchMenu.includes(property.name ?? '')
? html`<uui-button
compact
@click=${() => {
this._findLogsWithProperty(property);
}}
look="secondary"
label="Find logs with ${property.name}"
title="Find logs with ${property.name}"
><uui-icon name="umb:search"></uui-icon
></uui-button>`
: ''}
</div>
</li>`
)}
</ul>
<umb-button-with-dropdown look="secondary" placement="bottom-start" id="search-button" label="Search">
<uui-icon name="umb:search"></uui-icon>Search
<ul id="search-menu" slot="dropdown">
${this._searchMenuData.map(
(menuItem) => html`
<li>
<uui-menu-item
class="search-item"
href="${menuItem.href()}"
target="_blank"
label="${menuItem.label}"
title="${menuItem.title}">
<img slot="icon" src="${menuItem.icon}" width="16" height="16" alt="" />
</uui-menu-item>
</li>
`
)}
</ul>
</umb-button-with-dropdown>
</details>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-message': UmbLogViewerMessageElement;
}
}

View File

@@ -0,0 +1,158 @@
import { DirectionModel, LogMessageModel } from '@umbraco-cms/backend-api';
import { UmbLitElement } from '@umbraco-cms/element';
import { UUIScrollContainerElement, UUIPaginationElement } from '@umbraco-ui/uui';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
@customElement('umb-log-viewer-messages-list')
export class UmbLogViewerMessagesListElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
#message-list-header {
display: flex;
font-weight: 600;
}
#message-list-header > div {
box-sizing: border-box;
padding: 10px 20px;
display: flex;
align-items: center;
}
#timestamp {
flex: 1 0 14ch;
}
#level,
#machine {
flex: 1 0 14ch;
}
#message {
flex: 6 0 14ch;
}
#empty {
display: flex;
justify-content: center;
align-items: center;
gap: var(--uui-size-space-3);
}
#pagination {
margin: var(--uui-size-space-5, 18px) 0;
}
`,
];
@query('#logs-scroll-container')
private _logsScrollContainer!: UUIScrollContainerElement;
@state()
private _sortingDirection: DirectionModel = DirectionModel.ASCENDING;
@state()
private _logs: LogMessageModel[] = [];
@state()
private _logsTotal = 0;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observeLogs();
this.#logViewerContext.getLogs();
});
}
#observeLogs() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logs, (logs) => {
this._logs = logs ?? [];
});
this.observe(this.#logViewerContext.logsTotal, (total) => {
this._logsTotal = total ?? 0;
});
this.observe(this.#logViewerContext.sortingDirection, (direction) => {
this._sortingDirection = direction;
});
}
#sortLogs() {
this.#logViewerContext?.toggleSortOrder();
this.#logViewerContext?.setCurrentPage(1);
this.#logViewerContext?.getLogs();
}
_onPageChange(event: Event): void {
const current = (event.target as UUIPaginationElement).current;
this.#logViewerContext?.setCurrentPage(current);
this.#logViewerContext?.getLogs();
this._logsScrollContainer.scrollTop = 0;
}
private _renderPagination() {
if (!this._logsTotal) return '';
const totalPages = Math.ceil(this._logsTotal / 100);
if (totalPages <= 1) return '';
return html`<div id="pagination">
<uui-pagination .total=${totalPages} @change="${this._onPageChange}"></uui-pagination>
</div>`;
}
render() {
return html`<uui-box>
<p style="font-weight: bold;">Total items: ${this._logsTotal}</p>
<div id="message-list-header">
<div id="timestamp">
Timestamp
<uui-button compact @click=${this.#sortLogs} label="Sort logs">
<uui-symbol-sort
?descending=${this._sortingDirection === DirectionModel.DESCENDING}
active></uui-symbol-sort>
</uui-button>
</div>
<div id="level">Level</div>
<div id="machine">Machine name</div>
<div id="message">Message</div>
</div>
<uui-scroll-container id="logs-scroll-container" style="max-height: calc(100vh - 490px)">
${this._logs.length > 0
? html` ${this._logs.map(
(log) => html`<umb-log-viewer-message
.timestamp=${log.timestamp ?? ''}
.level=${log.level ?? ''}
.renderedMessage=${log.renderedMessage ?? ''}
.properties=${log.properties ?? []}
.exception=${log.exception ?? ''}
.messageTemplate=${log.messageTemplate ?? ''}></umb-log-viewer-message>`
)}`
: html`<umb-empty-state size="small"
><span id="empty">
<uui-icon name="umb:search"></uui-icon>Sorry, we cannot find what you are looking for.
</span></umb-empty-state
>`}
</uui-scroll-container>
${this._renderPagination()}
</uui-box>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-messages-list': UmbLogViewerMessagesListElement;
}
}

View File

@@ -0,0 +1,138 @@
import { UUIPopoverElement, UUISymbolExpandElement } from '@umbraco-ui/uui';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import {
PoolingCOnfig,
PoolingInterval,
UmbLogViewerWorkspaceContext,
UMB_APP_LOG_VIEWER_CONTEXT_TOKEN,
} from '../../../logviewer.context';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('umb-log-viewer-polling-button')
export class UmbLogViewerPollingButtonElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
#polling-interval-menu {
margin: 0;
padding: 0;
width: 20ch;
background-color: var(--uui-color-surface);
box-shadow: var(--uui-shadow-depth-3);
display: flex;
flex-direction: column;
transform: translateX(calc((100% - 33px) * -1));
}
#polling-enabled-icon {
margin-right: var(--uui-size-space-3);
margin-bottom: 1px;
-webkit-animation: rotate-center 0.8s ease-in-out infinite both;
animation: rotate-center 0.8s ease-in-out infinite both;
}
@-webkit-keyframes rotate-center {
0% {
-webkit-transform: rotate(0);
transform: rotate(0);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes rotate-center {
0% {
-webkit-transform: rotate(0);
transform: rotate(0);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
`,
];
@query('#polling-popover')
private _pollingPopover!: UUIPopoverElement;
@query('#polling-expand-symbol')
private _polingExpandSymbol!: UUISymbolExpandElement;
@state()
private _poolingConfig: PoolingCOnfig = { enabled: false, interval: 0 };
#pollingIntervals: PoolingInterval[] = [2000, 5000, 10000, 20000, 30000];
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observePoolingConfig();
this.#logViewerContext.getLogs();
});
}
#observePoolingConfig() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.polling, (poolingConfig) => {
this._poolingConfig = { ...poolingConfig };
});
}
#togglePolling() {
this.#logViewerContext?.togglePolling();
}
#setPolingInterval(interval: PoolingInterval) {
this.#logViewerContext?.setPollingInterval(interval);
this.#closePoolingPopover();
}
#openPoolingPopover() {
this._pollingPopover.open = true;
this._polingExpandSymbol.open = true;
}
#closePoolingPopover() {
this._pollingPopover.open = false;
this._polingExpandSymbol.open = false;
}
render() {
return html` <uui-button-group>
<uui-button label="Start pooling" @click=${this.#togglePolling}
>${this._poolingConfig.enabled
? html`<uui-icon name="umb:axis-rotation" id="polling-enabled-icon"></uui-icon>Polling
${this._poolingConfig.interval / 1000} seconds`
: 'Pooling'}</uui-button
>
<uui-popover placement="bottom-end" id="polling-popover" @close=${() => (this._polingExpandSymbol.open = false)}>
<uui-button slot="trigger" compact label="Choose pooling time" @click=${this.#openPoolingPopover}>
<uui-symbol-expand id="polling-expand-symbol"></uui-symbol-expand>
</uui-button>
<ul id="polling-interval-menu" slot="popover">
${this.#pollingIntervals.map(
(interval: PoolingInterval) =>
html`<uui-menu-item
label="Every ${interval / 1000} seconds"
@click-label=${() => this.#setPolingInterval(interval)}></uui-menu-item>`
)}
</ul>
</uui-popover>
</uui-button-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-polling-button': UmbLogViewerPollingButtonElement;
}
}

View File

@@ -0,0 +1,211 @@
import { UUIInputElement, UUIPopoverElement, UUISymbolExpandElement } from '@umbraco-ui/uui';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../../logviewer.context';
import { SavedLogSearchModel } from '@umbraco-cms/backend-api';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('umb-log-viewer-search-input')
export class UmbLogViewerSearchInputElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--uui-size-space-4);
}
#search-input {
width: 100%;
}
#saved-searches-button {
flex-shrink: 0;
}
#saved-searches-popover {
flex: 1;
}
#saved-searches-container {
width: 100%;
max-height: 300px;
background-color: var(--uui-color-surface);
box-shadow: var(--uui-shadow-depth-1);
}
.saved-search-item {
display: flex;
justify-content: space-between;
align-items: stretch;
border-bottom: 1px solid #e9e9eb;
}
.saved-search-item-button {
display: flex;
font-family: inherit;
flex: 1;
background: 0 0;
padding: 0 0;
border: 0;
clear: both;
cursor: pointer;
display: flex;
font-weight: 400;
line-height: 20px;
text-align: left;
align-items: center;
white-space: nowrap;
color: var(--uui-color-interactive);
}
.saved-search-item-button:hover {
background-color: var(--uui-color-surface-emphasis, rgb(250, 250, 250));
color: var(--color-standalone);
}
.saved-search-item-name {
font-weight: 600;
margin: 0 var(--uui-size-space-3);
}
#polling-symbol-expand,
#saved-search-expand-symbol,
uui-symbol-sort {
margin-left: var(--uui-size-space-3);
}
`,
];
@query('#saved-searches-popover')
private _savedSearchesPopover!: UUIPopoverElement;
@query('#saved-search-expand-symbol')
private _savedSearchesExpandSymbol!: UUISymbolExpandElement;
@state()
private _savedSearches: SavedLogSearchModel[] = [];
@state()
private _inputQuery = '';
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observeStuff();
this.#logViewerContext.getLogs();
});
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.savedSearches, (savedSearches) => {
this._savedSearches = savedSearches ?? [];
});
this.observe(this.#logViewerContext.filterExpression, (query) => {
this._inputQuery = query;
});
}
#toggleSavedSearchesPopover() {
this._savedSearchesPopover.open = !this._savedSearchesPopover.open;
}
#toggleSavedSearchesExpandSymbol() {
this._savedSearchesExpandSymbol.open = !this._savedSearchesExpandSymbol.open;
}
#openSavedSearchesPopover() {
this.#toggleSavedSearchesPopover();
this.#toggleSavedSearchesExpandSymbol();
}
#setQuery(event: Event) {
const target = event.target as UUIInputElement;
this._inputQuery = target.value as string;
this.#logViewerContext?.setFilterExpression(this._inputQuery);
}
#setQueryFromSavedSearch(query: string) {
this._inputQuery = query;
this.#logViewerContext?.setFilterExpression(query);
this.#logViewerContext?.setCurrentPage(1);
this.#logViewerContext?.getLogs();
this._savedSearchesPopover.open = false;
}
#clearQuery() {
this._inputQuery = '';
this.#logViewerContext?.setFilterExpression('');
this.#logViewerContext?.getLogs();
}
#search() {
this.#logViewerContext?.setCurrentPage(1);
this.#logViewerContext?.getLogs();
}
render() {
return html` <uui-popover
placement="bottom-start"
id="saved-searches-popover"
@close=${this.#toggleSavedSearchesExpandSymbol}>
<uui-input
id="search-input"
label="Search logs"
.placeholder=${'Search logs...'}
slot="trigger"
@input=${this.#setQuery}
.value=${this._inputQuery}>
${this._inputQuery
? html`<uui-button compact slot="append" label="Save search"
><uui-icon name="umb:favorite"></uui-icon></uui-button
><uui-button compact slot="append" label="Clear" @click=${this.#clearQuery}
><uui-icon name="umb:delete"></uui-icon
></uui-button>`
: html``}
<uui-button
compact
slot="append"
id="saved-searches-button"
@click=${this.#openSavedSearchesPopover}
label="Saved searches"
>Saved searches <uui-symbol-expand id="saved-search-expand-symbol"></uui-symbol-expand
></uui-button>
</uui-input>
<uui-scroll-container slot="popover" id="saved-searches-container" role="list">
${this._savedSearches.map(
(search) =>
html`<li class="saved-search-item">
<button
label="Search for ${search.name}"
class="saved-search-item-button"
@click=${() => this.#setQueryFromSavedSearch(search.query ?? '')}>
<span class="saved-search-item-name">${search.name}</span>
<span class="saved-search-item-query">${search.query}</span></button
><uui-button label="Remove saved search" color="danger"
><uui-icon name="umb:trash"></uui-icon
></uui-button>
</li>`
)}
</uui-scroll-container>
</uui-popover>
<uui-button look="primary" @click=${this.#search} label="Search">Search</uui-button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-search-input': UmbLogViewerSearchInputElement;
}
}

View File

@@ -0,0 +1,4 @@
import './components';
import { UmbLogViewerSearchViewElement } from './log-search-view.element';
export default UmbLogViewerSearchViewElement;

View File

@@ -0,0 +1,87 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbLitElement } from '@umbraco-cms/element';
import { UmbLogViewerWorkspaceContext, UMB_APP_LOG_VIEWER_CONTEXT_TOKEN } from '../../logviewer.context';
@customElement('umb-log-viewer-search-view')
export class UmbLogViewerSearchViewElement extends UmbLitElement {
static styles = [
UUITextStyles,
css`
#layout {
margin: 20px;
}
#levels-container,
#input-container {
display: flex;
align-items: center;
gap: var(--uui-size-space-4);
width: 100%;
margin-bottom: 20px;
}
#levels-container {
justify-content: space-between;
}
#dates-polling-container {
display: flex;
align-items: baseline;
}
umb-log-viewer-search-input {
flex: 1;
}
umb-log-viewer-date-range-selector {
flex-direction: row;
}
`,
];
@state()
private _canShowLogs = false;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT_TOKEN, (instance) => {
this.#logViewerContext = instance;
this.#observeCanShowLogs();
});
}
#observeCanShowLogs() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.canShowLogs, (canShowLogs) => {
this._canShowLogs = canShowLogs ?? false;
});
}
render() {
return html`
<div id="layout">
<div id="levels-container">
<umb-log-viewer-log-level-filter-menu></umb-log-viewer-log-level-filter-menu>
<div id="dates-polling-container">
<umb-log-viewer-date-range-selector horizontal></umb-log-viewer-date-range-selector>
<umb-log-viewer-polling-button> </umb-log-viewer-polling-button>
</div>
</div>
<div id="input-container">
<umb-log-viewer-search-input></umb-log-viewer-search-input>
</div>
${this._canShowLogs
? html`<umb-log-viewer-messages-list></umb-log-viewer-messages-list>`
: html`<umb-log-viewer-to-many-logs-warning id="to-many-logs-warning"></umb-log-viewer-to-many-logs-warning>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-log-viewer-search-view': UmbLogViewerSearchViewElement;
}
}

View File

@@ -0,0 +1,82 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { PopoverPlacement, UUIPopoverElement, UUISymbolExpandElement } from '@umbraco-ui/uui';
import { InterfaceColor, InterfaceLook } from '@umbraco-ui/uui-base/lib/types';
// TODO: maybe this should go to UI library? It's a common pattern
// TODO: consider not using this, but instead use dropdown, which is more generic shared component of backoffice. (this is at the movement only used in Log Viewer)
@customElement('umb-button-with-dropdown')
export class UmbButtonWithDropdownElement extends LitElement {
static styles = [
UUITextStyles,
css`
uui-symbol-expand {
margin-left: var(--uui-size-space-3);
}
`,
];
@property()
label = '';
@property()
open = false;
@property()
look: InterfaceLook = 'default';
@property()
color: InterfaceColor = 'default';
@property()
placement: PopoverPlacement = 'bottom-start';
@query('#symbol-expand')
symbolExpand!: UUISymbolExpandElement;
@query('#popover')
popover!: UUIPopoverElement;
#openPopover() {
this.open = true;
this.popover.open = true;
this.symbolExpand.open = true;
}
#closePopover() {
this.open = false;
this.popover.open = false;
this.symbolExpand.open = false;
}
#togglePopover() {
this.open ? this.#closePopover() : this.#openPopover();
}
render() {
return html`
<uui-popover placement=${this.placement} id="popover" @close=${this.#closePopover}>
<uui-button
slot="trigger"
.look=${this.look}
.color=${this.color}
.label=${this.label}
id="myPopoverBtn"
@click=${this.#togglePopover}>
<slot></slot>
<uui-symbol-expand id="symbol-expand" .open=${this.open}></uui-symbol-expand>
</uui-button>
<div slot="popover">
<slot name="dropdown"></slot>
</div>
</uui-popover>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-button-with-dropdown': UmbButtonWithDropdownElement;
}
}

View File

@@ -0,0 +1,17 @@
import './button-with-dropdown.element';
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import { UmbButtonWithDropdownElement } from './button-with-dropdown.element';
export default {
title: 'Components/Button with dropdown',
component: 'umb-button-with-dropdown',
id: 'umb-button-with-dropdown',
} as Meta;
export const AAAOverview: Story<UmbButtonWithDropdownElement> = () => html` <umb-button-with-dropdown>
Open me
<div slot="dropdown" style="background: pink; height: 300px">I am a dropdown</div>
</umb-button-with-dropdown>`;
AAAOverview.storyName = 'Overview';

View File

@@ -0,0 +1,20 @@
import './donut-slice';
import './donut-chart';
import { Meta } from '@storybook/web-components';
import { html } from 'lit-html';
export default {
title: 'Components/Donut chart',
component: 'umb-donut-chart',
id: 'umb-donut-chart',
tags: ['autodocs'],
} as Meta;
export const AAAOverview = () => html` <umb-donut-chart description="Colors of fruits">
<umb-donut-slice color="red" name="Red" amount="10" kind="apples"></umb-donut-slice>
<umb-donut-slice color="green" name="Green" amount="20" kind="apples"></umb-donut-slice>
<umb-donut-slice color="yellow" name="Yellow" amount="10" kind="bananas"></umb-donut-slice>
<umb-donut-slice color="purple" name="Purple" amount="69" kind="plums"></umb-donut-slice>
</umb-donut-chart>`;
AAAOverview.storyName = 'Overview';

View File

@@ -0,0 +1,337 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement, svg } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { clamp } from 'lodash-es';
import { UmbDonutSliceElement } from './donut-slice';
export interface Circle {
color: string;
name: string;
percent: number;
kind: string;
}
interface CircleWithCommands extends Circle {
offset: number;
commands: string;
}
//TODO: maybe move to UI Library
/**
* This is a donut chart component that can be used to display data in a circular way.
*
* @export
* @class UmbDonutChartElement
* @extends {LitElement}
*/
@customElement('umb-donut-chart')
export class UmbDonutChartElement extends LitElement {
static percentToDegrees(percent: number): number {
return percent * 3.6;
}
static styles = [
UUITextStyles,
css`
path {
pointer-events: visibleFill;
}
.circle {
filter: url(#erode);
}
.highlight {
transition: opacity 200ms linear;
filter: url(#filter);
opacity: 0;
}
.highlight:hover {
opacity: 0.5;
}
#container {
position: relative;
width: 200px;
}
#details-box {
background: #ffffffe6;
border: 1px solid var(--uui-color-border-standalone);
border-radius: var(--uui-border-radius);
box-sizing: border-box;
top: 0;
left: 0;
position: absolute;
opacity: 0;
padding: 0.5em;
line-height: 1.5;
font-size: var(--uui-type-small-size);
box-shadow: var(--uui-shadow-depth-1);
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;
}
#details-box.show {
opacity: 1;
}
#details-box uui-icon {
/* optically correct alignment */
color: var(--umb-donut-detail-color);
margin-right: 0.2em;
}
#details-title {
font-weight: bold;
display: flex;
align-items: center;
}
`,
];
/**
* Circle radius in pixels
*
* @memberof UmbDonutChartElement
*/
@property({ type: Number })
radius = 45;
/**
* The circle thickness in pixels
*
* @memberof UmbDonutChartElement
*/
@property({ type: Number, attribute: 'border-size' })
borderSize = 20;
/**
* The size of SVG element in pixels
*
* @memberof UmbDonutChartElement
*/
@property({ type: Number, attribute: 'svg-size' })
svgSize = 100;
/**
* Description of the graph, added for accessibility purposes
*
* @memberof UmbDonutChartElement
*/
@property()
description = '';
/**
* Hides the box that appears oh hover with the details of the slice
*
* @memberof UmbDonutChartElement
*/
@property({ type: Boolean })
hideDetailBox = false;
@queryAssignedElements({ selector: 'umb-donut-slice' })
private _slices!: UmbDonutSliceElement[];
@query('#container')
private _container!: HTMLDivElement;
@query('#details-box')
private _detailsBox!: HTMLDivElement;
@state()
private circles: CircleWithCommands[] = [];
@state()
private viewBox = 100;
@state()
private _posY = 0;
@state()
private _posX = 0;
@state()
private _detailName = '';
@state()
private _detailAmount = 0;
@state()
private _detailColor = 'black';
@state()
private _totalAmount = 0;
@state()
private _detailKind = '';
#containerBounds: DOMRect | undefined;
firstUpdated() {
this.#containerBounds = this._container.getBoundingClientRect();
}
protected willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
if (_changedProperties.has('radius') || _changedProperties.has('borderSize') || _changedProperties.has('svgSize')) {
if (this.borderSize > this.radius) {
throw new Error('Border size cannot be bigger than radius');
}
this.#printCircles();
}
}
#calculatePercentage(partialValue: number) {
if (this._totalAmount === 0) return 0;
const percent = Math.round((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),
color: slice.color,
name: slice.name,
kind: slice.kind,
};
})
);
}
#addCommands(Circles: Circle[]): CircleWithCommands[] {
let previousPercent = 0;
return Circles.map((slice) => {
const sliceWithCommands: CircleWithCommands = {
...slice,
commands: this.#getSliceCommands(slice, this.radius, this.svgSize, this.borderSize),
offset: previousPercent * 3.6 * -1,
};
previousPercent += slice.percent;
return sliceWithCommands;
});
}
#getSliceCommands(Circle: Circle, radius: number, svgSize: number, borderSize: number): string {
const degrees = UmbDonutChartElement.percentToDegrees(Circle.percent);
const longPathFlag = degrees > 180 ? 1 : 0;
const innerRadius = radius - borderSize;
const commands: string[] = [];
commands.push(`M ${svgSize / 2 + radius} ${svgSize / 2}`);
commands.push(`A ${radius} ${radius} 0 ${longPathFlag} 0 ${this.#getCoordFromDegrees(degrees, radius, svgSize)}`);
commands.push(`L ${this.#getCoordFromDegrees(degrees, innerRadius, svgSize)}`);
commands.push(`A ${innerRadius} ${innerRadius} 0 ${longPathFlag} 1 ${svgSize / 2 + innerRadius} ${svgSize / 2}`);
return commands.join(' ');
}
#getCoordFromDegrees(angle: number, radius: number, svgSize: number): string {
const x = Math.cos((angle * Math.PI) / 180);
const y = Math.sin((angle * Math.PI) / 180);
const coordX = x * radius + svgSize / 2;
const coordY = y * -radius + svgSize / 2;
return [coordX, coordY].join(' ');
}
#calculateDetailsBoxPosition = (event: MouseEvent) => {
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;
};
#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.percent;
this._detailColor = circle.color;
this._detailKind = circle.kind;
}
#showDetailsBox(event: MouseEvent) {
if (this.hideDetailBox) return;
this.#setDetailsBoxData(event);
this._detailsBox.classList.add('show');
}
#hideDetailsBox() {
if (this.hideDetailBox) return;
this._detailsBox.classList.remove('show');
}
#renderCircles() {
return svg`
<filter id="erode" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
<feMorphology operator="erode" radius="0.5 0.5" x="0%" y="0%" width="100%" height="100%" in="SourceGraphic" result="morphology"/>
</filter>
<filter id="filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
<feColorMatrix
type="matrix"
values="1.8 0 0 0 0
0 1.8 0 0 0
0 0 1.8 0 0
0 0 0 500 -20" x="0%" y="0%" width="100%" height="100%" in="merge1" result="colormatrix2"/>
<feMorphology operator="erode" radius="0.5 0.5" x="0%" y="0%" width="100%" height="100%" in="colormatrix2" result="morphology2"/>
<feFlood flood-color="#ffffff" flood-opacity="0.3" x="0%" y="0%" width="100%" height="100%" result="flood3"/>
<feComposite in="flood3" in2="SourceAlpha" operator="in" x="0%" y="0%" width="100%" height="100%" result="composite3"/>
<feMorphology operator="erode" radius="1 1" x="0%" y="0%" width="100%" height="100%" in="composite3" result="morphology1"/>
<feMerge x="0%" y="0%" width="100%" height="100%" result="merge1">
<feMergeNode in="morphology2"/>
<feMergeNode in="morphology1"/>
</feMerge>
<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`
<path
class="circle"
data-index="${i}"
fill="${circle.color}"
role="listitem"
d="${circle.commands}"
transform="rotate(${circle.offset} ${this.viewBox / 2} ${this.viewBox / 2})">
</path>
<path
data-index="${i}"
@mouseenter=${this.#showDetailsBox}
@mouseleave=${this.#hideDetailsBox}
class="highlight"
fill="${circle.color}"
role="listitem"
d="${circle.commands}"
transform="rotate(${circle.offset} ${this.viewBox / 2} ${this.viewBox / 2})">
</path>`
)}
`;
}
render() {
return html` <div id="container" @mousemove=${this.#calculateDetailsBoxPosition}>
<svg 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="umb:record"></uui-icon>${this._detailName}</div>
<span>${this._detailAmount} ${this._detailKind}</span>
</div>
</div>
<slot @slotchange=${this.#printCircles} @slice-update=${this.#printCircles}></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-donut-chart': UmbDonutChartElement;
}
}

View File

@@ -0,0 +1,51 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* This component is used to display a single slice of a donut chart. It only makes sense insice the donut chart
*
* @export
* @class UmbDonutSliceElement
* @fires slice-update - This event is fired when the slice is updated
* @extends {LitElement}
*/
@customElement('umb-donut-slice')
export class UmbDonutSliceElement extends LitElement {
/**
* Number of items that this slice represents
*
* @memberof UmbDonutSliceElement
*/
@property({ type: Number })
amount = 0;
/**
* Color of the slice. Any valid css color is accepted, custom properties are also supported
*
* @memberof UmbDonutSliceElement
*/
@property()
color = 'red';
/**
* Name of the slice. This is used to display the name of the slice in the donut chart
*
* @memberof UmbDonutSliceElement
*/
@property()
name = '';
/**
* Kind of the slice. This is shown on a details box when hovering over the slice
*
* @memberof UmbDonutSliceElement
*/
@property()
kind = '';
willUpdate() {
this.dispatchEvent(new CustomEvent('slice-update', { composed: true, bubbles: true }));
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-donut-slice': UmbDonutSliceElement;
}
}

View File

@@ -0,0 +1,2 @@
export * from './donut-chart';
export * from './donut-slice';

View File

@@ -3,6 +3,7 @@ import { css, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbLitElement } from '@umbraco-cms/element';
// TODO: maybe move this to UI Library.
@customElement('umb-dropdown')
export class UmbDropdownElement extends UmbLitElement {
static styles = [

View File

@@ -7,8 +7,10 @@ import './backoffice-frame/backoffice-header.element';
import './backoffice-frame/backoffice-main.element';
import './backoffice-frame/backoffice-modal-container.element';
import './backoffice-frame/backoffice-notification-container.element';
import './button-with-dropdown/button-with-dropdown.element';
import './code-block/code-block.element';
import './debug/debug.element';
import './donut-chart';
import './dropdown/dropdown.element';
import './empty-state/empty-state.element';
import './extension-slot/extension-slot.element';

View File

@@ -24,6 +24,7 @@ import { handlers as templateHandlers } from './domains/template.handlers';
import { handlers as languageHandlers } from './domains/language.handlers';
import { handlers as cultureHandlers } from './domains/culture.handlers';
import { handlers as redirectManagementHandlers } from './domains/redirect-management.handlers';
import { handlers as logViewerHandlers } from './domains/log-viewer.handlers';
import { handlers as packageHandlers } from './domains/package.handlers';
const handlers = [
@@ -52,6 +53,7 @@ const handlers = [
...languageHandlers,
...cultureHandlers,
...redirectManagementHandlers,
...logViewerHandlers,
...packageHandlers,
];

View File

@@ -5,4 +5,8 @@ export class UmbData<T> {
constructor(data: Array<T>) {
this.data = data;
}
get total() {
return this.data.length;
}
}

View File

@@ -0,0 +1,413 @@
import { logs } from './logs.data';
import { UmbData } from './data';
import { LogMessageModel, LogTemplateModel, SavedLogSearchModel } from '@umbraco-cms/backend-api';
// Temp mocked database
class UmbLogviewerSearchesData extends UmbData<SavedLogSearchModel> {
constructor(data: SavedLogSearchModel[]) {
super(data);
}
// skip can be number or null
getSavedSearches(skip = 0, take = this.data.length): Array<SavedLogSearchModel> {
return this.data.slice(skip, take);
}
getByName(name: string) {
return this.data.find((search) => search.name === name);
}
}
class UmbLogviewerTemplatesData extends UmbData<LogTemplateModel> {
constructor(data: LogTemplateModel[]) {
super(data);
}
// skip can be number or null
getTemplates(skip = 0, take = this.data.length): Array<LogTemplateModel> {
return this.data.slice(skip, take);
}
}
class UmbLogviewerMessagesData extends UmbData<LogMessageModel> {
constructor(data: LogTemplateModel[]) {
super(data);
}
// skip can be number or null
getLogs(skip = 0, take = this.data.length): Array<LogMessageModel> {
return this.data.slice(skip, take);
}
getLevelCount() {
const levels = this.data.map((log) => log.level ?? '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;
}
}
export const savedSearches: Array<SavedLogSearchModel> = [
{
name: 'Find all logs where the Level is NOT Verbose and NOT Debug',
query: "Not(@Level='Verbose') and Not(@Level='Debug')",
},
{
name: 'Find all logs that has an exception property (Warning, Error & Fatal with Exceptions)',
query: 'Has(@Exception)',
},
{
name: "Find all logs that have the property 'Duration'",
query: 'Has(Duration)',
},
{
name: "Find all logs that have the property 'Duration' and the duration is greater than 1000ms",
query: 'Has(Duration) and Duration > 1000',
},
{
name: "Find all logs that are from the namespace 'Umbraco.Core'",
query: "StartsWith(SourceContext, 'Umbraco.Core')",
},
{
name: 'Find all logs that use a specific log message template',
query: "@messageTemplate = '[Timing {TimingId}] {EndMessage} ({TimingDuration}ms)'",
},
{
name: 'Find logs where one of the items in the SortedComponentTypes property array is equal to',
query: "SortedComponentTypes[?] = 'Umbraco.Web.Search.ExamineComponent'",
},
{
name: 'Find logs where one of the items in the SortedComponentTypes property array contains',
query: "Contains(SortedComponentTypes[?], 'DatabaseServer')",
},
{
name: 'Find all logs that the message has localhost in it with SQL like',
query: "@Message like '%localhost%'",
},
{
name: "Find all logs that the message that starts with 'end' in it with SQL like",
query: "@Message like 'end%'",
},
{
name: 'bla',
query: 'bla bla',
},
];
export const messageTemplates: LogTemplateModel[] = [
{
messageTemplate: 'Create Foreign Key:\n {Sql}',
count: 90,
},
{
messageTemplate: 'Create Index:\n {Sql}',
count: 86,
},
{
messageTemplate: 'Create table:\n {Sql}',
count: 82,
},
{
messageTemplate: 'Create Primary Key:\n {Sql}',
count: 78,
},
{
messageTemplate: 'Creating data in {TableName}',
count: 58,
},
{
messageTemplate: 'Completed creating data in {TableName}',
count: 58,
},
{
messageTemplate: 'New table {TableName} was created',
count: 58,
},
{
messageTemplate: 'At {OrigState}',
count: 18,
},
{
messageTemplate: 'SQL [{ContextIndex}]: {Sql}',
count: 15,
},
{
messageTemplate: '{StartMessage} [Timing {TimingId}]',
count: 14,
},
{
messageTemplate: '{EndMessage} ({Duration}ms) [Timing {TimingId}]',
count: 14,
},
{
messageTemplate: 'Execute {MigrationType}',
count: 13,
},
{
messageTemplate: "Assigned Deploy permission letter '{Permission}' to user group '{UserGroupAlias}'",
count: 6,
},
{
messageTemplate: "Starting '{MigrationName}'...",
count: 5,
},
{
messageTemplate: 'Done (pending scope completion).',
count: 5,
},
{
messageTemplate:
"Umbraco Forms scheduled record deletion task will not run as it has been not enabled via configuration. To enable, set the configuration value at 'Umbraco:Forms:Options:ScheduledRecordDeletion:Enabled' to true.",
count: 5,
},
{
messageTemplate:
'Mapped Umbraco.Cloud.Deployment.SiteExtension.Messages.External.Git.ApplyChangesFromWwwRootToRepositoryCommand -> "siteext-input-queue"',
count: 3,
},
{
messageTemplate: 'Bus "Rebus 1" started',
count: 3,
},
{
messageTemplate: 'Acquiring MainDom.',
count: 3,
},
{
messageTemplate: 'Acquired MainDom.',
count: 3,
},
{
messageTemplate: 'Profiler is VoidProfiler, not profiling (must run debug mode to profile).',
count: 3,
},
{
messageTemplate:
"Found single permission letter for '{LegacyPermission}' on user group '{UserGroupAlias}', assuming this is the 'Notifications' permission instead of the Deploy 'Queue for transfer' permission",
count: 3,
},
{
messageTemplate: 'Started :: Running {edition} edition',
count: 3,
},
{
messageTemplate:
"File system watcher for deploy events started with filter 'deploy*' and notify filter 'FileName'.",
count: 3,
},
{
messageTemplate: 'Application started. Press Ctrl+C to shut down.',
count: 3,
},
{
messageTemplate: 'Hosting environment: {envName}',
count: 3,
},
{
messageTemplate: 'Content root path: {contentRoot}',
count: 3,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFRecordDataLongString as a primary key already exists.',
count: 2,
},
{
messageTemplate:
'No last synced Id found, this generally means this is a new server/install. A cold boot will be triggered.',
count: 2,
},
{
messageTemplate: 'Deploy permissions updated and saved',
count: 2,
},
{
messageTemplate: 'Starting :: Running on Umbraco Cloud',
count: 2,
},
{
messageTemplate:
'Registered with MainDom, localContentDbExists? {LocalContentDbExists}, localMediaDbExists? {LocalMediaDbExists}',
count: 2,
},
{
messageTemplate: 'Creating the content store, localContentDbExists? {LocalContentDbExists}',
count: 2,
},
{
messageTemplate: 'Creating the media store, localMediaDbExists? {LocalMediaDbExists}',
count: 2,
},
{
messageTemplate: 'Stopping ({SignalSource})',
count: 2,
},
{
messageTemplate: 'Released ({SignalSource})',
count: 2,
},
{
messageTemplate: 'Application is shutting down...',
count: 2,
},
{
messageTemplate: 'Bus "Rebus 1" stopped',
count: 2,
},
{
messageTemplate: 'Queued Hosted Service is stopping.',
count: 2,
},
{
messageTemplate: 'User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}',
count: 2,
},
{
messageTemplate: 'Starting unattended install.',
count: 1,
},
{
messageTemplate: 'Unattended install completed.',
count: 1,
},
{
messageTemplate: 'Configured with Azure database.',
count: 1,
},
{
messageTemplate: 'Initialized the SqlServer database schema.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFRecordDataBit as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFRecordDataDateTime as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFRecordDataInteger as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFUserFormSecurity as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFUserGroupSecurity as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database migration step not completed: could not create primary key constraint on UFUserGroupFormSecurity as a primary key already exists.',
count: 1,
},
{
messageTemplate:
'Database NuCache was serialized using {CurrentSerializer}. Currently configured NuCache serializer {Serializer}. Rebuilding Nucache',
count: 1,
},
{
messageTemplate: 'Starting :: Running locally',
count: 1,
},
{
messageTemplate: 'No XML encryptor configured. Key {KeyId:B} may be persisted to storage in unencrypted form.',
count: 1,
},
{
messageTemplate: 'Started :: Transitioned from azure init marker to deploy marker',
count: 1,
},
{
messageTemplate: 'Found {diskReadTrigger} or {diskReadOnStartTrigger} trigger file when starting, processing...',
count: 1,
},
{
messageTemplate: 'Beginning deployment {id}.',
count: 1,
},
{
messageTemplate: 'Suspend scheduled publishing.',
count: 1,
},
{
messageTemplate: 'Preparing',
count: 1,
},
{
messageTemplate: 'Reading state',
count: 1,
},
{
messageTemplate: 'No artifacts',
count: 1,
},
{
messageTemplate: 'Restore caches and indexes',
count: 1,
},
{
messageTemplate: 'Resume scheduled publishing.',
count: 1,
},
{
messageTemplate: 'Complete',
count: 1,
},
{
messageTemplate: 'Deployment {id} completed.',
count: 1,
},
{
messageTemplate: 'Work Status {WorkStatus}.',
count: 1,
},
{
messageTemplate: 'Released from MainDom',
count: 1,
},
{
messageTemplate: "Keep alive failed (at '{keepAlivePingUrl}').",
count: 1,
},
{
messageTemplate: 'Adding examine event handlers for {RegisteredIndexers} index providers.',
count: 1,
},
{
messageTemplate: 'Document {ContentName} (id={ContentId}) has been published.',
count: 1,
},
];
export const logLevels = {
total: 2,
items: [
{
name: 'Global',
level: 'Information',
},
{
name: 'UmbracoFile',
level: 'Verbose',
},
],
};
export const umbLogviewerData = {
searches: new UmbLogviewerSearchesData(savedSearches),
templates: new UmbLogviewerTemplatesData(messageTemplates),
logs: new UmbLogviewerMessagesData(logs),
logLevels: logLevels,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import { rest } from 'msw';
import { umbLogviewerData } from '../data/log-viewer.data';
import { umbracoPath } from '@umbraco-cms/utils';
import { SavedLogSearchModel } from '@umbraco-cms/backend-api';
export const handlers = [
//#region Searches
rest.get(umbracoPath('/log-viewer/saved-search'), (req, res, ctx) => {
const skip = req.url.searchParams.get('skip');
const skipNumber = skip ? Number.parseInt(skip) : undefined;
const take = req.url.searchParams.get('take');
const takeNumber = take ? Number.parseInt(take) : undefined;
const items = umbLogviewerData.searches.getSavedSearches(skipNumber, takeNumber);
const response = {
total: items.length,
items,
};
return res(ctx.delay(), ctx.status(200), ctx.json(response));
}),
rest.get(umbracoPath('/log-viewer/saved-search/:name'), (req, res, ctx) => {
const name = req.params.key as string;
if (!name) return;
const item = umbLogviewerData.searches.getByName(name);
return res(ctx.delay(), ctx.status(200), ctx.json(item));
}),
rest.post<SavedLogSearchModel>(umbracoPath('/log-viewer/saved-search'), async (req, res, ctx) => {
return res(ctx.delay(), ctx.status(200));
}),
rest.delete(umbracoPath('/log-viewer/saved-search/:name'), async (req, res, ctx) => {
return res(ctx.status(200));
}),
//#endregion
//#region Temaplates
rest.get(umbracoPath('/log-viewer/message-template'), (req, res, ctx) => {
const skip = req.url.searchParams.get('skip');
const skipNumber = skip ? Number.parseInt(skip) : undefined;
const take = req.url.searchParams.get('take');
const takeNumber = take ? Number.parseInt(take) : undefined;
const items = umbLogviewerData.templates.getTemplates(skipNumber, takeNumber);
const response = {
total: umbLogviewerData.templates.total,
items,
};
return res(ctx.delay(), ctx.status(200), ctx.json(response));
}),
//#endregion
//#region Logs
rest.get(umbracoPath('/log-viewer/level'), (req, res, ctx) => {
return res(ctx.delay(), ctx.status(200), ctx.json(umbLogviewerData.logLevels));
}),
rest.get(umbracoPath('/log-viewer/level-count'), (req, res, ctx) => {
return res(ctx.delay(), ctx.status(200), ctx.json(umbLogviewerData.logs.getLevelCount()));
}),
rest.get(umbracoPath('/log-viewer/log'), (req, res, ctx) => {
const skip = req.url.searchParams.get('skip');
const skipNumber = skip ? Number.parseInt(skip) : undefined;
const take = req.url.searchParams.get('take');
const takeNumber = take ? Number.parseInt(take) : undefined;
const items = umbLogviewerData.logs.getLogs(skipNumber, takeNumber);
const response = {
total: umbLogviewerData.logs.total,
items,
};
return res(ctx.delay(), ctx.status(200), ctx.json(response));
}),
];