Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d35d3a2
fix(frontend): dock the result panel at its default height
kz930 Oct 4, 2026
8846756
feat: read the result of any output port of an operator
kz930 Oct 20, 2026
13c1294
fix(frontend): keep a result table to the output port it shows
kz930 Oct 20, 2026
a64630b
feat(frontend): preview an operator's result when hovering it on the …
kz930 Oct 20, 2026
896a32a
feat(frontend): pin an operator's result beside it, with a tab per ou…
kz930 Oct 20, 2026
f27e1eb
fix(frontend): dock the result panel at its default height
kz930 Oct 4, 2026
d127081
feat(frontend): keep only the console in the bottom panel
kz930 Oct 20, 2026
b80462b
feat(frontend): pin an operator's result from its right-click menu
kz930 Oct 23, 2026
e7fa6eb
Merge upstream/main into feat/9053-read-any-output-port
kz930 Oct 26, 2026
bc9c706
Merge feat/9053-read-any-output-port into feat/9057-pinned-result-win…
kz930 Oct 26, 2026
8f43951
Merge upstream/main into feat/9057-pinned-result-windows
kz930 Oct 26, 2026
ac7682d
Merge feat/9057-pinned-result-windows into feat/9062-pinned-result-wi…
kz930 Oct 26, 2026
0234cc8
Merge feat/9062-pinned-result-windows into feat/9059-console-only-panel
kz930 Oct 26, 2026
fc3395a
Merge feat/9055-dock-result-panel into feat/9059-console-only-panel
kz930 Oct 26, 2026
7e4e75f
feat(frontend): close the result preview on every press and delete, a…
kz930 Nov 1, 2026
18159ea
feat(frontend): leave selectPage's default column limit to its own PR
kz930 Nov 1, 2026
032fe0c
Merge upstream/main into feat/9057-pinned-result-windows
kz930 Nov 1, 2026
bc0afea
Merge feat/9057-pinned-result-windows into feat/9062-pinned-result-wi…
kz930 Nov 1, 2026
fe818e7
Merge feat/9062-pinned-result-windows into feat/9059-console-only-panel
kz930 Nov 1, 2026
3f0efdd
feat(frontend): pin a result on a double-click and keep the console s…
kz930 Nov 1, 2026
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
2 changes: 0 additions & 2 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ import { FormlyNgZorroAntdModule } from "@ngx-formly/ng-zorro-antd";
import { FlarumComponent } from "./dashboard/component/user/flarum/flarum.component";
import { NzAlertModule } from "ng-zorro-antd/alert";
import { LeftPanelComponent } from "./workspace/component/left-panel/left-panel.component";
import { ErrorFrameComponent } from "./workspace/component/result-panel/error-frame/error-frame.component";
import { NzResizableModule } from "ng-zorro-antd/resizable";
import { WorkflowRuntimeStatisticsComponent } from "./dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-runtime-statistics/workflow-runtime-statistics.component";
import { TimeTravelComponent } from "./workspace/component/left-panel/time-travel/time-travel.component";
Expand Down Expand Up @@ -303,7 +302,6 @@ registerLocaleData(en);
ShareAccessComponent,
WorkflowExecutionHistoryComponent,
ConsoleFrameComponent,
ErrorFrameComponent,
ResultTableFrameComponent,
OperatorPropertyEditFrameComponent,
FilesUploaderComponent,
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@
>Status</label
>
</li>
<li nz-menu-item>
<label
nz-checkbox
[(ngModel)]="showResultPreview"
(ngModelChange)="toggleResultPreview()"
>Result preview</label
>
</li>
<li nz-menu-item>
<label
nz-checkbox
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class MenuComponent implements OnInit, OnDestroy {
public showNumWorkers: boolean = false;
public showStatus: boolean = false;
public showHeatmap: boolean = false;
public showResultPreview: boolean = true;
public heatmapView: HeatmapView = HeatmapView.Runtime;
public HeatmapView = HeatmapView; // make Angular HTML access enum definition
protected readonly USER_WORKFLOW = USER_WORKFLOW;
Expand Down Expand Up @@ -243,6 +244,7 @@ export class MenuComponent implements OnInit, OnDestroy {

public ngOnInit(): void {
this.restorePersistedHeatmapOverlay();
this.showResultPreview = this.workflowActionService.getJointGraphWrapper().isResultPreviewEnabled();
// The export flags are reset when a menu is destroyed, which is right when the workspace is
// left and wrong when a workflow's two views hand over and the results are kept. Recompute
// from what is in hand, so a menu arriving on retained results does not offer a dead button.
Expand Down Expand Up @@ -353,6 +355,10 @@ export class MenuComponent implements OnInit, OnDestroy {
document.body.removeChild(tempSpan);
}

toggleResultPreview() {
this.workflowActionService.getJointGraphWrapper().setResultPreviewEnabled(this.showResultPreview);
}

toggleNumWorkers() {
this.workflowActionService
.getJointGraphWrapper()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@
</ul>
</nz-dropdown-menu>

<div
*ngIf="operatorId"
class="operator-filter-notice">
Showing: {{ operatorLabel(operatorId) }}
<button
nz-button
nzType="text"
nzSize="small"
nz-tooltip
nzTooltipTitle="Show every operator's messages"
aria-label="Show every operator's messages"
(click)="showAllOperators()">
<span
nz-icon
nzType="close"></span>
</button>
</div>

<div
*ngIf="hiddenMessageCount > 0"
class="hidden-count-notice">
Expand Down Expand Up @@ -180,6 +198,14 @@
<div
nz-col
nzFlex="auto">
<nz-tag
*ngIf="!operatorId && messageOperatorId(entry) as entryOperatorId"
class="operator-tag"
nz-tooltip
nzTooltipTitle="Show only this operator's messages"
(click)="showOperator(entryOperatorId)">
{{ operatorLabel(entryOperatorId) }}
</nz-tag>
<nz-tag
*ngIf="showSource"
class="source-tag">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@
text-overflow: ellipsis;
}

.operator-filter-notice {
flex: 0 0 auto;
font-family: monaco, serif;
font-size: 11px;
line-height: 14px;
margin-bottom: 4px;
white-space: nowrap;
}

// The overlay renders outside this component, so it is only reachable through
// nzOverlayClassName. Items here are .ant-dropdown-menu-item, which the
// .ant-menu-item rule in styles.scss does not cover.
Expand Down Expand Up @@ -153,6 +162,12 @@
//border: solid #0e5df0;
text-align: end;
}

.operator-tag {
font-family: monaco, serif;
font-size: 8px;
cursor: pointer;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ import { ComputingUnitStatusService } from "../../../../common/service/computing
import { MockComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import { ExecuteWorkflowService } from "../../../service/execute-workflow/execute-workflow.service";
import { WorkflowCompilingService } from "../../../service/compile-workflow/workflow-compiling.service";
import { WorkflowConsoleService } from "../../../service/workflow-console/workflow-console.service";
import { WorkflowWebsocketService } from "../../../service/workflow-websocket/workflow-websocket.service";
import { NotificationService } from "../../../../common/service/notification/notification.service";
import { UdfDebugService } from "../../../service/operator-debug/udf-debug.service";
import { ExecutionState } from "../../../types/execute-workflow.interface";
import { ConsoleMessage } from "../../../types/workflow-common.interface";
import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
import { mockPoint, mockScanPredicate } from "../../../service/workflow-graph/model/mock-workflow-data";

function consoleMessage(name: string): ConsoleMessage {
return {
Expand All @@ -55,6 +58,8 @@ describe("ConsoleFrameComponent", () => {

let getWorkerIds: ReturnType<typeof vi.fn>;
let getConsoleMessages: ReturnType<typeof vi.fn>;
let getAllConsoleMessages: ReturnType<typeof vi.fn>;
let getErrorMessages: ReturnType<typeof vi.fn>;
let skipTuples: ReturnType<typeof vi.fn>;
let retryExecution: ReturnType<typeof vi.fn>;
let send: ReturnType<typeof vi.fn>;
Expand All @@ -67,6 +72,8 @@ describe("ConsoleFrameComponent", () => {
beforeEach(async () => {
getWorkerIds = vi.fn().mockReturnValue([]);
getConsoleMessages = vi.fn().mockReturnValue([]);
getAllConsoleMessages = vi.fn().mockReturnValue([]);
getErrorMessages = vi.fn().mockReturnValue([]);
skipTuples = vi.fn();
retryExecution = vi.fn();
send = vi.fn();
Expand All @@ -86,13 +93,18 @@ describe("ConsoleFrameComponent", () => {
useValue: {
getExecutionStateStream: () => executionStateStream.asObservable(),
getWorkerIds,
getErrorMessages,
skipTuples,
retryExecution,
},
},
{
provide: WorkflowConsoleService,
useValue: { getConsoleMessageUpdateStream: () => consoleUpdateStream.asObservable(), getConsoleMessages },
useValue: {
getConsoleMessageUpdateStream: () => consoleUpdateStream.asObservable(),
getConsoleMessages,
getAllConsoleMessages,
},
},
{ provide: WorkflowWebsocketService, useValue: { send } },
{ provide: NotificationService, useValue: { error: notifyError } },
Expand Down Expand Up @@ -165,11 +177,47 @@ describe("ConsoleFrameComponent", () => {
expect(getConsoleMessages).toHaveBeenCalledWith("op1");
});

it("renderConsole is a no-op without an operator id", () => {
component.operatorId = "";
it("renderConsole shows every operator's messages without an operator id, and no workers", () => {
component.operatorId = undefined;
component.workerIds = ["w-stale"];
getWorkerIds.mockClear();
getAllConsoleMessages.mockReturnValue([
{ operatorId: "opA", message: { ...consoleMessage("PRINT"), title: "from A" } },
{ operatorId: "opB", message: { ...consoleMessage("PRINT"), title: "from B" } },
]);

component.renderConsole();

expect(getWorkerIds).not.toHaveBeenCalled();
expect(component.workerIds).toEqual([]);
expect(component.consoleMessages.map(m => m.title)).toEqual(["from A", "from B"]);
});

it("adds the errors that stopped the run as ERROR messages, only the operator's own when one is set", () => {
const fatal = (operatorId: string, message: string) => ({
message,
details: "java.lang.IllegalArgumentException: bad value",
operatorId,
workerId: "",
type: { name: "EXECUTION_FAILURE" },
timestamp: { nanos: 0, seconds: 5 },
});
getErrorMessages.mockReturnValue([fatal("opA", "requirement failed: A broke"), fatal("opB", "B broke")]);
getConsoleMessages.mockReturnValue([consoleMessage("PRINT")]);

component.displayConsoleMessages("opA");

expect(component.consoleMessages.length).toBe(2);
const error = component.consoleMessages[1];
expect(error.msgType.name).toBe("ERROR");
expect(error.source).toBe("EXECUTION_FAILURE");
// the exception's class name and the requirement's prefix mean nothing to a user
expect(error.title).toBe("A broke");
expect(error.message).toBe("bad value");

component.displayConsoleMessages(undefined);

expect(component.consoleMessages.map(m => m.title)).toEqual(["A broke", "B broke"]);
});

it("displayConsoleMessages falls back to an empty list for an operator the service has never seen", () => {
Expand Down Expand Up @@ -319,6 +367,65 @@ describe("ConsoleFrameComponent", () => {
});
});

describe("which operator's messages", () => {
let actions: WorkflowActionService;
const scanId = mockScanPredicate.operatorID;
// a worker id ending in its index, as the worker tag's color is read from it
const print: ConsoleMessage = { ...consoleMessage("PRINT"), workerId: "w-1" };
const scanLabel = () => {
const operator = actions.getTexeraGraph().getOperator(scanId);
return operator.customDisplayName ?? operator.operatorType;
};

beforeEach(() => {
actions = TestBed.inject(WorkflowActionService);
actions.addOperator(mockScanPredicate, mockPoint);
});

it("tags each message with its operator while every operator is shown, and a tag narrows to it", () => {
const highlight = vi.spyOn(actions, "highlightOperators");
getAllConsoleMessages.mockReturnValue([{ operatorId: scanId, message: print }]);
component.operatorId = undefined;
component.renderConsole();
fixture.detectChanges();

const tag = fixture.debugElement.query(By.css(".operator-tag"));
expect((tag.nativeElement as HTMLElement).textContent?.trim()).toBe(scanLabel());
expect(fixture.debugElement.query(By.css(".operator-filter-notice"))).toBeNull();

tag.triggerEventHandler("click", new MouseEvent("click"));
expect(highlight).toHaveBeenCalledWith(false, scanId);
});

it("names the one operator shown, without tags, and its close button goes back to every operator", () => {
actions.getJointGraphWrapper().highlightOperators(scanId);
getConsoleMessages.mockReturnValue([print]);
component.operatorId = scanId;
component.renderConsole();
fixture.detectChanges();

const notice = fixture.debugElement.query(By.css(".operator-filter-notice"));
expect((notice.nativeElement as HTMLElement).textContent).toContain(`Showing: ${scanLabel()}`);
expect(fixture.debugElement.query(By.css(".operator-tag"))).toBeNull();

const awareness = vi.spyOn(actions.getTexeraGraph(), "updateSharedModelAwareness");
notice.query(By.css("button")).triggerEventHandler("click", new MouseEvent("click"));
expect(actions.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()).toEqual([]);
// co-editors are told the highlight is gone too
expect(awareness).toHaveBeenCalledWith("highlighted", []);
});

it("leaves a message from an operator no longer on the canvas untagged", () => {
getAllConsoleMessages.mockReturnValue([{ operatorId: "deleted-op", message: print }]);
component.operatorId = undefined;
component.renderConsole();
fixture.detectChanges();

expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(1);
expect(fixture.debugElement.query(By.css(".operator-tag"))).toBeNull();
});
});

// The tests above drive the class directly; these render the template so its
// *ngFor / *ngIf / (click) / [(ngModel)] branches actually execute.
describe("template rendering", () => {
Expand Down Expand Up @@ -513,6 +620,65 @@ describe("ConsoleFrameComponent", () => {
flush();
}));

// The error that failed the run is turned into a console message on every render. Unless it is
// the same message each time, the list takes it for a new one and jumps to its end.
it("stays where it is on a re-render while the run's error has not changed", fakeAsync(() => {
const fatal = {
message: "B broke",
details: "",
operatorId: "op1",
workerId: "w-1",
type: { name: "EXECUTION_FAILURE" },
timestamp: { nanos: 0, seconds: 5 },
};
getErrorMessages.mockReturnValue([fatal]);
getConsoleMessages.mockReturnValue([withBody]);
component.displayConsoleMessages("op1");
tick();
fixture.detectChanges();

const scrolled = watchScroll(fixture);

component.displayConsoleMessages("op1");
tick();
fixture.detectChanges();

expect(scrolled()).toBe(false);
flush();
}));

// Every compile sends a new error object with a new timestamp, though the error is the same.
it("stays where it is on a recompile that sends the same compilation error again", fakeAsync(() => {
let seconds = 0;
const compiledError = () => ({
op1: {
message: "IllegalArgumentException: no column 'a'",
details: "",
operatorId: "op1",
workerId: "",
type: { name: "COMPILATION_ERROR" },
timestamp: { nanos: 0, seconds: seconds++ },
},
});
vi.spyOn(TestBed.inject(WorkflowCompilingService), "getWorkflowCompilationErrors").mockImplementation(
compiledError
);
component.displayConsoleMessages("op1");
tick();
fixture.detectChanges();
const shown = component.consoleMessages[0];

const scrolled = watchScroll(fixture);

component.displayConsoleMessages("op1");
tick();
fixture.detectChanges();

expect(component.consoleMessages[0]).toBe(shown);
expect(scrolled()).toBe(false);
flush();
}));

it("does not render the debug input group when console input is disabled", () => {
component.consoleInputEnabled = false;
fixture.detectChanges();
Expand Down Expand Up @@ -645,6 +811,7 @@ describe("ConsoleFrameComponent settings dropdown", () => {
useValue: {
getExecutionStateStream: () => new Subject<StateEvent>().asObservable(),
getWorkerIds: () => [],
getErrorMessages: () => [],
skipTuples: () => {},
retryExecution: () => {},
},
Expand All @@ -654,6 +821,7 @@ describe("ConsoleFrameComponent settings dropdown", () => {
useValue: {
getConsoleMessageUpdateStream: () => new Subject<void>().asObservable(),
getConsoleMessages: () => [],
getAllConsoleMessages: () => [],
},
},
{ provide: WorkflowWebsocketService, useValue: { send: () => {} } },
Expand Down
Loading
Loading