diff --git a/README.md b/README.md index 2a0cd267..1d18b878 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,16 @@ Or, run yarn start:electron ``` +Or, for electron app, run with auto-refresh when there is code change + +```bash +# install electronmon in global, only need to do it once +npm i -g electronmon + +# then, in the repo root +electronmon examples/electron +``` + and use the Electron application. If there are errors that occurred while starting the app, see [Troubleshooting](#troubleshooting) for known issues. @@ -201,10 +211,10 @@ If the **Trace Viewer** icon is not in the left sidebar, select menu **View** fr There are a few ways to open traces. The main ones are using the **Open Trace Dialog** or the **File Explorer**. There are still some inconsistencies between them. | Desired action | via Open Trace Dialog | via File Explorer | -|------------------------------------------------|-----------------------|-------------------| -| Open single CTF trace (folder) | ✓ | ✓ | -| Open folder of CTF traces (create trace group) | ✓ | ✓ | -| Open single file trace (ex. JSON Chrome trace) | | ✓ | +| ---------------------------------------------- | --------------------- | ----------------- | +| Open single CTF trace (folder) | ✓ | ✓ | +| Open folder of CTF traces (create trace group) | ✓ | ✓ | +| Open single file trace (ex. JSON Chrome trace) | | ✓ | Regardless of the opening method used, if the selection is a folder, the tool will look for traces in **Common Trace Format (CTF)** format, such as **Linux Tracing Toolkit traces (LTTng)** Kernel and UST (Userspace) traces, and open all found CTF traces together under the same timeline. The trace events of each CTF trace will be analyzed in chronological order. With this, several traces can be opened as a group (e.g. LTTng Kernel and UST Traces). @@ -271,14 +281,14 @@ This section shows detailed information about a selected: This trace viewer depends on code from several other repos. Sometimes resolving issues in the trace viewer repo requires making changes in these code bases: -| Project | Description | Related issues | Links | -|---------------|----|--------------------------|---| -| [Theia][theia-webpage] | Theia is a framework for making custom IDEs. It provides reusable components (e.g. text editor, terminal) and is extensible. For example, this trace viewer is an extension for Theia-based IDEs. | | [Code][theia-code], [Ecosystem][theia-ecosystem] | -| [Trace Compass][tc-project] | Trace analysis tool and precursor to this trace viewer. | [label:"Trace Compass"][tc-gh-label] | [Dev info][tc-dev-info], [Dev setup][tc-dev-setup] | -| [Trace Compass Server][tc-server] | A reference implementation of a Trace Server. Manages and analyzes trace files and provides this data to the trace viewer over the [Trace Server Protocol (TSP)][tsp]. This Trace Server implementation was originally part of Trace Compass, so it requires the same dev setup. Because a protocol is used for communication (TSP), it is possible to develop alternative Trace Servers that are independent of Trace Compass. | [label:"Trace Server"][tc-server-gh-label] | [Dev setup][tc-dev-setup] (same as Trace Compass), [Code][tci-code] (same repo as Trace Compass incubator) | -| [Trace Server Protocol (TSP)][tsp] | Protocol used by the trace viewer to communicate with the trace server. | [label:"trace server protocol"][tsp-gh-label] | | -| [Client-side Trace Server Protocol implementation][tspc] | A client-side implementation of the Trace Server Protocol. Allows the trace viewer to communicate with the server. | | | -| [Timeline Chart][timeline-chart] | Implements the Gantt charts used in this trace viewer. | [label:timeline-chart][timeline-chart-gh-label] | | +| Project | Description | Related issues | Links | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| [Theia][theia-webpage] | Theia is a framework for making custom IDEs. It provides reusable components (e.g. text editor, terminal) and is extensible. For example, this trace viewer is an extension for Theia-based IDEs. | | [Code][theia-code], [Ecosystem][theia-ecosystem] | +| [Trace Compass][tc-project] | Trace analysis tool and precursor to this trace viewer. | [label:"Trace Compass"][tc-gh-label] | [Dev info][tc-dev-info], [Dev setup][tc-dev-setup] | +| [Trace Compass Server][tc-server] | A reference implementation of a Trace Server. Manages and analyzes trace files and provides this data to the trace viewer over the [Trace Server Protocol (TSP)][tsp]. This Trace Server implementation was originally part of Trace Compass, so it requires the same dev setup. Because a protocol is used for communication (TSP), it is possible to develop alternative Trace Servers that are independent of Trace Compass. | [label:"Trace Server"][tc-server-gh-label] | [Dev setup][tc-dev-setup] (same as Trace Compass), [Code][tci-code] (same repo as Trace Compass incubator) | +| [Trace Server Protocol (TSP)][tsp] | Protocol used by the trace viewer to communicate with the trace server. | [label:"trace server protocol"][tsp-gh-label] | | +| [Client-side Trace Server Protocol implementation][tspc] | A client-side implementation of the Trace Server Protocol. Allows the trace viewer to communicate with the server. | | | +| [Timeline Chart][timeline-chart] | Implements the Gantt charts used in this trace viewer. | [label:timeline-chart][timeline-chart-gh-label] | | ## Troubleshooting diff --git a/local-libs/traceviewer-libs/react-components/src/components/abstract-gantt-output-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/abstract-gantt-output-component.tsx index 09eeaf04..8e6f0e95 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/abstract-gantt-output-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/abstract-gantt-output-component.tsx @@ -96,15 +96,15 @@ export abstract class AbstractGanttOutputComponent< private vscrollLayer: TimeGraphVerticalScrollbar; private chartCursors: TimeGraphChartCursors; private markerChartCursors: TimeGraphChartCursors; - private arrowLayer: TimeGraphChartArrows; - private rangeEventsLayer: TimeGraphRangeEventsLayer; + protected arrowLayer: TimeGraphChartArrows; + protected rangeEventsLayer: TimeGraphRangeEventsLayer; private horizontalContainer: React.RefObject; protected chartTreeRef: React.RefObject; protected markerTreeRef: React.RefObject; private containerRef: React.RefObject; - private tspDataProvider: TspDataProvider; + protected tspDataProvider: TspDataProvider; private styleProvider: StyleProvider; private styleMap = new Map(); @@ -116,7 +116,7 @@ export abstract class AbstractGanttOutputComponent< this.doHandleContextMenuContributed(payload); protected onOrderChange = (ids: number[]) => this.doHandleOrderChange(ids); protected onOrderReset = () => this.doHandleOrderReset(); - private pendingSelection: TimeGraphEntry | undefined; + protected pendingSelection: TimeGraphEntry | undefined; private _debouncedUpdateChart = debounce(() => { this.chartLayer.updateChart(this.filterExpressionsMap()); @@ -435,7 +435,7 @@ export abstract class AbstractGanttOutputComponent< } } - private updateTotalHeight() { + protected updateTotalHeight() { const visibleEntries = [...this.state.chartTree].filter(entry => this.isVisible(entry)); this.totalHeight = visibleEntries.length * this.props.style.rowHeight; this.rowController.totalHeight = this.totalHeight; @@ -679,15 +679,7 @@ export abstract class AbstractGanttOutputComponent< renderChart(): React.ReactNode { return ( -
{ - ev.preventDefault(); - ev.stopPropagation(); - }} - style={{ height: 'auto' }} - > +
{this.renderAbstractGanttContent(`${this.props.chartId}-content`)}
{this.state.outputStatus === ResponseStatus.RUNNING && ( @@ -706,7 +698,7 @@ export abstract class AbstractGanttOutputComponent< return this.state.chartTree.length === 0; } - private isFilteredIn(row: TimelineChart.TimeGraphRowModel, strategy?: string): boolean { + protected isFilteredIn(row: TimelineChart.TimeGraphRowModel, strategy?: string): boolean { if (row.states.length === 1 && row.states[0].range.start === row.states[0].range.end) { // intentionally empty row should be visible return true; @@ -1016,13 +1008,13 @@ export abstract class AbstractGanttOutputComponent< } } - private getTimegraphRowIds() { + protected getTimegraphRowIds() { const { chartTree, columns, collapsedNodes } = this.state; const rowIds = getAllExpandedNodeIds(listToTree(chartTree, columns), collapsedNodes); return { rowIds }; } - private async fetchChartData( + protected async fetchChartData( range: TimelineChart.TimeGraphRange, resolution: number, fetchArrows: boolean, @@ -1101,7 +1093,7 @@ export abstract class AbstractGanttOutputComponent< }; } - private updateMarkersData( + protected updateMarkersData( rangeEvents: TimelineChart.TimeGraphAnnotation[], newRange: TimelineChart.TimeGraphRange, newResolution: number @@ -1594,7 +1586,7 @@ export abstract class AbstractGanttOutputComponent< this.setState({ multiSelectedRows: shiftClickedRows }); }; - private selectAndReveal(item: TimeGraphEntry) { + protected selectAndReveal(item: TimeGraphEntry) { const rowIndex = getIndexOfNode( item.id, listToTree(this.state.chartTree, this.state.columns), diff --git a/local-libs/traceviewer-libs/react-components/src/components/data-providers/tsp-data-provider.ts b/local-libs/traceviewer-libs/react-components/src/components/data-providers/tsp-data-provider.ts index 6ff55ad0..cdbdc71b 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/data-providers/tsp-data-provider.ts +++ b/local-libs/traceviewer-libs/react-components/src/components/data-providers/tsp-data-provider.ts @@ -175,6 +175,290 @@ export class TspDataProvider { }; } + /** + * Get data for sync analysis mode - fetches full range but normalizes time coordinates + * to map selection range to 0 to delta_t + */ + // async getDataForSyncAnalysis( + // ids: number[], + // entries: TimeGraphEntry[], + // fetchArrows: boolean, + // totalTimeRange: TimeRange, + // worldRange?: TimelineChart.TimeGraphRange, + // nbTimes?: number, + // annotationMarkers?: string[], + // markerSetId?: string, + // additionalProperties?: { [key: string]: any } + // ): Promise { + // this.timeGraphEntries = [...entries]; + // if (!this.timeGraphEntries.length || !worldRange || !nbTimes) { + // return { + // id: 'model', + // totalLength: this.totalRange, + // rows: [], + // rangeEvents: [], + // arrows: [], + // data: {} + // }; + // } + + // // Fire all TSP requests + // this.totalRange = totalTimeRange.getEnd() - totalTimeRange.getStart(); + // const start = totalTimeRange.getStart() + worldRange.start; + // const end = totalTimeRange.getStart() + worldRange.end; + // const timeGraphStateParams = QueryHelper.selectionTimeRangeQuery( + // start, + // end, + // nbTimes, + // ids, + // additionalProperties ? additionalProperties : {} + // ); + // const statesPromise = this.client.fetchTimeGraphStates(this.traceUUID, this.outputId, timeGraphStateParams); + + // const additionalProps: { [key: string]: any } = {}; + // if (annotationMarkers) { + // additionalProps['requested_marker_categories'] = annotationMarkers; + // } + // if (markerSetId) { + // additionalProps['requested_marker_set'] = markerSetId; + // } + // const annotationParams = QueryHelper.selectionTimeRangeQuery(start, end, nbTimes, ids, additionalProps); + // const annotations: Map = new Map(); + // const annotationsPromise = this.client.fetchAnnotations(this.traceUUID, this.outputId, annotationParams); + + // const arrowStart = worldRange.start + this.timeGraphEntries[0].start; + // const arrowEnd = worldRange.end + this.timeGraphEntries[0].start; + // const fetchParameters = QueryHelper.timeRangeQuery(arrowStart, arrowEnd, nbTimes); + + // // Wait for responses + // const [tspClientAnnotationsResponse, tspClientStatesResponse] = await Promise.all([ + // annotationsPromise, + // statesPromise + // ]); + + // // the start time which is normalized to logical 0 in timeline chart. + // const chartStart = totalTimeRange.getStart(); + + // const annotationsResponse = tspClientAnnotationsResponse.getModel(); + // const rangeEvents: TimelineChart.TimeGraphAnnotation[] = []; + // if (tspClientAnnotationsResponse.isOk() && annotationsResponse) { + // Object.entries(annotationsResponse.model.annotations).forEach(([category, categoryArray]) => { + // categoryArray.forEach(annotation => { + // if (annotation.type === Type.CHART) { + // if (annotation.entryId === -1) { + // rangeEvents.push(this.getAnnotation(category, annotation, rangeEvents.length, chartStart)); + // } else { + // let entryArray = annotations.get(annotation.entryId); + // if (entryArray === undefined) { + // entryArray = []; + // annotations.set(annotation.entryId, entryArray); + // } + // entryArray.push(this.getAnnotation(category, annotation, entryArray.length, chartStart)); + // } + // } + // }); + // }); + // } + + // const stateResponse = tspClientStatesResponse.getModel(); + + // if (tspClientStatesResponse.isOk() && stateResponse) { + // this.timeGraphRows = stateResponse.model.rows; + // this.timeGraphRowsOrdering(ids); + // } else { + // this.timeGraphRows = []; + // } + + // const rows: TimelineChart.TimeGraphRowModel[] = []; + // this.timeGraphRows.forEach((row: TimeGraphRow) => { + // const rowId: number = row.entryId; + // const entry = this.timeGraphEntries.find(tgEntry => tgEntry.id === rowId); + // if (entry) { + // rows.push(this.getRowModel(row, chartStart, rowId, entry)); + // } + // }); + + // for (const [entryId, entryArray] of annotations.entries()) { + // const row = rows.find(tgEntry => tgEntry.id === entryId); + // if (row) { + // row.annotations = entryArray; + // } + // } + + // let arrows: TimelineChart.TimeGraphArrow[] = []; + // if (fetchArrows) { + // const tspClientArrowsResponse = await this.client.fetchTimeGraphArrows( + // this.traceUUID, + // this.outputId, + // fetchParameters + // ); + // arrows = this.getArrows(tspClientArrowsResponse, worldRange, nbTimes); + // } + + // return { + // id: 'model', + // totalLength: this.totalRange, + // rows, + // arrows, + // rangeEvents, + // data: { + // originalStart: chartStart + // } + // }; + // } + async getDataForSyncAnalysis( + ids: number[], + entries: TimeGraphEntry[], + fetchArrows: boolean, + totalTimeRange: TimeRange, + selectionRange: TimelineChart.TimeGraphRange, + nbTimes?: number, + annotationMarkers?: string[], + markerSetId?: string, + additionalProperties?: { [key: string]: any } + ): Promise { + this.timeGraphEntries = [...entries]; + if (!this.timeGraphEntries.length || !nbTimes) { + return { + id: 'model', + totalLength: this.totalRange, + rows: [], + rangeEvents: [], + arrows: [], + data: {} + }; + } + + // Fire all TSP requests for the full range + this.totalRange = totalTimeRange.getEnd() - totalTimeRange.getStart(); + const start = totalTimeRange.getStart(); + const end = totalTimeRange.getEnd(); + const timeGraphStateParams = QueryHelper.selectionTimeRangeQuery( + start, + end, + nbTimes, + ids, + additionalProperties ? additionalProperties : {} + ); + const statesPromise = this.client.fetchTimeGraphStates(this.traceUUID, this.outputId, timeGraphStateParams); + + const additionalProps: { [key: string]: any } = {}; + if (annotationMarkers) { + additionalProps['requested_marker_categories'] = annotationMarkers; + } + if (markerSetId) { + additionalProps['requested_marker_set'] = markerSetId; + } + const annotationParams = QueryHelper.selectionTimeRangeQuery(start, end, nbTimes, ids, additionalProps); + const annotations: Map = new Map(); + const annotationsPromise = this.client.fetchAnnotations(this.traceUUID, this.outputId, annotationParams); + + const fetchParameters = QueryHelper.timeRangeQuery(start, end, nbTimes); + + // Wait for responses + const [tspClientAnnotationsResponse, tspClientStatesResponse] = await Promise.all([ + annotationsPromise, + statesPromise + ]); + + // Calculate normalization parameters + const selectionDuration = selectionRange.end - selectionRange.start; + const selectionStart = totalTimeRange.getStart() + selectionRange.start; + const selectionEnd = totalTimeRange.getStart() + selectionRange.end; + + // the start time which is normalized to logical 0 in timeline chart. + const chartStart = totalTimeRange.getStart(); + + const annotationsResponse = tspClientAnnotationsResponse.getModel(); + const rangeEvents: TimelineChart.TimeGraphAnnotation[] = []; + if (tspClientAnnotationsResponse.isOk() && annotationsResponse) { + Object.entries(annotationsResponse.model.annotations).forEach(([category, categoryArray]) => { + categoryArray.forEach(annotation => { + if (annotation.type === Type.CHART) { + if (annotation.entryId === -1) { + rangeEvents.push( + this.getAnnotationForSyncAnalysis( + category, + annotation, + rangeEvents.length, + chartStart, + selectionStart, + selectionDuration + ) + ); + } else { + let entryArray = annotations.get(annotation.entryId); + if (entryArray === undefined) { + entryArray = []; + annotations.set(annotation.entryId, entryArray); + } + entryArray.push( + this.getAnnotationForSyncAnalysis( + category, + annotation, + entryArray.length, + chartStart, + selectionStart, + selectionDuration + ) + ); + } + } + }); + }); + } + + const stateResponse = tspClientStatesResponse.getModel(); + + if (tspClientStatesResponse.isOk() && stateResponse) { + this.timeGraphRows = stateResponse.model.rows; + this.timeGraphRowsOrdering(ids); + } else { + this.timeGraphRows = []; + } + + const rows: TimelineChart.TimeGraphRowModel[] = []; + this.timeGraphRows.forEach((row: TimeGraphRow) => { + const rowId: number = row.entryId; + const entry = this.timeGraphEntries.find(tgEntry => tgEntry.id === rowId); + if (entry) { + rows.push( + this.getRowModelForSyncAnalysis(row, chartStart, rowId, entry, selectionStart, selectionDuration) + ); + } + }); + + for (const [entryId, entryArray] of annotations.entries()) { + const row = rows.find(tgEntry => tgEntry.id === entryId); + if (row) { + row.annotations = entryArray; + } + } + + let arrows: TimelineChart.TimeGraphArrow[] = []; + if (fetchArrows) { + const tspClientArrowsResponse = await this.client.fetchTimeGraphArrows( + this.traceUUID, + this.outputId, + fetchParameters + ); + arrows = this.getArrowsForSyncAnalysis(tspClientArrowsResponse, selectionStart, selectionDuration); + } + + return { + id: 'model', + totalLength: this.totalRange, // Use the full range for proper scaling + rows, + arrows, + rangeEvents, + data: { + originalStart: chartStart, + syncAnalysisMode: true, + selectionRange: selectionRange + } + }; + } + private getArrows( tspClientArrowsResponse: TspClientResponse>, viewRange?: TimelineChart.TimeGraphRange, @@ -303,6 +587,161 @@ export class TspDataProvider { }; } + private getAnnotationForSyncAnalysis( + category: string, + annotation: Annotation, + idx: number, + chartStart: bigint, + selectionStart: bigint, + selectionDuration: bigint + ) { + // Normalize time coordinates to map selection range to 0 to selectionDuration + const normalizedStart = this.normalizeTimeForSyncAnalysis(annotation.time, selectionStart, selectionDuration); + const normalizedEnd = this.normalizeTimeForSyncAnalysis( + annotation.time + annotation.duration, + selectionStart, + selectionDuration + ); + + return { + id: annotation.entryId + '-' + idx, + category: category, + range: { + start: normalizedStart, + end: normalizedEnd + }, + label: annotation.label, + data: { + style: annotation.style + } + }; + } + + private getRowModelForSyncAnalysis( + row: TimeGraphRow, + chartStart: bigint, + rowId: number, + entry: TimeGraphEntry, + selectionStart: bigint, + selectionDuration: bigint + ) { + let dimGapStates = false; + const states: TimelineChart.TimeGraphState[] = []; + let prevPossibleState = entry.start; + let nextPossibleState = entry.end; + row.states.forEach((state: TimeGraphState, idx: number) => { + if (!dimGapStates && state.tags && state.tags === 1) { + dimGapStates = true; + } + // Normalize time coordinates for sync analysis mode + const normalizedStart = this.normalizeTimeForSyncAnalysis(state.start, selectionStart, selectionDuration); + const normalizedEnd = this.normalizeTimeForSyncAnalysis(state.end, selectionStart, selectionDuration); + + states.push({ + id: row.entryId + '-' + idx, + label: state.label, + range: { + start: normalizedStart, + end: normalizedEnd + }, + data: { + style: state.style, + tags: state.tags + } + }); + + if (idx === 0) { + prevPossibleState = normalizedStart; + } + if (idx === row.states.length - 1) { + nextPossibleState = normalizedEnd; + } + }); + let gapStyle: OutputElementStyle; + if (!entry.style) { + gapStyle = this.getDefaultForGapStyle(); + } else { + gapStyle = entry.style; + } + if (dimGapStates) { + gapStyle.values.opacity = 0.3; + } else if (gapStyle.values.opacity) { + delete gapStyle.values.opacity; + } + + // Normalize entry range + const normalizedEntryStart = this.normalizeTimeForSyncAnalysis(entry.start, selectionStart, selectionDuration); + const normalizedEntryEnd = this.normalizeTimeForSyncAnalysis(entry.end, selectionStart, selectionDuration); + + return { + id: rowId, + name: entry.labels[0], + range: { + start: normalizedEntryStart, + end: normalizedEntryEnd + }, + states, + annotations: [], + prevPossibleState, + nextPossibleState, + gapStyle + }; + } + + private getArrowsForSyncAnalysis( + tspClientArrowsResponse: TspClientResponse>, + selectionStart: bigint, + selectionDuration: bigint + ): TimelineChart.TimeGraphArrow[] { + let timeGraphArrows: TimeGraphArrow[] = []; + const stateResponseArrows = tspClientArrowsResponse.getModel(); + if (tspClientArrowsResponse.isOk() && stateResponseArrows && stateResponseArrows.model) { + timeGraphArrows = stateResponseArrows.model; + } + + const arrows = timeGraphArrows.map(arrow => { + // Normalize arrow time coordinates + const normalizedStart = this.normalizeTimeForSyncAnalysis(arrow.start, selectionStart, selectionDuration); + const normalizedEnd = this.normalizeTimeForSyncAnalysis(arrow.end, selectionStart, selectionDuration); + + return { + sourceId: arrow.sourceId, + destinationId: arrow.targetId, + range: { + start: normalizedStart, + end: normalizedEnd + } as TimelineChart.TimeGraphRange + } as TimelineChart.TimeGraphArrow; + }); + return arrows; + } + + private normalizeTimeForSyncAnalysis(time: bigint, selectionStart: bigint, selectionDuration: bigint): bigint { + // Map time from absolute coordinates to normalized coordinates + // The selection range should span the full width of the chart + const relativeTime = time - selectionStart; + + // Scale the coordinates so that the selection range spans the full width + // We want the selected range to map to the full chart width, not just its own duration + const totalRange = this.totalRange; + if (totalRange === BigInt(0)) { + return BigInt(0); + } + + // Scale the relative time to span the full chart width + const scaledTime = (relativeTime * totalRange) / selectionDuration; + + // Ensure the time is within the chart bounds + if (scaledTime < BigInt(0)) { + return BigInt(0); + } + if (scaledTime > totalRange) { + return totalRange; + } + + return scaledTime; + } + async fetchStateTooltip( element: TimeGraphStateComponent, viewRange: TimeRange diff --git a/local-libs/traceviewer-libs/react-components/src/components/gantt-chart-output-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/gantt-chart-output-component.tsx index bfdd718e..ec2520f1 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/gantt-chart-output-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/gantt-chart-output-component.tsx @@ -7,24 +7,27 @@ import { AbstractGanttOutputState } from './abstract-gantt-output-component'; import { EntryTree } from './utils/filter-tree/entry-tree'; -import { validateNumArray } from './utils/filter-tree/utils'; -import { ResponseStatus } from 'tsp-typescript-client'; +import { getCollapsedNodesFromAutoExpandLevel, listToTree, validateNumArray } from './utils/filter-tree/utils'; +import { QueryHelper, ResponseStatus } from 'tsp-typescript-client'; +import { BIMath } from 'timeline-chart/lib/bigint-utils'; +import ColumnHeader from './utils/filter-tree/column-header'; +import { isEqual } from 'lodash'; type GanttChartOutputProps = AbstractGanttOutputProps & { initialViewRange?: TimelineChart.TimeGraphRange; children?: React.ReactNode; onResetZoom?: () => void; + syncedRange: { start: bigint; end: bigint; offset: bigint } | undefined; }; type GanttChartOutputState = AbstractGanttOutputState & { zoomResetCounter?: number; + isSyncRange: boolean; }; export class GanttChartOutputComponent extends AbstractGanttOutputComponent< GanttChartOutputProps, GanttChartOutputState > { - private initialViewRangeSnapshot?: TimelineChart.TimeGraphRange; - constructor(props: GanttChartOutputProps) { super(props); @@ -48,21 +51,205 @@ export class GanttChartOutputComponent extends AbstractGanttOutputComponent< searchString: '', filters: [], emptyNodes: [], - marginTop: 0 + marginTop: 0, + isSyncRange: false }; + } + + private isSyncedRangeValid( + syncedRange: GanttChartOutputProps['syncedRange'] + ): syncedRange is { start: bigint; end: bigint; offset: bigint } { + return syncedRange !== undefined && syncedRange.start !== undefined && syncedRange.end !== undefined; + } + + private normalizeRange(start: bigint, end: bigint): { start: bigint; end: bigint } { + // Ensure start is always less than end (handle right-to-left selection) + if (start > end) { + return { start: end, end: start }; + } + return { start, end }; + } + + private updateSyncModeUnitController(_range: TimelineChart.TimeGraphRange): void { + // In sync mode, use the selection range as the view range to update the time axis + if (this.isSyncedRangeValid(this.props.syncedRange)) { + const selectionStart = this.props.syncedRange.start; + const selectionEnd = this.props.syncedRange.end; + + // Set the absolute range to the full trace range + const fullRangeWidth = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = fullRangeWidth; - // Store a snapshot of the initial view range - if (props.initialViewRange) { - this.initialViewRangeSnapshot = { start: props.initialViewRange.start, end: props.initialViewRange.end }; + // Set the view range to start from 0 with the selection duration + const normalized = this.normalizeRange(selectionStart, selectionEnd); + const selectionDuration = normalized.end - normalized.start; + this.props.unitController.viewRange = { + start: BigInt(0), + end: selectionDuration + }; + } else { + // Fallback to full range if selection is invalid + const fullRangeWidth = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = fullRangeWidth; + this.props.unitController.viewRange = { start: BigInt(0), end: fullRangeWidth }; + } + + // Force complete chart refresh with new range + if (this.chartLayer) { + this.chartLayer.updateChart(); + // Also update the chart layer to ensure proper rendering + setTimeout(() => { + if (this.chartLayer) { + this.chartLayer.update(); + // Force a resize event to ensure proper width calculation + window.dispatchEvent(new Event('resize')); + // Force chart to rebuild with new data + this.chartLayer.updateChart(); + } + }, 0); } } + async componentDidMount(): Promise { + await super.componentDidMount(); + } + + async fetchTreeForSyncAnalysis(): Promise { + if (!this.isSyncedRangeValid(this.props.syncedRange)) { + return ResponseStatus.FAILED; + } + + const resolution = Math.ceil( + Number(this.props.range.getEnd() - this.props.range.getStart()) / this.COARSE_RESOLUTION_FACTOR + ); + + const parameters = QueryHelper.timeRangeQuery( + this.props.range.getStart(), + this.props.range.getEnd(), + resolution, + { + selection_range: [ + this.props.selectionRange?.getStart() ?? + BigInt(0) + (this.props.selectionRange?.getOffset() ?? BigInt(0)), + this.props.selectionRange?.getEnd() ?? + BigInt(0) + (this.props.selectionRange?.getOffset() ?? BigInt(0)) + ] + } + ); + const tspClientResponse = await this.props.tspClient.fetchTimeGraphTree( + this.props.traceId, + this.props.outputDescriptor.id, + parameters + ); + const treeResponse = tspClientResponse.getModel(); + if (tspClientResponse.isOk() && treeResponse) { + if (treeResponse.model) { + const headers = treeResponse.model.headers; + const columns: ColumnHeader[] = []; + if (headers && headers.length > 0) { + headers.forEach(header => { + columns.push({ title: header.name, sortable: true, resizable: true, tooltip: header.tooltip }); + }); + } else { + columns.push({ title: '', sortable: true, resizable: true }); + } + const autoCollapsedNodes = getCollapsedNodesFromAutoExpandLevel( + listToTree(treeResponse.model.entries, columns), + treeResponse.model.autoExpandLevel + ); + this.setState( + { + outputStatus: treeResponse.status, + chartTree: treeResponse.model.entries, + defaultOrderedIds: treeResponse.model.entries.map(entry => entry.id), + collapsedNodes: autoCollapsedNodes, + columns + }, + this.updateTotalHeight + ); + } else { + this.setState({ + outputStatus: treeResponse.status + }); + } + return treeResponse.status; + } + this.setState({ + outputStatus: ResponseStatus.FAILED + }); + return ResponseStatus.FAILED; + } + + async fetchTree(): Promise { + const parameters = QueryHelper.timeRangeQuery(this.props.range.getStart(), this.props.range.getEnd()); + const tspClientResponse = await this.props.tspClient.fetchTimeGraphTree( + this.props.traceId, + this.props.outputDescriptor.id, + parameters + ); + const treeResponse = tspClientResponse.getModel(); + if (tspClientResponse.isOk() && treeResponse) { + if (treeResponse.model) { + const headers = treeResponse.model.headers; + const columns: ColumnHeader[] = []; + if (headers && headers.length > 0) { + headers.forEach(header => { + columns.push({ title: header.name, sortable: true, resizable: true, tooltip: header.tooltip }); + }); + } else { + columns.push({ title: '', sortable: true, resizable: true }); + } + const autoCollapsedNodes = getCollapsedNodesFromAutoExpandLevel( + listToTree(treeResponse.model.entries, columns), + treeResponse.model.autoExpandLevel + ); + this.setState( + { + outputStatus: treeResponse.status, + chartTree: treeResponse.model.entries, + defaultOrderedIds: treeResponse.model.entries.map(entry => entry.id), + collapsedNodes: autoCollapsedNodes, + columns + }, + this.updateTotalHeight + ); + } else { + this.setState({ + outputStatus: treeResponse.status + }); + } + return treeResponse.status; + } + this.setState({ + outputStatus: ResponseStatus.FAILED + }); + return ResponseStatus.FAILED; + } + renderTree(): React.ReactNode { this.onOrderChange = this.onOrderChange.bind(this); this.onOrderReset = this.onOrderReset.bind(this); // TODO Show header, when we can have entries in-line with timeline-chart return ( <> +
+ +
+
); } + + protected async fetchChartData( + range: TimelineChart.TimeGraphRange, + resolution: number, + fetchArrows: boolean, + rowIds?: number[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + additionalProperties?: { [key: string]: any } + ): Promise<{ rows: TimelineChart.TimeGraphRowModel[]; range: TimelineChart.TimeGraphRange; resolution: number }> { + const spinnerElement = document.getElementById( + this.props.traceId + this.props.outputDescriptor.id + 'handleSpinner' + ); + if (spinnerElement) { + spinnerElement.style.visibility = 'visible'; + } + + const strategy = additionalProperties?.filter_query_parameters?.strategy; + const ids = rowIds ? rowIds : this.getTimegraphRowIds().rowIds; + + let newRange: TimelineChart.TimeGraphRange = { start: BigInt(0), end: BigInt(0) }; + let syncAnalysisMode = false; + let selectionRange: TimelineChart.TimeGraphRange | undefined; + + if (this.state.isSyncRange && this.isSyncedRangeValid(this.props.syncedRange)) { + const normStart = this.props.syncedRange.start; + const normEnd = this.props.syncedRange.end; + + // Convert absolute timestamps to relative timestamps within the total range + const totalRangeStart = this.props.range.getStart(); + const relativeStart = normStart - totalRangeStart; + const relativeEnd = normEnd - totalRangeStart; + + selectionRange = { + start: BIMath.min(relativeStart, relativeEnd), + end: BIMath.max(relativeStart, relativeEnd) + }; + + syncAnalysisMode = true; + } else { + newRange = range; + } + const nbTimes = Math.ceil(Number(newRange.end - newRange.start) / resolution) + 1; + + let timeGraphData: TimelineChart.TimeGraphModel; + if (syncAnalysisMode && selectionRange) { + console.log(this.props.selectionRange?.getStart(), this.props.selectionRange?.getEnd()); + + const _additionalProperties = { + ...additionalProperties, + selection_range: [ + this.props.selectionRange?.getStart() ?? + BigInt(0) + (this.props.selectionRange?.getOffset() ?? BigInt(0)), + this.props.selectionRange?.getEnd() ?? + BigInt(0) + (this.props.selectionRange?.getOffset() ?? BigInt(0)) + ] + }; + + // Full range with normalized time coordinates in sync mode + timeGraphData = await this.tspDataProvider.getDataForSyncAnalysis( + ids, + this.state.chartTree, + fetchArrows, + this.props.range, + selectionRange, + nbTimes, + this.props.markerCategories, + this.props.markerSetId, + _additionalProperties + ); + } else { + // Use normal mode + timeGraphData = await this.tspDataProvider.getData( + ids, + this.state.chartTree, + fetchArrows, + this.props.range, + newRange, + nbTimes, + this.props.markerCategories, + this.props.markerSetId, + additionalProperties + ); + } + + if (timeGraphData) { + this.updateMarkersData(timeGraphData.rangeEvents, newRange, nbTimes); + if (this.rangeEventsLayer) { + this.rangeEventsLayer.addRangeEvents(timeGraphData.rangeEvents); + } + } + + if (spinnerElement) { + spinnerElement.style.visibility = 'hidden'; + } + + let rows = timeGraphData ? timeGraphData.rows : []; + let emptyNodes: number[] = [...this.state.emptyNodes]; + if (this.shouldHideEmptyNodes) { + rows = rows.filter(row => { + if (this.isFilteredIn(row, strategy)) { + emptyNodes = emptyNodes.filter(id => id !== row.id); + return true; + } + if (!emptyNodes.includes(row.id)) { + emptyNodes.push(row.id); + } + return false; + }); + } else { + emptyNodes = []; + } + + if (fetchArrows && timeGraphData?.arrows && this.arrowLayer) { + this.arrowLayer.addArrows( + timeGraphData.arrows, + this.getTimegraphRowIds().rowIds.filter(rowId => !emptyNodes.includes(rowId)) + ); + } + this.setState({ emptyNodes }); + + // Apply the pending selection here since the row provider had been called before this method. + if (this.pendingSelection) { + const foundElement = this.pendingSelection; + this.pendingSelection = undefined; + this.selectAndReveal(foundElement); + } + + // Update unit controller for sync analysis mode + if (syncAnalysisMode && selectionRange) { + const fullRangeWidth = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = fullRangeWidth; + // In sync mode, set the view range to start from 0 with the selection duration + if (this.state.isSyncRange && this.isSyncedRangeValid(this.props.syncedRange)) { + const normalized = this.normalizeRange(this.props.syncedRange.start, this.props.syncedRange.end); + const selectionDuration = normalized.end - normalized.start; + this.props.unitController.viewRange = { + start: BigInt(0), + end: selectionDuration + }; + } + } + + return { + rows: rows, + range: newRange, + resolution: resolution + }; + } + + private handleSyncXRange = () => { + this.setState( + prev => ({ isSyncRange: !prev.isSyncRange }), + () => { + // Update unit controller after state change + if (this.state.isSyncRange && this.isSyncedRangeValid(this.props.syncedRange)) { + // In sync mode, set the view range to start from 0 with the selection duration + const fullRangeWidth = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = fullRangeWidth; + const normalized = this.normalizeRange(this.props.syncedRange.start, this.props.syncedRange.end); + const selectionDuration = normalized.end - normalized.start; + this.props.unitController.viewRange = { + start: BigInt(0), + end: selectionDuration + }; + } else if (!this.state.isSyncRange) { + // Reset to original range when sync is disabled + const originalRange = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = originalRange; + this.props.unitController.viewRange = { start: BigInt(0), end: originalRange }; + } + if (this.chartLayer) { + this.chartLayer.updateChart(); + } + } + ); + }; + + async componentDidUpdate(prevProps: GanttChartOutputProps, prevState: GanttChartOutputState): Promise { + super.componentDidUpdate(prevProps, prevState); + + const syncModeChanged = !isEqual(prevState.isSyncRange, this.state.isSyncRange); + const syncedRangeChanged = !isEqual(prevProps.syncedRange, this.props.syncedRange); + + if (syncModeChanged || syncedRangeChanged) { + if (this.chartLayer) { + this.chartLayer.updateChart(); + } + + if (this.state.isSyncRange && this.isSyncedRangeValid(this.props.syncedRange)) { + // In sync mode, set the view range to start from 0 with the selection duration + this.fetchTreeForSyncAnalysis(); + const fullRangeWidth = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = fullRangeWidth; + const normalized = this.normalizeRange(this.props.syncedRange.start, this.props.syncedRange.end); + const selectionDuration = normalized.end - normalized.start; + this.props.unitController.viewRange = { + start: BigInt(0), + end: selectionDuration + }; + } else if (!this.state.isSyncRange) { + // Reset to original range when sync is disabled + const originalRange = this.props.range.getEnd() - this.props.range.getStart(); + this.props.unitController.absoluteRange = originalRange; + this.props.unitController.viewRange = { start: BigInt(0), end: originalRange }; + } + } + + // Also handle selection changes when sync mode is already active + if (this.state.isSyncRange && syncedRangeChanged && this.isSyncedRangeValid(this.props.syncedRange)) { + const normalized = this.normalizeRange(this.props.syncedRange.start, this.props.syncedRange.end); + const selectionDuration = normalized.end - normalized.start; + this.props.unitController.viewRange = { + start: BigInt(0), + end: selectionDuration + }; + } + + // Update the unit controller when in sync mode or when there is change + if (this.state.isSyncRange && this.props.unitController.selectionRange && syncedRangeChanged) { + const selectionRange = this.props.unitController.selectionRange; + this.updateSyncModeUnitController(selectionRange); + } + } } diff --git a/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx index 19d39d9d..c0102ca9 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx @@ -28,6 +28,7 @@ import { UnitControllerHistoryHandler } from './utils/unit-controller-history-ha import { TraceOverviewComponent } from './trace-overview-component'; import { TimeRangeUpdatePayload } from 'traceviewer-base/lib/signals/time-range-data-signal-payloads'; import { GanttChartOutputComponent } from './gantt-chart-output-component'; +import createNumberTranslator from './utils/number-translator'; const ResponsiveGridLayout = WidthProvider(Responsive); @@ -190,16 +191,7 @@ export class TraceContextComponent extends React.Component { - const originalStart = this.state.currentRange.getStart(); - theNumber += originalStart; - const zeroPad = (num: bigint) => String(num).padStart(3, '0'); - const seconds = theNumber / BigInt(1000000000); - const millis = zeroPad((theNumber / BigInt(1000000)) % BigInt(1000)); - const micros = zeroPad((theNumber / BigInt(1000)) % BigInt(1000)); - const nanos = zeroPad(theNumber % BigInt(1000)); - return seconds + '.' + millis + ' ' + micros + ' ' + nanos; - }; + this.unitController.numberTranslator = createNumberTranslator(true, this.state.currentRange.getStart()); this.unitController.worldRenderFactor = 0.25; this.historyHandler = new UnitControllerHistoryHandler(this.unitController); if (this.props.persistedState?.currentTimeSelection) { @@ -689,13 +681,13 @@ export class TraceContextComponent extends React.Component {outputs.map(output => { let onOutputRemove; - let responseType; + let providerType; if (isOverview) { onOutputRemove = this.props.onOverviewRemove; - responseType = 'OVERVIEW'; + providerType = 'OVERVIEW'; } else { onOutputRemove = this.props.onOutputRemove; - responseType = output.type; + providerType = output.type; } const outputProps: AbstractOutputProps = { @@ -717,7 +709,7 @@ export class TraceContextComponent extends React.Component { - const zeroPad = (num: bigint) => String(num).padStart(3, '0'); - const seconds = theNumber / BigInt(1000000000); - const millis = zeroPad((theNumber / BigInt(1000000)) % BigInt(1000)); - const micros = zeroPad((theNumber / BigInt(1000)) % BigInt(1000)); - const nanos = zeroPad(theNumber % BigInt(1000)); - return seconds + '.' + millis + ' ' + micros + ' ' + nanos; - }; + ganttChartUnitController.numberTranslator = createNumberTranslator(false); // Restore view range if available, otherwise set to global view range const fgViewRange = this.state.ganttChartRanges?.[output.id]; if (fgViewRange) { ganttChartUnitController.viewRange = fgViewRange; - } else { - // Use the global view range from the parent unit controller + } else if (!this.unitController.selectionRange) { + // Only use the global view range if there's no selection range + // (to avoid overriding sync mode settings) const globalViewRange = this.unitController.viewRange; ganttChartUnitController.viewRange = { start: globalViewRange.start, end: globalViewRange.end }; } + + // If we have a selection range, use it as the initial view range + // This ensures sync mode works correctly from the start + if (this.unitController.selectionRange) { + ganttChartUnitController.selectionRange = { + start: this.unitController.selectionRange.start, + end: this.unitController.selectionRange.end + }; + } // Listen for view range changes ganttChartUnitController.onViewRangeChanged((_old, newRange) => { this.handleGanttChartViewRangeChange(output.id, newRange); @@ -827,6 +822,11 @@ export class TraceContextComponent extends React.Component string { + return function (theNumber: bigint): string { + if (isTimeBased && originalStart) { + theNumber += originalStart; + } + const zeroPad = (num: bigint) => String(num).padStart(3, '0'); + const seconds = theNumber / BigInt(1000000000); + const millis = zeroPad((theNumber / BigInt(1000000)) % BigInt(1000)); + const micros = zeroPad((theNumber / BigInt(1000)) % BigInt(1000)); + const nanos = zeroPad(theNumber % BigInt(1000)); + return seconds + '.' + millis + ' ' + micros + ' ' + nanos; + }; +} diff --git a/local-libs/traceviewer-libs/react-components/src/components/utils/time-axis-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/utils/time-axis-component.tsx index 734e0e98..71cd0dd7 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/utils/time-axis-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/utils/time-axis-component.tsx @@ -18,9 +18,19 @@ interface TimeAxisProps { } export class TimeAxisComponent extends React.Component { + private containerRef = React.createRef(); + + componentDidUpdate(prevProps: TimeAxisProps): void { + // Force update when unit controller changes + if (prevProps.unitController !== this.props.unitController) { + // The key prop approach in trace-context-component.tsx will handle re-rendering + } + } + render(): JSX.Element { return (