From 616d1dd7c942f8db4003f331232967b0f9088fc2 Mon Sep 17 00:00:00 2001 From: Tanishq Gandhi Date: Sat, 16 May 2026 01:35:05 -0700 Subject: [PATCH] feat(result-pane): interactive grid with backend pushdown and transformation diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the operator result pane from a static 5-row nz-table into an interactive, full-dataset spreadsheet view with row-level filtering, sorting, search, and a per-operator transformation summary. Frontend - Replace nz-table with ag-grid Community (MIT) using the Infinite Row Model; pagination with auto-fit page size; column reorder/hide/pin/resize. - Custom header component restores inline column stats (Min / Max / Non-Null / category %). - Per-cell renderer keeps image preview + hover-only download icon. - Row inspector docks inline below the grid (replaces the prior popup modal): shows the row as a JSON tree with prev/next/close. - Row search input above the grid, debounced 250 ms. - Transformation diff strip above the grid: upstream-vs-current row delta and column diff (added / removed / kept / type-changed) with an expandable detail drawer. - Result panel itself is now a fixed bottom dock (top-edge resize only) — no more floating popup. Backend - Extend ResultPaginationRequest with optional filters / sorts / rowSearch. - New VirtualDocument query methods: getRangeWithQuery, countWithQuery (defaulted to no-op fallback). - IcebergPredicateBuilder compiles ColumnFilter into Iceberg Expressions with type-aware value parsing (eq/ne/lt/le/gt/ge/startsWith/isNull/isNotNull/in pushed down; contains/endsWith handled as residual). - IcebergDocument.getRangeWithQuery / countWithQuery: pushdown + residual filter + rowSearch + in-memory sort with a configurable cap (storage.result.sort.max-rows, default 100k); responses include a sortSkipped flag so the UI can prompt the user to narrow the filter. - PaginatedResultEvent carries totalNumTuples and sortSkipped for the filtered/sorted path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../event/PaginatedResultEvent.scala | 28 +- .../request/ResultPaginationRequest.scala | 11 +- .../web/service/ExecutionResultService.scala | 44 +- common/config/src/main/resources/storage.conf | 13 + .../texera/amber/config/StorageConfig.scala | 3 + .../core/storage/model/QueryClauses.scala | 40 + .../core/storage/model/VirtualDocument.scala | 34 + .../result/iceberg/IcebergDocument.scala | 185 +++- .../iceberg/IcebergPredicateBuilder.scala | 160 ++++ frontend/package.json | 2 + .../result-panel/result-panel.component.html | 46 +- .../result-panel/result-panel.component.scss | 116 ++- .../result-panel/result-panel.component.ts | 112 +-- .../result-cell-renderer.component.ts | 139 +++ .../result-header.component.ts | 287 +++++++ .../result-table-frame.component.html | 273 +++--- .../result-table-frame.component.scss | 168 ++-- .../result-table-frame.component.spec.ts | 13 +- .../result-table-frame.component.ts | 795 ++++++++++-------- .../transformation-diff.component.html | 249 ++++++ .../transformation-diff.component.scss | 396 +++++++++ .../transformation-diff.component.ts | 260 ++++++ .../workflow-result.service.ts | 46 +- .../types/workflow-websocket.interface.ts | 22 + frontend/src/main.ts | 5 + frontend/yarn.lock | 31 + 26 files changed, 2694 insertions(+), 784 deletions(-) create mode 100644 common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/QueryClauses.scala create mode 100644 common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergPredicateBuilder.scala create mode 100644 frontend/src/app/workspace/component/result-panel/result-table-frame/result-cell-renderer.component.ts create mode 100644 frontend/src/app/workspace/component/result-panel/result-table-frame/result-header.component.ts create mode 100644 frontend/src/app/workspace/component/result-panel/result-table-frame/transformation-diff.component.html create mode 100644 frontend/src/app/workspace/component/result-panel/result-table-frame/transformation-diff.component.scss create mode 100644 frontend/src/app/workspace/component/result-panel/result-table-frame/transformation-diff.component.ts diff --git a/amber/src/main/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEvent.scala b/amber/src/main/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEvent.scala index 081d072180b..910d9f4bc94 100644 --- a/amber/src/main/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEvent.scala +++ b/amber/src/main/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEvent.scala @@ -31,6 +31,24 @@ object PaginatedResultEvent { ): PaginatedResultEvent = { PaginatedResultEvent(req.requestID, req.operatorID, req.pageIndex, table, schema) } + + def apply( + req: ResultPaginationRequest, + table: List[ObjectNode], + schema: List[Attribute], + totalNumTuples: Long, + sortSkipped: Boolean + ): PaginatedResultEvent = { + PaginatedResultEvent( + req.requestID, + req.operatorID, + req.pageIndex, + table, + schema, + Some(totalNumTuples), + sortSkipped + ) + } } case class PaginatedResultEvent( @@ -38,5 +56,13 @@ case class PaginatedResultEvent( operatorID: String, pageIndex: Int, table: List[ObjectNode], - schema: List[Attribute] + schema: List[Attribute], + // Total rows matching the request's filters / rowSearch. Optional because the + // unfiltered fast path doesn't bother recomputing it — the frontend already + // tracks totalNumTuples from WebPaginationUpdate in that case. + totalNumTuples: Option[Long] = None, + // True when sort was requested but the filtered row count exceeded the + // configured `storage.result.sort.max-rows` cap; the table comes back in + // scan order so the UI can prompt the user to narrow their filter. + sortSkipped: Boolean = false ) extends TexeraWebSocketEvent diff --git a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/ResultPaginationRequest.scala b/amber/src/main/scala/org/apache/texera/web/model/websocket/request/ResultPaginationRequest.scala index 4a3e0a58a3e..195d9bbc965 100644 --- a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/ResultPaginationRequest.scala +++ b/amber/src/main/scala/org/apache/texera/web/model/websocket/request/ResultPaginationRequest.scala @@ -19,12 +19,21 @@ package org.apache.texera.web.model.websocket.request +import org.apache.texera.amber.core.storage.model.{ColumnFilter, SortSpec} + case class ResultPaginationRequest( requestID: String, operatorID: String, pageIndex: Int, pageSize: Int, + // The columnOffset / columnLimit / columnSearch fields predate ag-grid's + // built-in column virtualization and column-toggle UI. Kept defaulted for + // wire compatibility with the Python SDK; new frontends do not set them. columnOffset: Int = 0, columnLimit: Int = Int.MaxValue, - columnSearch: Option[String] = None + columnSearch: Option[String] = None, + // Phase 2 / Phase 3 of the result-pane upgrade — row-level pushdown. + filters: Seq[ColumnFilter] = Seq.empty, + sorts: Seq[SortSpec] = Seq.empty, + rowSearch: Option[String] = None ) extends TexeraWebSocketRequest diff --git a/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala b/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala index b335ed0c3c7..00d16789af4 100644 --- a/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala +++ b/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala @@ -23,7 +23,7 @@ import org.apache.pekko.actor.Cancellable import com.fasterxml.jackson.annotation.{JsonTypeInfo, JsonTypeName} import com.fasterxml.jackson.databind.node.ObjectNode import com.typesafe.scalalogging.LazyLogging -import org.apache.texera.amber.config.ApplicationConfig +import org.apache.texera.amber.config.{ApplicationConfig, StorageConfig} import org.apache.texera.amber.core.storage.model.VirtualDocument import org.apache.texera.amber.core.storage.result._ import org.apache.texera.amber.core.storage.{DocumentFactory, VFSURIFactory} @@ -454,16 +454,44 @@ class ExecutionResultService( ) } - val paginationIterable = { - virtualDocument + val hasQuery = + request.filters.nonEmpty || request.sorts.nonEmpty || request.rowSearch.exists(_.nonEmpty) + + if (!hasQuery) { + val paginationIterable = virtualDocument .getRange(from, from + request.pageSize, columns) .to(Iterable) + val mappedResults = convertTuplesToJson(paginationIterable) + val attributes = paginationIterable.headOption + .map(_.getSchema.getAttributes) + .getOrElse(List.empty) + PaginatedResultEvent.apply(request, mappedResults, attributes) + } else { + // Filter / sort / rowSearch path: compute totalNumTuples up front so the + // frontend datasource can size the infinite scrollbar after the user's + // filter applies. If sort was requested and the matched row count blows + // the cap, IcebergDocument silently returns scan order — we surface that + // via `sortSkipped` so the UI can banner. + val totalMatching = virtualDocument.countWithQuery(request.filters, request.rowSearch) + val sortRequested = request.sorts.nonEmpty + val sortSkipped = sortRequested && totalMatching > StorageConfig.resultSortMaxRows + + val paginationIterable = virtualDocument + .getRangeWithQuery( + from, + from + request.pageSize, + columns, + request.filters, + request.sorts, + request.rowSearch + ) + .to(Iterable) + val mappedResults = convertTuplesToJson(paginationIterable) + val attributes = paginationIterable.headOption + .map(_.getSchema.getAttributes) + .getOrElse(List.empty) + PaginatedResultEvent.apply(request, mappedResults, attributes, totalMatching, sortSkipped) } - val mappedResults = convertTuplesToJson(paginationIterable) - val attributes = paginationIterable.headOption - .map(_.getSchema.getAttributes) - .getOrElse(List.empty) - PaginatedResultEvent.apply(request, mappedResults, attributes) case None => // Handle the case when storageUri is empty diff --git a/common/config/src/main/resources/storage.conf b/common/config/src/main/resources/storage.conf index 29d5f7be512..95b56bb2fb8 100644 --- a/common/config/src/main/resources/storage.conf +++ b/common/config/src/main/resources/storage.conf @@ -18,6 +18,19 @@ # See PR https://github.com/Texera/texera/pull/3326 for configuration guidelines. storage { + # Configuration for result pane query behavior. + # The result pane (frontend) lets users sort by any column; Iceberg cannot push + # ORDER BY into the file reader, so sorts are evaluated in process memory. To + # prevent OOM on very large operator outputs, sorting is skipped once the + # post-filter row count exceeds this cap — the response carries a flag so the UI + # can prompt the user to narrow the filter. + result { + sort { + max-rows = 100000 + max-rows = ${?STORAGE_RESULT_SORT_MAX_ROWS} + } + } + # Configuration for Apache Iceberg, used for storing the workflow results & stats iceberg { catalog { diff --git a/common/config/src/main/scala/org/apache/texera/amber/config/StorageConfig.scala b/common/config/src/main/scala/org/apache/texera/amber/config/StorageConfig.scala index 728e3c0c2de..10644e9d009 100644 --- a/common/config/src/main/scala/org/apache/texera/amber/config/StorageConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/amber/config/StorageConfig.scala @@ -34,6 +34,9 @@ object StorageConfig { val jdbcUsername: String = conf.getString("storage.jdbc.username") val jdbcPassword: String = conf.getString("storage.jdbc.password") + // Result-pane query specifics + val resultSortMaxRows: Long = conf.getLong("storage.result.sort.max-rows") + // Iceberg specifics val icebergCatalogType: String = conf.getString("storage.iceberg.catalog.type") val icebergRESTCatalogUri: String = conf.getString("storage.iceberg.catalog.rest.uri") diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/QueryClauses.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/QueryClauses.scala new file mode 100644 index 00000000000..09e4986d5e7 --- /dev/null +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/QueryClauses.scala @@ -0,0 +1,40 @@ +/* + * 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.amber.core.storage.model + +/** + * Row-level filter predicate for a VirtualDocument query. + * + * Values are sent as strings over the wire for simplicity; storage backends are + * responsible for parsing them against the column's declared type (see + * IcebergPredicateBuilder for the canonical implementation). + * + * Supported `op` values: eq, ne, lt, le, gt, ge, contains, startsWith, endsWith, + * isNull, isNotNull, in. + */ +case class ColumnFilter( + columnName: String, + op: String, + value: Option[String] = None, + values: Option[Seq[String]] = None +) + +/** Sort specification for a single column. `direction` must be "asc" or "desc". */ +case class SortSpec(columnName: String, direction: String) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/VirtualDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/VirtualDocument.scala index 8e72c52a803..7f2ca9a263d 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/VirtualDocument.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/VirtualDocument.scala @@ -61,6 +61,40 @@ abstract class VirtualDocument[T] extends ReadonlyVirtualDocument[T] { def getRange(from: Int, until: Int, columns: Option[Seq[String]] = None): Iterator[T] = throw new NotImplementedError("getRange method is not implemented") + /** + * Get an iterator over rows matching `filters` and `rowSearch`, ordered by `sorts`, + * sliced to `[from, until)` of the filtered/sorted view. + * + * Unlike `getRange`, the `from`/`until` indices here address the post-filter, + * post-sort sequence — not the underlying stored order. Implementations that don't + * support query pushdown should leave the default in place, which ignores + * filters/sorts/rowSearch and falls through to `getRange`. + * + * @param from index (inclusive) into the filtered, sorted view + * @param until index (exclusive) into the filtered, sorted view + * @param columns columns to project (None = all columns) + * @param filters row predicates ANDed together + * @param sorts sort keys applied in order + * @param rowSearch free-text substring; implementation defines how it expands across columns + */ + def getRangeWithQuery( + from: Int, + until: Int, + columns: Option[Seq[String]] = None, + filters: Seq[ColumnFilter] = Seq.empty, + sorts: Seq[SortSpec] = Seq.empty, + rowSearch: Option[String] = None + ): Iterator[T] = getRange(from, until, columns) + + /** + * Count rows that satisfy `filters` and `rowSearch`. Used by paginated UIs to + * size the scrollbar after a filter is applied. Default falls back to total count. + */ + def countWithQuery( + filters: Seq[ColumnFilter] = Seq.empty, + rowSearch: Option[String] = None + ): Long = getCount + /** * get an iterator of all items after the specified index `offset` * @param offset the starting index (exclusive) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala index e10152cdaeb..380493b7394 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala @@ -19,14 +19,16 @@ package org.apache.texera.amber.core.storage.result.iceberg +import org.apache.texera.amber.config.StorageConfig import org.apache.texera.amber.core.storage.IcebergCatalogInstance -import org.apache.texera.amber.core.storage.model.{BufferedItemWriter, VirtualDocument} +import org.apache.texera.amber.core.storage.model.{BufferedItemWriter, ColumnFilter, SortSpec, VirtualDocument} import org.apache.texera.amber.core.storage.util.StorageUtil.{withLock, withReadLock, withWriteLock} import org.apache.texera.amber.util.IcebergUtil import org.apache.commons.io.IOUtils import org.apache.iceberg.catalog.{Catalog, TableIdentifier} import org.apache.iceberg.data.Record import org.apache.iceberg.exceptions.NoSuchTableException +import org.apache.iceberg.expressions.Expression import org.apache.iceberg.types.{Conversions, Types} import org.apache.iceberg.{FileScanTask, Table} @@ -128,6 +130,187 @@ private[storage] class IcebergDocument[T >: Null <: AnyRef]( table.newScan().planFiles().iterator().asScala.map(f => f.file().recordCount()).sum } + /** + * Query-aware overload of getRange. Translates `filters` to Iceberg predicates for + * pushdown into the Parquet reader, then applies residual filters (`contains`, + * `endsWith`), `rowSearch`, and `sorts` in memory. The `from`/`until` slice is + * taken from the post-filter, post-sort view. + * + * Sort is capped by `storage.result.sort.max-rows`: when the filtered count + * exceeds the cap, results are returned unsorted in scan order. Callers can + * detect this by comparing `countWithQuery` to the cap. + */ + override def getRangeWithQuery( + from: Int, + until: Int, + columns: Option[Seq[String]] = None, + filters: Seq[ColumnFilter] = Seq.empty, + sorts: Seq[SortSpec] = Seq.empty, + rowSearch: Option[String] = None + ): Iterator[T] = { + if (filters.isEmpty && sorts.isEmpty && rowSearch.isEmpty) { + return getRange(from, until, columns) + } + + withReadLock(lock) { + val tableOpt = IcebergUtil.loadTableMetadata(catalog, tableNamespace, tableName) + if (tableOpt.isEmpty) return Iterator.empty + val table = tableOpt.get + table.refresh() + + val (pushdownExpr, residualFilters) = + IcebergPredicateBuilder.buildPushdownAndResidual(filters, table.schema()) + + // Sort and rowSearch may need columns not present in `columns` (e.g. sort by + // a hidden column, or rowSearch across all strings). Materialize each scan + // with the full schema so residual evaluation works; we re-project at the end. + val records = scanRecords(table, pushdownExpr) + val filtered = records + .filter(rec => matchesAll(rec, residualFilters, table.schema())) + .filter(rec => matchesRowSearch(rec, rowSearch, table.schema())) + + val ordered: Iterator[Record] = + if (sorts.isEmpty) filtered + else sortBoundedOrFallback(filtered, sorts, table.schema()) + + val sliced = ordered.slice(from, until) + + val projectionSchema = columns match { + case Some(cols) => tableSchema.select(cols.asJava) + case None => tableSchema + } + sliced.map(rec => deserde(projectionSchema, rec)) + } + } + + /** + * Count of rows matching `filters` and `rowSearch`. Used by paginated UIs to size + * the scrollbar after a filter is applied; returns `getCount` (file-stat sum) when + * no predicates are present, otherwise scans the filtered iterator. + */ + override def countWithQuery( + filters: Seq[ColumnFilter] = Seq.empty, + rowSearch: Option[String] = None + ): Long = { + if (filters.isEmpty && rowSearch.isEmpty) return getCount + + withReadLock(lock) { + val tableOpt = IcebergUtil.loadTableMetadata(catalog, tableNamespace, tableName) + if (tableOpt.isEmpty) return 0L + val table = tableOpt.get + table.refresh() + val (pushdownExpr, residualFilters) = + IcebergPredicateBuilder.buildPushdownAndResidual(filters, table.schema()) + val records = scanRecords(table, pushdownExpr) + records + .filter(rec => matchesAll(rec, residualFilters, table.schema())) + .count(rec => matchesRowSearch(rec, rowSearch, table.schema())).toLong + } + } + + /** + * Plan scan tasks under the given pushdown expression, sorted by file sequence + * number to keep the in-memory residual evaluator deterministic. + */ + private def scanRecords(table: Table, pushdownExpr: Option[Expression]): Iterator[Record] = { + val baseScan = table.newScan() + val scan = pushdownExpr.fold(baseScan)(baseScan.filter) + val fileTasks = scan.planFiles().iterator().asScala.toSeq.sortBy(_.file().fileSequenceNumber()) + fileTasks.iterator.flatMap { task => + IcebergUtil.readDataFileAsIterator(task.file(), tableSchema, table) + } + } + + private def matchesAll(record: Record, filters: Seq[ColumnFilter], schema: org.apache.iceberg.Schema): Boolean = { + filters.forall(f => matchesOne(record, f, schema)) + } + + private def matchesOne(record: Record, filter: ColumnFilter, schema: org.apache.iceberg.Schema): Boolean = { + val raw = record.getField(filter.columnName) + val needle = filter.value.getOrElse("") + filter.op match { + case "contains" => raw != null && raw.toString.contains(needle) + case "endsWith" => raw != null && raw.toString.endsWith(needle) + case _ => true // pushdown-handled ops never reach here + } + } + + /** + * rowSearch is a substring match across every string-typed column. We do it on + * the raw Record so we can answer based on the field type without deserializing + * the whole row first. + */ + private def matchesRowSearch( + record: Record, + rowSearch: Option[String], + schema: org.apache.iceberg.Schema + ): Boolean = { + rowSearch match { + case None | Some("") => true + case Some(needle) => + val n = needle.toLowerCase + schema.columns().asScala.exists { col => + val v = record.getField(col.name()) + v != null && v.toString.toLowerCase.contains(n) + } + } + } + + /** + * In-memory sort, capped at `storage.result.sort.max-rows`. When the filtered + * count exceeds the cap we return the iterator in scan order — the caller is + * expected to check `countWithQuery` against the cap and surface a UI warning. + */ + private def sortBoundedOrFallback( + records: Iterator[Record], + sorts: Seq[SortSpec], + schema: org.apache.iceberg.Schema + ): Iterator[Record] = { + val cap = StorageConfig.resultSortMaxRows + val buffer = mutable.ArrayBuffer.empty[Record] + val it = records + var overCap = false + while (it.hasNext && !overCap) { + val r = it.next() + buffer += r + if (buffer.size > cap) overCap = true + } + if (overCap) { + // Concatenate what we've buffered with the remainder, unsorted. + buffer.iterator ++ it + } else { + val ordering = sorts.foldRight(Ordering.by[Record, Int](_ => 0)) { (spec, base) => + val cmp = recordOrdering(spec.columnName, spec.direction) + new Ordering[Record] { + override def compare(a: Record, b: Record): Int = { + val r = cmp.compare(a, b) + if (r != 0) r else base.compare(a, b) + } + } + } + buffer.sorted(ordering).iterator + } + } + + private def recordOrdering(columnName: String, direction: String): Ordering[Record] = { + val base: Ordering[Record] = new Ordering[Record] { + override def compare(a: Record, b: Record): Int = { + val va = a.getField(columnName) + val vb = b.getField(columnName) + (va, vb) match { + case (null, null) => 0 + case (null, _) => -1 + case (_, null) => 1 + case (x: java.lang.Comparable[_], y) => + x.asInstanceOf[java.lang.Comparable[Any]].compareTo(y) + case (x, y) => + x.toString.compareTo(y.toString) + } + } + } + if (direction.equalsIgnoreCase("desc")) base.reverse else base + } + /** * Creates a BufferedItemWriter for writing data to the table. * diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergPredicateBuilder.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergPredicateBuilder.scala new file mode 100644 index 00000000000..a1a41fd9f1f --- /dev/null +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergPredicateBuilder.scala @@ -0,0 +1,160 @@ +/* + * 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.amber.core.storage.result.iceberg + +import org.apache.iceberg.Schema +import org.apache.iceberg.expressions.{Expression, Expressions} +import org.apache.iceberg.types.Type +import org.apache.iceberg.types.Types +import org.apache.texera.amber.core.storage.model.ColumnFilter + +/** + * Translates [[ColumnFilter]]s (string-typed-over-the-wire) into Iceberg [[Expression]]s + * suitable for predicate pushdown via `Scan.filter(...)`. + * + * Iceberg pushes the resulting predicates into the Parquet reader and prunes whole + * data files using min/max stats, so accurate type parsing here is the single biggest + * lever for read performance on filtered queries. Filters whose semantics Iceberg + * cannot express (`contains`, `endsWith`) are reported back to the caller via + * [[buildPushdownAndResidual]] so the caller can run them as an in-memory pass. + */ +object IcebergPredicateBuilder { + + /** Operators we can fully express as an Iceberg [[Expression]]. */ + private val PushdownOps: Set[String] = + Set("eq", "ne", "lt", "le", "gt", "ge", "startsWith", "isNull", "isNotNull", "in") + + /** Operators we evaluate after the scan because Iceberg has no native equivalent. */ + private val ResidualOps: Set[String] = Set("contains", "endsWith") + + case class ParseError(columnName: String, value: String, expectedType: String) extends RuntimeException( + s"Cannot interpret '$value' as $expectedType for column '$columnName'" + ) + + /** + * Split the filters into (pushdownExpression, residualFilters): + * - pushdownExpression: ANDed Iceberg predicate suitable for `Scan.filter` + * - residualFilters: filters that must be applied in memory over scan output + * + * Throws [[ParseError]] for malformed values so the caller can surface a typed + * error back to the UI rather than silently returning wrong rows. + */ + def buildPushdownAndResidual( + filters: Seq[ColumnFilter], + schema: Schema + ): (Option[Expression], Seq[ColumnFilter]) = { + if (filters.isEmpty) return (None, Seq.empty) + + val pushable = filters.filter(f => PushdownOps.contains(f.op)) + val residual = filters.filterNot(f => PushdownOps.contains(f.op)) + + residual.foreach { f => + if (!ResidualOps.contains(f.op)) { + throw new IllegalArgumentException(s"Unsupported filter op '${f.op}' on column '${f.columnName}'") + } + } + + val expression = pushable + .map(toExpression(_, schema)) + .reduceOption[Expression]((acc, e) => Expressions.and(acc, e)) + + (expression, residual) + } + + /** + * Convert a single pushdown-capable filter to an Iceberg [[Expression]]. + * Caller is responsible for filtering to PushdownOps first. + */ + def toExpression(filter: ColumnFilter, schema: Schema): Expression = { + val field = Option(schema.findField(filter.columnName)) + .getOrElse(throw new IllegalArgumentException(s"Unknown column: ${filter.columnName}")) + val icebergType = field.`type`() + + filter.op match { + case "isNull" => Expressions.isNull(filter.columnName) + case "isNotNull" => Expressions.notNull(filter.columnName) + case "in" => + val parsed = filter.values + .getOrElse( + throw new IllegalArgumentException(s"`in` filter requires `values` (column: ${filter.columnName})") + ) + .map(v => parseValue(filter.columnName, v, icebergType)) + Expressions.in(filter.columnName, parsed: _*) + case op => + val raw = filter.value.getOrElse( + throw new IllegalArgumentException(s"`$op` filter requires `value` (column: ${filter.columnName})") + ) + val parsed = parseValue(filter.columnName, raw, icebergType) + op match { + case "eq" => Expressions.equal(filter.columnName, parsed) + case "ne" => Expressions.notEqual(filter.columnName, parsed) + case "lt" => Expressions.lessThan(filter.columnName, parsed) + case "le" => Expressions.lessThanOrEqual(filter.columnName, parsed) + case "gt" => Expressions.greaterThan(filter.columnName, parsed) + case "ge" => Expressions.greaterThanOrEqual(filter.columnName, parsed) + case "startsWith" => Expressions.startsWith(filter.columnName, raw) + case _ => throw new IllegalArgumentException(s"Op `$op` is not pushdown-capable") + } + } + } + + /** + * Parse a string value into the JVM type Iceberg expects for the given column type. + * Throws [[ParseError]] when the value doesn't fit the type — letting the websocket + * layer translate that into a structured client error. + */ + def parseValue(columnName: String, raw: String, icebergType: Type): AnyRef = { + try { + icebergType match { + case _ if icebergType == Types.IntegerType.get() => Integer.valueOf(raw.trim) + case _ if icebergType == Types.LongType.get() => java.lang.Long.valueOf(raw.trim) + case _ if icebergType == Types.DoubleType.get() => java.lang.Double.valueOf(raw.trim) + case _ if icebergType == Types.FloatType.get() => java.lang.Float.valueOf(raw.trim) + case _ if icebergType == Types.BooleanType.get() => java.lang.Boolean.valueOf(raw.trim) + case _ if icebergType == Types.StringType.get() => raw + case _ if icebergType == Types.TimestampType.withoutZone() => parseTimestampMicros(raw) + case _ if icebergType == Types.TimestampType.withZone() => parseTimestampMicros(raw) + case _ if icebergType == Types.DateType.get() => Integer.valueOf(java.time.LocalDate.parse(raw.trim).toEpochDay.toInt) + case _ => raw + } + } catch { + case _: NumberFormatException | _: java.time.format.DateTimeParseException => + throw ParseError(columnName, raw, icebergType.toString) + } + } + + /** + * Iceberg stores TIMESTAMP as microseconds-since-epoch. Accept either a numeric + * micros value, an ISO-8601 instant, or a millis-since-epoch number (sniffed by + * magnitude). All three are common shapes for ag-grid's date filter values. + */ + private def parseTimestampMicros(raw: String): java.lang.Long = { + val trimmed = raw.trim + if (trimmed.forall(_.isDigit) || (trimmed.startsWith("-") && trimmed.drop(1).forall(_.isDigit))) { + val n = java.lang.Long.parseLong(trimmed) + // Numbers below year-3000 in millis (< ~3.3e13) come from JS Date.getTime(); + // anything larger we treat as already-micros. + if (math.abs(n) < 100000000000000L) java.lang.Long.valueOf(n * 1000L) else java.lang.Long.valueOf(n) + } else { + val instant = java.time.Instant.parse(trimmed) + java.lang.Long.valueOf(instant.getEpochSecond * 1000000L + instant.getNano / 1000L) + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 08b298260e3..e04da490196 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,8 @@ "@ngneat/until-destroy": "8.1.4", "@ngx-formly/core": "6.3.12", "@ngx-formly/ng-zorro-antd": "6.3.12", + "ag-grid-angular": "^33", + "ag-grid-community": "^33", "ai": "5.0.93", "ajv": "8.10.0", "concaveman": "2.0.0", diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.html b/frontend/src/app/workspace/component/result-panel/result-panel.component.html index d2c6a535493..cfca0afe42c 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.html @@ -22,18 +22,6 @@ nz-menu id="result-buttons" [ngClass]="{'shadow': !width}"> -
  • - -
  • -
  • + [class.hidden]="!width" + (nzResize)="onResize($event)">
      - + *ngIf="width">
    • + nz-tooltip="Minimize Result Panel"> @@ -86,9 +58,7 @@
      -

      +

      Result Panel{{operatorTitle ? ': ' + operatorTitle : ''}}

      No results available to display.
      + [nzDirections]="['top']"> diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.scss b/frontend/src/app/workspace/component/result-panel/result-panel.component.scss index 229857b4a44..ea8bb4bd8b8 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel.component.scss +++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.scss @@ -19,52 +19,52 @@ :host { display: block; - height: 100%; - width: 100%; position: fixed; + inset: 0; + pointer-events: none; z-index: 4; } #texera-workspace { position: relative; + pointer-events: none; + height: 100%; } +/** + * Result panel is docked along the bottom edge: full viewport width, height + * resizable from the top edge only. No drag-to-move. The host wrapper has + * pointer-events: none so the canvas behind the panel remains interactive + * everywhere the panel isn't drawn; the container re-enables them. + */ #result-container { - position: absolute; - top: -300px; + position: fixed; left: 0; - background: white; - border-radius: 5px; - box-shadow: - 0 3px 1px -2px #0003, - 0 2px 2px #00000024, - 0 1px 5px #0000001f; + right: 0; + bottom: 0; + pointer-events: auto; + background: #ffffff; + border-top: 1px solid #e8e8e8; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + overflow: hidden; + transition: height 120ms ease; } -// make modal body scrollable and modal size fixed -.modal-body { - min-height: calc(50vh); - overflow-y: auto !important; +#result-container.hidden { + height: 0 !important; + border-top: 0; + box-shadow: none; } -// change the internal color of pretty json of the result details - -$type-colors: ( - string: black, - number: #27837a, - boolean: #751866, - date: #10536d, - array: #999, - object: #999, - function: #999, - "null": #fff, - undefined: #fff, -); #content { - overflow-y: auto; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; padding-top: 38px; - width: inherit; - height: inherit; + width: 100%; } .result-content, @@ -73,20 +73,35 @@ $type-colors: ( height: 100%; } +:host ::ng-deep .result-content.ant-tabs { + flex: 1 1 auto; + min-height: 0; +} + +/** + * Floating "open" pill, shown when the dock is minimised. Sits in the + * bottom-left so the user can re-open the panel without hunting for a menu. + */ #result-buttons { - position: absolute; - bottom: 0; - left: 0; - z-index: 4; + position: fixed; + bottom: 8px; + left: 8px; + z-index: 5; display: flex; + pointer-events: auto; } +/** + * Minimise (−) button anchored to the panel's top-right. Floats above the + * title bar. + */ #panel-button { - position: fixed; + position: absolute; top: 0; right: 0; z-index: 4; display: flex; + pointer-events: auto; } .ant-menu-item { @@ -96,10 +111,6 @@ $type-colors: ( padding: 0 9px; } -#divider { - margin: 0; -} - .shadow { border-radius: 5px; box-shadow: @@ -109,11 +120,30 @@ $type-colors: ( } #title { - padding: 5px 9px; - border-bottom: 1px solid #e0e0e0; + padding: 6px 12px; + border-bottom: 1px solid #e8e8e8; position: absolute; top: 0; - background: white; - width: 100%; + left: 0; + right: 38px; + margin: 0; + background: #fafafa; + font-size: 13px; + font-weight: 600; + color: rgba(0, 0, 0, 0.85); z-index: 2; } + +/** + * Re-style the resize handle on the top edge so it reads as a horizontal grip. + */ +:host ::ng-deep .nz-resizable-handle-top { + height: 6px; + top: -3px; + cursor: ns-resize; + background: transparent; +} + +:host ::ng-deep .nz-resizable-handle-top:hover { + background: rgba(24, 144, 255, 0.2); +} diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.ts b/frontend/src/app/workspace/component/result-panel/result-panel.component.ts index 3260afb290d..dae927a0d4e 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel.component.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.ts @@ -17,16 +17,7 @@ * under the License. */ -import { - ChangeDetectorRef, - Component, - ElementRef, - HostListener, - OnDestroy, - OnInit, - Type, - ViewChild, -} from "@angular/core"; +import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit, Type } from "@angular/core"; import { merge } from "rxjs"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; @@ -43,25 +34,20 @@ import { ErrorFrameComponent } from "./error-frame/error-frame.component"; import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service"; import { NzResizeEvent, NzResizableDirective, NzResizeHandlesComponent } from "ng-zorro-antd/resizable"; import { VisualizationFrameContentComponent } from "../visualization-panel-content/visualization-frame-content.component"; -import { calculateTotalTranslate3d } from "../../../common/util/panel-dock"; -import { isDefined } from "../../../common/util/predicate"; -import { CdkDragEnd, CdkDrag, CdkDragHandle } from "@angular/cdk/drag-drop"; import { PanelService } from "../../service/panel/panel.service"; import { WorkflowCompilingService } from "../../service/compile-workflow/workflow-compiling.service"; import { CompilationState } from "../../types/workflow-compiling.interface"; import { WorkflowFatalError } from "../../types/workflow-websocket.interface"; -import { NzMenuDirective, NzMenuItemComponent, NzMenuDividerDirective } from "ng-zorro-antd/menu"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; import { NgClass, NgIf, NgFor, NgComponentOutlet, KeyValuePipe } from "@angular/common"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzIconDirective } from "ng-zorro-antd/icon"; -import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; -import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; -import { FormlyRepeatDndComponent } from "../../../common/formly/repeat-dnd/repeat-dnd.component"; -export const DEFAULT_WIDTH = 800; -export const DEFAULT_HEIGHT = 500; +export const DEFAULT_HEIGHT = 360; +/** Width is now a boolean-ish toggle: > 0 means the dock is open. */ +export const OPEN_FLAG = 1; /** * ResultPanelComponent is the bottom level area that displays the * execution result of a workflow after the execution finishes. @@ -79,32 +65,26 @@ export const DEFAULT_HEIGHT = 500; ɵNzTransitionPatchDirective, NzTooltipDirective, NzIconDirective, - NzMenuDividerDirective, - CdkDrag, NzResizableDirective, - NzSpaceCompactItemDirective, - NzButtonComponent, - CdkDragHandle, NzTabsComponent, NzTabComponent, NgFor, NgComponentOutlet, NzResizeHandlesComponent, - FormlyRepeatDndComponent, KeyValuePipe, ], }) export class ResultPanelComponent implements OnInit, OnDestroy { - @ViewChild("dynamicComponent") - componentOutlets!: ElementRef; frameComponentConfigs: Map; componentInputs: {} }> = new Map(); protected readonly window = window; - id = -1; - width = DEFAULT_WIDTH; + /** + * `width` is kept as a 0-or-OPEN_FLAG toggle for back-compat with template + * `*ngIf="width"` checks. The panel is full-width when open; height alone is + * the user-tunable dimension since this is a bottom dock. + */ + width = OPEN_FLAG; height = DEFAULT_HEIGHT; operatorTitle = ""; - dragPosition = { x: 0, y: 0 }; - returnPosition = { x: 0, y: 0 }; // the highlighted operator ID for display result table / visualization / breakpoint currentOperatorId?: string | undefined; @@ -128,20 +108,11 @@ export class ResultPanelComponent implements OnInit, OnDestroy { } ngOnInit(): void { - const style = localStorage.getItem("result-panel-style"); - if (style) document.getElementById("result-container")!.style.cssText = style; - const translates = document.getElementById("result-container")!.style.transform; - const [xOffset, yOffset, _] = calculateTotalTranslate3d(translates); - this.returnPosition = { x: -xOffset, y: -yOffset }; - this.updateReturnPosition(DEFAULT_HEIGHT, this.height); this.registerAutoRerenderResultPanel(); this.registerAutoOpenResultPanel(); this.handleResultPanelForVersionPreview(); this.panelService.closePanelStream.pipe(untilDestroyed(this)).subscribe(() => this.closePanel()); - this.panelService.resetPanelStream.pipe(untilDestroyed(this)).subscribe(() => { - this.resetPanelPosition(); - this.openPanel(); - }); + this.panelService.resetPanelStream.pipe(untilDestroyed(this)).subscribe(() => this.openPanel()); this.workflowActionService.resultPanelOpen$.pipe(untilDestroyed(this)).subscribe(open => { if (open) { this.openPanel(); @@ -153,13 +124,7 @@ export class ResultPanelComponent implements OnInit, OnDestroy { @HostListener("window:beforeunload") ngOnDestroy(): void { - localStorage.setItem("result-panel-width", String(this.width)); localStorage.setItem("result-panel-height", String(this.height)); - - const resultContainer = document.getElementById("result-container"); - if (resultContainer) { - localStorage.setItem("result-panel-style", resultContainer.style.cssText); - } } handleResultPanelForVersionPreview() { @@ -366,58 +331,19 @@ export class ResultPanelComponent implements OnInit, OnDestroy { } openPanel() { - this.height = DEFAULT_HEIGHT; - this.width = DEFAULT_WIDTH; + this.height = Number(localStorage.getItem("result-panel-height")) || DEFAULT_HEIGHT; + this.width = OPEN_FLAG; this.resizeService.changePanelSize(this.width, this.height); } closePanel() { - this.height = 32.5; this.width = 0; + this.resizeService.changePanelSize(this.width, this.height); } - resetPanelPosition() { - this.dragPosition = { x: this.returnPosition.x, y: this.returnPosition.y }; - } - - isPanelDocked() { - return this.returnPosition.x === this.dragPosition.x && this.returnPosition.y === this.dragPosition.y; - } - - handleStartDrag() { - let visualizationResult = this.componentOutlets.nativeElement.querySelector("#html-content"); - if (visualizationResult !== null) { - visualizationResult.style.zIndex = -1; - } - } - - handleEndDrag({ source }: CdkDragEnd) { - /** - * records the most recent panel location, updating dragPosition when dragging is over - */ - const { x, y } = source.getFreeDragPosition(); - this.dragPosition = { x: x, y: y }; - let visualizationResult = this.componentOutlets.nativeElement.querySelector("#html-content"); - if (visualizationResult !== null) { - visualizationResult.style.zIndex = 0; - } - } - - onResize({ width, height }: NzResizeEvent) { - cancelAnimationFrame(this.id); - this.updateReturnPosition(this.height, height); - this.id = requestAnimationFrame(() => { - this.width = width!; - this.height = height!; - this.resizeService.changePanelSize(this.width, this.height); - }); - } - - updateReturnPosition(prevHeight: number, newHeight: number | undefined) { - /** - * Updating returnPosition ensures that even if the panel gets resized,it can be docked correctly to the left-bottom corner of the canvas. - */ - if (!isDefined(newHeight)) return; - this.returnPosition = { x: this.returnPosition.x, y: this.returnPosition.y + prevHeight - newHeight }; + onResize({ height }: NzResizeEvent) { + if (height === undefined) return; + this.height = height; + this.resizeService.changePanelSize(this.width, this.height); } } diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-cell-renderer.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-cell-renderer.component.ts new file mode 100644 index 00000000000..02d4eb5bb42 --- /dev/null +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-cell-renderer.component.ts @@ -0,0 +1,139 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component } from "@angular/core"; +import { NgIf } from "@angular/common"; +import { ICellRendererAngularComp } from "ag-grid-angular"; +import { ICellRendererParams } from "ag-grid-community"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; + +/** + * Identifies a base64-encoded image data URL so the cell can render an inline + * thumbnail instead of the raw string. Kept inline to avoid a fan-out import + * for a one-line check. + */ +function isImageDataUrl(value: unknown): value is string { + return typeof value === "string" && /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(value); +} + +export interface ResultCellRendererParams extends ICellRendererParams { + onDownload: (rowIndex: number, columnName: string) => void; +} + +@Component({ + selector: "texera-result-cell-renderer", + template: ` +
      + + + {{ displayValue }} + + +
      + `, + styles: [ + ` + .cell-wrapper { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + padding-right: 28px; + } + .cell-content { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + } + .image-thumbnail { + display: block; + width: 32px; + height: 32px; + object-fit: cover; + } + .download-button { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.2s ease-in-out; + padding: 4px; + } + .download-button i { + font-size: 14px; + color: #1890ff; + } + :host:hover .download-button { + opacity: 0.7; + } + :host:hover .download-button:hover { + opacity: 1; + } + `, + ], + imports: [NgIf, NzButtonComponent, NzIconDirective], +}) +export class ResultCellRendererComponent implements ICellRendererAngularComp { + displayValue = ""; + isImage = false; + columnName = ""; + private rowIndex = 0; + private onDownload?: (rowIndex: number, columnName: string) => void; + + agInit(params: ResultCellRendererParams): void { + this.update(params); + } + + refresh(params: ResultCellRendererParams): boolean { + this.update(params); + return true; + } + + private update(params: ResultCellRendererParams): void { + const raw = params.value; + this.columnName = params.colDef?.field ?? ""; + this.rowIndex = params.node.rowIndex ?? 0; + this.onDownload = params.onDownload; + this.isImage = isImageDataUrl(raw); + this.displayValue = raw === null || raw === undefined ? "" : raw.toString(); + } + + onDownloadClick(event: Event): void { + event.stopPropagation(); + this.onDownload?.(this.rowIndex, this.columnName); + } +} diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-header.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-header.component.ts new file mode 100644 index 00000000000..e2a56fde497 --- /dev/null +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-header.component.ts @@ -0,0 +1,287 @@ +/** + * 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, ElementRef, ViewChild } from "@angular/core"; +import { NgIf } from "@angular/common"; +import { IHeaderAngularComp } from "ag-grid-angular"; +import { IHeaderParams } from "ag-grid-community"; +import { NzIconDirective } from "ng-zorro-antd/icon"; + +export type HeaderStats = { + min?: number; + max?: number; + not_null_count?: number; + firstCat?: string; + firstPercent?: number; + secondCat?: string; + secondPercent?: number; + other?: number; + reachedLimit?: number; +}; + +export interface ResultHeaderParams extends IHeaderParams { + stats?: HeaderStats; +} + +/** + * Custom ag-grid header that shows the column name on top and an inline stats + * block (Min / Max / Non-Null / Category %) below — restoring the per-column + * summary that existed in the old nz-table result pane. Clicking the title + * progresses sort just like the default header; the filter menu opens via the + * funnel icon on the right of the title row. + */ +@Component({ + selector: "texera-result-header", + template: ` +
      +
      + {{ displayName }} + + + + + + +
      +
      +
      + Min + {{ format(stats?.min) }} +
      +
      + Max + {{ format(stats?.max) }} +
      +
      + Non-Null + {{ formatInt(stats?.not_null_count) }} +
      +
      + + {{ stats?.firstCat }} + ~ + + {{ formatPercent(stats?.firstPercent) }} +
      +
      + + {{ stats?.secondCat }} + ~ + + {{ formatPercent(stats?.secondPercent) }} +
      +
      + Other + {{ formatPercent(stats?.other) }} +
      +
      +
      + `, + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + } + .texera-header { + display: flex; + flex-direction: column; + height: 100%; + padding: 4px 0; + } + .texera-header-title-row { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; + padding: 0 4px 2px; + } + .texera-header-title { + flex: 1 1 auto; + font-weight: 600; + font-size: 13px; + color: rgba(0, 0, 0, 0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .texera-header-sort i { + font-size: 10px; + color: #1890ff; + } + .texera-header-menu { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + cursor: pointer; + opacity: 0.6; + } + .texera-header-menu:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.04); + } + .texera-header-menu i { + font-size: 11px; + } + .texera-header-menu i.active { + color: #1890ff; + } + .texera-header-stats { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 4px 0; + font-size: 11px; + color: rgba(0, 0, 0, 0.55); + font-weight: 400; + line-height: 1.4; + } + .stat-row { + display: flex; + justify-content: space-between; + gap: 6px; + } + .stat-label { + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .stat-value { + font-variant-numeric: tabular-nums; + color: rgba(0, 0, 0, 0.75); + } + .stat-approx { + opacity: 0.6; + } + `, + ], + imports: [NgIf, NzIconDirective], +}) +export class ResultHeaderComponent implements IHeaderAngularComp { + @ViewChild("menuButton", { read: ElementRef }) menuButton?: ElementRef; + + displayName = ""; + stats: HeaderStats | null = null; + sort: "asc" | "desc" | null = null; + filterActive = false; + + private params!: ResultHeaderParams; + + agInit(params: ResultHeaderParams): void { + this.update(params); + params.column.addEventListener("sortChanged", () => this.refreshLocalState()); + params.column.addEventListener("filterChanged", () => this.refreshLocalState()); + } + + refresh(params: ResultHeaderParams): boolean { + this.update(params); + return true; + } + + private update(params: ResultHeaderParams): void { + this.params = params; + this.displayName = params.displayName; + this.stats = params.stats ?? null; + this.refreshLocalState(); + } + + private refreshLocalState(): void { + this.sort = (this.params.column.getSort() as "asc" | "desc" | null) ?? null; + this.filterActive = this.params.column.isFilterActive(); + } + + hasAnyStat(): boolean { + if (!this.stats) return false; + return ( + this.stats.min !== undefined || + this.stats.max !== undefined || + this.stats.not_null_count !== undefined || + this.stats.firstPercent !== undefined || + this.stats.secondPercent !== undefined || + this.stats.other !== undefined + ); + } + + onSortClick(event: MouseEvent): void { + if (!this.params.enableSorting) return; + this.params.progressSort(event.shiftKey); + } + + onMenuClick(event: MouseEvent): void { + event.stopPropagation(); + this.params.showColumnMenu(event.target as HTMLElement); + } + + format(value: number | undefined): string { + if (value === undefined || value === null) return ""; + if (typeof value !== "number") return String(value); + if (Number.isInteger(value)) return value.toString(); + return value.toFixed(2); + } + + formatInt(value: number | undefined): string { + if (value === undefined || value === null) return ""; + return Math.round(value).toLocaleString(); + } + + formatPercent(value: number | undefined): string { + if (value === undefined || value === null) return ""; + return `${value.toFixed(1)}%`; + } +} diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html index 5400d978ee3..4bd6f92e589 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html @@ -17,164 +17,137 @@ under the License. --> -
      -

      Empty result set

      -

      Tip: enable the eye icon on the operator and execute the workflow to view its results.

      -
      -
      -