From 08f639b9e00a3394a9ba89913d7f00677c3e1f79 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 3 May 2021 12:56:52 +0200 Subject: [PATCH] Implement activity stats --- .../ActivityIdSpecification.cs | 13 + .../elsa-designer-tree/elsa-designer-tree.tsx | 2 +- .../tree/elsa-designer-tree/models.ts | 14 - .../elsa-workflow-blueprint-viewer-screen.tsx | 3 +- .../elsa-workflow-instance-journal.tsx | 12 +- .../elsa-workflow-instance-viewer-screen.tsx | 272 +++++++------- .../src/services/elsa-client.ts | 41 ++- .../elsa-workflows-studio/src/utils/utils.ts | 333 +++++++++--------- .../Workflows/HelloWorld.cs | 2 +- .../Workflows/HelloWorld.v2.cs | 2 +- .../Endpoints/ActivityStats/Get.cs | 87 +++++ .../Endpoints/ActivityStats/Models.cs | 19 + 12 files changed, 481 insertions(+), 319 deletions(-) create mode 100644 src/core/Elsa.Core/Persistence/Specifications/WorkflowExecutionLogRecords/ActivityIdSpecification.cs create mode 100644 src/server/Elsa.Server.Api/Endpoints/ActivityStats/Get.cs create mode 100644 src/server/Elsa.Server.Api/Endpoints/ActivityStats/Models.cs diff --git a/src/core/Elsa.Core/Persistence/Specifications/WorkflowExecutionLogRecords/ActivityIdSpecification.cs b/src/core/Elsa.Core/Persistence/Specifications/WorkflowExecutionLogRecords/ActivityIdSpecification.cs new file mode 100644 index 000000000..7840a20e6 --- /dev/null +++ b/src/core/Elsa.Core/Persistence/Specifications/WorkflowExecutionLogRecords/ActivityIdSpecification.cs @@ -0,0 +1,13 @@ +using System; +using System.Linq.Expressions; +using Elsa.Models; + +namespace Elsa.Persistence.Specifications.WorkflowExecutionLogRecords +{ + public class ActivityIdSpecification : Specification + { + public string ActivityId { get; set; } + public ActivityIdSpecification(string activityId) => ActivityId = activityId; + public override Expression> ToExpression() => x => x.ActivityId == ActivityId; + } +} \ No newline at end of file diff --git a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx index a1f687a01..fafcaf3ec 100644 --- a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx +++ b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx @@ -497,7 +497,7 @@ export class ElsaWorkflowDesigner {

${activity.displayName}

- ${this.activityContextMenuButton} + ${!!this.activityContextMenuButton ? this.activityContextMenuButton : ''}
diff --git a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/models.ts b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/models.ts index 69e66949d..7a0f79c74 100644 --- a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/models.ts +++ b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/models.ts @@ -6,20 +6,6 @@ export enum WorkflowDesignerMode { Blueprint } -interface AggregatedActivityEvent { - eventName: string; - count: number; -} - -export interface ActivityStats { - fault?: WorkflowFault; - averageExecutionTime: number; - fastestExecutionTime: number; - slowestExecutionTime: number; - lastExecutedAt: Date; - events: Array; -} - export interface ActivityContextMenuState { shown: boolean; x: number; diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-blueprint-viewer/elsa-workflow-blueprint-viewer-screen/elsa-workflow-blueprint-viewer-screen.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-blueprint-viewer/elsa-workflow-blueprint-viewer-screen/elsa-workflow-blueprint-viewer-screen.tsx index 7b329ee5c..ea3984b8d 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-blueprint-viewer/elsa-workflow-blueprint-viewer-screen/elsa-workflow-blueprint-viewer-screen.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-blueprint-viewer/elsa-workflow-blueprint-viewer-screen/elsa-workflow-blueprint-viewer-screen.tsx @@ -97,12 +97,11 @@ export class ElsaWorkflowBlueprintViewerScreen { updateModels(workflowBlueprint: WorkflowBlueprint) { this.workflowBlueprint = workflowBlueprint; this.workflowModel = this.mapWorkflowModel(workflowBlueprint); - debugger; } mapWorkflowModel(workflowBlueprint: WorkflowBlueprint): WorkflowModel { return { - activities: workflowBlueprint.activities.filter(x => x.parentId == null).map(this.mapActivityModel), + activities: workflowBlueprint.activities.filter(x => x.parentId == workflowBlueprint.id).map(this.mapActivityModel), connections: workflowBlueprint.connections.map(this.mapConnectionModel), persistenceBehavior: workflowBlueprint.persistenceBehavior, }; diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-journal/elsa-workflow-instance-journal.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-journal/elsa-workflow-instance-journal.tsx index 447aa1218..2b37ab706 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-journal/elsa-workflow-instance-journal.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-journal/elsa-workflow-instance-journal.tsx @@ -8,6 +8,7 @@ import { } from "../../../../models"; import {activityIconProvider} from "../../../../services/activity-icon-provider"; import {createElsaClient} from "../../../../services/elsa-client"; +import {durationToString} from "../../../../utils/utils"; interface Tab { id: string; @@ -95,7 +96,7 @@ export class ElsaWorkflowInstanceJournal { selectActivityRecordInternal(record?: WorkflowExecutionLogRecord) { const activity = !!record ? this.workflowBlueprint.activities.find(x => x.id === record.activityId) : null; this.selectedRecordId = !!record ? record.id : null; - this.selectedActivityId = activity != null ? activity.parentId != null ? activity.parentId : activity.id : null; + this.selectedActivityId = activity != null ? activity.parentId != this.workflowBlueprint.id ? activity.parentId : activity.id : null; } getEventColor(eventName: string) { @@ -264,14 +265,7 @@ export class ElsaWorkflowInstanceJournal { filteredRecordData[key] = valueText; } - const deltaTimeText = !!deltaTime ? deltaTime.asHours() > 1 - ? `${deltaTime.asHours()} h` - : deltaTime.asMinutes() > 1 - ? `${deltaTime.asMinutes()} m` - : deltaTime.asSeconds() > 1 - ? `${deltaTime.asSeconds()} s` - : `${deltaTime.asMilliseconds()} ms` - : null; + const deltaTimeText = durationToString(deltaTime); return (
  • diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-viewer-screen/elsa-workflow-instance-viewer-screen.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-viewer-screen/elsa-workflow-instance-viewer-screen.tsx index d5d3f9d56..a67617ccd 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-viewer-screen/elsa-workflow-instance-viewer-screen.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instance-viewer/elsa-workflow-instance-viewer-screen/elsa-workflow-instance-viewer-screen.tsx @@ -13,11 +13,15 @@ import { WorkflowPersistenceBehavior, WorkflowStatus } from "../../../../models"; -import {createElsaClient} from "../../../../services/elsa-client"; +import {ActivityStats, createElsaClient} from "../../../../services/elsa-client"; import {pluginManager} from '../../../../services/plugin-manager'; import state from '../../../../utils/store'; import {ActivityContextMenuState, WorkflowDesignerMode} from "../../../designers/tree/elsa-designer-tree/models"; import {registerClickOutside} from "stencil-click-outside"; +import {editor} from "monaco-editor"; +import create = editor.create; +import moment from "moment"; +import {durationToString} from "../../../../utils/utils"; @Component({ tag: 'elsa-workflow-instance-viewer-screen', @@ -35,6 +39,7 @@ export class ElsaWorkflowInstanceViewerScreen { @State() workflowBlueprint: WorkflowBlueprint; @State() workflowModel: WorkflowModel; @State() selectedActivityId?: string; + @State() activityStats?: ActivityStats; @State() activityContextMenuState: ActivityContextMenuState = { shown: false, @@ -131,7 +136,7 @@ export class ElsaWorkflowInstanceViewerScreen { mapWorkflowModel(workflowBlueprint: WorkflowBlueprint): WorkflowModel { return { - activities: workflowBlueprint.activities.filter(x => x.parentId == null).map(this.mapActivityModel), + activities: workflowBlueprint.activities.filter(x => x.parentId == workflowBlueprint.id).map(this.mapActivityModel), connections: workflowBlueprint.connections.map(this.mapConnectionModel), persistenceBehavior: workflowBlueprint.persistenceBehavior, }; @@ -184,20 +189,28 @@ export class ElsaWorkflowInstanceViewerScreen { this.selectedActivityId = activity != null ? activity.parentId != null ? activity.parentId : activity.id : null; } - onActivitySelected(e: CustomEvent) { + async onActivitySelected(e: CustomEvent) { this.selectedActivityId = e.detail.activityId; - this.journal.selectActivityRecord(this.selectedActivityId); + await this.journal.selectActivityRecord(this.selectedActivityId); } - onActivityDeselected(e: CustomEvent) { + async onActivityDeselected(e: CustomEvent) { if (this.selectedActivityId == e.detail.activityId) this.selectedActivityId = null; - this.journal.selectActivityRecord(this.selectedActivityId); + await this.journal.selectActivityRecord(this.selectedActivityId); } - onActivityContextMenuButtonClicked(e: CustomEvent) { + async onActivityContextMenuButtonClicked(e: CustomEvent) { this.activityContextMenuState = e.detail; + this.activityStats = null; + + if (!e.detail.shown) { + return; + } + + const elsaClient = createElsaClient(this.serverUrl); + this.activityStats = await elsaClient.activityStatsApi.get(this.workflowInstanceId, e.detail.activity.activityId); } render() { @@ -247,6 +260,132 @@ export class ElsaWorkflowInstanceViewerScreen { } renderActivityPerformanceMenu() { + const activityStats: ActivityStats = this.activityStats; + + const renderStats = function () { + return ( + + ) + }; + + const renderLoader = function () { + return
    Loading...
    ; + }; + return } diff --git a/src/designer/elsa-workflows-studio/src/services/elsa-client.ts b/src/designer/elsa-workflows-studio/src/services/elsa-client.ts index 44141ae9d..3948ae4b5 100644 --- a/src/designer/elsa-workflows-studio/src/services/elsa-client.ts +++ b/src/designer/elsa-workflows-studio/src/services/elsa-client.ts @@ -10,7 +10,7 @@ import { VersionOptions, WorkflowBlueprint, WorkflowBlueprintSummary, WorkflowContextOptions, WorkflowDefinition, - WorkflowDefinitionSummary, WorkflowExecutionLogRecord, WorkflowInstance, WorkflowInstanceSummary, + WorkflowDefinitionSummary, WorkflowExecutionLogRecord, WorkflowFault, WorkflowInstance, WorkflowInstanceSummary, WorkflowPersistenceBehavior, WorkflowStatus } from "../models"; @@ -153,13 +153,19 @@ export const createElsaClient = function (serverUrl: string): ElsaClient { return response.data; } }, - designerApi:{ - runtimeSelectItemsApi:{ + designerApi: { + runtimeSelectItemsApi: { get: async (providerTypeName: string, context?: any): Promise> => { - const response = await httpClient.post('v1/designer/runtime-select-list-items', { providerTypeName: providerTypeName, context: context }); - return response.data; + const response = await httpClient.post('v1/designer/runtime-select-list-items', {providerTypeName: providerTypeName, context: context}); + return response.data; } } + }, + activityStatsApi: { + get: async (workflowInstanceId: string, activityId?: any): Promise => { + const response = await httpClient.get(`v1/workflow-instances/${workflowInstanceId}/activity-stats/${activityId}`); + return response.data; + } } } } @@ -172,6 +178,7 @@ export interface ElsaClient { workflowExecutionLogApi: WorkflowExecutionLogApi; scriptingApi: ScriptingApi; designerApi: DesignerApi; + activityStatsApi: ActivityStatsApi; } export interface ActivitiesApi { @@ -214,7 +221,7 @@ export interface WorkflowInstancesApi { export interface WorkflowExecutionLogApi { get(workflowInstanceId: string, page?: number, pageSize?: number): Promise>; - + } export interface BulkDeleteWorkflowsRequest { @@ -237,6 +244,10 @@ export interface RuntimeSelectItemsApi { get(providerTypeName: string, context?: any): Promise> } +export interface ActivityStatsApi { + get(workflowInstanceId: string, activityId: string): Promise; +} + export interface SaveWorkflowDefinitionRequest { workflowDefinitionId?: string; name?: string; @@ -257,3 +268,21 @@ export interface ExportWorkflowResponse { fileName: string; data: Blob; } + +export interface ActivityStats { + fault?: ActivityFault; + averageExecutionTime: string; + fastestExecutionTime: string; + slowestExecutionTime: string; + lastExecutedAt: Date; + eventCounts: Array; +} + +interface ActivityEventCount { + eventName: string; + count: number; +} + +interface ActivityFault{ + message: string; +} \ No newline at end of file diff --git a/src/designer/elsa-workflows-studio/src/utils/utils.ts b/src/designer/elsa-workflows-studio/src/utils/utils.ts index ac2c0d6f9..bf12a475c 100644 --- a/src/designer/elsa-workflows-studio/src/utils/utils.ts +++ b/src/designer/elsa-workflows-studio/src/utils/utils.ts @@ -1,246 +1,259 @@ import {ActivityDefinition, ActivityDefinitionProperty, ActivityModel, ConnectionModel, WorkflowModel} from "../models"; import * as collection from 'lodash/collection'; +import {Duration} from "moment"; declare global { - interface Array { - distinct(): Array; - - last(): T; - } -} - -export type Map = { - [key: string]: T -}; - -export function format(first: string, middle: string, last: string): string { - return (first || '') + (middle ? ` ${middle}` : '') + (last ? ` ${last}` : ''); -} - -export interface Array { + interface Array { distinct(): Array; last(): T; + } +} - find(predicate: (this: void, value: T, index: number, obj: T[]) => value is S, thisArg?: any): S | undefined; +export type Map = { + [key: string]: T +}; - find(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined; +export function format(first: string, middle: string, last: string): string { + return (first || '') + (middle ? ` ${middle}` : '') + (last ? ` ${last}` : ''); +} - push(...items: T[]): number; +export interface Array { + distinct(): Array; + + last(): T; + + find(predicate: (this: void, value: T, index: number, obj: T[]) => value is S, thisArg?: any): S | undefined; + + find(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined; + + push(...items: T[]): number; } Array.prototype.distinct = function () { - return [...new Set(this)]; + return [...new Set(this)]; } if (!Array.prototype.last) { - Array.prototype.last = function () { - return this[this.length - 1]; - }; + Array.prototype.last = function () { + return this[this.length - 1]; + }; } export function isNumeric(str: string): boolean { - return !isNaN(str as any) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... - !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail + return !isNaN(str as any) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... + !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail } export function getChildActivities(workflowModel: WorkflowModel, parentId?: string) { - if (parentId == null) { - const targetIds = new Set(workflowModel.connections.map(x => x.targetId)); - return workflowModel.activities.filter(x => !targetIds.has(x.activityId)); - } else { - const targetIds = new Set(workflowModel.connections.filter(x => x.sourceId === parentId).map(x => x.targetId)); - return workflowModel.activities.filter(x => targetIds.has(x.activityId)); - } + if (parentId == null) { + const targetIds = new Set(workflowModel.connections.map(x => x.targetId)); + return workflowModel.activities.filter(x => !targetIds.has(x.activityId)); + } else { + const targetIds = new Set(workflowModel.connections.filter(x => x.sourceId === parentId).map(x => x.targetId)); + return workflowModel.activities.filter(x => targetIds.has(x.activityId)); + } } export function getInboundConnections(workflowModel: WorkflowModel, activityId: string) { - return workflowModel.connections.filter(x => x.targetId === activityId); + return workflowModel.connections.filter(x => x.targetId === activityId); } export function getOutboundConnections(workflowModel: WorkflowModel, activityId: string) { - return workflowModel.connections.filter(x => x.sourceId === activityId); + return workflowModel.connections.filter(x => x.sourceId === activityId); } export function removeActivity(workflowModel: WorkflowModel, activityId: string): WorkflowModel { - const inboundConnections = getInboundConnections(workflowModel, activityId); - const outboundConnections = getOutboundConnections(workflowModel, activityId); - const connectionsToRemove = [...inboundConnections, ...outboundConnections]; + const inboundConnections = getInboundConnections(workflowModel, activityId); + const outboundConnections = getOutboundConnections(workflowModel, activityId); + const connectionsToRemove = [...inboundConnections, ...outboundConnections]; - return { - ...workflowModel, - activities: workflowModel.activities.filter(x => x.activityId != activityId), - connections: workflowModel.connections.filter(x => connectionsToRemove.indexOf(x) < 0) - }; + return { + ...workflowModel, + activities: workflowModel.activities.filter(x => x.activityId != activityId), + connections: workflowModel.connections.filter(x => connectionsToRemove.indexOf(x) < 0) + }; } export function removeConnection(workflowModel: WorkflowModel, sourceId: string, outcome: string): WorkflowModel { - return { - ...workflowModel, - connections: workflowModel.connections.filter(x => !(x.sourceId === sourceId && x.outcome === outcome)) - }; + return { + ...workflowModel, + connections: workflowModel.connections.filter(x => !(x.sourceId === sourceId && x.outcome === outcome)) + }; } export function findActivity(workflowModel: WorkflowModel, activityId: string) { - return workflowModel.activities.find(x => x.activityId === activityId); + return workflowModel.activities.find(x => x.activityId === activityId); } export function addConnection(workflowModel: WorkflowModel, connection: ConnectionModel); export function addConnection(workflowModel: WorkflowModel, sourceId: string, targetId: string, outcome: string); export function addConnection(workflowModel: WorkflowModel, ...args: any) { - const connection = typeof (args) == 'object' ? args as ConnectionModel : {sourceId: args[0], targetId: args[1], outcome: args[3]}; + const connection = typeof (args) == 'object' ? args as ConnectionModel : {sourceId: args[0], targetId: args[1], outcome: args[3]}; - return { - ...workflowModel, - connections: [...workflowModel.connections, connection] - }; + return { + ...workflowModel, + connections: [...workflowModel.connections, connection] + }; } export function setActivityDefinitionProperty(activityDefinition: ActivityDefinition, name: string, expression: string, syntax: string) { - setProperty(activityDefinition.properties, name, expression, syntax); + setProperty(activityDefinition.properties, name, expression, syntax); } export function setActivityModelProperty(activityModel: ActivityModel, name: string, expression: string, syntax: string) { - setProperty(activityModel.properties, name, expression, syntax); + setProperty(activityModel.properties, name, expression, syntax); } export function setProperty(properties: Array, name: string, expression: string, syntax?: string) { - let property: ActivityDefinitionProperty = properties.find(x => x.name == name); + let property: ActivityDefinitionProperty = properties.find(x => x.name == name); - if (!syntax) - syntax = 'Literal'; + if (!syntax) + syntax = 'Literal'; - if (!property) { - const expressions = {}; - expressions[syntax] = expression; - property = {name: name, expressions: expressions, syntax: syntax}; - properties.push(property); - } else { - property.expressions[syntax] = expression; - property.syntax = syntax; - } + if (!property) { + const expressions = {}; + expressions[syntax] = expression; + property = {name: name, expressions: expressions, syntax: syntax}; + properties.push(property); + } else { + property.expressions[syntax] = expression; + property.syntax = syntax; + } } export function getOrCreateProperty(activity: ActivityModel, name: string, defaultExpression?: () => string, defaultSyntax?: () => string): ActivityDefinitionProperty { - let property: ActivityDefinitionProperty = activity.properties.find(x => x.name == name); + let property: ActivityDefinitionProperty = activity.properties.find(x => x.name == name); - if (!property) { - const expressions = {}; - let syntax = defaultSyntax ? defaultSyntax() : undefined; + if (!property) { + const expressions = {}; + let syntax = defaultSyntax ? defaultSyntax() : undefined; - if (!syntax) - syntax = 'Literal'; + if (!syntax) + syntax = 'Literal'; - expressions[syntax] = defaultExpression ? defaultExpression() : undefined; - property = {name: name, expressions: expressions, syntax: null}; - activity.properties.push(property); - } + expressions[syntax] = defaultExpression ? defaultExpression() : undefined; + property = {name: name, expressions: expressions, syntax: null}; + activity.properties.push(property); + } - return property; + return property; } export function parseJson(json: string): any { - if(!json) - return null; - - try { - return JSON.parse(json); - } catch (e) { - console.warn(`Error parsing JSON: ${e}`); - } - return undefined; + if (!json) + return null; + + try { + return JSON.parse(json); + } catch (e) { + console.warn(`Error parsing JSON: ${e}`); + } + return undefined; } export function parseQuery(queryString?: string): any { - if (!queryString) - return {}; + if (!queryString) + return {}; - const query = {}; - const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&'); - for (let i = 0; i < pairs.length; i++) { - const pair = pairs[i].split('='); - query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); - } - return query; + const query = {}; + const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&'); + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i].split('='); + query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); + } + return query; } export function queryToString(query: any): string { - const q = query || {}; - return collection.map(q, (v, k) => `${k}=${v}`).join('&'); + const q = query || {}; + return collection.map(q, (v, k) => `${k}=${v}`).join('&'); } export function mapSyntaxToLanguage(syntax: string): any { - switch (syntax) { - case 'Json': - return 'json'; - case 'JavaScript': - return 'javascript'; - case 'Liquid': - return 'handlebars'; - case 'Literal': - default: - return 'plaintext'; - } + switch (syntax) { + case 'Json': + return 'json'; + case 'JavaScript': + return 'javascript'; + case 'Liquid': + return 'handlebars'; + case 'Literal': + default: + return 'plaintext'; + } } +export function durationToString(duration: Duration) { + return !!duration ? duration.asHours() > 1 + ? `${duration.asHours()} h` + : duration.asMinutes() > 1 + ? `${duration.asMinutes()} m` + : duration.asSeconds() > 1 + ? `${duration.asSeconds()} s` + : `${duration.asMilliseconds()} ms` + : null; +} + +// TODO: Replace with momentJS. export function timeSince(time) { - switch (typeof time) { - case 'number': - break; - case 'string': - time = +new Date(time); - break; - case 'object': - if (time.constructor === Date) time = time.getTime(); - break; - default: - time = +new Date(); - } - const time_formats = [ - [60, 'seconds', 1], // 60 - [120, '1 minute ago', '1 minute from now'], // 60*2 - [3600, 'minutes', 60], // 60*60, 60 - [7200, '1 hour ago', '1 hour from now'], // 60*60*2 - [86400, 'hours', 3600], // 60*60*24, 60*60 - [172800, 'Yesterday', 'Tomorrow'], // 60*60*24*2 - [604800, 'days', 86400], // 60*60*24*7, 60*60*24 - [1209600, 'Last week', 'Next week'], // 60*60*24*7*4*2 - [2419200, 'weeks', 604800], // 60*60*24*7*4, 60*60*24*7 - [4838400, 'Last month', 'Next month'], // 60*60*24*7*4*2 - [29030400, 'months', 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 - [58060800, 'Last year', 'Next year'], // 60*60*24*7*4*12*2 - [2903040000, 'years', 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 - [5806080000, 'Last century', 'Next century'], // 60*60*24*7*4*12*100*2 - [58060800000, 'centuries', 2903040000] // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 - ]; - let seconds = (+new Date() - time) / 1000, - token = 'ago', - list_choice = 1; + switch (typeof time) { + case 'number': + break; + case 'string': + time = +new Date(time); + break; + case 'object': + if (time.constructor === Date) time = time.getTime(); + break; + default: + time = +new Date(); + } + const time_formats = [ + [60, 'seconds', 1], // 60 + [120, '1 minute ago', '1 minute from now'], // 60*2 + [3600, 'minutes', 60], // 60*60, 60 + [7200, '1 hour ago', '1 hour from now'], // 60*60*2 + [86400, 'hours', 3600], // 60*60*24, 60*60 + [172800, 'Yesterday', 'Tomorrow'], // 60*60*24*2 + [604800, 'days', 86400], // 60*60*24*7, 60*60*24 + [1209600, 'Last week', 'Next week'], // 60*60*24*7*4*2 + [2419200, 'weeks', 604800], // 60*60*24*7*4, 60*60*24*7 + [4838400, 'Last month', 'Next month'], // 60*60*24*7*4*2 + [29030400, 'months', 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 + [58060800, 'Last year', 'Next year'], // 60*60*24*7*4*12*2 + [2903040000, 'years', 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 + [5806080000, 'Last century', 'Next century'], // 60*60*24*7*4*12*100*2 + [58060800000, 'centuries', 2903040000] // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 + ]; + let seconds = (+new Date() - time) / 1000, + token = 'ago', + list_choice = 1; - if (seconds === 0) { - return 'Just now'; - } - if (seconds < 0) { - seconds = Math.abs(seconds); - token = 'from now'; - list_choice = 2; - } - let i = 0, - format; + if (seconds === 0) { + return 'Just now'; + } + if (seconds < 0) { + seconds = Math.abs(seconds); + token = 'from now'; + list_choice = 2; + } + let i = 0, + format; - // tslint:disable-next-line: no-conditional-assignment - while (format = time_formats[i++]) { - if (seconds < format[0]) { - if (typeof format[2] === 'string') { - return format[list_choice]; - } else { - return Math.floor(seconds / format[2]) + ' ' + format[1] + ' ' + token; - } - } + // tslint:disable-next-line: no-conditional-assignment + while (format = time_formats[i++]) { + if (seconds < format[0]) { + if (typeof format[2] === 'string') { + return format[list_choice]; + } else { + return Math.floor(seconds / format[2]) + ' ' + format[1] + ' ' + token; + } } - return time; + } + return time; } diff --git a/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.cs b/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.cs index 30d36cfcb..c0ea1f91a 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.cs +++ b/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.cs @@ -10,7 +10,7 @@ namespace Elsa.Samples.Server.Host.Workflows { builder .WithWorkflowDefinitionId("HelloWorld") - .WithVersion(1) + .WithVersion(1, false, false) .WithDisplayName("Hello World!") .HttpEndpoint("/hello-world") .WriteHttpResponse(HttpStatusCode.OK, "Hello World!", "text/plain"); diff --git a/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.v2.cs b/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.v2.cs index 3838208f8..188d575dc 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.v2.cs +++ b/src/samples/server/Elsa.Samples.Server.Host/Workflows/HelloWorld.v2.cs @@ -10,7 +10,7 @@ namespace Elsa.Samples.Server.Host.Workflows { builder .WithWorkflowDefinitionId("HelloWorld") - .WithVersion(2) + .WithVersion(2, true, true) .WithDisplayName("Hello World!") .HttpEndpoint("/hello-world/v2") .WriteHttpResponse(HttpStatusCode.OK, "Hello World V2!", "text/plain"); diff --git a/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Get.cs b/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Get.cs new file mode 100644 index 000000000..1f80712c7 --- /dev/null +++ b/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Get.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Elsa.Models; +using Elsa.Persistence; +using Elsa.Persistence.Specifications; +using Elsa.Persistence.Specifications.WorkflowExecutionLogRecords; +using Elsa.Server.Api.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NodaTime; +using Open.Linq.AsyncExtensions; +using Swashbuckle.AspNetCore.Annotations; + +namespace Elsa.Server.Api.Endpoints.ActivityStats +{ + [ApiController] + [ApiVersion("1")] + [Route("v{apiVersion:apiVersion}/workflow-instances/{workflowInstanceId}/activity-stats/{activityId}")] + [Produces("application/json")] + public class Get : Controller + { + private readonly IWorkflowExecutionLogStore _workflowExecutionLogStore; + private readonly IEndpointContentSerializerSettingsProvider _serializerSettingsProvider; + + public Get(IWorkflowExecutionLogStore workflowExecutionLogStore, IEndpointContentSerializerSettingsProvider serializerSettingsProvider) + { + _workflowExecutionLogStore = workflowExecutionLogStore; + _serializerSettingsProvider = serializerSettingsProvider; + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ActivityStats))] + [SwaggerOperation( + Summary = "Returns an aggregated view of activity events and execution statistics.", + Description = "Returns an aggregated view of activity events and execution statistics.", + OperationId = "ActivityStats.Get", + Tags = new[] { "ActivityStats" }) + ] + public async Task> Handle(string workflowInstanceId, string activityId, CancellationToken cancellationToken = default) + { + var specification = new WorkflowInstanceIdSpecification(workflowInstanceId).And(new ActivityIdSpecification(activityId)); + var orderBy = OrderBySpecification.OrderBy(x => x.Timestamp); + var records = await _workflowExecutionLogStore.FindManyAsync(specification, orderBy, null, cancellationToken).ToList(); + var eventCounts = records.GroupBy(x => x.EventName); + var executions = GetExecutions(records).ToList(); + var executionTimes = executions.Select(x => x.Duration).OrderBy(x => x).ToList(); + var faultRecord = records.FirstOrDefault(x => x.EventName == "Faulted"); + var activityFault = faultRecord != null ? new ActivityFault(faultRecord.Message!) : null; + + var model = new ActivityStats + { + EventCounts = eventCounts.Select(x => new ActivityEventCount(x.Key!, x.Count())).ToList(), + LastExecutedAt = executions.Select(x => x.Timestamp).OrderByDescending(x => x).FirstOrDefault(), + SlowestExecutionTime = executionTimes.FirstOrDefault(), + FastestExecutionTime = executionTimes.LastOrDefault(), + AverageExecutionTime = Duration.FromTicks(executionTimes.Average(x => x.TotalTicks)), + Fault = activityFault + }; + + return Json(model, _serializerSettingsProvider.GetSettings()); + } + + private IEnumerable GetExecutions(List records) + { + var filteredRecords = records.Where(x => x.EventName is "Executing" or "Executed").OrderBy(x => x.Timestamp).ToList(); + + if(!filteredRecords.Any()) + yield break; + + var index = 0; + + foreach (var @record in filteredRecords) + { + if (@record.EventName == "Executed") + { + var previousRecord = filteredRecords.ElementAt(index - 1); + var duration = @record.Timestamp - previousRecord.Timestamp; + yield return new ActivityExecutionStat(@record.Timestamp, duration); + } + + index++; + } + } + } +} \ No newline at end of file diff --git a/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Models.cs b/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Models.cs new file mode 100644 index 000000000..777088868 --- /dev/null +++ b/src/server/Elsa.Server.Api/Endpoints/ActivityStats/Models.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using NodaTime; + +namespace Elsa.Server.Api.Endpoints.ActivityStats +{ + public record ActivityStats + { + public IList EventCounts { get; set; } = default!; + public ActivityFault? Fault { get; set; } + public Duration? AverageExecutionTime { get; set; } + public Duration? FastestExecutionTime { get; set; } + public Duration? SlowestExecutionTime { get; set; } + public Instant? LastExecutedAt { get; set; } + } + + public record ActivityFault(string Message); + public record ActivityEventCount(string EventName, int Count); + internal record ActivityExecutionStat(Instant Timestamp, Duration Duration); +} \ No newline at end of file