From d64f0448e09f407480f888c091c8dc5280c5fc90 Mon Sep 17 00:00:00 2001 From: Mrudhulraj Date: Sun, 28 Jun 2026 19:02:22 -0700 Subject: [PATCH 01/10] fix(dashboard): Remove duplicated rows when dataset shared Publicly in the hub page fix(dashboard): Remove duplicated rows when dataset shared Publicly in the hub page --- .../dashboard/DatasetSearchQueryBuilder.scala | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala index 64c8c311068..96c329f794d 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala @@ -66,31 +66,39 @@ object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging { params: DashboardResource.SearchQueryParams, includePublic: Boolean = false ): TableLike[_] = { + // Case 1: if `uid` is (set) and `includePublic` is false + // -> return ONLY datasets that given `uid` has explicit access to. + // Case 2: if `uid` is (null) and `includePublic` is true + // -> return ONLY datasets that are public + // Case 3 (DUPLICATE ROWS): if `uid` is (set) and `includePublic` is true + // -> Intersection of datasets that are public and explicitly shared with user is returned + // Case 4: if `uid` is (null) and `includePublic` is false + // -> return public datasets by default as user might not be logged in val baseJoin = DATASET - .leftJoin(DATASET_USER_ACCESS) - .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) - .leftJoin(USER) - .on(USER.UID.eq(DATASET.OWNER_UID)) + .leftJoin(DATASET_USER_ACCESS) + .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) + .and(if (uid == null) DSL.falseCondition() else DATASET_USER_ACCESS.UID.eq(uid)) + .leftJoin(USER) + .on(USER.UID.eq(DATASET.OWNER_UID)) - // Default condition starts as true, ensuring all datasets are selected initially. - var condition: Condition = DSL.trueCondition() - - if (uid == null) { - // If `uid` is null, the user is not logged in or performing a public search - // We only select datasets marked as public - condition = DATASET.IS_PUBLIC.eq(true) - } else { - // When `uid` is present, we add a condition to only include datasets with direct user access. - val userAccessCondition = DATASET_USER_ACCESS.UID.eq(uid) - - if (includePublic) { - // If `includePublic` is true, we extend visibility to public datasets as well. - condition = userAccessCondition.or(DATASET.IS_PUBLIC.eq(true)) - } else { - condition = userAccessCondition + // Set the `condition` where clause here + val condition: Condition = + if (uid == null) { + // Case 2 and 4 + // Get all the public datasets by default + DATASET.IS_PUBLIC.eq(true) + } else { + if(includePublic) { + // Case 3 + // Get all the datasets that `uid` has access to and the public datasets + DATASET.IS_PUBLIC.eq(true).or(DATASET_USER_ACCESS.UID.isNotNull) + } else{ + // Case 1 + // If `includePublic` is false get only user accessible datasets + DATASET_USER_ACCESS.UID.isNotNull + } } - } - baseJoin.where(condition) + baseJoin.where(condition) } override protected def constructWhereClause( @@ -140,7 +148,9 @@ object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging { val dd = DashboardDataset( dataset, owner.getEmail, - record.get(DATASET_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum]), + Option(record.get( + DATASET_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum] + )).getOrElse(PrivilegeEnum.NONE), dataset.getOwnerUid == uid, size ) From 980a355c8112a8ac71955e888fd03b295332574d Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:56:21 -0700 Subject: [PATCH 02/10] docs(agents): document the local dev stack and worktree workflow (#6015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? `AGENTS.md` documents the single-node and k8s deploy paths but never tells an agent how to run the local dev stack. This PR: - adds a "Where Things Live" row routing to `bin/local-dev.sh` — the single entry point (infra in Docker; backend, frontend, and agent-service run natively); - expands the "Develop in a worktree" section to: - prefer `bin/local-dev.sh` while developing; - bounce the stack across worktree switches (`down` in the old worktree, `up` in the new one), since the native services bind fixed ports and share one PID/state dir, so only one worktree's stack runs at a time; - use the non-interactive CLI subcommands — the interactive TUI (`-i`) is for humans, not agents. - drops "Local" from the single-node / k8s deploy row — both can deploy anywhere, not only locally. Only the wrapper is surfaced, never the internal `bin/local-dev/main.sh`. ### Any related issues, documentation, discussions? Closes #6014 ### How was this PR tested? Docs-only change; no tests. Verified the linked `bin/local-dev/README.md` path resolves and that the described subcommands (`up` / `down` / `status` / `logs` / `-i`) match `bin/local-dev.sh --help`. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --- AGENTS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eef0aea460d..d537ffcbd86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ engine, an Angular UI, and the agent service. JVM modules wired in | PR template | [.github/PULL_REQUEST_TEMPLATE](.github/PULL_REQUEST_TEMPLATE) | | Issue templates | [bug](.github/ISSUE_TEMPLATE/bug-template.yaml) / [task](.github/ISSUE_TEMPLATE/task-template.yaml) / [feature](.github/ISSUE_TEMPLATE/feature-template.yaml) | | License-header coverage; vendored `workflow-operator` | [.licenserc.yaml](.licenserc.yaml); [project/AddMetaInfLicenseFiles.scala](project/AddMetaInfLicenseFiles.scala) | -| Local single-node / k8s deploy | [single-node](bin/single-node/README.md), [k8s](bin/k8s/README.md) | +| Run the local dev stack (infra in Docker; backend/frontend/agent-service native) | [bin/local-dev.sh](bin/local-dev/README.md) | +| Single-node / k8s deploy | [single-node](bin/single-node/README.md), [k8s](bin/k8s/README.md) | If a topic is above, **read that file** instead of asking here. @@ -62,6 +63,13 @@ Reset to `upstream/main` at start; `git log upstream/main..HEAD` should contain only this PR's commits before pushing; remove the worktree after merge. +Prefer [`bin/local-dev.sh`](bin/local-dev/README.md) to run the stack while +developing. Its native services bind fixed ports and share one PID/state dir, +so only one worktree's stack runs at a time: `bin/local-dev.sh down` in the +old worktree before switching, then `up` in the new one. Use the +non-interactive CLI subcommands (`up` / `down` / `status` / `logs`); the +interactive TUI (`-i`) is for humans, not agents. + ### Environment | Component | Version | From e35a985e67b2d36c2f08b7c19845212ab88c8315 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:30:26 -0700 Subject: [PATCH 03/10] chore(frontend): fix NG8113/NG8107 template diagnostics, add test, promote to errors (#5985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? Fix the two remaining Angular extended-diagnostic warnings surfaced by the frontend build, add a unit test for the affected logic, and promote both checks to build errors so regressions can't silently creep back in: - **NG8113** — `CardItemComponent` listed `NzWaveDirective` in its standalone `imports` array but never used it in the template. Removed the unused directive import. - **NG8107** — `computing-unit-selection.component.html` used `pve.name?.trim()`, but `PveDraft.name` is a non-nullable `string`, so the optional chaining is redundant. Extracted the predicate into an `isCreateDisabled(pve)` component method bound via `[disabled]="isCreateDisabled(pve)"`, and added unit tests covering empty / whitespace / valid / padded names (the create button stays disabled until the env name has non-whitespace content). - **Enforcement** — set `unusedStandaloneImports` (NG8113) and `optionalChainNotNullable` (NG8107) to `"error"` under `angularCompilerOptions.extendedDiagnostics.checks`. Placed in `tsconfig.app.json` (the prod build, `build:ci`) rather than the base tsconfig, because `extendedDiagnostics` requires `strictTemplates`, which the unit-test builds deliberately disable; `tsconfig.test.json` clears the inherited option to avoid `NG4003`. No runtime behavior change. ### Any related issues, documentation, discussions? Closes #5984 ### How was this PR tested? - `ng test` (the affected spec): **46 passed**, including the 4 new `isCreateDisabled` cases. - `ng build`: succeeds with **0** NG8113/NG8107 diagnostics (now configured as errors) and produces the normal bundle. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../card-item/card-item.component.ts | 2 -- .../computing-unit-selection.component.html | 2 +- ...computing-unit-selection.component.spec.ts | 24 +++++++++++++++++++ .../computing-unit-selection.component.ts | 8 +++++++ frontend/src/tsconfig.app.json | 12 +++++++++- frontend/src/tsconfig.test.json | 6 ++++- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index 14f43f3ab46..ab0462bf06c 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -37,7 +37,6 @@ import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzCheckboxComponent } from "ng-zorro-antd/checkbox"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; -import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; @@ -79,7 +78,6 @@ import { isDefined } from "../../../../../common/util/predicate"; NzIconDirective, NzButtonComponent, NzPopconfirmDirective, - NzWaveDirective, ɵNzTransitionPatchDirective, ], }) diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html index f6409f1aeef..ae8738fe691 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html @@ -688,7 +688,7 @@ nz-button nzType="primary" (click)="createVirtualEnvironment(envIndex)" - [disabled]="!pve.name?.trim()" + [disabled]="isCreateDisabled(pve)" [nzLoading]="pve.isInstalling"> OK diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts index d02135458db..efa67002629 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts @@ -609,4 +609,28 @@ describe("PowerButtonComponent", () => { expect(runWsSpy).not.toHaveBeenCalled(); }); }); + + describe("isCreateDisabled", () => { + // Backs the per-environment OK button's [disabled]="isCreateDisabled(pve)": + // the button stays disabled until the name has non-whitespace content. + function pveWithName(name: string): any { + return { name } as any; + } + + it("disables when the name is empty", () => { + expect(component.isCreateDisabled(pveWithName(""))).toBe(true); + }); + + it("disables when the name is only whitespace", () => { + expect(component.isCreateDisabled(pveWithName(" "))).toBe(true); + }); + + it("enables when the name has non-whitespace content", () => { + expect(component.isCreateDisabled(pveWithName("env1"))).toBe(false); + }); + + it("enables when a valid name is padded with whitespace", () => { + expect(component.isCreateDisabled(pveWithName(" env1 "))).toBe(false); + }); + }); }); diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts index 483a8f57d6b..ac59584218d 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts @@ -775,6 +775,14 @@ export class ComputingUnitSelectionComponent implements OnInit { return this.pves.some(p => p.isLocked && p.name.trim() === trimmed); } + /** + * Whether the per-environment "OK" (create/install) button should be + * disabled: true until the environment name has non-whitespace content. + */ + isCreateDisabled(pve: PveDraft): boolean { + return !pve.name.trim(); + } + private refreshAvailableDbPves(): void { this.workflowPveService .listUserPves() diff --git a/frontend/src/tsconfig.app.json b/frontend/src/tsconfig.app.json index 6f84762b813..a05a55217b9 100644 --- a/frontend/src/tsconfig.app.json +++ b/frontend/src/tsconfig.app.json @@ -6,7 +6,17 @@ }, // ask Angular to check template error during the compilation process "angularCompilerOptions": { - "fullTemplateTypeCheck": true + "fullTemplateTypeCheck": true, + // Promote these template diagnostics to build errors so regressions fail + // the prod build (build:ci). Kept here rather than in the base tsconfig + // because extendedDiagnostics requires strictTemplates, which the test + // builds (tsconfig.test.json / tsconfig.spec.json) deliberately disable. + "extendedDiagnostics": { + "checks": { + "unusedStandaloneImports": "error", + "optionalChainNotNullable": "error" + } + } }, "files": ["main.ts"], "include": ["**/*.d.ts"] diff --git a/frontend/src/tsconfig.test.json b/frontend/src/tsconfig.test.json index 189bc3cc6a0..743bb9f6b4f 100644 --- a/frontend/src/tsconfig.test.json +++ b/frontend/src/tsconfig.test.json @@ -11,6 +11,10 @@ "angularCompilerOptions": { "strictTemplates": false, "strictNullInputTypes": false, - "fullTemplateTypeCheck": false + "fullTemplateTypeCheck": false, + // extendedDiagnostics (inherited from tsconfig.app.json) requires + // strictTemplates, which this test build disables. Clear it to avoid + // NG4003; these diagnostics are enforced by the prod build instead. + "extendedDiagnostics": null } } From f5eb5b739d03f31f283b1db64e60dcbba01e54ba Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Sun, 28 Jun 2026 22:44:42 -0700 Subject: [PATCH 04/10] refactor(execution-service): consolidate init-error reporting into WorkflowExecutionService (#5922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? Consolidates execution init-error reporting into a **single site owned by `WorkflowExecutionService`**, replacing the two-phase split #5781 introduced (per @Yicong-Huang's suggestion, tracked in #5921). - `WorkflowExecutionService` now registers its `fatalErrors → WorkflowErrorEvent` (and state) diff handler as the **first** construction action. Construction itself does no external work and cannot throw — it only assigns `workflowSettings`, creates a `WebsocketInput` (a `PublishSubject`), and registers the handler. All throwing work lives in `executeWorkflow()`, which runs **after** `executionService.onNext(...)` publishes the execution, so any failure there is recorded by `errorHandler` into the metadata store and surfaced by the handler that `connectToExecution` forwards. - `WorkflowService.initExecutionService` drops the pre-publish `errorSubject` fallback: the `executionPublished` gating and `reportFatalErrorsToSubscribers` are gone, and the catch is simply `errorHandler(e)`. The now-unused `errorSubject` field and its `connect()` subscription are removed. - `WorkflowServiceSpec` is removed — it only tested the deleted `reportFatalErrorsToSubscribers`; the surfacing behavior is exercised by the integration/e2e suites. Net: a single reporting path (the metadata-store diff handler), with no change to the init-error surfacing #5781 added — construction is provably side-effect-free, so the pre-publish window no longer has a failure mode. ### Any related issues, documentation, discussions? Resolves #5921 — the follow-up refactor agreed during the #5781 review. ### How was this PR tested? `scalafmtCheckAll` clean. This is a behavior-preserving refactor of init-error reporting: the single reporting path is the metadata-store diff handler that already surfaced post-publish errors, and construction is side-effect-free so no error can escape it. The full compile, scalafix, and the integration/e2e suites that exercise init-error surfacing run in CI. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF. --------- Co-authored-by: Claude Opus 4.8 --- .../service/WorkflowExecutionService.scala | 11 +- .../texera/web/service/WorkflowService.scala | 44 +----- .../WorkflowExecutionServiceSpec.scala | 145 ++++++++++++++++++ .../web/service/WorkflowServiceSpec.scala | 85 ---------- 4 files changed, 161 insertions(+), 124 deletions(-) create mode 100644 amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala delete mode 100644 amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceSpec.scala diff --git a/amber/src/main/scala/org/apache/texera/web/service/WorkflowExecutionService.scala b/amber/src/main/scala/org/apache/texera/web/service/WorkflowExecutionService.scala index 741687e02c9..031b9fec70e 100644 --- a/amber/src/main/scala/org/apache/texera/web/service/WorkflowExecutionService.scala +++ b/amber/src/main/scala/org/apache/texera/web/service/WorkflowExecutionService.scala @@ -66,9 +66,11 @@ class WorkflowExecutionService( ) extends SubscriptionManager with LazyLogging { - workflowContext.workflowSettings = request.workflowSettings - val wsInput = new WebsocketInput(errorHandler) - + // Wire error/state reporting first, before any other construction work, so a + // fatalErrors update (recorded by errorHandler) always has an emitter. + // Construction itself does no external work and cannot throw; the throwing + // work lives in executeWorkflow(), whose failures reach the UI through this + // same handler. addSubscription( executionStateStore.metadataStore.registerDiffHandler((oldState, newState) => { val outputEvents = new mutable.ArrayBuffer[TexeraWebSocketEvent]() @@ -85,6 +87,9 @@ class WorkflowExecutionService( }) ) + workflowContext.workflowSettings = request.workflowSettings + val wsInput = new WebsocketInput(errorHandler) + private def createStateEvent(state: ExecutionMetadataStore): WorkflowStateEvent = { if (state.isRecovering && state.state != COMPLETED) { WorkflowStateEvent("Recovering") diff --git a/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala b/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala index 90934287ebb..b0a4f0c846c 100644 --- a/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala +++ b/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala @@ -50,7 +50,7 @@ import org.apache.texera.amber.error.ErrorUtils.{ } import org.apache.texera.dao.jooq.generated.tables.pojos.User import org.apache.texera.service.util.LargeBinaryManager -import org.apache.texera.web.model.websocket.event.{TexeraWebSocketEvent, WorkflowErrorEvent} +import org.apache.texera.web.model.websocket.event.TexeraWebSocketEvent import org.apache.texera.web.model.websocket.request.WorkflowExecuteRequest import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowExecutionsResource import org.apache.texera.web.service.WorkflowService.mkWorkflowStateId @@ -101,7 +101,6 @@ class WorkflowService( with LazyLogging { // state across execution: - private val errorSubject = BehaviorSubject.create[TexeraWebSocketEvent]().toSerialized val stateStore = new WorkflowStateStore() var executionService: BehaviorSubject[WorkflowExecutionService] = BehaviorSubject.create() @@ -150,8 +149,7 @@ class WorkflowService( evtPub.subscribe { evts: Iterable[TexeraWebSocketEvent] => evts.foreach(onNext) } ) .toSeq - val errorSubscription = errorSubject.subscribe { evt: TexeraWebSocketEvent => onNext(evt) } - new CompositeDisposable(subscriptions :+ errorSubscription: _*) + new CompositeDisposable(subscriptions: _*) } def connectToExecution(onNext: TexeraWebSocketEvent => Unit): Disposable = { @@ -277,14 +275,11 @@ class WorkflowService( } } } - // Once the execution is published via `executionService.onNext`, the normal - // state-store path surfaces fatal errors to the UI: `errorHandler` writes - // them into `executionStateStore.metadataStore`, whose diff handler (set up - // in the WorkflowExecutionService constructor) emits a WorkflowErrorEvent - // that `connectToExecution` forwards. Before that point, neither the emitter - // nor a subscriber exists yet, so a failure in the constructor itself would - // be recorded but never reach the frontend -- see the fallback in `catch`. - var executionPublished = false + // WorkflowExecutionService construction does no external work and cannot + // throw; it registers its error/state diff handler up front. Once published + // via `executionService.onNext`, any failure in `executeWorkflow()` is + // recorded by `errorHandler` into the metadata store, whose handler emits a + // WorkflowErrorEvent that `connectToExecution` forwards. try { val execution = new WorkflowExecutionService( controllerConf, @@ -298,36 +293,13 @@ class WorkflowService( ) lifeCycleManager.registerCleanUpOnStateChange(executionStateStore) executionService.onNext(execution) - executionPublished = true execution.executeWorkflow() } catch { - case e: Throwable => - errorHandler(e) - // If the execution was never published, no `connectToExecution` - // subscriber is bound to `executionStateStore`, so the state-store path - // above cannot deliver the error. Push it directly in that pre-publish - // window only; once published, the state-store path already surfaces it - // (pushing here too would double-emit). - if (!executionPublished) { - reportFatalErrorsToSubscribers(executionStateStore) - } + case e: Throwable => errorHandler(e) } } - /** - * Push the fatal errors currently recorded in `stateStore` to connected - * websocket subscribers (via `errorSubject`). - * - * Fallback used only when execution initialization fails before the execution - * is published (e.g. the WorkflowExecutionService constructor throws): in that - * window the per-execution state store has no diff-handler emitter and no - * websocket subscriber, so the error -- already recorded by `errorHandler` -- - * would otherwise be logged but never reach the frontend. - */ - private[service] def reportFatalErrorsToSubscribers(stateStore: ExecutionStateStore): Unit = - errorSubject.onNext(WorkflowErrorEvent(stateStore.metadataStore.getState.fatalErrors)) - def convertToJson(frontendVersion: String): String = { val environmentVersionMap = Map( "engine_version" -> Json.toJson(frontendVersion) diff --git a/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala b/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala new file mode 100644 index 00000000000..99adb6bee9f --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala @@ -0,0 +1,145 @@ +/* + * 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. + */ + +package org.apache.texera.web.service + +import com.google.protobuf.timestamp.Timestamp +import org.apache.texera.amber.core.workflow.{WorkflowContext, WorkflowSettings} +import org.apache.texera.amber.core.workflowruntimestate.FatalErrorType.EXECUTION_FAILURE +import org.apache.texera.amber.core.workflowruntimestate.WorkflowFatalError +import org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState.{ + FAILED, + RUNNING +} +import org.apache.texera.web.model.websocket.event.{ + TexeraWebSocketEvent, + WorkflowErrorEvent, + WorkflowStateEvent +} +import org.apache.texera.web.model.websocket.request.{LogicalPlanPojo, WorkflowExecuteRequest} +import org.apache.texera.web.storage.ExecutionStateStore +import org.apache.texera.web.storage.ExecutionStateStore.updateWorkflowState +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.net.URI +import java.time.Instant +import scala.collection.mutable + +/** + * Regression guard for the consolidated init-error reporting path (#5921): + * `WorkflowExecutionService` registers its metadata-store diff handler at + * construction, so a fatalErrors update -- e.g. the one `errorHandler` records + * when `executeWorkflow` fails -- surfaces as a `WorkflowErrorEvent` through the + * normal websocket-event observable. + * + * The unused `controllerConfig` / `resultService` are passed as `null` on + * purpose: construction must stay side-effect-free (all throwing work is in + * `executeWorkflow`), so a future change that dereferences them during + * construction would fail here. + */ +class WorkflowExecutionServiceSpec extends AnyFlatSpec with Matchers { + + private def buildService( + store: ExecutionStateStore, + errorHandler: Throwable => Unit = (_: Throwable) => () + ): WorkflowExecutionService = { + val request = WorkflowExecuteRequest( + executionName = "test", + engineVersion = "test", + logicalPlan = LogicalPlanPojo(List.empty, List.empty, List.empty, List.empty), + replayFromExecution = None, + workflowSettings = WorkflowSettings(), + emailNotificationEnabled = false, + computingUnitId = 0 + ) + new WorkflowExecutionService( + null, + new WorkflowContext(), + null, + request, + store, + errorHandler, + None, + new URI("vfs:///test") + ) + } + + /** Subscribe to the metadata store's websocket-event stream and collect events. */ + private def collectEvents( + store: ExecutionStateStore + ): mutable.ArrayBuffer[TexeraWebSocketEvent] = { + val events = mutable.ArrayBuffer.empty[TexeraWebSocketEvent] + store.metadataStore.getWebsocketEventObservable.subscribe { + (evts: Iterable[TexeraWebSocketEvent]) => events ++= evts + } + events + } + + "WorkflowExecutionService" should + "surface a recorded fatal error as a WorkflowErrorEvent via the metadata-store handler" in { + val store = new ExecutionStateStore() + buildService(store) // registers the diff handler at construction + val events = collectEvents(store) + + val err = + WorkflowFatalError(EXECUTION_FAILURE, Timestamp(Instant.now), "boom during init", "", "", "") + store.metadataStore.updateState(_.addFatalErrors(err)) + + val errorEvents = events.collect { case e: WorkflowErrorEvent => e } + errorEvents should have size 1 + errorEvents.head.fatalErrors should contain(err) + } + + it should "report fatal errors recorded at successive phases through the same handler" in { + val store = new ExecutionStateStore() + // Mirror WorkflowService's real errorHandler, which records into the + // metadata store. The service invokes this same handler at every phase + // (compile, runtime creation, startWorkflow failure), so invoking it + // repeatedly here stands in for failures arising at different phases. + val recordError: Throwable => Unit = t => + store.metadataStore.updateState(metadataStore => + updateWorkflowState(FAILED, metadataStore).addFatalErrors( + WorkflowFatalError(EXECUTION_FAILURE, Timestamp(Instant.now), t.toString, "", "", "") + ) + ) + buildService(store, recordError) + val events = collectEvents(store) + + recordError(new RuntimeException("init phase")) + recordError(new RuntimeException("runtime phase")) + + val errorEvents = events.collect { case e: WorkflowErrorEvent => e } + errorEvents should have size 2 + errorEvents.last.fatalErrors.map(_.message) should contain allOf ( + "java.lang.RuntimeException: init phase", + "java.lang.RuntimeException: runtime phase" + ) + } + + it should "emit a WorkflowStateEvent when the execution state changes" in { + val store = new ExecutionStateStore() + buildService(store) + val events = collectEvents(store) + + store.metadataStore.updateState(_.withState(RUNNING)) + + events.collect { case e: WorkflowStateEvent => e } should not be empty + } +} diff --git a/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceSpec.scala b/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceSpec.scala deleted file mode 100644 index 7c1d879c93d..00000000000 --- a/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceSpec.scala +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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. - */ - -package org.apache.texera.web.service - -import com.google.protobuf.timestamp.Timestamp -import org.apache.texera.amber.core.virtualidentity.WorkflowIdentity -import org.apache.texera.amber.core.workflowruntimestate.FatalErrorType.EXECUTION_FAILURE -import org.apache.texera.amber.core.workflowruntimestate.WorkflowFatalError -import org.apache.texera.web.model.websocket.event.{TexeraWebSocketEvent, WorkflowErrorEvent} -import org.apache.texera.web.storage.ExecutionStateStore -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import java.time.Instant -import scala.collection.mutable.ArrayBuffer - -/** - * Unit tests for `WorkflowService.reportFatalErrorsToSubscribers`, the seam - * that surfaces init-time fatal errors to the websocket. When execution - * initialization fails, the error is recorded in the metadata store; this push - * is what makes it visible to connected clients instead of only logged. - */ -class WorkflowServiceSpec extends AnyFlatSpec with Matchers { - - private def fatalError(message: String): WorkflowFatalError = - WorkflowFatalError(EXECUTION_FAILURE, Timestamp(Instant.now), message, "", "", "") - - /** A WorkflowService with a subscriber collecting every event it pushes. */ - private def serviceWithCollector(): (WorkflowService, ArrayBuffer[TexeraWebSocketEvent]) = { - val service = new WorkflowService(WorkflowIdentity(1), computingUnitId = 1, cleanUpTimeout = 30) - val events = ArrayBuffer.empty[TexeraWebSocketEvent] - service.connect(evt => events += evt) - (service, events) - } - - private def errorEventsIn(events: ArrayBuffer[TexeraWebSocketEvent]): Seq[WorkflowErrorEvent] = - events.collect { case e: WorkflowErrorEvent => e }.toSeq - - "WorkflowService" should - "push a WorkflowErrorEvent carrying the store's fatal error to connected subscribers" in { - val (service, events) = serviceWithCollector() - val store = new ExecutionStateStore() - val err = fatalError("boom during init") - store.metadataStore.updateState(_.addFatalErrors(err)) - - service.reportFatalErrorsToSubscribers(store) - - val errorEvents = errorEventsIn(events) - errorEvents should have size 1 - // Forwards exactly the store's fatal errors -- no more, no less. - errorEvents.head.fatalErrors should contain theSameElementsAs Seq(err) - } - - it should "carry every fatal error currently recorded in the store" in { - val (service, events) = serviceWithCollector() - val store = new ExecutionStateStore() - val first = fatalError("first") - val second = fatalError("second") - store.metadataStore.updateState(_.addFatalErrors(first).addFatalErrors(second)) - - service.reportFatalErrorsToSubscribers(store) - - val errorEvents = errorEventsIn(events) - errorEvents should have size 1 - // Exactly the two recorded errors -- no extras. - errorEvents.head.fatalErrors should contain theSameElementsAs Seq(first, second) - } -} From 4638d663c30eaf563f15de587c86c394facf23f1 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:18:20 -0700 Subject: [PATCH 05/10] ci: require 60% patch coverage in Codecov (#6022) ### What changes were proposed in this PR? The Codecov patch status was `informational: true`, so it never gated PRs. This makes it a real requirement: - set `target: 60%` on `coverage.status.patch.default`; - add `threshold: 10%` so the patch passes when coverage is at least `target - threshold` = 50%, softening the quantization on tiny diffs (e.g. a 2-coverable-line change passes at 1 line / 50%); - drop the `informational` flag so the patch check can fail. PRs must now cover at least 50% of the lines they change (60% target, 10% slack). Project status (auto target, 1% threshold), flag carryforward, and ignore rules are unchanged. The explanatory comment in `codecov.yml` is updated to match. ### Any related issues, documentation, discussions? Closes #6021 ### How was this PR tested? Config-only change; `codecov.yml` validated as well-formed YAML. Codecov applies the new policy on the next PR upload. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --- codecov.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/codecov.yml b/codecov.yml index 9053861361a..09bb75b5d79 100644 --- a/codecov.yml +++ b/codecov.yml @@ -25,7 +25,7 @@ # 3. Default status checks fail on any project drop and require 100% # patch coverage, so non-test refactors get a red Codecov check # even when behavior is preserved. Allow 1% slack on project and -# keep patch coverage informational. +# require 60% patch coverage. flag_management: default_rules: @@ -68,10 +68,14 @@ coverage: threshold: 1% patch: default: - # Surface patch coverage in the Codecov report but don't gate - # PRs on it: a 100%-patch policy blocks plenty of legitimate - # refactors / config-only changes that have no tests to add. - informational: true + # Require new/changed lines in a PR to be at least 60% covered. + # Lower than a 100%-patch policy so legitimate refactors and + # config-only changes aren't blocked, but high enough that net-new + # code ships with tests. The 10% threshold softens the quantization + # on tiny diffs (e.g. a 2-coverable-line change passes at 1 line / + # 50%) — patch passes when coverage is >= target - threshold = 50%. + target: 60% + threshold: 10% comment: # `flag_management.default_rules.carryforward: true` above already From ec1da54f90b4bd66ecb7a51f7a238f1f80085cbf Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:20:59 -0700 Subject: [PATCH 06/10] refactor(UI): unify dataset card view onto the shared CardItemComponent (#5949) ### What changes were proposed in this PR? > [Stacked on #5947] This PR consolidates the public and private dataset listings to use the shared CardItemComponent. - Replace texera-dataset-card-item with the shared read-only texera-card-item in the public hub. - Remove the unused DatasetCardItemComponent and its tests. - Align the hub list/card toggle with the workflow and private dataset listings. - Update the like button styling on the shared card. This keeps dataset cards consistent across all listings and reduces duplicated code. Demo: public-datasets ### Any related issues, documentation, discussions? Closes #5948 ### How was this PR tested? All existing tests passed, and the changes were manually tested. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Opus 4.8 --- .../dataset-card-item.component.html | 101 --------- .../dataset-card-item.component.scss | 198 ------------------ .../dataset-card-item.component.spec.ts | 158 -------------- .../dataset-card-item.component.ts | 134 ------------ .../card-item/card-item.component.html | 2 +- .../card-item/card-item.component.scss | 15 +- .../card-item/card-item.component.ts | 1 + .../hub-search-result.component.html | 18 +- .../hub-search-result.component.scss | 24 +-- .../hub-search-result.component.ts | 4 +- 10 files changed, 24 insertions(+), 631 deletions(-) delete mode 100644 frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html delete mode 100644 frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss delete mode 100644 frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts delete mode 100644 frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html deleted file mode 100644 index 205e3848f32..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - -
- dataset cover - #{{ entry.id }} -
-
diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss deleted file mode 100644 index 60f4e159133..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss +++ /dev/null @@ -1,198 +0,0 @@ -/** - * 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. - */ - -.dataset-card { - height: 100%; - display: flex; - flex-direction: column; - border-radius: 8px; - overflow: hidden; - cursor: pointer; -} - -.dataset-card-body-link { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - color: inherit; -} - -.cover-container { - position: relative; - height: 124px; - background: #f5f5f5; - overflow: hidden; - - .cover-image { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - .cover-id-badge { - position: absolute; - left: 8px; - bottom: 8px; - padding: 2px 8px; - font-size: 12px; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - background: rgba(15, 14, 12, 0.72); - color: white; - } -} - -.card-title { - display: -webkit-box; - height: calc(15px * 1.35 * 2); - margin-bottom: 10px; - font-size: 15px; - font-weight: 600; - line-height: 1.35; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - word-break: break-word; -} - -.truncate-single-line { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-meta { - display: flex; - flex-direction: column; - gap: 5px; - margin-top: auto; - padding-top: 4px; - min-width: 0; - - .meta-line { - display: flex; - align-items: center; - gap: 6px; - color: #595959; - min-width: 0; - - &--owner { - font-size: 13px; - - .meta-owner { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - } - - .meta-avatar { - flex-shrink: 0; - - ::ng-deep nz-avatar.ant-avatar { - width: 20px; - height: 20px; - line-height: 20px; - font-size: 10px; - } - ::ng-deep .owner-badge { - font-size: 9px; - } - } - - .meta-dot { - flex-shrink: 0; - color: #bfbfbf; - font-size: 13px; - line-height: 1; - user-select: none; - } - - .meta-updated { - flex-shrink: 0; - font-size: 12px; - color: #8c8c8c; - white-space: nowrap; - } - } - - &--stats { - justify-content: space-between; - font-size: 12px; - color: #8c8c8c; - } - - .meta-stat { - display: inline-flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - white-space: nowrap; - - i { - font-size: 12px; - } - } - - .meta-stat--like { - padding: 0 10px; - border: 1px solid #e8e8e8; - border-radius: 999px; - background: transparent; - color: inherit; - font-size: 12px; - gap: 8px; - cursor: pointer; - transition: border-color 0.15s; - - i { - font-size: 11px; - transition: color 0.15s; - } - - &.liked i, - &:not(.disabled):not(.liked):hover i { - color: #e0506e; - } - - &:not(.disabled):hover { - border-color: #e0506e; - } - - &.disabled { - cursor: default; - } - } - } - - .meta-stats-left { - display: inline-flex; - align-items: center; - gap: 12px; - min-width: 0; - } - - .meta-hr { - height: 1px; - background: #f0f0f0; - margin: 2px 0; - } -} diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts deleted file mode 100644 index 1ca8248441c..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of, throwError } from "rxjs"; -import type { Mocked } from "vitest"; - -import { DatasetCardItemComponent } from "./dataset-card-item.component"; -import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; -import { HubService } from "../../../../hub/service/hub.service"; -import { UserService } from "../../../../common/service/user/user.service"; -import { StubUserService } from "../../../../common/service/user/stub-user.service"; -import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant"; -import { commonTestProviders } from "../../../../common/testing/test-utils"; - -function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { - return { - type: "dataset", - id: 42, - accessibleUserIds: [1, 2], - coverImageUrl: undefined, - likeCount: 5, - isLiked: false, - ...overrides, - } as unknown as DashboardEntry; -} - -describe("DatasetCardItemComponent", () => { - let component: DatasetCardItemComponent; - let fixture: ComponentFixture; - let hubService: Mocked; - - beforeEach(async () => { - const hubServiceSpy = { - toggleLike: vi.fn().mockReturnValue(of({ liked: true, likeCount: 7 })), - }; - - await TestBed.configureTestingModule({ - imports: [DatasetCardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], - providers: [ - { - provide: DatasetService, - useValue: { - getDatasetCoverUrl: vi.fn().mockReturnValue(of({ url: "https://s3.example/presigned" })), - }, - }, - { provide: HubService, useValue: hubServiceSpy }, - { provide: UserService, useClass: StubUserService }, - ...commonTestProviders, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DatasetCardItemComponent); - component = fixture.componentInstance; - hubService = TestBed.inject(HubService) as unknown as Mocked; - }); - - describe("entryLink", () => { - it("routes to the private dataset page when the current user has access", () => { - component.currentUid = 1; - component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.entryLink).toEqual([USER_DATASET, "99"]); - }); - - it("routes to the hub detail page when the current user has no access", () => { - component.currentUid = 5; - component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.entryLink).toEqual([HUB_DATASET_RESULT_DETAIL, "99"]); - }); - }); - - describe("coverImageSrc", () => { - it("falls back to the default cover when coverImageUrl is missing", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - component.entry = makeDatasetEntry({ coverImageUrl: undefined }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - expect(datasetService.getDatasetCoverUrl).not.toHaveBeenCalled(); - }); - - it("swaps in the presigned URL once the backend resolves it", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - component.entry = makeDatasetEntry({ id: 7, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(datasetService.getDatasetCoverUrl).toHaveBeenCalledWith(7); - expect(component.coverImageSrc).toBe("https://s3.example/presigned"); - }); - - it("falls back to the default cover when the backend returns a null url", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - datasetService.getDatasetCoverUrl.mockReturnValueOnce(of({ url: null })); - component.entry = makeDatasetEntry({ id: 9, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - }); - - it("falls back to the default cover when the backend errors", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - datasetService.getDatasetCoverUrl.mockReturnValueOnce(throwError(() => new Error("403"))); - component.entry = makeDatasetEntry({ id: 11, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - }); - }); - - describe("toggleLike", () => { - beforeEach(() => { - component.currentUid = 1; - component.entry = makeDatasetEntry(); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - }); - - it("does nothing when the user is not signed in", () => { - component.currentUid = undefined; - component.toggleLike(); - expect(hubService.toggleLike).not.toHaveBeenCalled(); - }); - - it("toggles to liked and reconciles state from the server", () => { - component.isLiked = false; - component.toggleLike(); - expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", false); - expect(component.isLiked).toBe(true); - expect(component.likeCount).toBe(7); - }); - - it("toggles to unliked and reconciles state from the server", () => { - hubService.toggleLike.mockReturnValueOnce(of({ liked: false, likeCount: 6 })); - component.isLiked = true; - component.toggleLike(); - expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", true); - expect(component.isLiked).toBe(false); - expect(component.likeCount).toBe(6); - }); - }); -}); diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts deleted file mode 100644 index 5e9fd3e27f1..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { RouterLink } from "@angular/router"; -import { NzCardComponent } from "ng-zorro-antd/card"; -import { NzIconDirective } from "ng-zorro-antd/icon"; -import { DashboardEntry } from "../../../type/dashboard-entry"; -import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; -import { HubService } from "../../../../hub/service/hub.service"; -import { formatSize } from "../../../../common/util/size-formatter.util"; -import { formatCount, formatRelativeTime } from "../../../../common/util/format.util"; -import { isDefined } from "../../../../common/util/predicate"; -import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant"; - -@UntilDestroy() -@Component({ - selector: "texera-dataset-card-item", - templateUrl: "./dataset-card-item.component.html", - styleUrls: ["./dataset-card-item.component.scss"], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, NzCardComponent, NzIconDirective, UserAvatarComponent], -}) -export class DatasetCardItemComponent implements OnChanges { - @Input() currentUid: number | undefined; - @Input() entry!: DashboardEntry; - - entryLink: string[] = []; - coverImageSrc: string = ""; - readonly defaultCover = "assets/card_background.jpg"; - likeCount = 0; - viewCount = 0; - isLiked = false; - - constructor( - private datasetService: DatasetService, - private hubService: HubService, - private cdr: ChangeDetectorRef - ) {} - - ngOnChanges(changes: SimpleChanges): void { - if (changes["entry"] || changes["currentUid"]) { - this.initializeEntry(); - } - if (changes["entry"]) { - this.likeCount = this.entry.likeCount ?? 0; - this.viewCount = this.entry.viewCount ?? 0; - this.isLiked = this.entry.isLiked ?? false; - } - } - - private initializeEntry(): void { - if (!this.entry || this.entry.type !== "dataset" || typeof this.entry.id !== "number") { - return; - } - const did = this.entry.id; - const owners = this.entry.accessibleUserIds; - if (this.currentUid !== undefined && owners.includes(this.currentUid)) { - this.entryLink = [USER_DATASET, String(did)]; - } else { - this.entryLink = [HUB_DATASET_RESULT_DETAIL, String(did)]; - } - - this.coverImageSrc = this.defaultCover; - if (this.entry.coverImageUrl) { - this.datasetService - .getDatasetCoverUrl(did) - .pipe(untilDestroyed(this)) - .subscribe({ - next: ({ url }) => { - this.coverImageSrc = url ?? this.defaultCover; - this.cdr.markForCheck(); - }, - error: () => { - this.coverImageSrc = this.defaultCover; - this.cdr.markForCheck(); - }, - }); - } - } - - onCoverError(event: Event): void { - const image = event.target as HTMLImageElement; - image.onerror = null; - image.src = this.defaultCover; - } - - toggleLike(): void { - if (!isDefined(this.currentUid) || !isDefined(this.entry.id)) return; - // optimistic flip; server response reconciles or reverts - const previousLiked = this.isLiked; - this.isLiked = !previousLiked; - this.likeCount += previousLiked ? -1 : 1; - this.cdr.markForCheck(); - - this.hubService - .toggleLike(this.entry.id, this.entry.type, previousLiked) - .pipe(untilDestroyed(this)) - .subscribe({ - next: ({ liked, likeCount }) => { - this.isLiked = liked; - this.likeCount = likeCount; - this.cdr.markForCheck(); - }, - error: () => { - this.isLiked = previousLiked; - this.likeCount += previousLiked ? 1 : -1; - this.cdr.markForCheck(); - }, - }); - } - - formatSize = formatSize; - formatCount = formatCount; - formatRelativeTime = formatRelativeTime; -} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html index f44f25597ec..fce5a5f97c5 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -153,7 +153,7 @@ (click)="toggleLike(); $event.stopPropagation()"> {{ formatCount(this.likeCount) }} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss index 92a8ab7c6cc..273dbb938be 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss @@ -16,10 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -// Styled to match the dataset card view (dataset-card-item.component.scss) for -// a consistent card look across the dashboard: 8px radius, 124px cover, a +// A consistent card look across the dashboard: 8px radius, 124px cover, a // muted #8c8c8c / #595959 meta palette, #f0f0f0 dividers and a #e0506e like -// accent. Markup/structure intentionally unchanged. +// accent. .card-item { width: 100%; @@ -206,9 +205,19 @@ } .like-btn { + margin-left: auto; + padding: 0 12px; + border: 1px solid #e8e8e8; + border-radius: 999px; + + &:hover { + background: transparent; + } + &.liked, &:not([disabled]):hover { color: #e0506e; + border-color: #e0506e; } } diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index ab0462bf06c..e0cc6321e51 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -135,6 +135,7 @@ export class CardItemComponent implements OnChanges { ) {} initializeEntry() { + this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE; if (this.entry.type === "workflow") { if (typeof this.entry.id === "number") { this.disableDelete = !this.entry.workflow.isOwner; diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html index b910262df7e..15bce4a703a 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html @@ -27,11 +27,9 @@ class="view-toggle"> @@ -56,10 +52,10 @@ - - +
diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss index 1d0e4b5b4b7..4bfd66aed8d 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss @@ -28,28 +28,6 @@ .view-toggle { display: inline-flex; - background: #f5f5f5; - border-radius: 6px; - padding: 2px; + gap: 8px; margin-left: auto; - - button { - height: 28px; - padding: 0 10px; - border: none; - background: transparent; - color: #8c8c8c; - border-radius: 4px; - - &:hover { - color: #595959; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - background: #fff; - color: #1f1f1f; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); - } - } } diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts index 1f69bdcff71..2cb582c4e9d 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts @@ -28,7 +28,7 @@ import { SearchResultsViewMode, } from "../../../dashboard/component/user/search-results/search-results.component"; import { FiltersComponent } from "../../../dashboard/component/user/filters/filters.component"; -import { DatasetCardItemComponent } from "../../../dashboard/component/user/dataset-card-item/dataset-card-item.component"; +import { CardItemComponent } from "../../../dashboard/component/user/list-item/card-item/card-item.component"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SortMethod } from "../../../dashboard/type/sort-method"; import { UserService } from "../../../common/service/user/user.service"; @@ -53,7 +53,7 @@ const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; SortButtonComponent, FiltersComponent, SearchResultsComponent, - DatasetCardItemComponent, + CardItemComponent, ], }) export class HubSearchResultComponent implements OnInit, AfterViewInit { From 86613e457330c741d8708f41c21e31ae364d6d68 Mon Sep 17 00:00:00 2001 From: Elliot Lin <36275109+ELin2025@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:01:39 -0700 Subject: [PATCH 07/10] feat(frontend): add HuggingFace task selector and model browser component (#5566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ This PR is stacked on #5574. Until that lands, the diff below may also include PR 5's QA/ranking task changes depending on which base GitHub is showing. The new code in this PR is the HuggingFaceComponent (task selector + model browser) under frontend/src/app/workspace/component/hugging-face/, plus the formly registration in formly-config.ts and the declaration in app.module.ts. Once PR #5574 merges and this PR is retargeted to main, the diff should auto-clean to the PR 6a frontend selector changes only. ### What changes were proposed in this PR? Add `HuggingFaceComponent`, a custom formly field type (`huggingface`) that provides: - A task dropdown listing all supported HuggingFace inference tasks (fetched from the Texera backend's `/huggingface/tasks` endpoint, with a static fallback list) - A paginated model list with client-side search, fetched from the Texera backend's `/huggingface/models` endpoint (which proxies HuggingFace Hub) - Per-task field state preservation — when switching tasks, previously entered values (modelId, promptColumn, etc.) are saved and restored This PR registers the component in `formly-config.ts` and declares it in `AppModule`. The component is not yet wired into the HuggingFace operator's property editor; the `jsonSchemaMapIntercept` mapping that routes the `modelId` field to this component is added in the follow-up property-editor PR (PR 7). ### Any related issues, documentation, discussions? - Tracking issue: https://github.com/apache/texera/issues/5314 - Closes: https://github.com/apache/texera/issues/5314 - Stacked on: PR tracked in issue #5292 - Parent issue: https://github.com/apache/texera/issues/5041 ### How was this PR tested? 7 unit tests added in `hugging-face.component.spec.ts` covering: - Static task list is non-empty and contains expected tasks (text-generation, image tasks, audio tasks, QA/ranking tasks) - Task tags are unique - Cache invalidation does not throw Run with `ng test`. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.7 --------- Signed-off-by: Elliot Lin <36275109+ELin2025@users.noreply.github.com> Co-authored-by: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/src/app/app.module.ts | 2 + .../src/app/common/formly/formly-config.ts | 2 + .../hugging-face/hugging-face.component.html | 208 ++++++ .../hugging-face/hugging-face.component.scss | 162 +++++ .../hugging-face.component.spec.ts | 664 +++++++++++++++++ .../hugging-face/hugging-face.component.ts | 680 ++++++++++++++++++ 6 files changed, 1718 insertions(+) create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.html create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 5af25b43866..bdebbfba2e5 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -107,6 +107,7 @@ import { AgentPanelComponent } from "./workspace/component/agent/agent-panel/age import { AgentChatComponent } from "./workspace/component/agent/agent-panel/agent-chat/agent-chat.component"; import { AgentRegistrationComponent } from "./workspace/component/agent/agent-panel/agent-registration/agent-registration.component"; import { HuggingFaceImageUploadComponent } from "./workspace/component/hugging-face-image-upload/hugging-face-image-upload.component"; +import { HuggingFaceComponent } from "./workspace/component/hugging-face/hugging-face.component"; import { DatasetFileSelectorComponent } from "./workspace/component/dataset-file-selector/dataset-file-selector.component"; import { DatasetVersionSelectorComponent } from "./workspace/component/dataset-version-selector/dataset-version-selector.component"; import { DatasetSelectionModalComponent } from "./workspace/component/dataset-selection-modal/dataset-selection-modal.component"; @@ -331,6 +332,7 @@ registerLocaleData(en); AgentChatComponent, AgentRegistrationComponent, AgentInteractionComponent, + HuggingFaceComponent, HuggingFaceImageUploadComponent, DatasetFileSelectorComponent, DatasetVersionSelectorComponent, diff --git a/frontend/src/app/common/formly/formly-config.ts b/frontend/src/app/common/formly/formly-config.ts index ba80dc51f96..f385cf03591 100644 --- a/frontend/src/app/common/formly/formly-config.ts +++ b/frontend/src/app/common/formly/formly-config.ts @@ -30,6 +30,7 @@ import { FormlyRepeatDndComponent } from "./repeat-dnd/repeat-dnd.component"; import { UiUdfParametersComponent } from "../../workspace/component/ui-udf-parameters/ui-udf-parameters.component"; import { DatasetVersionSelectorComponent } from "../../workspace/component/dataset-version-selector/dataset-version-selector.component"; import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component"; +import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component"; /** * Configuration for using Json Schema with Formly. @@ -81,6 +82,7 @@ export const TEXERA_FORMLY_CONFIG = { { name: "codearea", component: CodeareaCustomTemplateComponent }, { name: "inputautocomplete", component: DatasetFileSelectorComponent, wrappers: ["form-field"] }, { name: "datasetversionselector", component: DatasetVersionSelectorComponent, wrappers: ["form-field"] }, + { name: "huggingface", component: HuggingFaceComponent, wrappers: ["form-field"] }, { name: "huggingface-image-upload", component: HuggingFaceImageUploadComponent, wrappers: ["form-field"] }, { name: "repeat-section-dnd", component: FormlyRepeatDndComponent }, { name: "ui-udf-parameters", component: UiUdfParametersComponent, wrappers: ["form-field"] }, diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html new file mode 100644 index 00000000000..b44624721ac --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html @@ -0,0 +1,208 @@ + + +
+ + + + + + + + +
+ {{ tasksError }} + +
+ + + + + + + + + + + + + + +
+ + Loading models... +
+ + +
+ {{ errorMessage }} + +
+ + +
+ Results may be incomplete. Use the search bar to find models not shown here. +
+ + +
+ +
+ Selected: + {{ formControl.value }} + +
+ + +
+ {{ isSearching ? 'No models found for "' + searchText + '".' : 'No models available.' }} +
+ + +
+ {{ model.id }} + + + + {{ model.downloads | number }} + + + + {{ model.likes | number }} + + +
+
+ + +
+ + Page {{ currentPage + 1 }} of {{ totalPages }} + +
+
+ + diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss new file mode 100644 index 00000000000..f16ddc91536 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss @@ -0,0 +1,162 @@ +/** + * 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. + */ + +.hf-model-select-container { + width: 100%; +} + +.hf-section-label { + display: block; + font-size: 14px; + font-weight: normal; + color: rgba(0, 0, 0, 0.85); + line-height: 32px; + margin-top: 8px; + + .hf-required { + display: inline-block; + color: #ff4d4f; + font-size: 14px; + font-family: SimSun, sans-serif; + line-height: 1; + margin-right: 4px; + } +} + +.hf-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + + .loading-text { + font-size: 12px; + color: #999; + } +} + +.hf-error { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0; + + .error-text { + font-size: 12px; + color: #ff4d4f; + } +} + +.hf-truncation-notice { + font-size: 12px; + color: #faad14; + padding: 4px 0; + margin-bottom: 4px; +} + +.hf-model-list { + border: 1px solid #d9d9d9; + border-radius: 4px; + max-height: 360px; + overflow-y: auto; +} + +.hf-selected-model { + display: flex; + align-items: center; + padding: 6px 10px; + background: #e6f7ff; + border-bottom: 1px solid #d9d9d9; + font-size: 12px; + + .hf-selected-label { + font-weight: 500; + margin-right: 6px; + color: rgba(0, 0, 0, 0.65); + } + + .hf-selected-value { + color: #1890ff; + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.hf-empty { + padding: 16px; + text-align: center; + color: #999; + font-size: 12px; +} + +.hf-model-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background 0.15s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #fafafa; + } + + &.hf-model-item-selected { + background: #e6f7ff; + } + + .hf-model-id { + font-size: 12px; + color: rgba(0, 0, 0, 0.85); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; + } + + .hf-model-meta { + font-size: 11px; + color: #999; + white-space: nowrap; + flex-shrink: 0; + } +} + +.hf-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 8px 0; + margin-top: 4px; + + .hf-page-info { + font-size: 12px; + color: rgba(0, 0, 0, 0.65); + } +} diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts new file mode 100644 index 00000000000..3e2e5bca728 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts @@ -0,0 +1,664 @@ +/** + * 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 { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { FormControl, FormGroup } from "@angular/forms"; +import { FieldTypeConfig } from "@ngx-formly/core"; +import { AppSettings } from "../../../common/app-setting"; +import { + HuggingFaceComponent, + HuggingFaceModelOption, + HuggingFaceTaskOption, + STATIC_TASK_OPTIONS, + invalidateHuggingFaceModelCache, +} from "./hugging-face.component"; + +const API = "api"; + +function buildModels(count: number, prefix = "model"): HuggingFaceModelOption[] { + return Array.from({ length: count }, (_, i) => ({ + id: `${prefix}/${prefix}-${i}`, + label: `${prefix}-${i}`, + downloads: 1000 - i, + likes: 500 - i, + })); +} + +function buildTaskResponse(): HuggingFaceTaskOption[] { + return [ + { tag: "text-generation", label: "Text Generation" }, + { tag: "image-classification", label: "Image Classification" }, + ]; +} + +/** + * Build a minimal FormlyFieldConfig with a FormGroup backing it, + * similar to what Formly provides at runtime. + */ +function buildFieldWithFormGroup(taskValue = "", modelIdValue = ""): { field: FieldTypeConfig; formGroup: FormGroup } { + const formGroup = new FormGroup({ + task: new FormControl(taskValue), + modelId: new FormControl(modelIdValue), + promptColumn: new FormControl(""), + imageInput: new FormControl(""), + audioInput: new FormControl(""), + inputImageColumn: new FormControl(""), + inputAudioColumn: new FormControl(""), + candidateLabels: new FormControl(""), + sentencesColumn: new FormControl(""), + contextColumn: new FormControl(""), + systemPrompt: new FormControl("You are a helpful assistant."), + maxNewTokens: new FormControl(256), + temperature: new FormControl(0.7), + }); + + const model: Record = { + task: taskValue, + modelId: modelIdValue, + }; + + const field = { + key: "modelId", + formControl: formGroup.get("modelId")! as FormControl, + form: formGroup, + model, + props: {}, + parent: { fieldGroup: [] }, + options: { detectChanges: vi.fn() }, + } as unknown as FieldTypeConfig; + + return { field, formGroup }; +} + +// ── Pure unit tests (no TestBed) ── + +describe("HuggingFaceComponent (unit)", () => { + beforeEach(() => { + invalidateHuggingFaceModelCache(); + }); + + it("should export a non-empty static task list", () => { + expect(STATIC_TASK_OPTIONS.length).toBeGreaterThan(0); + }); + + it("should include text-generation in static task options", () => { + const textGen = STATIC_TASK_OPTIONS.find(t => t.tag === "text-generation"); + expect(textGen).toBeTruthy(); + expect(textGen!.label).toBe("Text Generation"); + }); + + it("should include image tasks in static task options", () => { + const imageTasks = STATIC_TASK_OPTIONS.filter(t => + ["image-classification", "object-detection", "image-segmentation", "image-to-text"].includes(t.tag) + ); + expect(imageTasks.length).toBe(4); + }); + + it("should include audio tasks in static task options", () => { + const audioTasks = STATIC_TASK_OPTIONS.filter(t => + ["automatic-speech-recognition", "audio-classification", "text-to-speech"].includes(t.tag) + ); + expect(audioTasks.length).toBe(3); + }); + + it("should include QA/ranking tasks in static task options", () => { + const qaTasks = STATIC_TASK_OPTIONS.filter(t => + ["question-answering", "zero-shot-classification", "sentence-similarity", "text-ranking"].includes(t.tag) + ); + expect(qaTasks.length).toBe(4); + }); + + it("should clear caches on invalidateHuggingFaceModelCache", () => { + expect(() => invalidateHuggingFaceModelCache()).not.toThrow(); + }); + + it("should have unique tags in static task options", () => { + const tags = STATIC_TASK_OPTIONS.map(t => t.tag); + const uniqueTags = new Set(tags); + expect(uniqueTags.size).toBe(tags.length); + }); +}); + +// ── TestBed-based integration tests ── + +describe("HuggingFaceComponent (TestBed)", () => { + let component: HuggingFaceComponent; + let fixture: ComponentFixture; + let http: HttpTestingController; + + beforeEach(async () => { + invalidateHuggingFaceModelCache(); + + await TestBed.configureTestingModule({ + imports: [HuggingFaceComponent, HttpClientTestingModule], + }).compileComponents(); + + vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); + + fixture = TestBed.createComponent(HuggingFaceComponent); + component = fixture.componentInstance; + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + // Destroy the component to trigger ngOnDestroy and clean up subscriptions/timers + fixture.destroy(); + // Flush any pending icon SVG requests from NzIconModule before verifying + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + http.verify(); + }); + + /** Flush any pending NzIcon SVG asset requests. */ + function flushIconRequests() { + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + } + + /** Set up field + trigger ngOnInit, then flush the two startup HTTP requests. */ + function initComponent(taskTag = "text-generation", models: HuggingFaceModelOption[] = buildModels(3)) { + const { field } = buildFieldWithFormGroup(taskTag); + component.field = field; + fixture.detectChanges(); // triggers ngOnInit + flushIconRequests(); + + // ngOnInit fires two HTTP requests: tasks + models + const tasksReq = http.expectOne(`${API}/huggingface/tasks`); + tasksReq.flush(buildTaskResponse()); + + const modelsReq = http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)); + modelsReq.flush(models); + flushIconRequests(); + } + + // ── Creation ── + + it("should create the component", () => { + initComponent(); + expect(component).toBeTruthy(); + }); + + it("should default selectedTaskTag to text-generation", () => { + initComponent(); + expect(component.selectedTaskTag).toBe("text-generation"); + }); + + // ── Task loading ── + + describe("task loading", () => { + it("should fetch tasks from the API on init", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + const tasksReq = http.expectOne(`${API}/huggingface/tasks`); + expect(tasksReq.request.method).toBe("GET"); + tasksReq.flush(buildTaskResponse()); + + // Also flush the models request + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + expect(component.taskOptions).toEqual(buildTaskResponse()); + expect(component.tasksLoading).toBe(false); + }); + + it("should fall back to STATIC_TASK_OPTIONS when API returns empty array", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush([]); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + expect(component.taskOptions).toEqual(STATIC_TASK_OPTIONS); + }); + + it("should fall back to STATIC_TASK_OPTIONS on task fetch error", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).error(new ProgressEvent("error")); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + expect(component.taskOptions).toEqual(STATIC_TASK_OPTIONS); + expect(component.tasksError).toBeTruthy(); + expect(component.tasksLoading).toBe(false); + }); + + it("retryTasksLoad should clear error and re-fetch tasks", fakeAsync(() => { + initComponent(); + + // Simulate a prior error state by directly calling retryTasksLoad + // First, force an error so retryTasksLoad has something to retry + invalidateHuggingFaceModelCache(); + component.tasksError = "previous error"; + component.retryTasksLoad(); + tick(); + + const tasksReq = http.expectOne(`${API}/huggingface/tasks`); + tasksReq.flush(buildTaskResponse()); + + expect(component.tasksError).toBeNull(); + expect(component.taskOptions).toEqual(buildTaskResponse()); + })); + }); + + // ── Model loading ── + + describe("model loading", () => { + it("should fetch models for the selected task on init", () => { + const { field } = buildFieldWithFormGroup("image-classification"); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + const modelsReq = http.expectOne(`${API}/huggingface/models?task=image-classification`); + expect(modelsReq.request.method).toBe("GET"); + modelsReq.flush(buildModels(5)); + + expect(component.pagedModels.length).toBe(5); + expect(component.loading).toBe(false); + }); + + it("should show loading state while models are being fetched", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + // Tasks request is pending, but check model loading state + expect(component.loading).toBe(true); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2)); + + expect(component.loading).toBe(false); + }); + + it("should set truncated flag from X-Texera-Truncated header", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + const modelsReq = http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)); + modelsReq.flush(buildModels(5), { headers: { "X-Texera-Truncated": "true" } }); + + expect(component.truncated).toBe(true); + }); + + it("should not set truncated when header is absent", () => { + initComponent("text-generation", buildModels(5)); + expect(component.truncated).toBe(false); + }); + + it("should display error on model fetch failure", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).error(new ProgressEvent("error")); + + expect(component.errorMessage).toBeTruthy(); + expect(component.loading).toBe(false); + expect(component.pagedModels.length).toBe(0); + }); + + it("retryLoad should clear error and re-fetch models", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).error(new ProgressEvent("error")); + + expect(component.errorMessage).toBeTruthy(); + + component.retryLoad(); + const retryReq = http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)); + retryReq.flush(buildModels(3)); + + expect(component.errorMessage).toBeNull(); + expect(component.pagedModels.length).toBe(3); + }); + + it("should use cached models on second access for the same task", () => { + initComponent("text-generation", buildModels(3)); + expect(component.pagedModels.length).toBe(3); + + // Simulate switching away and back — models should come from cache, no HTTP request + component.onTaskSelected("image-classification"); + const modelsReq = http.expectOne(`${API}/huggingface/models?task=image-classification`); + modelsReq.flush(buildModels(2, "img")); + + component.onTaskSelected("text-generation"); + // No new HTTP request for text-generation — it's cached + expect(component.pagedModels.length).toBe(3); + }); + }); + + // ── Pagination ── + + describe("pagination", () => { + it("should page models with PAGE_SIZE of 50", () => { + initComponent("text-generation", buildModels(120)); + + expect(component.totalPages).toBe(3); + expect(component.currentPage).toBe(0); + expect(component.pagedModels.length).toBe(50); + }); + + it("should navigate to next page", () => { + initComponent("text-generation", buildModels(120)); + + component.nextPage(); + expect(component.currentPage).toBe(1); + expect(component.pagedModels.length).toBe(50); + expect(component.pagedModels[0].id).toBe("model/model-50"); + }); + + it("should navigate to previous page", () => { + initComponent("text-generation", buildModels(120)); + + component.nextPage(); + expect(component.currentPage).toBe(1); + + component.prevPage(); + expect(component.currentPage).toBe(0); + expect(component.pagedModels[0].id).toBe("model/model-0"); + }); + + it("should not go below page 0", () => { + initComponent("text-generation", buildModels(120)); + + component.prevPage(); + expect(component.currentPage).toBe(0); + }); + + it("should not go past the last page", () => { + initComponent("text-generation", buildModels(120)); + + component.nextPage(); + component.nextPage(); + expect(component.currentPage).toBe(2); + expect(component.pagedModels.length).toBe(20); // 120 - 2*50 = 20 + + component.nextPage(); + expect(component.currentPage).toBe(2); // stays at last page + }); + + it("hasNextPage should return correct value", () => { + initComponent("text-generation", buildModels(120)); + + expect(component.hasNextPage).toBe(true); + component.nextPage(); + expect(component.hasNextPage).toBe(true); + component.nextPage(); + expect(component.hasNextPage).toBe(false); + }); + + it("goToPage should clamp to valid range", () => { + initComponent("text-generation", buildModels(120)); + + component.goToPage(999); + expect(component.currentPage).toBe(2); // last page + + component.goToPage(0); + expect(component.currentPage).toBe(0); + }); + + it("should show single page for small model lists", () => { + initComponent("text-generation", buildModels(10)); + + expect(component.totalPages).toBe(1); + expect(component.currentPage).toBe(0); + expect(component.pagedModels.length).toBe(10); + expect(component.hasNextPage).toBe(false); + }); + + it("should handle empty model list", () => { + initComponent("text-generation", []); + + expect(component.totalPages).toBe(1); + expect(component.currentPage).toBe(0); + expect(component.pagedModels.length).toBe(0); + }); + }); + + // ── Search ── + + describe("search", () => { + it("should filter models locally when list is not truncated", () => { + const models = [ + { id: "bert-base", label: "bert-base", downloads: 100, likes: 50 }, + { id: "gpt2", label: "gpt2", downloads: 200, likes: 100 }, + { id: "bert-large", label: "bert-large", downloads: 80, likes: 40 }, + ]; + initComponent("text-generation", models); + + component.onSearchInput("bert"); + + expect(component.pagedModels.length).toBe(2); + expect(component.pagedModels.every(m => m.id.includes("bert"))).toBe(true); + }); + + it("should be case-insensitive for local search", () => { + const models = [ + { id: "BERT-Base", label: "BERT-Base", downloads: 100, likes: 50 }, + { id: "gpt2", label: "gpt2", downloads: 200, likes: 100 }, + ]; + initComponent("text-generation", models); + + component.onSearchInput("bert"); + expect(component.pagedModels.length).toBe(1); + expect(component.pagedModels[0].id).toBe("BERT-Base"); + }); + + it("should clear filter when search text is empty", () => { + const models = buildModels(5); + initComponent("text-generation", models); + + component.onSearchInput("model-0"); + expect(component.pagedModels.length).toBe(1); + + component.onSearchInput(""); + expect(component.pagedModels.length).toBe(5); + }); + + it("should clear filter when search text is whitespace", () => { + const models = buildModels(5); + initComponent("text-generation", models); + + component.onSearchInput("model-0"); + expect(component.pagedModels.length).toBe(1); + + component.onSearchInput(" "); + expect(component.pagedModels.length).toBe(5); + }); + + it("clearSearch should reset search state", () => { + initComponent("text-generation", buildModels(5)); + + component.onSearchInput("model-0"); + expect(component.searchText).toBe("model-0"); + + component.clearSearch(); + expect(component.searchText).toBe(""); + expect(component.searchLoading).toBe(false); + expect(component.pagedModels.length).toBe(5); + }); + + it("isSearching should return true when filtered models exist", () => { + initComponent("text-generation", buildModels(5)); + + expect(component.isSearching).toBe(false); + + component.onSearchInput("model-0"); + expect(component.isSearching).toBe(true); + + component.clearSearch(); + expect(component.isSearching).toBe(false); + }); + + it("should use server-side search when list is truncated", fakeAsync(() => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + const modelsReq = http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)); + modelsReq.flush(buildModels(5), { headers: { "X-Texera-Truncated": "true" } }); + + expect(component.truncated).toBe(true); + + // Trigger server-side search + component.onSearchInput("special-model"); + tick(300); // debounceTime + + const searchReq = http.expectOne( + req => req.url.includes("/huggingface/models") && req.url.includes("search=special-model") + ); + const searchResults = [{ id: "special-model/v1", label: "special-model/v1" }]; + searchReq.flush(searchResults); + + expect(component.pagedModels.length).toBe(1); + expect(component.pagedModels[0].id).toBe("special-model/v1"); + expect(component.searchLoading).toBe(false); + })); + + it("should reset pagination to page 0 on search", () => { + initComponent("text-generation", buildModels(120)); + + component.nextPage(); + expect(component.currentPage).toBe(1); + + component.onSearchInput("model-1"); + expect(component.currentPage).toBe(0); + }); + }); + + // ── Task selection ── + + describe("task selection", () => { + it("onTaskSelected should update selectedTaskTag", () => { + initComponent(); + + component.onTaskSelected("image-classification"); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(2, "img")); + + expect(component.selectedTaskTag).toBe("image-classification"); + }); + + it("onTaskSelected should load models for the new task", () => { + initComponent(); + + component.onTaskSelected("image-classification"); + const req = http.expectOne(`${API}/huggingface/models?task=image-classification`); + req.flush(buildModels(4, "img")); + + expect(component.pagedModels.length).toBe(4); + }); + + it("onTaskSelected should clear search state", () => { + initComponent("text-generation", buildModels(5)); + + component.onSearchInput("model-0"); + expect(component.searchText).toBe("model-0"); + + component.onTaskSelected("image-classification"); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]); + + expect(component.searchText).toBe(""); + }); + + it("should persist task to model and form control", () => { + const { field, formGroup } = buildFieldWithFormGroup("text-generation"); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + component.onTaskSelected("image-classification"); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]); + + expect(formGroup.get("task")!.value).toBe("image-classification"); + expect(field.model!["task"]).toBe("image-classification"); + }); + + it("should restore task-scoped field state when switching back", () => { + const { field, formGroup } = buildFieldWithFormGroup("text-generation"); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + // Set a custom value while on text-generation + formGroup.get("systemPrompt")!.setValue("Custom prompt"); + + // Switch to image-classification + component.onTaskSelected("image-classification"); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]); + + // The systemPrompt should be reset (first visit defaults) + expect(formGroup.get("systemPrompt")!.value).toBe("You are a helpful assistant."); + + // Switch back to text-generation — should restore the custom prompt + component.onTaskSelected("text-generation"); + expect(formGroup.get("systemPrompt")!.value).toBe("Custom prompt"); + }); + + it("should read initial task tag from the form model", () => { + const { field } = buildFieldWithFormGroup("image-classification"); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(2, "img")); + + expect(component.selectedTaskTag).toBe("image-classification"); + }); + }); + + // ── Model selection ── + + describe("model selection", () => { + it("onModelSelected should set the formControl value", () => { + const { field } = buildFieldWithFormGroup(); + component.field = field; + fixture.detectChanges(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(3)); + + component.onModelSelected("model/model-1"); + expect(field.formControl!.value).toBe("model/model-1"); + }); + }); + + // ── Cleanup ── + + describe("cleanup", () => { + it("should clean up on destroy without errors", () => { + initComponent(); + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts new file mode 100644 index 00000000000..f84d4f940f6 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts @@ -0,0 +1,680 @@ +/** + * 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, OnInit, OnDestroy, ChangeDetectorRef } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core"; +import { HttpClient } from "@angular/common/http"; +import { NzSelectModule } from "ng-zorro-antd/select"; +import { NzInputModule } from "ng-zorro-antd/input"; +import { NzSpinModule } from "ng-zorro-antd/spin"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { AppSettings } from "../../../common/app-setting"; +import { of, Subject, Subscription } from "rxjs"; +import { catchError, debounceTime, finalize, switchMap, takeUntil } from "rxjs/operators"; + +export interface HuggingFaceModelOption { + id: string; + label: string; + pipeline_tag?: string; + downloads?: number; + likes?: number; +} + +export interface HuggingFaceTaskOption { + tag: string; + label: string; +} + +// ── Static fallback task list (used when the dynamic fetch fails) ── +export const STATIC_TASK_OPTIONS: HuggingFaceTaskOption[] = [ + { tag: "text-generation", label: "Text Generation" }, + { tag: "automatic-speech-recognition", label: "Automatic Speech Recognition" }, + { tag: "audio-classification", label: "Audio Classification" }, + { tag: "text-classification", label: "Text Classification" }, + { tag: "text-to-speech", label: "Text to Speech" }, + { tag: "token-classification", label: "Token Classification" }, + { tag: "question-answering", label: "Question Answering" }, + { tag: "table-question-answering", label: "Table Question Answering" }, + { tag: "zero-shot-classification", label: "Zero-Shot Classification" }, + { tag: "translation", label: "Translation" }, + { tag: "summarization", label: "Summarization" }, + { tag: "feature-extraction", label: "Feature Extraction" }, + { tag: "fill-mask", label: "Fill-Mask" }, + { tag: "sentence-similarity", label: "Sentence Similarity" }, + { tag: "text-ranking", label: "Text Ranking" }, + { tag: "image-classification", label: "Image Classification" }, + { tag: "object-detection", label: "Object Detection" }, + { tag: "image-segmentation", label: "Image Segmentation" }, + { tag: "image-to-text", label: "Image to Text" }, + { tag: "visual-question-answering", label: "Visual Question Answering" }, + { tag: "document-question-answering", label: "Document Question Answering" }, + { tag: "zero-shot-image-classification", label: "Zero-Shot Image Classification" }, +]; + +const PAGE_SIZE = 50; + +const TRUNCATED_HEADER = "X-Texera-Truncated"; + +// ── Module-level caches (reused across component instances) ── +const allModelsByTag: Map = new Map(); +const truncatedByTag: Set = new Set(); +const inFlightByTag: Map = new Map(); +const errorByTag: Map = new Map(); + +let cachedTaskOptions: HuggingFaceTaskOption[] | null = null; +let tasksFetchSubscription: Subscription | null = null; +let tasksFetchError: string | null = null; + +/** Clear all cached data (useful for tests or manual invalidation). */ +export function invalidateHuggingFaceModelCache(): void { + allModelsByTag.clear(); + truncatedByTag.clear(); + errorByTag.clear(); + inFlightByTag.forEach(sub => sub.unsubscribe()); + inFlightByTag.clear(); + cachedTaskOptions = null; + tasksFetchError = null; + tasksFetchSubscription?.unsubscribe(); + tasksFetchSubscription = null; +} + +@Component({ + selector: "texera-hugging-face-model-select", + templateUrl: "./hugging-face.component.html", + styleUrls: ["hugging-face.component.scss"], + imports: [ + CommonModule, + FormsModule, + NzSelectModule, + NzInputModule, + NzSpinModule, + NzButtonModule, + NzIconModule, + FormlyModule, + ], +}) +export class HuggingFaceComponent extends FieldType implements OnInit, OnDestroy { + private readonly taskScopedKeys = [ + "modelId", + "promptColumn", + "imageInput", + "audioInput", + "inputImageColumn", + "inputAudioColumn", + "candidateLabels", + "sentencesColumn", + "contextColumn", + "systemPrompt", + "maxNewTokens", + "temperature", + ] as const; + private readonly taskStateByTag = new Map>>(); + // ── Task state ── + taskOptions: HuggingFaceTaskOption[] = cachedTaskOptions ?? STATIC_TASK_OPTIONS; + selectedTaskTag = "text-generation"; + tasksLoading = false; + tasksError: string | null = null; + + // ── All models for the current task (fetched once from backend, cached) ── + private allModels: HuggingFaceModelOption[] = []; + + // ── Displayed state ── + pagedModels: HuggingFaceModelOption[] = []; + currentPage = 0; + totalPages = 0; + + loading = false; + errorMessage: string | null = null; + + // ── Truncation notice ── + truncated = false; + + // ── Search state ── + searchText = ""; + searchLoading = false; + private filteredModels: HuggingFaceModelOption[] | null = null; + private readonly searchSubject$ = new Subject(); + private searchSubscription: Subscription | null = null; + + private readonly destroy$ = new Subject(); + private subscription: Subscription | null = null; + private taskPollInterval: ReturnType | null = null; + private modelPollInterval: ReturnType | null = null; + private initTimeout: ReturnType | null = null; + + constructor( + private http: HttpClient, + private cdr: ChangeDetectorRef + ) { + super(); + } + + ngOnInit(): void { + const savedTag = this.getCurrentTaskTag(); + this.selectedTaskTag = savedTag ?? this.selectedTaskTag; + this.syncTaskSelection(this.selectedTaskTag, false); + this.loadTasks(); + this.loadAllModels(); + this.setupServerSearch(); + // Formly can attach sibling controls after this field initializes. + // Re-sync once the control tree settles so a fresh operator starts in a valid task state. + this.initTimeout = setTimeout( + () => this.syncTaskSelection(this.getCurrentTaskTag() ?? this.selectedTaskTag, false), + 0 + ); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.subscription?.unsubscribe(); + this.searchSubscription?.unsubscribe(); + this.searchSubject$.complete(); + if (this.taskPollInterval !== null) { + clearInterval(this.taskPollInterval); + } + if (this.modelPollInterval !== null) { + clearInterval(this.modelPollInterval); + } + if (this.initTimeout !== null) { + clearTimeout(this.initTimeout); + } + } + + // ── Task loading ── + + /** + * Fetch available pipeline tags from the backend, which proxies HuggingFace's /api/tasks. + * Falls back to STATIC_TASK_OPTIONS if the fetch fails. + */ + private loadTasks(): void { + // Already fetched and cached + if (cachedTaskOptions !== null) { + this.taskOptions = cachedTaskOptions; + return; + } + + // Previous fetch errored — show static list, don't retry automatically + if (tasksFetchError !== null) { + this.tasksError = tasksFetchError; + this.taskOptions = STATIC_TASK_OPTIONS; + return; + } + + // Another component instance already has a fetch in flight — wait for it + if (tasksFetchSubscription !== null) { + this.tasksLoading = true; + if (this.taskPollInterval !== null) clearInterval(this.taskPollInterval); + const poll = setInterval(() => { + if (cachedTaskOptions !== null || tasksFetchError !== null) { + clearInterval(poll); + this.taskPollInterval = null; + this.tasksLoading = false; + this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS; + if (tasksFetchError) this.tasksError = tasksFetchError; + this.cdr.detectChanges(); + } else if (tasksFetchSubscription === null) { + // Fetch was canceled before populating caches; stop polling and fall back. + clearInterval(poll); + this.taskPollInterval = null; + this.tasksLoading = false; + this.taskOptions = STATIC_TASK_OPTIONS; + this.cdr.detectChanges(); + } + }, 200); + this.taskPollInterval = poll; + return; + } + + this.tasksLoading = true; + this.tasksError = null; + this.cdr.detectChanges(); + + tasksFetchSubscription = this.http + .get(`${AppSettings.getApiEndpoint()}/huggingface/tasks`) + .pipe( + takeUntil(this.destroy$), + finalize(() => { + // If takeUntil fires before next/error, reset the module-level guard + // so the next component instance can start a fresh fetch. + if (cachedTaskOptions === null && tasksFetchError === null) { + tasksFetchSubscription = null; + } + }) + ) + .subscribe({ + next: tasks => { + tasksFetchSubscription = null; + cachedTaskOptions = tasks.length > 0 ? tasks : STATIC_TASK_OPTIONS; + this.taskOptions = cachedTaskOptions; + this.tasksLoading = false; + this.cdr.detectChanges(); + }, + error: (err: unknown) => { + console.error("Failed to load HuggingFace tasks:", err); + tasksFetchSubscription = null; + tasksFetchError = "Could not load tasks from Hugging Face. Using default list."; + this.tasksError = tasksFetchError; + this.taskOptions = STATIC_TASK_OPTIONS; + this.tasksLoading = false; + this.cdr.detectChanges(); + }, + }); + } + + retryTasksLoad(): void { + tasksFetchError = null; + this.tasksError = null; + this.loadTasks(); + } + + // ── Task selection ── + + onTaskSelected(tag: string): void { + const previousTask = this.getCurrentTaskTag() ?? this.selectedTaskTag; + this.snapshotTaskState(previousTask); + this.syncTaskSelection(tag, true); + this.restoreTaskState(tag); + this.searchText = ""; + this.filteredModels = null; + // Cancel any in-flight server search for the previous task + this.searchSubject$.next(""); + this.loadAllModels(); + } + + // ── Data loading ── + + /** + * Fetch ALL models for the selected task. + * The backend paginates through HF Hub internally and caches the result. + * The first request per task may be slow; subsequent requests are instant. + */ + private loadAllModels(): void { + const tag = this.selectedTaskTag || "text-generation"; + + this.loading = false; + this.errorMessage = null; + + // Fast path: cached on the frontend + if (allModelsByTag.has(tag)) { + this.allModels = allModelsByTag.get(tag)!; + this.truncated = truncatedByTag.has(tag); + this.goToPage(0); + return; + } + + // Previous error + if (errorByTag.has(tag)) { + this.errorMessage = errorByTag.get(tag)!; + this.allModels = []; + this.pagedModels = []; + this.totalPages = 0; + return; + } + + // Another instance is already fetching this task — wait for it + if (inFlightByTag.has(tag)) { + this.loading = true; + if (this.modelPollInterval !== null) clearInterval(this.modelPollInterval); + const poll = setInterval(() => { + if (allModelsByTag.has(tag) || errorByTag.has(tag)) { + clearInterval(poll); + this.modelPollInterval = null; + this.loading = false; + if (allModelsByTag.has(tag)) { + this.allModels = allModelsByTag.get(tag)!; + this.truncated = truncatedByTag.has(tag); + this.goToPage(0); + } else { + this.errorMessage = errorByTag.get(tag)!; + this.cdr.detectChanges(); + } + } else if (!inFlightByTag.has(tag)) { + // Fetch was canceled before populating caches; stop polling and fall back. + clearInterval(poll); + this.modelPollInterval = null; + this.loading = false; + this.cdr.detectChanges(); + } + }, 200); + this.modelPollInterval = poll; + return; + } + + // Cancel previous + this.subscription?.unsubscribe(); + this.subscription = null; + + this.allModels = []; + this.pagedModels = []; + this.totalPages = 0; + + // Show spinner immediately for the initial fetch — it can take a while + // as the backend pages through HF Hub for the first time. + this.loading = true; + this.cdr.detectChanges(); + + this.subscription = this.http + .get( + `${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}`, + { observe: "response" } + ) + .pipe( + takeUntil(this.destroy$), + finalize(() => { + // If takeUntil cancels before next/error fires, clear the in-flight + // guard so a later instance re-fetches instead of polling forever. + if (!allModelsByTag.has(tag) && !errorByTag.has(tag)) { + inFlightByTag.delete(tag); + } + }) + ) + .subscribe({ + next: resp => { + const models = resp.body ?? []; + if (resp.headers.get(TRUNCATED_HEADER) === "true") { + truncatedByTag.add(tag); + } + allModelsByTag.set(tag, models); + inFlightByTag.delete(tag); + this.loading = false; + this.truncated = truncatedByTag.has(tag); + this.allModels = models; + this.goToPage(0); + }, + error: (err: unknown) => { + console.error(`Failed to load HuggingFace models for task '${tag}':`, err); + const msg = "Failed to load models. Click retry to try again."; + errorByTag.set(tag, msg); + inFlightByTag.delete(tag); + this.loading = false; + this.errorMessage = msg; + this.cdr.detectChanges(); + }, + }); + + inFlightByTag.set(tag, this.subscription); + } + + // ── Pagination (client-side over the active list) ── + + private get activeList(): HuggingFaceModelOption[] { + return this.filteredModels !== null ? this.filteredModels : this.allModels; + } + + goToPage(page: number): void { + const list = this.activeList; + this.totalPages = Math.max(1, Math.ceil(list.length / PAGE_SIZE)); + this.currentPage = Math.min(page, this.totalPages - 1); + const start = this.currentPage * PAGE_SIZE; + this.pagedModels = list.slice(start, start + PAGE_SIZE); + this.cdr.detectChanges(); + } + + prevPage(): void { + if (this.currentPage > 0) { + this.goToPage(this.currentPage - 1); + } + } + + nextPage(): void { + if (this.currentPage < this.totalPages - 1) { + this.goToPage(this.currentPage + 1); + } + } + + get hasNextPage(): boolean { + return this.currentPage < this.totalPages - 1; + } + + retryLoad(): void { + const tag = this.selectedTaskTag || "text-generation"; + errorByTag.delete(tag); + this.loadAllModels(); + } + + // ── Search ── + + private setupServerSearch(): void { + this.searchSubscription = this.searchSubject$ + .pipe( + debounceTime(300), + switchMap(query => { + if (!query.trim()) { + this.searchLoading = false; + this.cdr.detectChanges(); + return of(null); + } + const tag = this.selectedTaskTag || "text-generation"; + this.searchLoading = true; + this.cdr.detectChanges(); + return this.http + .get< + HuggingFaceModelOption[] + >(`${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}&search=${encodeURIComponent(query)}`) + .pipe( + catchError((err: unknown) => { + console.error("Server-side search failed:", err); + this.searchLoading = false; + this.cdr.detectChanges(); + return of(null); + }) + ); + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: models => { + if (models === null) return; + this.searchLoading = false; + this.filteredModels = models; + this.goToPage(0); + }, + }); + } + + onSearchInput(query: string): void { + this.searchText = query; + if (!query.trim()) { + this.filteredModels = null; + this.searchLoading = false; + // Cancel any in-flight server search via switchMap + this.searchSubject$.next(""); + this.goToPage(0); + return; + } + if (this.truncated) { + // Server-side search — needed because local list is incomplete + this.searchSubject$.next(query); + } else { + // Local filter — full list is available + const lower = query.toLowerCase(); + this.filteredModels = this.allModels.filter(m => m.id.toLowerCase().includes(lower)); + this.goToPage(0); + } + } + + clearSearch(): void { + this.searchText = ""; + this.filteredModels = null; + this.searchLoading = false; + // Cancel any in-flight server search via switchMap + this.searchSubject$.next(""); + this.goToPage(0); + } + + get isSearching(): boolean { + return this.filteredModels !== null || this.searchLoading; + } + + // ── Model selection ── + + onModelSelected(modelId: string): void { + this.formControl.setValue(modelId); + } + + // ── Private helpers ── + + private getCurrentTaskTag(): string | undefined { + const fromModel = this.model?.task; + if (typeof fromModel === "string" && fromModel.trim().length > 0) { + return fromModel; + } + const fromParentControl = this.formControl?.parent?.get("task")?.value; + if (typeof fromParentControl === "string" && fromParentControl.trim().length > 0) { + return fromParentControl; + } + const fromFieldForm = this.field.form?.get("task")?.value; + if (typeof fromFieldForm === "string" && fromFieldForm.trim().length > 0) { + return fromFieldForm; + } + return undefined; + } + + private persistTaskSelection(tag: string): void { + // 1. Update the backing model FIRST so expression functions read the new value. + if (this.model) { + this.model.task = tag; + } + + // 2. Update the hidden task form control. Using emitEvent: true (default) + // ensures formly picks up the change and re-evaluates all sibling expressions. + const taskControlFromField = this.field.form?.get("task"); + if (taskControlFromField) { + taskControlFromField.setValue(tag); + } + + const taskControlFromParent = this.formControl?.parent?.get("task"); + if (taskControlFromParent && taskControlFromParent !== taskControlFromField) { + taskControlFromParent.setValue(tag); + } + + // 3. Force formly to re-evaluate ALL field expressions (not just this field's subtree). + // this.field is the modelId field; its parent covers all sibling fields. + const rootField = this.field.parent ?? this.field; + this.field.options?.detectChanges?.(rootField); + } + + private syncTaskSelection(tag: string, resetTaskSpecificFields: boolean): void { + this.selectedTaskTag = tag; + if (resetTaskSpecificFields) { + this.resetTaskStateForFirstVisit(tag); + } + this.persistTaskSelection(tag); + this.refreshTaskScopedValidity(); + } + + private refreshTaskScopedValidity(): void { + const keys = [ + "task", + "modelId", + "promptColumn", + "imageInput", + "audioInput", + "inputImageColumn", + "inputAudioColumn", + "candidateLabels", + "sentencesColumn", + "contextColumn", + "systemPrompt", + "maxNewTokens", + "temperature", + ]; + for (const key of keys) { + const control = this.field.form?.get(key) ?? this.formControl?.parent?.get(key); + control?.updateValueAndValidity({ emitEvent: false }); + } + this.field.form?.updateValueAndValidity({ emitEvent: false }); + this.formControl?.parent?.updateValueAndValidity({ emitEvent: false }); + + // Emit a single value change after all fields are settled so the + // workflow action service picks up the new operator properties. + this.formControl?.parent?.updateValueAndValidity({ emitEvent: true }); + } + + private snapshotTaskState(tag: string): void { + if (!tag) { + return; + } + const snapshot: Partial> = {}; + for (const key of this.taskScopedKeys) { + snapshot[key] = this.readFieldValue(key); + } + this.taskStateByTag.set(tag, snapshot); + } + + private restoreTaskState(tag: string): void { + const snapshot = this.taskStateByTag.get(tag); + if (!snapshot) { + return; + } + for (const key of this.taskScopedKeys) { + if (Object.prototype.hasOwnProperty.call(snapshot, key)) { + this.writeFieldValue(key, snapshot[key]); + } + } + this.refreshTaskScopedValidity(); + } + + private resetTaskStateForFirstVisit(tag: string): void { + if (this.taskStateByTag.has(tag)) { + return; + } + const defaults: Partial> = { + modelId: "", + promptColumn: "", + imageInput: "", + audioInput: "", + inputImageColumn: "", + inputAudioColumn: "", + candidateLabels: "", + sentencesColumn: "", + contextColumn: "", + systemPrompt: "You are a helpful assistant.", + maxNewTokens: 256, + temperature: 0.7, + }; + for (const key of this.taskScopedKeys) { + this.writeFieldValue(key, defaults[key] ?? ""); + } + } + + private readFieldValue(key: (typeof this.taskScopedKeys)[number]): unknown { + const control = this.field.form?.get(key) ?? this.formControl?.parent?.get(key); + if (control) { + return control.value; + } + return this.model?.[key]; + } + + private writeFieldValue(key: (typeof this.taskScopedKeys)[number], value: unknown): void { + const control = this.field.form?.get(key) ?? this.formControl?.parent?.get(key); + if (control) { + control.setValue(value, { emitEvent: false }); + control.markAsDirty(); + control.updateValueAndValidity({ emitEvent: false }); + } + if (this.model) { + (this.model as Record)[key] = value; + } + } +} From a94fb193f78c8ab2a5cdf82af6b984932adf528a Mon Sep 17 00:00:00 2001 From: Elliot Lin <36275109+ELin2025@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:20:31 -0700 Subject: [PATCH 08/10] feat(frontend): add HuggingFace audio upload component (#5567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? Add `HuggingFaceAudioUploadComponent`, a custom formly field type (`huggingface-audio-upload`) that provides: - An audio file picker that uploads to the Texera backend's `/huggingface/upload-audio` endpoint for server-side storage - Authenticated audio preview/playback — fetches server-stored audio via `HttpClient` (which carries the JWT) and creates a blob URL for the `