Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

<div
class="heatmap-legend"
*ngIf="legend$ | async as legend">
<div class="heatmap-legend__title">{{ legend.title }}</div>
<div class="heatmap-legend__bar"></div>
<div class="heatmap-legend__labels">
<span>{{ legend.minLabel }}</span>
<span>{{ legend.maxLabel }}</span>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

.heatmap-legend {
position: absolute;
// Bottom-left, lifted above the mini-map preview toggle in the corner. The mini-map
// itself occupies the bottom-right of the canvas.
bottom: 72px;
left: 0px;
z-index: 10;
padding: 8px 10px;
font-size: 12px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
pointer-events: none;

&__title {
font-weight: 600;
margin-bottom: 4px;
}

&__bar {
width: 140px;
height: 10px;
border-radius: 2px;
// Mirrors the cold -> hot stops in heatmap-color.ts (scoreToColor).
background: linear-gradient(to right, #5b9bd5, #ffffbf, #e05a52);
}

&__labels {
display: flex;
justify-content: space-between;
margin-top: 2px;
color: #666;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { Component } from "@angular/core";
import { AsyncPipe, NgIf } from "@angular/common";
import { combineLatest, Observable } from "rxjs";
import { map } from "rxjs/operators";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service";
import { OperatorPerformanceMetrics } from "../../service/workflow-status/performance-metrics";
import {
formatMetricForView,
HeatmapView,
heatmapViewTitle,
rawMetricForView,
} from "../../service/heatmap/heatmap-scoring";

interface HeatmapLegendState {
readonly view: HeatmapView;
readonly title: string;
readonly minLabel: string;
readonly maxLabel: string;
}

/**
* Presentational legend for the performance heat-map overlay. Shows the active view's name, the
* cold -> hot color scale, and the actual min/max metric values behind that scale. Hidden when the
* overlay is off (view is null).
*/
@Component({
selector: "texera-heatmap-legend",
templateUrl: "./heatmap-legend.component.html",
styleUrls: ["./heatmap-legend.component.scss"],
imports: [NgIf, AsyncPipe],
})
export class HeatmapLegendComponent {
public readonly legend$: Observable<HeatmapLegendState | null>;

constructor(
private workflowActionService: WorkflowActionService,
private workflowStatusService: WorkflowStatusService
) {
this.legend$ = combineLatest([
this.workflowActionService.getJointGraphWrapper().getHeatmapViewStream(),
this.workflowStatusService.getPerformanceMetricsStream(),
]).pipe(map(([view, metrics]) => (view === null ? null : this.buildState(view, metrics))));
}

private buildState(view: HeatmapView, metrics: Record<string, OperatorPerformanceMetrics>): HeatmapLegendState {
const raws = Object.values(metrics)
.map(m => rawMetricForView(m, view))
.filter(v => Number.isFinite(v));
const hasData = raws.length > 0;
const min = hasData ? Math.min(...raws) : 0;
const max = hasData ? Math.max(...raws) : 0;
return {
view,
title: heatmapViewTitle(view),
minLabel: hasData ? formatMetricForView(min, view) : "—",
maxLabel: hasData ? formatMetricForView(max, view) : "—",
};
}
}
33 changes: 33 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,39 @@
>Status</label
>
</li>
<li nz-menu-item>
<label
nz-checkbox
[(ngModel)]="showHeatmap"
(ngModelChange)="toggleHeatmap()"
>Performance</label
>
</li>
<li
nz-menu-item
*ngIf="showHeatmap"
class="heatmap-view-options"
[nzDisabled]="true">
<nz-radio-group
[(ngModel)]="heatmapView"
(ngModelChange)="setHeatmapView($event)">
<label
nz-radio
[nzValue]="HeatmapView.Runtime"
>Runtime</label
>
<label
nz-radio
[nzValue]="HeatmapView.Throughput"
>Throughput</label
>
<label
nz-radio
[nzValue]="HeatmapView.IoImbalance"
>I/O imbalance</label
>
</nz-radio-group>
</li>
</ul>
</nz-dropdown-menu>
<button
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,24 @@ texera-coeditor-user-icon {

::ng-deep .layers-dropdown {
user-select: none;

// Present the performance views as a sub-group nested under the "Performance" toggle:
// indented, stacked vertically, with a tree-style left rule.
.heatmap-view-options {
cursor: default;

.ant-radio-group {
display: flex;
flex-direction: column;
row-gap: 2px;
margin-left: 24px;
padding-left: 10px;
border-left: 2px solid #f0f0f0;
}

.ant-radio-wrapper {
margin-left: 0;
color: rgba(0, 0, 0, 0.85);
}
}
}
38 changes: 38 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { WorkflowVersionService } from "../../../dashboard/service/user/workflow
import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
import { NotificationService } from "../../../common/service/notification/notification.service";
import { ExecutionState } from "../../types/execute-workflow.interface";
import { HeatmapView } from "../../service/heatmap/heatmap-scoring";
import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
import { mockPoint, mockScanPredicate } from "../../service/workflow-graph/model/mock-workflow-data";
import { saveAs } from "file-saver";
Expand Down Expand Up @@ -603,6 +604,43 @@ describe("MenuComponent", () => {
});
});

describe("toggleHeatmap / setHeatmapView", () => {
it("publishes the selected view to the joint graph wrapper when enabled", () => {
const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView");

component.showHeatmap = true;
component.heatmapView = HeatmapView.Throughput;
component.toggleHeatmap();

expect(setSpy).toHaveBeenCalledWith(HeatmapView.Throughput);
});

it("publishes null to the joint graph wrapper when disabled", () => {
const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView");

component.showHeatmap = false;
component.toggleHeatmap();

expect(setSpy).toHaveBeenCalledWith(null);
});

it("pushes a newly selected view only while the overlay is enabled", () => {
const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView");

component.showHeatmap = true;
component.setHeatmapView(HeatmapView.IoImbalance);
expect(component.heatmapView).toBe(HeatmapView.IoImbalance);
expect(setSpy).toHaveBeenCalledWith(HeatmapView.IoImbalance);

setSpy.mockClear();
component.showHeatmap = false;
component.setHeatmapView(HeatmapView.Runtime);
// View selection is remembered, but nothing is pushed while the overlay is off.
expect(component.heatmapView).toBe(HeatmapView.Runtime);
expect(setSpy).not.toHaveBeenCalled();
});
});

describe("toggleStatus", () => {
it("removes hide-operator-status when enabled and repositions the status label", () => {
const operator = fakeElement("operator");
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { UndoRedoService } from "../../service/undo-redo/undo-redo.service";
import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { ExecutionState } from "../../types/execute-workflow.interface";
import { HeatmapView } from "../../service/heatmap/heatmap-scoring";
import { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service";
import { WorkflowResultExportService } from "../../service/workflow-result-export/workflow-result-export.service";
import { catchError, debounceTime, filter, mergeMap, switchMap, tap } from "rxjs/operators";
Expand Down Expand Up @@ -70,6 +71,7 @@ import { UserIconComponent } from "../../../dashboard/component/user/user-icon/u
import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown";
import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu";
import { NzCheckboxComponent } from "ng-zorro-antd/checkbox";
import { NzRadioComponent, NzRadioGroupComponent } from "ng-zorro-antd/radio";
import { NzPopoverDirective } from "ng-zorro-antd/popover";
import { NzSwitchComponent } from "ng-zorro-antd/switch";
import { NzBadgeComponent } from "ng-zorro-antd/badge";
Expand Down Expand Up @@ -114,6 +116,8 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
NzMenuDirective,
NzMenuItemComponent,
NzCheckboxComponent,
NzRadioComponent,
NzRadioGroupComponent,
NgTemplateOutlet,
ComputingUnitSelectionComponent,
NzPopoverDirective,
Expand All @@ -138,6 +142,9 @@ export class MenuComponent implements OnInit, OnDestroy {
public showGrid: boolean = false;
public showNumWorkers: boolean = false;
public showStatus: boolean = false;
public showHeatmap: boolean = false;
public heatmapView: HeatmapView = HeatmapView.Runtime;
public HeatmapView = HeatmapView; // make Angular HTML access enum definition
protected readonly USER_WORKFLOW = USER_WORKFLOW;

@Input() public writeAccess: boolean = false;
Expand Down Expand Up @@ -539,6 +546,19 @@ export class MenuComponent implements OnInit, OnDestroy {
this.workflowActionService.getJointGraphWrapper().setRegionsDisplayed(this.showRegion);
}

public toggleHeatmap(): void {
// The editor subscribes to this stream and colors operator fills (canvas + mini-map).
// A null view turns the overlay off; a view enables it.
this.workflowActionService.getJointGraphWrapper().setHeatmapView(this.showHeatmap ? this.heatmapView : null);
}

public setHeatmapView(view: HeatmapView): void {
this.heatmapView = view;
if (this.showHeatmap) {
this.workflowActionService.getJointGraphWrapper().setHeatmapView(view);
}
}

/**
* This method will run the autoLayout function
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
<div
id="workflow-editor"
(contextmenu)="nzContextMenu.create($event, menu)"></div>
<texera-heatmap-legend></texera-heatmap-legend>
<div
class="heatmap-tooltip"
*ngIf="heatmapTooltip"
[style.left.px]="heatmapTooltip.x"
[style.top.px]="heatmapTooltip.y">
<div class="heatmap-tooltip__title">{{ heatmapTooltip.title }}</div>
<div class="heatmap-tooltip__row">{{ heatmapTooltip.metricLabel }}</div>
<div class="heatmap-tooltip__row">Heat: {{ heatmapTooltip.heatLabel }}</div>
</div>
<nz-dropdown-menu
nzNoAnimation
#menu="nzDropdownMenu">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,41 @@

#workflow-editor-wrapper {
height: 100%;
// Anchors absolutely-positioned canvas overlays (e.g. the heat-map legend).
position: relative;
}

#workflow-editor {
height: 100%;
}

// Smoothly animate operator body fill changes (e.g. heat-map recolor on view switch / toggle).
::ng-deep #workflow-editor .body {
transition: fill 0.25s ease;
}

.heatmap-tooltip {
position: absolute;
z-index: 11;
padding: 6px 8px;
font-size: 12px;
line-height: 1.4;
color: #fff;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
pointer-events: none;
white-space: nowrap;

&__title {
font-weight: 600;
margin-bottom: 2px;
}

&__row {
color: #eee;
}
}

::ng-deep .agent-action {
// Agent action highlights - temporary 5-second visual indicators
// Styles are defined inline in JointJS element, but this provides a hook for customization
Expand Down
Loading
Loading