diff --git a/package.json b/package.json index a72b2abf..a5014df7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "private": true, "scripts": { - "prepare": "lerna run prepare", + "prepare": "yarn build", + "build": "yarn -s tsref && yarn -s tsbuild", + "tsref": "node scripts/typescript-references.js", + "tsbuild": "tsc -b", "watch": "lerna exec --stream --parallel -- \"yarn run watch\"", "clean": "lerna run clean", "vsce:package": "lerna run vsce:package", @@ -35,11 +38,16 @@ "webpack-cli": "^4.5.0" }, "workspaces": [ + "packages/base", + "packages/react-components", "vscode-trace-common", "vscode-trace-webviews", "vscode-trace-extension" + ], "resolutions": { - "@vscode/vsce": "2.25.0" + "@vscode/vsce": "2.25.0", + "@types/react": "18.3.8", + "@types/lodash": "4.17.14" } } diff --git a/packages/base/.eslintrc.js b/packages/base/.eslintrc.js new file mode 100644 index 00000000..a6115b86 --- /dev/null +++ b/packages/base/.eslintrc.js @@ -0,0 +1,26 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", // Specifies the ESLint parser + parserOptions: { + ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features + sourceType: "module", // Allows for the use of imports + tsconfigRootDir: __dirname, + project: 'tsconfig.json', + projectFolderIgnoreList: [ + '/lib/' + ] + }, + extends: [ + 'plugin:@typescript-eslint/recommended', + '../../configs/base.eslintrc.json', + '../../configs/warnings.eslintrc.json', + '../../configs/errors.eslintrc.json' + ], + ignorePatterns: [ + 'node_modules', + 'lib', + '.eslintrc.js', + 'plugins' + ] +}; diff --git a/packages/base/LICENSE b/packages/base/LICENSE new file mode 100644 index 00000000..74a365fe --- /dev/null +++ b/packages/base/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, 2021 Ericsson and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/base/README.md b/packages/base/README.md new file mode 100644 index 00000000..bfb7c8b6 --- /dev/null +++ b/packages/base/README.md @@ -0,0 +1,9 @@ +# Description + +The Trace Viewer base package contains trace management utilities for managing traces using Trace Server applications that implement the Trace Server Protocol (TSP). While being initially used within the Theia Trace Viewer extension, its code base is independent to any Theia APIs and hence can be integrated in other web applications. + +## Additional Information + +- [Theia Trace Viewer Extension git repository](https://github.com/eclipse-cdt-cloud/theia-trace-extension) +- [Trace Server Protocol git repository](https://github.com/eclipse-cdt-cloud/trace-server-protocol) +- [Reference Trace Server - Download (Eclipse Trace Compass)](https://download.eclipse.org/tracecompass.incubator/trace-server/rcp/) diff --git a/packages/base/package.json b/packages/base/package.json new file mode 100644 index 00000000..5ed23af9 --- /dev/null +++ b/packages/base/package.json @@ -0,0 +1,41 @@ +{ + "name": "traceviewer-base", + "version": "0.7.2", + "description": "Trace Viewer base package, contains trace management utilities", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-cdt-cloud/theia-trace-extension" + }, + "bugs": { + "url": "https://github.com/eclipse-cdt-cloud/theia-trace-extension/issues" + }, + "homepage": "https://github.com/eclipse-cdt-cloud/theia-trace-extension", + "files": [ + "lib", + "src" + ], + "dependencies": { + "tsp-typescript-client": "^0.6.0" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^3.4.0", + "@typescript-eslint/parser": "^3.4.0", + "eslint": "^7.3.0", + "eslint-plugin-import": "^2.21.2", + "eslint-plugin-no-null": "^1.0.2", + "eslint-plugin-react": "^7.20.0", + "rimraf": "^5.0.0", + "typescript": "4.9.5" + }, + "scripts": { + "build": "tsc -b", + "clean": "rimraf lib *.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "echo 'test'", + "watch": "tsc -w", + "format:write": "prettier --write ./src", + "format:check": "prettier --check ./src" + } +} diff --git a/packages/base/src/experiment-manager.ts b/packages/base/src/experiment-manager.ts new file mode 100644 index 00000000..fcae417e --- /dev/null +++ b/packages/base/src/experiment-manager.ts @@ -0,0 +1,160 @@ +import { Trace } from 'tsp-typescript-client/lib/models/trace'; +import { ITspClient } from 'tsp-typescript-client/lib/protocol/tsp-client'; +import { Query } from 'tsp-typescript-client/lib/models/query/query'; +import { OutputDescriptor } from 'tsp-typescript-client/lib/models/output-descriptor'; +import { Experiment } from 'tsp-typescript-client/lib/models/experiment'; +import { TraceManager } from './trace-manager'; +import { TspClientResponse } from 'tsp-typescript-client/lib/protocol/tsp-client-response'; +import { signalManager } from './signals/signal-manager'; + +export class ExperimentManager { + private fOpenExperiments: Map = new Map(); + private fTspClient: ITspClient; + private fTraceManager: TraceManager; + + constructor(tspClient: ITspClient, traceManager: TraceManager) { + this.fTspClient = tspClient; + this.fTraceManager = traceManager; + signalManager().on('EXPERIMENT_DELETED', (experiment: Experiment) => this.onExperimentDeleted(experiment)); + } + + /** + * Get an array of opened experiments + * @returns Array of experiment + */ + async getOpenedExperiments(): Promise { + const openedExperiments: Array = []; + // Look on the server for opened experiments + const experimentsResponse = await this.fTspClient.fetchExperiments(); + const experiments = experimentsResponse.getModel(); + if (experimentsResponse.isOk() && experiments) { + openedExperiments.push(...experiments); + } + return openedExperiments; + } + + /** + * Get a specific experiment information + * @param experimentUUID experiment UUID + */ + async getExperiment(experimentUUID: string): Promise { + // Check if the experiment is in "cache" + let experiment = this.fOpenExperiments.get(experimentUUID); + + // If the experiment is undefined, check on the server + if (!experiment) { + const experimentResponse = await this.fTspClient.fetchExperiment(experimentUUID); + if (experimentResponse.isOk()) { + experiment = experimentResponse.getModel(); + } + } + return experiment; + } + + /** + * Get an array of OutputDescriptor for a given experiment + * @param experimentUUID experiment UUID + */ + async getAvailableOutputs(experimentUUID: string): Promise { + const outputsResponse = await this.fTspClient.experimentOutputs(experimentUUID); + if (outputsResponse && outputsResponse.isOk()) { + return outputsResponse.getModel(); + } + return undefined; + } + + /** + * Open a given experiment on the server + * @param experimentURI experiment URI to open + * @param experimentName Optional name for the experiment. If not specified the URI name is used + * @returns The opened experiment + */ + async openExperiment(experimentName: string, traces: Array): Promise { + const name = experimentName; + + const traceURIs = new Array(); + for (let i = 0; i < traces.length; i++) { + traceURIs.push(traces[i].UUID); + } + + const tryCreate = async function ( + tspClient: ITspClient, + retry: number + ): Promise> { + return tspClient.createExperiment( + new Query({ + name: retry === 0 ? name : name + '(' + retry + ')', + traces: traceURIs + }) + ); + }; + let tryNb = 0; + let experimentResponse: TspClientResponse | undefined; + while (experimentResponse === undefined || experimentResponse.getStatusCode() === 409) { + experimentResponse = await tryCreate(this.fTspClient, tryNb); + tryNb++; + } + const experiment = experimentResponse.getModel(); + if (experimentResponse.isOk() && experiment) { + this.addExperiment(experiment); + signalManager().emit('EXPERIMENT_OPENED', experiment); + return experiment; + } + // TODO Handle any other experiment open errors + return undefined; + } + + /** + * Update the experiment with the latest info from the server. + * @param experimentUUID experiment UUID + * @returns The updated experiment or undefined if the experiment failed to update + */ + async updateExperiment(experimentUUID: string): Promise { + const experimentResponse = await this.fTspClient.fetchExperiment(experimentUUID); + const experiment = experimentResponse.getModel(); + if (experiment && experimentResponse.isOk()) { + this.fOpenExperiments.set(experimentUUID, experiment); + return experiment; + } + return undefined; + } + + /** + * Delete the given experiment from the server + * @param experimentUUID experiment UUID + */ + async deleteExperiment(experimentUUID: string): Promise { + const experimentToDelete = this.fOpenExperiments.get(experimentUUID); + if (experimentToDelete) { + await this.fTspClient.deleteExperiment(experimentUUID); + const deletedExperiment = this.removeExperiment(experimentUUID); + if (deletedExperiment) { + signalManager().emit('EXPERIMENT_DELETED', deletedExperiment); + } + } + } + + private onExperimentDeleted(experiment: Experiment) { + /* + * TODO: Do not close traces used by another experiment + */ + // Close each trace + const traces = experiment.traces; + for (let i = 0; i < traces.length; i++) { + this.fTraceManager.deleteTrace(traces[i].UUID); + } + } + + public addExperiment(experiment: Experiment): void { + this.fOpenExperiments.set(experiment.UUID, experiment); + experiment.traces.forEach(trace => { + this.fTraceManager.addTrace(trace); + }); + } + + private removeExperiment(experimentUUID: string): Experiment | undefined { + const deletedExperiment = this.fOpenExperiments.get(experimentUUID); + this.fOpenExperiments.delete(experimentUUID); + return deletedExperiment; + } +} diff --git a/packages/base/src/lazy-tsp-client.ts b/packages/base/src/lazy-tsp-client.ts new file mode 100644 index 00000000..3430a281 --- /dev/null +++ b/packages/base/src/lazy-tsp-client.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { ITspClient } from 'tsp-typescript-client'; +import { HttpTspClient } from 'tsp-typescript-client/lib/protocol/http-tsp-client'; + +/** + * Hack! + * The `LazyTspClient` replaces _every_ method with an asynchronous one. + * Only keep methods, discard properties. + */ +export type LazyTspClient = { + [K in keyof ITspClient]: ITspClient[K] extends (...args: infer A) => infer R | Promise + ? (...args: A) => Promise + : never; // Discard property. +}; + +export type LazyTspClientFactory = typeof LazyTspClientFactory; +export function LazyTspClientFactory(provider: () => Promise): ITspClient { + // All methods from the `HttpTspClient` are asynchronous. The `LazyTspClient` + // will just delay each call to its methods by first awaiting for the + // asynchronous `baseUrl` resolution to then get a valid `HttpTspClient`. + + // Save the current HttpTspClient and the URL used for it. + let tspClient: HttpTspClient; + let lastUrl: string; + // eslint-disable-next-line no-null/no-null + return new Proxy(Object.create(null), { + get(target, property, _receiver) { + let method = target[property]; + if (!method) { + target[property] = method = async (...args: any[]) => { + tspClient = await provider().then(baseUrl => { + // If the url has not been updated keep the same client. + if (lastUrl === baseUrl) { + return tspClient; + } + // If the url has changed save it and create a new client. + lastUrl = baseUrl; + return new HttpTspClient(baseUrl); + }); + return (tspClient as any)[property](...args); + }; + } + return method; + } + }) as LazyTspClient as ITspClient; +} diff --git a/packages/base/src/message-manager.ts b/packages/base/src/message-manager.ts new file mode 100644 index 00000000..6398c566 --- /dev/null +++ b/packages/base/src/message-manager.ts @@ -0,0 +1,36 @@ +export enum MessageCategory { + TRACE_CONTEXT, + SERVER_MESSAGE, + SERVER_STATUS +} + +export enum MessageSeverity { + ERROR, + WARNING, + INFO, + DEBUG +} + +export interface StatusMessage { + text: string; + category?: MessageCategory; + severity?: MessageSeverity; +} + +export declare interface MessageManager { + addStatusMessage(messageKey: string, message: StatusMessage): void; + removeStatusMessage(messageKey: string): void; +} + +export class MessageManager implements MessageManager { + addStatusMessage( + messageKey: string, + { text, category = MessageCategory.SERVER_MESSAGE, severity = MessageSeverity.INFO }: StatusMessage + ): void { + console.log('New status message', messageKey, text, category, severity); + } + + removeStatusMessage(messageKey: string): void { + console.log('Removing status message status message', messageKey); + } +} diff --git a/packages/base/src/signals/available-views-changed-signal-payload.tsx b/packages/base/src/signals/available-views-changed-signal-payload.tsx new file mode 100644 index 00000000..ef3eea8b --- /dev/null +++ b/packages/base/src/signals/available-views-changed-signal-payload.tsx @@ -0,0 +1,20 @@ +import { OutputDescriptor } from 'tsp-typescript-client/lib/models/output-descriptor'; +import { Experiment } from 'tsp-typescript-client/lib/models/experiment'; + +export class AvailableViewsChangedSignalPayload { + private _availableOutputDescriptors: OutputDescriptor[]; + private _experiment: Experiment; + + constructor(availableOutputDescriptors: OutputDescriptor[], experiment: Experiment) { + this._availableOutputDescriptors = availableOutputDescriptors; + this._experiment = experiment; + } + + public getAvailableOutputDescriptors(): OutputDescriptor[] { + return this._availableOutputDescriptors; + } + + public getExperiment(): Experiment { + return this._experiment; + } +} diff --git a/packages/base/src/signals/context-menu-contributed-signal-payload.tsx b/packages/base/src/signals/context-menu-contributed-signal-payload.tsx new file mode 100644 index 00000000..e94bf8e2 --- /dev/null +++ b/packages/base/src/signals/context-menu-contributed-signal-payload.tsx @@ -0,0 +1,41 @@ +/*************************************************************************************** + * Copyright (c) 2024 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ +export interface MenuItem { + id: string; + label: string; + // Parent Menu that this item belongs to - undefined indicates root menu item + parentMenuId?: string; +} + +export interface SubMenu { + id: string; + label: string; + items: MenuItem[]; + submenu: SubMenu | undefined; +} + +export interface ContextMenuItems { + submenus: SubMenu[]; + items: MenuItem[]; +} + +export class ContextMenuContributedSignalPayload { + private outputDescriptorId: string; + private menuItems: ContextMenuItems; + + constructor(descriptorId: string, menuItems: ContextMenuItems) { + this.outputDescriptorId = descriptorId; + this.menuItems = menuItems; + } + + public getOutputDescriptorId(): string { + return this.outputDescriptorId; + } + + public getMenuItems(): ContextMenuItems { + return this.menuItems; + } +} diff --git a/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx b/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx new file mode 100644 index 00000000..6a680b6d --- /dev/null +++ b/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx @@ -0,0 +1,35 @@ +/*************************************************************************************** + * Copyright (c) 2024 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export class ContextMenuItemClickedSignalPayload { + private outputDescriptorId: string; + private itemId: string; + private parentMenuId: string | undefined; + private props: { [key: string]: any }; + + constructor(descriptorId: string, itemId: string, props: { [key: string]: any }, parentMenuId?: string) { + this.outputDescriptorId = descriptorId; + this.itemId = itemId; + this.props = props; + this.parentMenuId = parentMenuId; + } + + public getOutputDescriptorId(): string { + return this.outputDescriptorId; + } + + public getItemId(): string { + return this.itemId; + } + + public getProps(): { [key: string]: any } { + return this.props; + } + + public getParentMenuId(): string | undefined { + return this.parentMenuId; + } +} diff --git a/packages/base/src/signals/item-properties-signal-payload.tsx b/packages/base/src/signals/item-properties-signal-payload.tsx new file mode 100644 index 00000000..1ca07f4e --- /dev/null +++ b/packages/base/src/signals/item-properties-signal-payload.tsx @@ -0,0 +1,29 @@ +/*************************************************************************************** + * Copyright (c) 2024 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ + +export class ItemPropertiesSignalPayload { + private outputDescriptorId: string | undefined; + private experimentUUID: string | undefined; + private properties: { [key: string]: string }; + + constructor(props: { [key: string]: string }, expUUID?: string, descriptorId?: string) { + this.properties = props; + this.experimentUUID = expUUID; + this.outputDescriptorId = descriptorId; + } + + public getOutputDescriptorId(): string | undefined { + return this.outputDescriptorId; + } + + public getExperimentUUID(): string | undefined { + return this.experimentUUID; + } + + public getProperties(): { [key: string]: string } { + return this.properties; + } +} diff --git a/packages/base/src/signals/opened-traces-updated-signal-payload.tsx b/packages/base/src/signals/opened-traces-updated-signal-payload.tsx new file mode 100644 index 00000000..a21a461c --- /dev/null +++ b/packages/base/src/signals/opened-traces-updated-signal-payload.tsx @@ -0,0 +1,11 @@ +export class OpenedTracesUpdatedSignalPayload { + private _numberOfOpenedTraces: number; + + constructor(numberOfOpenedTraces: number) { + this._numberOfOpenedTraces = numberOfOpenedTraces; + } + + public getNumberOfOpenedTraces(): number { + return this._numberOfOpenedTraces; + } +} diff --git a/packages/base/src/signals/output-added-signal-payload.tsx b/packages/base/src/signals/output-added-signal-payload.tsx new file mode 100644 index 00000000..86a8d24b --- /dev/null +++ b/packages/base/src/signals/output-added-signal-payload.tsx @@ -0,0 +1,20 @@ +import { OutputDescriptor } from 'tsp-typescript-client/lib/models/output-descriptor'; +import { Experiment } from 'tsp-typescript-client/lib/models/experiment'; + +export class OutputAddedSignalPayload { + private outputDescriptor: OutputDescriptor; + private experiment: Experiment; + + constructor(outputDescriptor: OutputDescriptor, trace: Experiment) { + this.outputDescriptor = outputDescriptor; + this.experiment = trace; + } + + public getOutputDescriptor(): OutputDescriptor { + return this.outputDescriptor; + } + + public getExperiment(): Experiment { + return this.experiment; + } +} diff --git a/packages/base/src/signals/row-selections-changed-signal-payload.tsx b/packages/base/src/signals/row-selections-changed-signal-payload.tsx new file mode 100644 index 00000000..d15d437a --- /dev/null +++ b/packages/base/src/signals/row-selections-changed-signal-payload.tsx @@ -0,0 +1,33 @@ +/*************************************************************************************** + * Copyright (c) 2024 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export class RowSelectionsChangedSignalPayload { + private traceId: string; + private outputDescriptorId: string; + private rows: { id: number; parentId?: number; metadata?: { [key: string]: any } }[]; + + constructor( + traceId: string, + descriptorId: string, + rows: { id: number; parentId?: number; metadata?: { [key: string]: any } }[] + ) { + this.outputDescriptorId = descriptorId; + this.traceId = traceId; + this.rows = rows; + } + + public getOutputDescriptorId(): string { + return this.outputDescriptorId; + } + + public getTraceId(): string { + return this.traceId; + } + + public getRows(): { id: number; parentId?: number; metadata?: { [key: string]: any } }[] { + return this.rows; + } +} diff --git a/packages/base/src/signals/signal-manager.ts b/packages/base/src/signals/signal-manager.ts new file mode 100644 index 00000000..ba17157e --- /dev/null +++ b/packages/base/src/signals/signal-manager.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'events'; +import { OutputDescriptor } from 'tsp-typescript-client'; +import { Experiment } from 'tsp-typescript-client/lib/models/experiment'; +import { Trace } from 'tsp-typescript-client/lib/models/trace'; +import { OpenedTracesUpdatedSignalPayload } from './opened-traces-updated-signal-payload'; +import { OutputAddedSignalPayload } from './output-added-signal-payload'; +import { TimeRangeUpdatePayload } from './time-range-data-signal-payloads'; +import { ContextMenuContributedSignalPayload } from './context-menu-contributed-signal-payload'; +import { ContextMenuItemClickedSignalPayload } from './context-menu-item-clicked-signal-payload'; +import { RowSelectionsChangedSignalPayload } from './row-selections-changed-signal-payload'; +import { ItemPropertiesSignalPayload } from './item-properties-signal-payload'; + +export interface Signals { + TRACE_OPENED: [trace: Trace]; + TRACE_DELETED: [payload: { trace: Trace }]; + EXPERIMENT_OPENED: [experiment: Experiment]; + EXPERIMENT_CLOSED: [experiment: Experiment]; + EXPERIMENT_DELETED: [experiment: Experiment]; + EXPERIMENT_SELECTED: [experiment: Experiment | undefined]; + EXPERIMENT_UPDATED: [experiment: Experiment]; + OPENED_TRACES_UPDATED: [payload: OpenedTracesUpdatedSignalPayload]; + AVAILABLE_OUTPUTS_CHANGED: void; + OUTPUT_ADDED: [payload: OutputAddedSignalPayload]; + ITEM_PROPERTIES_UPDATED: [payload: ItemPropertiesSignalPayload]; + THEME_CHANGED: [theme: string]; + SELECTION_CHANGED: [payload: { [key: string]: string }]; + ROW_SELECTIONS_CHANGED: [payload: RowSelectionsChangedSignalPayload]; + CLOSE_TRACEVIEWERTAB: [traceUUID: string]; + TRACEVIEWERTAB_ACTIVATED: [experiment: Experiment]; + UPDATE_ZOOM: [hasZoomedIn: boolean]; + RESET_ZOOM: void; + MARKER_CATEGORIES_FETCHED: void; + MARKERSETS_FETCHED: void; + MARKER_CATEGORY_CLOSED: [traceViewerId: string, markerCategory: string]; + TRACE_SERVER_STARTED: void; + UNDO: void; + REDO: void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PIN_VIEW: [output: OutputDescriptor, extra?: any]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + UNPIN_VIEW: [output: OutputDescriptor, extra?: any]; + OPEN_OVERVIEW_OUTPUT: [traceId: string]; + OVERVIEW_OUTPUT_SELECTED: [traceId: string, outputDescriptor: OutputDescriptor]; + SAVE_AS_CSV: [traceId: string, data: string]; + VIEW_RANGE_UPDATED: [payload: TimeRangeUpdatePayload]; + SELECTION_RANGE_UPDATED: [payload: TimeRangeUpdatePayload]; + REQUEST_SELECTION_RANGE_CHANGE: [payload: TimeRangeUpdatePayload]; + OUTPUT_DATA_CHANGED: [descriptors: OutputDescriptor[]]; + CONTRIBUTE_CONTEXT_MENU: [payload: ContextMenuContributedSignalPayload]; + CONTEXT_MENU_ITEM_CLICKED: [payload: ContextMenuItemClickedSignalPayload]; +} + +export type SignalType = keyof Signals; +export type SignalArgs = T extends void ? [] : T; + +export class SignalManager extends EventEmitter { + /** + * Registers an event handler for a specific signal type. + * Provides type-safe event registration with correct payload types for each signal. + * + * @template K - The signal type (key of Signals interface) + * @param event - The event name to listen for + * @param listener - The callback function to execute when the event occurs + * Type of arguments is automatically inferred from Signals interface + * @returns The signal manager instance for chaining + * + * @example + * // Single argument event + * signalManager().on('THEME_CHANGED', (theme: string) => { + * console.log(`Theme changed to: ${theme}`); + * }); + * + * // Tuple argument event + * signalManager().on('PIN_VIEW', (output: OutputDescriptor, extra?: any) => { + * console.log(`Pinning view for output: ${output.id}`); + * }); + */ + on( + event: K, + listener: ( + ...args: SignalArgs extends [] ? [] : [...SignalArgs] + ) => void | Promise + ): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return super.on(event, listener as (...args: any[]) => void | Promise); + } + + /** + * Removes an event handler for a specific signal type. + * Ensures type safety by requiring the listener signature to match the signal type. + * + * @template K - The signal type (key of Signals interface) + * @param event - The event name to remove the listener from + * @param listener - The callback function to remove + * @returns The signal manager instance for chaining + * + * @example + * const themeHandler = (theme: string) => console.log(theme); + * signalManager().off('THEME_CHANGED', themeHandler); + */ + off( + event: K, + listener: ( + ...args: SignalArgs extends [] ? [] : [...SignalArgs] + ) => void | Promise + ): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return super.off(event, listener as (...args: any[]) => void | Promise); + } + + /** + * Emits a signal with type-safe arguments based on the signal type. + * Arguments are automatically validated against the Signals interface. + * + * @template K - The signal type (key of Signals interface) + * @param event - The event name to emit + * @param args - The arguments to pass to listeners, type checked against Signals interface + * @returns true if the event had listeners, false otherwise + * + * @example + * // Single argument emission + * signalManager().emit('THEME_CHANGED', 'dark'); + * + * // Tuple argument emission + * signalManager().emit('MARKER_CATEGORY_CLOSED', 'viewer1', 'category1'); + * + * // Void event emission + * signalManager().emit('RESET_ZOOM'); + */ + emit( + event: K, + ...args: SignalArgs extends [] ? [] : [...SignalArgs] + ): boolean { + return super.emit(event, ...args); + } +} + +let instance: SignalManager = new SignalManager(); + +export const setSignalManagerInstance = (sm: SignalManager): void => { + instance = sm; +}; + +export const signalManager = (): SignalManager => instance; diff --git a/packages/base/src/signals/time-range-data-signal-payloads.tsx b/packages/base/src/signals/time-range-data-signal-payloads.tsx new file mode 100644 index 00000000..fa331537 --- /dev/null +++ b/packages/base/src/signals/time-range-data-signal-payloads.tsx @@ -0,0 +1,6 @@ +import { TimeRange } from '../utils/time-range'; + +export interface TimeRangeUpdatePayload { + experimentUUID: string; + timeRange?: TimeRange; +} diff --git a/packages/base/src/trace-manager.ts b/packages/base/src/trace-manager.ts new file mode 100644 index 00000000..a99eee43 --- /dev/null +++ b/packages/base/src/trace-manager.ts @@ -0,0 +1,141 @@ +import { Trace } from 'tsp-typescript-client/lib/models/trace'; +import { ITspClient } from 'tsp-typescript-client/lib/protocol/tsp-client'; +import { Query } from 'tsp-typescript-client/lib/models/query/query'; +import { OutputDescriptor } from 'tsp-typescript-client/lib/models/output-descriptor'; +import { TspClientResponse } from 'tsp-typescript-client/lib/protocol/tsp-client-response'; +import { signalManager } from './signals/signal-manager'; + +export class TraceManager { + private fOpenTraces: Map = new Map(); + private fTspClient: ITspClient; + + constructor(tspClient: ITspClient) { + this.fTspClient = tspClient; + } + + /** + * Get an array of opened traces + * @returns Array of Trace + */ + async getOpenedTraces(): Promise { + const openedTraces: Array = []; + // Look on the server for opened trace + const tracesResponse = await this.fTspClient.fetchTraces(); + const traces = tracesResponse.getModel(); + if (tracesResponse.isOk() && traces) { + openedTraces.push(...traces); + } + return openedTraces; + } + + /** + * Get a specific trace information + * @param traceUUID Trace UUID + */ + async getTrace(traceUUID: string): Promise { + // Check if the trace is in "cache" + let trace = this.fOpenTraces.get(traceUUID); + + // If the trace is undefined, check on the server + if (!trace) { + const traceResponse = await this.fTspClient.fetchTrace(traceUUID); + if (traceResponse.isOk()) { + trace = traceResponse.getModel(); + } + } + return trace; + } + + /** + * Get an array of OutputDescriptor for a given trace + * @param traceUUID Trace UUID + */ + async getAvailableOutputs(traceUUID: string): Promise { + // Check if the trace is opened + const trace = this.fOpenTraces.get(traceUUID); + if (trace) { + const outputsResponse = await this.fTspClient.experimentOutputs(trace.UUID); + return outputsResponse.getModel(); + } + return undefined; + } + + /** + * Open a given trace on the server + * @param traceURI Trace URI to open + * @param traceName Optional name for the trace. If not specified the URI name is used + * @returns The opened trace + */ + async openTrace(traceURI: string, traceName?: string): Promise { + const name = traceName ? traceName : traceURI.replace(/\/$/, '').replace(/(.*\/)?/, ''); + + const tryOpen = async function (tspClient: ITspClient, retry: number): Promise> { + return tspClient.openTrace( + new Query({ + name: retry === 0 ? name : name + '(' + retry + ')', + uri: traceURI + }) + ); + }; + let tryNb = 0; + let traceResponse: TspClientResponse | undefined; + while (traceResponse === undefined || traceResponse.getStatusCode() === 409) { + traceResponse = await tryOpen(this.fTspClient, tryNb); + tryNb++; + } + const trace = traceResponse.getModel(); + if (traceResponse.isOk() && trace) { + this.addTrace(trace); + signalManager().emit('TRACE_OPENED', trace); + return trace; + } + // TODO Handle trace open errors + return undefined; + } + + /** + * Update the trace with the latest info from the server. + * @param traceName Trace name to update + * @returns The updated trace or undefined if the trace was not open previously + */ + async updateTrace(traceUUID: string): Promise { + const currentTrace = this.fOpenTraces.get(traceUUID); + if (currentTrace) { + const traceResponse = await this.fTspClient.fetchTrace(currentTrace.UUID); + const trace = traceResponse.getModel(); + if (trace && traceResponse.isOk()) { + this.fOpenTraces.set(traceUUID, trace); + return trace; + } + } + + return undefined; + } + + /** + * Delete the given trace on the server + * @param traceUUID Trace UUID + */ + async deleteTrace(traceUUID: string): Promise { + const traceToClose = this.fOpenTraces.get(traceUUID); + if (traceToClose) { + const deleteResponse = await this.fTspClient.deleteTrace(traceUUID); + if (deleteResponse.getStatusCode() !== 409) { + const deletedTrace = this.removeTrace(traceUUID); + if (deletedTrace) { + signalManager().emit('TRACE_DELETED', { trace: deletedTrace }); + } + } + } + } + + public addTrace(trace: Trace): void { + this.fOpenTraces.set(trace.UUID, trace); + } + + private removeTrace(traceUUID: string): Trace | undefined { + const deletedTrace = this.fOpenTraces.get(traceUUID); + this.fOpenTraces.delete(traceUUID); + return deletedTrace; + } +} diff --git a/packages/base/src/tsp-client-provider.ts b/packages/base/src/tsp-client-provider.ts new file mode 100644 index 00000000..5a7b93f5 --- /dev/null +++ b/packages/base/src/tsp-client-provider.ts @@ -0,0 +1,15 @@ +import { ITspClient } from 'tsp-typescript-client/lib/protocol/tsp-client'; +import { ExperimentManager } from './experiment-manager'; +import { TraceManager } from './trace-manager'; + +export interface ITspClientProvider { + getTspClient(): ITspClient; + getTraceManager(): TraceManager; + getExperimentManager(): ExperimentManager; + /** + * Add a listener for trace server url changes + * @param listener The listener function to be called when the url is + * changed + */ + addTspClientChangeListener(listener: (tspClient: ITspClient) => void): void; +} diff --git a/packages/base/src/utils/convert-color-string-to-hex.ts b/packages/base/src/utils/convert-color-string-to-hex.ts new file mode 100644 index 00000000..3cc46562 --- /dev/null +++ b/packages/base/src/utils/convert-color-string-to-hex.ts @@ -0,0 +1,28 @@ +/** + * Converts a string representing a color into a number. Works with both RGB strings and hex number strings. Ignores alpha values. + * @param {string} rgb RGB or hex string for a color. + * @returns {number} Hex number of the input string. Ignores alpha value if present. + */ +export function convertColorStringToHexNumber(rgb: string): number { + let string = '0'; + rgb.trim(); + if (rgb[0] === '#') { + // We are working with hex string. + string = '0x' + rgb.slice(1); + } else if (rgb[0] === 'r') { + // Working with RGB String + const match = rgb.match(/\d+/g); + if (match) { + string = + '0x' + + match + .map(x => { + x = parseInt(x).toString(16); + return x.length === 1 ? '0' + x : x; + }) + .join(''); + string = string.slice(0, 8); + } + } + return Number(string); +} diff --git a/packages/base/src/utils/time-range.ts b/packages/base/src/utils/time-range.ts new file mode 100644 index 00000000..368c9ed9 --- /dev/null +++ b/packages/base/src/utils/time-range.ts @@ -0,0 +1,93 @@ +export interface TimeRangeString { + start: string; + end: string; + offset?: string; +} + +export class TimeRange { + private start: bigint; + private end: bigint; + private offset: bigint | undefined; + + /** + * Constructor. + * @param start Range start time + * @param end Range end time + * @param offset Time offset, if this is defined the start and end time should be relative to this value + */ + constructor(start: bigint, end: bigint, offset?: bigint); + /** + * Constructor. + * @param timeRangeString string object returned by this.toString() + */ + constructor(timeRangeString: TimeRangeString); + /** + * Constructor. + * Default TimeRange with 0 for values + */ + constructor(); + constructor(a?: TimeRangeString | bigint, b?: bigint, c?: bigint) { + if (typeof a === 'bigint' && typeof b === 'bigint') { + this.start = a; + this.end = b; + this.offset = c; + } else if (typeof a === 'object') { + const timeRangeString: TimeRangeString = a; + const { start, end, offset } = timeRangeString; + this.start = BigInt(start); + this.end = BigInt(end); + this.offset = offset ? BigInt(offset) : undefined; + } else { + this.start = BigInt(0); + this.end = BigInt(0); + this.offset = undefined; + } + } + + /** + * Get the range start time. + * If an offset is present the return value is start + offset. + */ + public getStart(): bigint { + if (this.offset !== undefined) { + return this.start + this.offset; + } + return this.start; + } + + /** + * Get the range end time. + * If an offset is present the return value is end + offset. + */ + public getEnd(): bigint { + if (this.offset !== undefined) { + return this.end + this.offset; + } + return this.end; + } + + /** + * Get range duration + */ + public getDuration(): bigint { + return this.end - this.start; + } + + /** + * Return the time offset + */ + public getOffset(): bigint | undefined { + return this.offset; + } + + /** + * Create a string object that can be JSON.stringified + */ + public toString(): TimeRangeString { + return { + start: this.start.toString(), + end: this.end.toString(), + offset: this.offset?.toString() + }; + } +} diff --git a/packages/base/src/utils/value-hash.ts b/packages/base/src/utils/value-hash.ts new file mode 100644 index 00000000..b4c28cc1 --- /dev/null +++ b/packages/base/src/utils/value-hash.ts @@ -0,0 +1,23 @@ +/** + * Transforms a string value to a numerical value, either parsing the string as + * a number or by running some kind of hash function on the string. This + * function for a same string will always return the same result. + * + * @param str the string value to hash + */ +const hash = (str: string): number => { + const int = parseInt(str); + if (!isNaN(int)) { + return int; + } + // Based on https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript + let hashCode = 0; + for (let i = 0; i < str.length; i++) { + const chr = str.charCodeAt(i); + hashCode = (hashCode << 5) - hashCode + chr; + hashCode |= 0; // Convert to 32bit integer + } + return hashCode; +}; + +export default hash; diff --git a/packages/base/tsconfig.json b/packages/base/tsconfig.json new file mode 100644 index 00000000..1e65f61d --- /dev/null +++ b/packages/base/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "composite": true, + "strict": true, + "sourceMap": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "module": "commonjs", + "target": "ES2017", + "downlevelIteration": true, + "rootDir": "src", + "outDir": "lib", + "declaration": true, + "skipLibCheck": true, + "jsx": "react" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/packages/react-components/.eslintrc.js b/packages/react-components/.eslintrc.js new file mode 100644 index 00000000..9605d889 --- /dev/null +++ b/packages/react-components/.eslintrc.js @@ -0,0 +1,58 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", // Specifies the ESLint parser + parserOptions: { + ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features + sourceType: "module", // Allows for the use of imports + ecmaFeatures: { + jsx: true // Allows for the parsing of JSX + } + }, + settings: { + react: { + version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use + } + }, + extends: [ + 'plugin:react/recommended', + 'plugin:@typescript-eslint/recommended', + '../../configs/base.eslintrc.json', + '../../configs/warnings.eslintrc.json', + '../../configs/errors.eslintrc.json' + ], + ignorePatterns: [ + 'node_modules', + 'lib', + '.eslintrc.js', + 'plugins', + '**/*/__tests__', + '**/*/__mocks__', + 'jestSetup.ts', + 'jest-shim.ts' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json', + projectFolderIgnoreList: [ + '/lib/' + ] + }, + overrides: [ + { + // Apply rule override only to files with the following extensions + files: ['*.tsx', '*.jsx'], + rules: { + '@typescript-eslint/ban-types': [ + 'error', + { + extendDefaults: true, + types: { + '{}': false, + }, + }, + ], + }, + }, + ] +}; diff --git a/packages/react-components/LICENSE b/packages/react-components/LICENSE new file mode 100644 index 00000000..74a365fe --- /dev/null +++ b/packages/react-components/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, 2021 Ericsson and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/react-components/README.md b/packages/react-components/README.md new file mode 100644 index 00000000..b8f6daf9 --- /dev/null +++ b/packages/react-components/README.md @@ -0,0 +1,15 @@ +# Description + +The Trace Viewer react-components package contains views and utilities for visualizing traces and logs via the Trace Server Protocol, connected a Trace Server application. While being initially used within the Theia Trace Viewer extension, its code base is independent to any Theia APIs and hence can be integrated in other web applications. + +## Styling + +The Trace Viewer react-components package does not define CSS styles for its components, but it provides CSS variables that can be map to custom CSS styles or variables. Any project that uses the package should define its own CSS styles for the components. All the mappable variables have the prefix `--trace-viewer`. + +An example (of integration with Theia) that contains all the mappable variables can be found in [here](https://github.com/eclipse-cdt-cloud/theia-trace-extension/blob/master/theia-extensions/viewer-prototype/style/trace-viewer.css). + +## Additional Information + +- [Theia Trace Viewer Extension git repository](https://github.com/eclipse-cdt-cloud/theia-trace-extension) +- [Trace Server Protocol git repository](https://github.com/eclipse-cdt-cloud/trace-server-protocol) +- [Reference Trace Server - Download (Eclipse Trace Compass)](https://download.eclipse.org/tracecompass.incubator/trace-server/rcp/) diff --git a/packages/react-components/jest-shim.ts b/packages/react-components/jest-shim.ts new file mode 100644 index 00000000..5ea18080 --- /dev/null +++ b/packages/react-components/jest-shim.ts @@ -0,0 +1,6 @@ +import { TextEncoder } from 'util'; +/* + * Add TextEncoder from NodeJS util as it is + * no longer available for Jest tests after React 18. + */ +global.TextEncoder = TextEncoder; diff --git a/packages/react-components/jest.config.json b/packages/react-components/jest.config.json new file mode 100644 index 00000000..16e70c10 --- /dev/null +++ b/packages/react-components/jest.config.json @@ -0,0 +1,26 @@ +{ + "globals": { + "ts-jest": { + "tsconfig": "tsconfig.json" + } + }, + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testEnvironment": "jsdom", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ], + "setupFiles": [ + "./jest-shim.ts" + ], + "setupFilesAfterEnv": [ + "jest-canvas-mock" + ] +} diff --git a/packages/react-components/package.json b/packages/react-components/package.json new file mode 100644 index 00000000..5e94fa3b --- /dev/null +++ b/packages/react-components/package.json @@ -0,0 +1,78 @@ +{ + "name": "traceviewer-react-components", + "version": "0.7.2", + "description": "Trace Compass react components", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-cdt-cloud/theia-trace-extension" + }, + "bugs": { + "url": "https://github.com/eclipse-cdt-cloud/theia-trace-extension/issues" + }, + "homepage": "https://github.com/eclipse-cdt-cloud/theia-trace-extension", + "files": [ + "lib", + "src", + "style" + ], + "dependencies": { + "@ag-grid-community/core": "^32.0.1", + "@ag-grid-community/infinite-row-model": "^32.0.0", + "@ag-grid-community/react": "^32.0.0", + "@ag-grid-community/styles": "^32.0.0", + "@fortawesome/fontawesome-svg-core": "^1.2.17 <1.3.0", + "@fortawesome/free-solid-svg-icons": "^5.8.1", + "@fortawesome/react-fontawesome": "^0.2.2", + "@mui/material": "^5.10.14", + "@vscode/codicons": "^0.0.29", + "chart.js": "^2.8.0", + "d3": "^7.1.1", + "lodash": "^4.17.15", + "lodash.debounce": "^4.0.8", + "react-chartjs-2": "^2.7.6", + "react-contexify": "^5.0.0", + "react-grid-layout": "1.2.0", + "react-modal": "^3.8.1", + "react-tooltip": "4.2.14", + "react-virtualized": "^9.21.0", + "timeline-chart": "^0.4.1", + "traceviewer-base": "0.7.2", + "tsp-typescript-client": "^0.6.0" + }, + "devDependencies": { + "@testing-library/react": "^15.0.6", + "@types/chart.js": "^2.7.52", + "@types/d3": "^7.1.0", + "@types/jest": "^28.0.0", + "@types/lodash": "^4.14.142", + "@types/lodash.debounce": "^4.0.0", + "@types/react-grid-layout": "^1.1.1", + "@types/react-modal": "^3.8.2", + "@types/react-test-renderer": "^18.3.0", + "@types/react-virtualized": "^9.21.1", + "@typescript-eslint/eslint-plugin": "^3.4.0", + "@typescript-eslint/parser": "^3.4.0", + "eslint": "^7.3.0", + "eslint-plugin-import": "^2.21.2", + "eslint-plugin-no-null": "^1.0.2", + "eslint-plugin-react": "^7.20.0", + "jest": "^28.1.3", + "jest-canvas-mock": "^2.4.0", + "jest-environment-jsdom": "^28.1.3", + "react-test-renderer": "^18.2.0", + "rimraf": "^5.0.0", + "ts-jest": "^28.0.8", + "typescript": "4.9.5" + }, + "scripts": { + "build": "tsc -b", + "clean": "rimraf lib *.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "jest --config jest.config.json", + "watch": "tsc -b -w", + "format:write": "prettier --write ./src", + "format:check": "prettier --check ./src" + } +} diff --git a/packages/react-components/src/components/__tests__/__snapshots__/table-renderer-components.test.tsx.snap b/packages/react-components/src/components/__tests__/__snapshots__/table-renderer-components.test.tsx.snap new file mode 100644 index 00000000..55def768 --- /dev/null +++ b/packages/react-components/src/components/__tests__/__snapshots__/table-renderer-components.test.tsx.snap @@ -0,0 +1,706 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Cell renderer 1`] = `"test cell"`; + +exports[` Cell renderer with search selection 1`] = ` +Array [ + + + test + + , + + cell + , +] +`; + +exports[` Empty search filter renderer 1`] = ` +
+ +
+`; + +exports[` Loading renderer in loading 1`] = ` + +`; + +exports[` Loading renderer not loading 1`] = `"test cell"`; + +exports[` Renders AG-Grid table with provided props & state 1`] = ` +
+
+
+ +
+ +
+
+ + + ... + + +
+
+
+
+
+
+
+
+
+
+
+ +