This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 122f75ec51d [Feature] SSE Materialized View UI + Data Sources hub
(#18537)
122f75ec51d is described below
commit 122f75ec51da41a1ce53f25e141a4c2206a3e7f9
Author: Hongkun Xu <[email protected]>
AuthorDate: Wed May 20 16:14:14 2026 +0800
[Feature] SSE Materialized View UI + Data Sources hub (#18537)
* [feature] SSE Materialized View — controller UI (listing + details)
Adds a controller-side Materialized View management UI and the supporting
REST endpoints. Read-only details + drop only — full create/edit-from-UI is
deferred to a follow-up.
**Backend**
- `PinotMaterializedViewRestletResource`:
- `GET /materializedViews` — listing with name, base tables, watermark,
VALID / STALE / total partition counts, last refresh time, staleness SLO.
- `GET /materializedViews/{name}` — full definition (defined SQL, base
tables, split spec, partition expression maps, staleness SLO) plus runtime
(watermarkMs + per-bucket state map sorted by bucketStartMs).
- `DELETE /materializedViews/{name}` — delegates to
`PinotHelixResourceManager.deleteTable` so the same dependent-MV safety guards
apply.
- New `MATERIALIZED_VIEW_TAG` for swagger grouping.
**Frontend**
- New left-nav entry "Materialized Views" linking to `/materialized-views`.
- New listing page at `/materialized-views`: paginated table of all MVs
with key health columns.
- New details page at `/materialized-views/{name}`:
- Summary (name, base tables, watermark, staleness SLO, partition counts
by state).
- Defined SQL (CodeMirror, read-only).
- Full definition JSON (CodeMirror, read-only).
- Per-partition table (bucketStartMs as ISO timestamp, state,
segmentCount, crc, lastRefreshTime).
- "Refresh" + "Drop MV" actions; drop goes through a confirmation dialog.
- New `requests/index.ts` helpers: `getMaterializedViewList`,
`getMaterializedView`, `deleteMaterializedView`.
Existing controller wiring (PR 1) handles all the underlying ZK
reads/writes and the dependent-MV guard on `deleteTable`; this PR is the
operator-facing surface on top of it.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Hongkun Xu <[email protected]>
* Format definedSQL in materialized view details
Apply @sqltools/formatter (already used by Query.tsx) to the persisted
single-line definedSQL before rendering it in the read-only CodeMirror
viewer, so the SELECT/GROUP BY structure is visually scannable. The
formatter call is memoized on the SQL text and falls back to the raw
string on parser errors so the page never blanks on an unrecognized
dialect. Bumped the SQL box height from 220 to 280 to fit the now
multi-line layout.
Co-Authored-By: Hongkun Xu <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* Add Data Sources hub grouping Tables and Materialized Views
Introduce a new /data-sources page accessible from the left sidebar
that surfaces both queryable surfaces (Tables, Materialized Views) as
cards with live counts, making it the discoverable entry point for the
data-source types Pinot exposes. The standalone "Materialized Views"
sidebar entry is removed — MV is now reached via Data Sources —
while the existing "Tables" card on the Cluster Manager homepage is
left in place for backward compatibility.
Co-Authored-By: Hongkun Xu <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---------
Co-authored-by: Xiang Fu <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
.../pinot/controller/api/resources/Constants.java | 1 +
.../PinotMaterializedViewRestletResource.java | 328 +++++++++++++++++++++
.../main/resources/app/components/Breadcrumbs.tsx | 2 +
.../src/main/resources/app/components/Layout.tsx | 3 +
.../main/resources/app/pages/DataSourcesPage.tsx | 157 ++++++++++
.../app/pages/MaterializedViewDetails.tsx | 323 ++++++++++++++++++++
.../app/pages/MaterializedViewListingPage.tsx | 124 ++++++++
.../src/main/resources/app/requests/index.ts | 11 +
pinot-controller/src/main/resources/app/router.tsx | 6 +
.../src/main/resources/app/utils/Utils.tsx | 16 +
10 files changed, 971 insertions(+)
diff --git
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
index 77964ada533..e39bdf70d95 100644
---
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
+++
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
@@ -55,6 +55,7 @@ public class Constants {
public static final String APP_CONFIGS = "AppConfigs";
public static final String PERIODIC_TASK_TAG = "PeriodicTask";
public static final String UPSERT_RESOURCE_TAG = "Upsert";
+ public static final String MATERIALIZED_VIEW_TAG = "MaterializedView";
public static final String QUERY_WORKLOAD_TAG = "QueryWorkload";
public static final String RESET_OFFSET_FROM = "ResetOffsetFrom";
public static final String RESET_OFFSET_TO = "ResetOffsetTo";
diff --git
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotMaterializedViewRestletResource.java
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotMaterializedViewRestletResource.java
new file mode 100644
index 00000000000..0af4614afab
--- /dev/null
+++
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotMaterializedViewRestletResource.java
@@ -0,0 +1,328 @@
+/**
+ * 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.pinot.controller.api.resources;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.base.Preconditions;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiKeyAuthDefinition;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.SecurityDefinition;
+import io.swagger.annotations.SwaggerDefinition;
+import java.util.List;
+import java.util.Map;
+import javax.inject.Inject;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.helix.AccessOption;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.metadata.ZKMetadataProvider;
+import
org.apache.pinot.controller.api.exception.ControllerApplicationException;
+import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.core.auth.Actions;
+import org.apache.pinot.core.auth.Authorize;
+import org.apache.pinot.core.auth.TargetType;
+import
org.apache.pinot.materializedview.metadata.MaterializedViewDefinitionMetadata;
+import
org.apache.pinot.materializedview.metadata.MaterializedViewDefinitionMetadataUtils;
+import
org.apache.pinot.materializedview.metadata.MaterializedViewRuntimeMetadata;
+import
org.apache.pinot.materializedview.metadata.MaterializedViewRuntimeMetadataUtils;
+import org.apache.pinot.materializedview.metadata.PartitionInfo;
+import org.apache.pinot.materializedview.metadata.PartitionState;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static
org.apache.pinot.spi.utils.CommonConstants.SWAGGER_AUTHORIZATION_KEY;
+
+
+/// REST endpoints for the Materialized View UI: list, describe, drop.
+///
+/// Read endpoints hydrate from the ZK definition + runtime znodes that the
consistency manager
+/// and minion task executor maintain. The drop endpoint delegates to
+/// `PinotHelixResourceManager.deleteTable` so the same dependent-MV guards
and segment-cleanup
+/// paths apply.
+///
+/// Authorization model: the per-name endpoints (GET, DELETE) are scoped to
`TargetType.TABLE`
+/// with the path param as the resource identifier, so an operator that owns
the underlying MV
+/// table can manage it via these endpoints just as they would via
`/tables/{name}`. The listing
+/// endpoint stays cluster-scoped (mirrors `GET /tables`).
+@Api(tags = Constants.MATERIALIZED_VIEW_TAG, authorizations =
{@Authorization(value = SWAGGER_AUTHORIZATION_KEY)})
+@SwaggerDefinition(securityDefinition =
@SecurityDefinition(apiKeyAuthDefinitions = {
+ @ApiKeyAuthDefinition(name = HttpHeaders.AUTHORIZATION, in =
ApiKeyAuthDefinition.ApiKeyLocation.HEADER,
+ key = SWAGGER_AUTHORIZATION_KEY,
+ description = "The format of the key is ```\"Basic <token>\" or
\"Bearer <token>\"```")}))
+@Path("/")
+public class PinotMaterializedViewRestletResource {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(PinotMaterializedViewRestletResource.class);
+
+ @Inject
+ PinotHelixResourceManager _pinotHelixResourceManager;
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/materializedViews")
+ @Authorize(targetType = TargetType.CLUSTER, action =
Actions.Cluster.GET_TABLE)
+ @ApiOperation(value = "List materialized views",
+ notes = "Returns a summary entry for every MV definition znode in the
cluster: name, base tables, "
+ + "watermarkMs, number of VALID/STALE partitions, and last refresh
time.")
+ @ApiResponses(value = {@ApiResponse(code = 200, message = "Success")})
+ public String listMaterializedViews() {
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
+ String definitionParent =
ZKMetadataProvider.getPropertyStorePathForMaterializedViewDefinitionPrefix();
+ List<String> viewTableNames =
propertyStore.getChildNames(definitionParent, AccessOption.PERSISTENT);
+
+ ArrayNode mvs = JsonUtils.newArrayNode();
+ if (viewTableNames != null) {
+ for (String viewTableName : viewTableNames) {
+ try {
+ ObjectNode summary = buildSummary(viewTableName);
+ if (summary != null) {
+ mvs.add(summary);
+ }
+ } catch (Exception e) {
+ // One broken znode must not break the entire listing — surface it
as a placeholder
+ // entry that carries `error` so the UI can render it as a problem
row.
+ LOGGER.warn("Failed to summarize MV {}: {}", viewTableName,
e.getMessage());
+ ObjectNode err = JsonUtils.newObjectNode();
+ err.put("materializedViewTableName", viewTableName);
+ err.put("error", e.getMessage());
+ mvs.add(err);
+ }
+ }
+ }
+ ObjectNode result = JsonUtils.newObjectNode();
+ result.set("materializedViews", mvs);
+ return result.toString();
+ }
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/materializedViews/{materializedViewTableName}")
+ @Authorize(targetType = TargetType.TABLE, paramName =
"materializedViewTableName",
+ action = Actions.Table.GET_TABLE_CONFIG)
+ @ApiOperation(value = "Get materialized view definition + runtime",
+ notes = "Returns the full ZK definition (definedSQL, base tables, split
spec, partition expressions, "
+ + "stalenessThresholdMs) and runtime metadata (watermarkMs,
per-bucket state map).")
+ @ApiResponses(value = {
+ @ApiResponse(code = 200, message = "Success"),
+ @ApiResponse(code = 404, message = "MV not found")})
+ public String getMaterializedView(
+ @ApiParam(value = "MV table name (with type suffix, e.g.
airlineStatsMv_OFFLINE)", required = true)
+ @PathParam("materializedViewTableName") String viewTableName) {
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
+ MaterializedViewDefinitionMetadata definition =
+ MaterializedViewDefinitionMetadataUtils.fetch(propertyStore,
viewTableName);
+ if (definition == null) {
+ throw new ControllerApplicationException(LOGGER,
+ "Materialized view not found: " + viewTableName,
Response.Status.NOT_FOUND);
+ }
+ MaterializedViewRuntimeMetadata runtime =
MaterializedViewRuntimeMetadataUtils.fetch(propertyStore, viewTableName);
+
+ ObjectNode result = JsonUtils.newObjectNode();
+ // Pass the already-fetched runtime so the partition-summary aggregates
and the per-bucket
+ // table are computed from the SAME ZK snapshot. Two reads would risk
rendering
+ // VALID/STALE counts that disagree with the partition states on the same
page.
+ result.set("definition", buildDefinitionNode(definition, runtime));
+ result.set("runtime", buildRuntimeNode(runtime));
+ return result.toString();
+ }
+
+ @DELETE
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/materializedViews/{materializedViewTableName}")
+ @Authorize(targetType = TargetType.TABLE, paramName =
"materializedViewTableName",
+ action = Actions.Table.DELETE_TABLE)
+ @ApiOperation(value = "Drop a materialized view",
+ notes = "Drops both the underlying Pinot table (segments, table config,
schema) and the MV definition + "
+ + "runtime znodes. Equivalent to DELETE /tables/{name} followed by
ZK cleanup, but routes through the "
+ + "table-delete path so the same dependent-MV safety checks apply
(an MV-over-MV is rejected).")
+ @ApiResponses(value = {
+ @ApiResponse(code = 200, message = "Success"),
+ @ApiResponse(code = 404, message = "MV not found"),
+ @ApiResponse(code = 409, message = "MV cannot be dropped because other
MVs depend on it")})
+ public String deleteMaterializedView(
+ @ApiParam(value = "MV table name (with type suffix)", required = true)
+ @PathParam("materializedViewTableName") String viewTableName) {
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
+ MaterializedViewDefinitionMetadata definition =
+ MaterializedViewDefinitionMetadataUtils.fetch(propertyStore,
viewTableName);
+ if (definition == null) {
+ throw new ControllerApplicationException(LOGGER,
+ "Materialized view not found: " + viewTableName,
Response.Status.NOT_FOUND);
+ }
+ // Definition znode is keyed by the fully-qualified name
(`<rawName>_OFFLINE`), so a
+ // successful fetch above guarantees `viewTableName` carries a
recognizable type suffix.
+ // Any subsequent code-path bug that lets a raw name through must fail
loud, not silently
+ // default to OFFLINE.
+ TableType tableType =
TableNameBuilder.getTableTypeFromTableName(viewTableName);
+ Preconditions.checkState(tableType != null,
+ "MV definition fetch succeeded for unrecognized table name format:
%s", viewTableName);
+ try {
+ // Delegate to the regular table-delete path.
PinotHelixResourceManager.deleteTable already
+ // unregisters from the consistency manager and removes the MV
definition + runtime znodes
+ // via MaterializedViewDefinitionMetadataUtils.delete +
MaterializedViewRuntimeMetadataUtils.delete.
+ _pinotHelixResourceManager.deleteTable(viewTableName, tableType, null);
+ } catch (IllegalStateException e) {
+ // Thrown when a dependent MV blocks the delete.
+ throw new ControllerApplicationException(LOGGER, e.getMessage(),
Response.Status.CONFLICT, e);
+ } catch (WebApplicationException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER,
+ "Failed to drop materialized view " + viewTableName + ": " +
e.getMessage(),
+ Response.Status.INTERNAL_SERVER_ERROR, e);
+ }
+ ObjectNode result = JsonUtils.newObjectNode();
+ result.put("status", "Materialized view " + viewTableName + " dropped.");
+ return result.toString();
+ }
+
+ /// Builds the listing-row summary (small, suitable for the listing page
table).
+ private ObjectNode buildSummary(String viewTableName) {
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
+ MaterializedViewDefinitionMetadata definition =
+ MaterializedViewDefinitionMetadataUtils.fetch(propertyStore,
viewTableName);
+ if (definition == null) {
+ return null;
+ }
+ MaterializedViewRuntimeMetadata runtime =
MaterializedViewRuntimeMetadataUtils.fetch(propertyStore, viewTableName);
+
+ ObjectNode summary = JsonUtils.newObjectNode();
+ summary.put("materializedViewTableName",
definition.getMaterializedViewTableNameWithType());
+ summary.set("baseTables",
JsonUtils.objectToJsonNode(definition.getBaseTables()));
+ summary.put("stalenessThresholdMs", definition.getStalenessThresholdMs());
+
+ if (runtime == null) {
+ summary.put("watermarkMs", 0L);
+ summary.put("validPartitions", 0);
+ summary.put("stalePartitions", 0);
+ summary.put("totalPartitions", 0);
+ summary.putNull("lastRefreshTime");
+ return summary;
+ }
+ summary.put("watermarkMs", runtime.getWatermarkMs());
+ int valid = 0;
+ int stale = 0;
+ long latestRefresh = 0L;
+ for (PartitionInfo info : runtime.getPartitions().values()) {
+ if (info.getState() == PartitionState.VALID) {
+ valid++;
+ } else if (info.getState() == PartitionState.STALE) {
+ stale++;
+ }
+ if (info.getLastRefreshTime() > latestRefresh) {
+ latestRefresh = info.getLastRefreshTime();
+ }
+ }
+ summary.put("validPartitions", valid);
+ summary.put("stalePartitions", stale);
+ summary.put("totalPartitions", runtime.getPartitions().size());
+ if (latestRefresh > 0L) {
+ summary.put("lastRefreshTime", latestRefresh);
+ } else {
+ summary.putNull("lastRefreshTime");
+ }
+ return summary;
+ }
+
+ private ObjectNode buildDefinitionNode(MaterializedViewDefinitionMetadata
definition,
+ MaterializedViewRuntimeMetadata runtime) {
+ ObjectNode node = JsonUtils.newObjectNode();
+ node.put("materializedViewTableName",
definition.getMaterializedViewTableNameWithType());
+ node.put("definedSQL", definition.getDefinedSql());
+ node.set("baseTables",
JsonUtils.objectToJsonNode(definition.getBaseTables()));
+ node.set("partitionExprMaps",
JsonUtils.objectToJsonNode(definition.getPartitionExprMaps()));
+ node.put("stalenessThresholdMs", definition.getStalenessThresholdMs());
+ // Summary partition stats computed from the SAME runtime snapshot the
partition table
+ // below uses, so the two views can never disagree on a single page render.
+ appendPartitionSummary(node, runtime);
+ if (definition.getSplitSpec() != null) {
+ ObjectNode split = JsonUtils.newObjectNode();
+ split.put("sourceTimeColumn",
definition.getSplitSpec().getSourceTimeColumn());
+ split.put("sourceTimeFormat",
definition.getSplitSpec().getSourceTimeFormat());
+ split.put("materializedViewTimeColumn",
definition.getSplitSpec().getMaterializedViewTimeColumn());
+ split.put("bucketMs", definition.getSplitSpec().getBucketMs());
+ node.set("splitSpec", split);
+ } else {
+ node.putNull("splitSpec");
+ }
+ return node;
+ }
+
+ /// Counts VALID / STALE / total partitions from the supplied runtime
snapshot and attaches
+ /// them to the definition response. The details UI shows these in the
Summary block; without
+ /// this the page would have to scan the partition array three times
client-side.
+ private static void appendPartitionSummary(ObjectNode definitionNode,
MaterializedViewRuntimeMetadata runtime) {
+ int valid = 0;
+ int stale = 0;
+ int total = 0;
+ if (runtime != null) {
+ total = runtime.getPartitions().size();
+ for (PartitionInfo info : runtime.getPartitions().values()) {
+ if (info.getState() == PartitionState.VALID) {
+ valid++;
+ } else if (info.getState() == PartitionState.STALE) {
+ stale++;
+ }
+ }
+ }
+ definitionNode.put("validPartitions", valid);
+ definitionNode.put("stalePartitions", stale);
+ definitionNode.put("totalPartitions", total);
+ }
+
+ private ObjectNode buildRuntimeNode(MaterializedViewRuntimeMetadata runtime)
{
+ if (runtime == null) {
+ return JsonUtils.newObjectNode().put("absent", true);
+ }
+ ObjectNode node = JsonUtils.newObjectNode();
+ node.put("watermarkMs", runtime.getWatermarkMs());
+ ArrayNode partitions = JsonUtils.newArrayNode();
+ Map<Long, PartitionInfo> partitionMap = runtime.getPartitions();
+ // Emit partitions in ascending bucket-start order so the UI doesn't need
to sort.
+ partitionMap.entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .forEach(entry -> {
+ ObjectNode p = JsonUtils.newObjectNode();
+ p.put("bucketStartMs", entry.getKey());
+ p.put("state", entry.getValue().getState().name());
+ p.put("segmentCount",
entry.getValue().getFingerprint().getSegmentCount());
+ p.put("crc", entry.getValue().getFingerprint().getCrcChecksum());
+ p.put("lastRefreshTime", entry.getValue().getLastRefreshTime());
+ partitions.add(p);
+ });
+ node.set("partitions", partitions);
+ return node;
+ }
+}
diff --git a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
index 2316e551d10..7e67445b23c 100644
--- a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
+++ b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
@@ -57,8 +57,10 @@ const breadcrumbNameMap: { [key: string]: string } = {
'/servers': 'Servers',
'/minions': 'Minions',
'/minion-task-manager': 'Minion Task Manager',
+ '/data-sources': 'Data Sources',
'/tables': 'Tables',
'/logical-tables': 'Logical Tables',
+ '/materialized-views': 'Materialized Views',
'/query': 'Query Console',
'/cluster': 'Cluster Manager',
'/zookeeper': 'Zookeeper Browser',
diff --git a/pinot-controller/src/main/resources/app/components/Layout.tsx
b/pinot-controller/src/main/resources/app/components/Layout.tsx
index a2b213b3fb0..3c4c2c4d073 100644
--- a/pinot-controller/src/main/resources/app/components/Layout.tsx
+++ b/pinot-controller/src/main/resources/app/components/Layout.tsx
@@ -28,9 +28,12 @@ import ZookeeperIcon from './SvgIcons/ZookeeperIcon';
import MinionTaskIcon from './SvgIcons/MinionTaskIcon';
import app_state from '../app_state';
import AccountCircleOutlinedIcon from
'@material-ui/icons/AccountCircleOutlined';
+import StorageIcon from '@material-ui/icons/Storage';
const BASE_NAVIGATION_ITEMS = [
{ id: 1, name: 'Cluster Manager', link: '/', icon: <ClusterManagerIcon /> },
+ { id: 8, name: 'Data Sources', link: '/data-sources',
+ icon: <StorageIcon style={{ width: 24, height: 24, verticalAlign: 'sub' }}
/> },
{ id: 2, name: 'Query Console', link: '/query', icon: <QueryConsoleIcon /> },
{ id: 6, name: 'Minion Tasks', link: '/minion-task-manager', icon:
<MinionTaskIcon /> },
{ id: 4, name: 'Swagger REST API', link: 'help', target: '_blank', icon:
<SwaggerIcon /> }
diff --git a/pinot-controller/src/main/resources/app/pages/DataSourcesPage.tsx
b/pinot-controller/src/main/resources/app/pages/DataSourcesPage.tsx
new file mode 100644
index 00000000000..a848cfefb81
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/pages/DataSourcesPage.tsx
@@ -0,0 +1,157 @@
+/**
+ * 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 React, { useEffect, useState } from 'react';
+import { Grid, makeStyles, Paper } from '@material-ui/core';
+import { Link } from 'react-router-dom';
+import Skeleton from '@material-ui/lab/Skeleton';
+import PinotMethodUtils from '../utils/PinotMethodUtils';
+import { NotificationContext } from
'../components/Notification/NotificationContext';
+import { getMaterializedViewList } from '../requests';
+
+const useStyles = makeStyles(() => ({
+ gridContainer: {
+ padding: 20,
+ backgroundColor: 'white',
+ maxHeight: 'calc(100vh - 70px)',
+ overflowY: 'auto',
+ },
+ paper: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: '24px 0',
+ height: '100%',
+ color: '#4285f4',
+ borderRadius: 4,
+ marginBottom: 15,
+ textAlign: 'center',
+ backgroundColor: 'rgba(66, 133, 244, 0.1)',
+ borderColor: 'rgba(66, 133, 244, 0.5)',
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ '& h2, h4, p': { margin: 0 },
+ '& h4': {
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ fontWeight: 600,
+ },
+ '& p': {
+ marginTop: 10,
+ color: '#5f6b7a',
+ fontSize: 13,
+ maxWidth: 320,
+ },
+ '&:hover': { borderColor: '#4285f4' },
+ },
+ paperLinks: {
+ textDecoration: 'none',
+ height: '100%',
+ },
+}));
+
+/**
+ * Hub page that groups together the queryable surfaces ("data sources")
exposed by the
+ * cluster. Currently lists physical tables and materialized views; new card
entries can
+ * be added here as additional data-source types are introduced.
+ */
+const DataSourcesPage = () => {
+ const classes = useStyles();
+ const { dispatch } = React.useContext(NotificationContext);
+
+ const [tablesCount, setTablesCount] = useState<number | null>(null);
+ const [mvCount, setMvCount] = useState<number | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ PinotMethodUtils.getQueryTablesList({ bothType: true })
+ .then((res: any) => {
+ if (cancelled) {
+ return;
+ }
+ setTablesCount(res?.records?.length ?? 0);
+ })
+ .catch((e: any) => {
+ if (cancelled) {
+ return;
+ }
+ setTablesCount(0);
+ dispatch({
+ type: 'error',
+ message: `Failed to load tables count: ${e?.message || e}`,
+ show: true,
+ });
+ });
+
+ getMaterializedViewList()
+ .then((res) => {
+ if (cancelled) {
+ return;
+ }
+ const mvs = (res.data && res.data.materializedViews) || [];
+ setMvCount(mvs.length);
+ })
+ .catch((e: any) => {
+ if (cancelled) {
+ return;
+ }
+ setMvCount(0);
+ dispatch({
+ type: 'error',
+ message: `Failed to load materialized views count:
${e?.response?.data?.error || e?.message || e}`,
+ show: true,
+ });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [dispatch]);
+
+ const loading = <Skeleton animation="wave" width={50} />;
+
+ return (
+ <Grid item xs className={classes.gridContainer}>
+ <Grid container spacing={3}>
+ <Grid item xs={12} sm={6}>
+ <Link to="/tables" className={classes.paperLinks}>
+ <Paper className={classes.paper}>
+ <h4>Tables</h4>
+ <h2>{tablesCount === null ? loading : tablesCount}</h2>
+ <p>Physical real-time and offline tables that store the
underlying segments and serve queries directly.</p>
+ </Paper>
+ </Link>
+ </Grid>
+ <Grid item xs={12} sm={6}>
+ <Link to="/materialized-views" className={classes.paperLinks}>
+ <Paper className={classes.paper}>
+ <h4>Materialized Views</h4>
+ <h2>{mvCount === null ? loading : mvCount}</h2>
+ <p>Derived, pre-aggregated tables refreshed by minion tasks to
accelerate recurring queries.</p>
+ </Paper>
+ </Link>
+ </Grid>
+ </Grid>
+ </Grid>
+ );
+};
+
+export default DataSourcesPage;
diff --git
a/pinot-controller/src/main/resources/app/pages/MaterializedViewDetails.tsx
b/pinot-controller/src/main/resources/app/pages/MaterializedViewDetails.tsx
new file mode 100644
index 00000000000..b83842a26e1
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/pages/MaterializedViewDetails.tsx
@@ -0,0 +1,323 @@
+/**
+ * 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 React, { useEffect, useMemo, useState } from 'react';
+import { Grid, makeStyles } from '@material-ui/core';
+import { RouteComponentProps, useHistory } from 'react-router-dom';
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+import sqlFormatter from '@sqltools/formatter';
+import { TableData } from 'Models';
+import AppLoader from '../components/AppLoader';
+import CustomizedTables from '../components/Table';
+import SimpleAccordion from '../components/SimpleAccordion';
+import CustomButton from '../components/CustomButton';
+import Confirm from '../components/Confirm';
+import NotFound from '../components/NotFound';
+import { NotificationContext } from
'../components/Notification/NotificationContext';
+import Utils from '../utils/Utils';
+import { getMaterializedView, deleteMaterializedView } from '../requests';
+import 'codemirror/lib/codemirror.css';
+import 'codemirror/theme/material.css';
+import 'codemirror/mode/sql/sql';
+import 'codemirror/mode/javascript/javascript';
+
+const useStyles = makeStyles(() => ({
+ gridContainer: {
+ padding: 20,
+ backgroundColor: 'white',
+ maxHeight: 'calc(100vh - 70px)',
+ overflowY: 'auto',
+ },
+ block: {
+ border: '1px #BDCCD9 solid',
+ borderRadius: 4,
+ marginBottom: 20,
+ },
+ sqlOutput: {
+ border: '1px solid #BDCCD9',
+ '& .CodeMirror': { height: 280 },
+ },
+ jsonOutput: {
+ border: '1px solid #BDCCD9',
+ '& .CodeMirror': { height: 320 },
+ },
+ summary: {
+ padding: 12,
+ },
+ kv: {
+ display: 'grid',
+ gridTemplateColumns: '220px 1fr',
+ rowGap: 6,
+ columnGap: 16,
+ },
+ label: {
+ color: '#7f8a96',
+ fontWeight: 500,
+ },
+ value: {
+ fontFamily: 'monospace',
+ },
+ controls: {
+ marginBottom: 12,
+ display: 'flex',
+ gap: 12,
+ },
+}));
+
+const sqlOptions = {
+ lineNumbers: true,
+ mode: 'text/x-sql',
+ styleActiveLine: true,
+ theme: 'default',
+ readOnly: true,
+};
+
+const jsonOptions = {
+ lineNumbers: true,
+ mode: 'application/json',
+ styleActiveLine: true,
+ theme: 'default',
+ readOnly: true,
+};
+
+const PARTITION_COLUMNS = ['Bucket Start', 'State', 'Segment Count', 'CRC',
'Last Refresh'];
+
+interface MaterializedViewDefinition {
+ materializedViewTableName: string;
+ definedSQL: string;
+ baseTables: string[];
+ partitionExprMaps: Record<string, string>;
+ stalenessThresholdMs: number;
+ validPartitions: number;
+ stalePartitions: number;
+ totalPartitions: number;
+ splitSpec: {
+ sourceTimeColumn: string;
+ sourceTimeFormat: string;
+ materializedViewTimeColumn: string;
+ bucketMs: number;
+ } | null;
+}
+
+interface MaterializedViewRuntimePartition {
+ bucketStartMs: number;
+ state: string;
+ segmentCount: number;
+ crc: number;
+ lastRefreshTime: number;
+}
+
+interface MaterializedViewRuntime {
+ watermarkMs?: number;
+ partitions?: MaterializedViewRuntimePartition[];
+ absent?: boolean;
+}
+
+type Props = {
+ materializedViewTableName: string;
+};
+
+const MaterializedViewDetails = ({ match }: RouteComponentProps<Props>) => {
+ const { materializedViewTableName } = match.params;
+ const classes = useStyles();
+ const history = useHistory();
+ const { dispatch } = React.useContext(NotificationContext);
+
+ const [fetching, setFetching] = useState(true);
+ const [notFound, setNotFound] = useState(false);
+ const [definition, setDefinition] = useState<MaterializedViewDefinition |
null>(null);
+ const [runtime, setRuntime] = useState<MaterializedViewRuntime | null>(null);
+ const [confirmDialog, setConfirmDialog] = useState(false);
+ // Bumped by the Reload button to re-run the load effect; using a nonce
instead of a hand-
+ // mirrored fetch keeps the cancellation contract (the effect's cleanup
flips `cancelled`
+ // on unmount or dep-change) in one place.
+ const [reloadNonce, setReloadNonce] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ setFetching(true);
+ setNotFound(false);
+ getMaterializedView(materializedViewTableName)
+ .then((res) => {
+ // Guard: if the user navigated to a different MV or triggered another
reload while
+ // this request was in flight, discard the response so we never paint
stale data
+ // under a new URL / older snapshot.
+ if (cancelled) {
+ return;
+ }
+ setDefinition(res.data?.definition || null);
+ setRuntime(res.data?.runtime || null);
+ setFetching(false);
+ })
+ .catch((e: any) => {
+ if (cancelled) {
+ return;
+ }
+ if (e?.response?.status === 404) {
+ setNotFound(true);
+ } else {
+ dispatch({
+ type: 'error',
+ message: e?.response?.data?.error || e?.message || 'Failed to
load',
+ show: true,
+ });
+ }
+ setFetching(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [materializedViewTableName, reloadNonce, dispatch]);
+
+ const reload = () => setReloadNonce((n) => n + 1);
+
+ // Pretty-print the persisted single-line SQL for display. Memoized so the
formatter only runs
+ // when the definition actually changes, and so falls back to raw text if
formatting throws
+ // (e.g. on a query the parser doesn't recognize) — never block the page on
a formatter error.
+ const formattedSQL = useMemo(() => {
+ const raw = definition?.definedSQL || '';
+ if (!raw) {
+ return '';
+ }
+ try {
+ return sqlFormatter.format(raw);
+ } catch {
+ return raw;
+ }
+ }, [definition?.definedSQL]);
+
+ const buildPartitionRows = (): TableData => {
+ const partitions = runtime?.partitions ?? [];
+ const records = partitions.map((p) => [
+ Utils.formatEpochMillis(p.bucketStartMs),
+ p.state,
+ p.segmentCount,
+ p.crc,
+ Utils.formatEpochMillis(p.lastRefreshTime),
+ ]);
+ return { columns: PARTITION_COLUMNS, records };
+ };
+
+ const drop = async () => {
+ try {
+ await deleteMaterializedView(materializedViewTableName);
+ setConfirmDialog(false);
+ dispatch({ type: 'success', message: `Dropped
${materializedViewTableName}`, show: true });
+ // Navigate immediately rather than via setTimeout — the success toast
is rendered by
+ // the listing page just as well, and a delayed push leaks the timer if
the user
+ // navigates manually in the meantime.
+ history.push('/materialized-views');
+ } catch (e: any) {
+ const detail = e?.response?.data?.error || e?.message || 'Failed to
drop';
+ dispatch({ type: 'error', message: detail, show: true });
+ setConfirmDialog(false);
+ }
+ };
+
+ if (fetching && !definition) {
+ return <AppLoader />;
+ }
+ if (notFound) {
+ return <NotFound message={`Materialized view
"${materializedViewTableName}" was not found.`} />;
+ }
+
+ return (
+ <Grid item xs className={classes.gridContainer}>
+ <div className={classes.controls}>
+ <CustomButton onClick={reload}>Reload</CustomButton>
+ <CustomButton
+ isDisabled={false}
+ onClick={() => setConfirmDialog(true)}
+ tooltipTitle="Drop this materialized view and its segments"
+ enableTooltip={true}
+ >
+ Drop MV
+ </CustomButton>
+ </div>
+
+ <div className={classes.block}>
+ <SimpleAccordion headerTitle="Summary" showSearchBox={false}>
+ <div className={classes.summary}>
+ <div className={classes.kv}>
+ <span className={classes.label}>Name</span>
+ <span
className={classes.value}>{definition?.materializedViewTableName ||
materializedViewTableName}</span>
+ <span className={classes.label}>Base tables</span>
+ <span className={classes.value}>
+ {Array.isArray(definition?.baseTables) ?
definition!.baseTables.join(', ') : '—'}
+ </span>
+ <span className={classes.label}>Watermark</span>
+ <span
className={classes.value}>{Utils.formatEpochMillis(runtime?.watermarkMs)}</span>
+ <span className={classes.label}>Staleness SLO (ms)</span>
+ <span
className={classes.value}>{definition?.stalenessThresholdMs ?? 0}</span>
+ <span className={classes.label}>Partitions (total)</span>
+ <span className={classes.value}>{definition?.totalPartitions ??
0}</span>
+ <span className={classes.label}>Partitions (VALID)</span>
+ <span className={classes.value}>{definition?.validPartitions ??
0}</span>
+ <span className={classes.label}>Partitions (STALE)</span>
+ <span className={classes.value}>{definition?.stalePartitions ??
0}</span>
+ </div>
+ </div>
+ </SimpleAccordion>
+ </div>
+
+ <div className={classes.block}>
+ <SimpleAccordion headerTitle="Defined SQL" showSearchBox={false}>
+ <CodeMirror
+ options={sqlOptions}
+ value={formattedSQL}
+ className={classes.sqlOutput}
+ autoCursor={false}
+ />
+ </SimpleAccordion>
+ </div>
+
+ <div className={classes.block}>
+ <SimpleAccordion headerTitle="Definition + Partition Summary (JSON)"
showSearchBox={false}>
+ <CodeMirror
+ options={jsonOptions}
+ value={JSON.stringify(definition || {}, null, 2)}
+ className={classes.jsonOutput}
+ autoCursor={false}
+ />
+ </SimpleAccordion>
+ </div>
+
+ <div className={classes.block}>
+ <CustomizedTables
+ title="Partitions"
+ data={buildPartitionRows()}
+ showSearchBox={true}
+ inAccordionFormat={true}
+ />
+ </div>
+
+ <Confirm
+ openDialog={confirmDialog}
+ dialogTitle="Drop materialized view?"
+ dialogContent={`This will delete ${materializedViewTableName} and all
its segments. This action cannot be undone.`}
+ successCallback={drop}
+ closeDialog={() => setConfirmDialog(false)}
+ dialogYesLabel="Drop"
+ dialogNoLabel="Cancel"
+ />
+ </Grid>
+ );
+};
+
+export default MaterializedViewDetails;
diff --git
a/pinot-controller/src/main/resources/app/pages/MaterializedViewListingPage.tsx
b/pinot-controller/src/main/resources/app/pages/MaterializedViewListingPage.tsx
new file mode 100644
index 00000000000..fdfd9c49d77
--- /dev/null
+++
b/pinot-controller/src/main/resources/app/pages/MaterializedViewListingPage.tsx
@@ -0,0 +1,124 @@
+/**
+ * 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 React, { useEffect, useState } from 'react';
+import { Grid, makeStyles } from '@material-ui/core';
+import { TableData } from 'Models';
+import CustomizedTables from '../components/Table';
+import { NotificationContext } from
'../components/Notification/NotificationContext';
+import Utils from '../utils/Utils';
+import { getMaterializedViewList } from '../requests';
+
+const useStyles = makeStyles(() => ({
+ gridContainer: {
+ padding: 20,
+ backgroundColor: 'white',
+ maxHeight: 'calc(100vh - 70px)',
+ overflowY: 'auto',
+ },
+}));
+
+const COLUMNS = [
+ 'Materialized View',
+ 'Base Tables',
+ 'Watermark',
+ 'VALID',
+ 'STALE',
+ 'Total',
+ 'Last Refresh',
+ 'Staleness SLO (ms)',
+ 'Status',
+];
+
+const MaterializedViewListingPage = () => {
+ const classes = useStyles();
+ const { dispatch } = React.useContext(NotificationContext);
+ const [data, setData] = useState<TableData>({ columns: COLUMNS, records: []
});
+
+ useEffect(() => {
+ let mounted = true;
+ getMaterializedViewList()
+ .then((res) => {
+ if (!mounted) {
+ return;
+ }
+ const mvs = (res.data && res.data.materializedViews) || [];
+ const records = mvs.map((m: any) => {
+ // The server emits placeholder entries `{materializedViewTableName,
error}` for
+ // znodes it could not summarize (e.g. malformed metadata). Carry
the `error` message
+ // into a Status column so operators can spot the problem without
having to look at
+ // controller logs.
+ if (m.error) {
+ return [
+ m.materializedViewTableName || '',
+ '—',
+ '—',
+ '—',
+ '—',
+ '—',
+ '—',
+ '—',
+ `ERROR: ${m.error}`,
+ ];
+ }
+ return [
+ m.materializedViewTableName || '',
+ Array.isArray(m.baseTables) ? m.baseTables.join(', ') : '',
+ Utils.formatEpochMillis(m.watermarkMs),
+ m.validPartitions ?? 0,
+ m.stalePartitions ?? 0,
+ m.totalPartitions ?? 0,
+ Utils.formatEpochMillis(m.lastRefreshTime),
+ m.stalenessThresholdMs ?? 0,
+ 'OK',
+ ];
+ });
+ setData({ columns: COLUMNS, records });
+ })
+ .catch((err) => {
+ if (!mounted) {
+ return;
+ }
+ // Surface the failure rather than rendering an empty table (which
would look identical
+ // to "no MVs configured"). Operators using the UI to diagnose a
problem need to be able
+ // to tell those two states apart.
+ const detail = err?.response?.data?.error || err?.message ||
String(err);
+ dispatch({ type: 'error', message: `Failed to load materialized views:
${detail}`, show: true });
+ setData({ columns: COLUMNS, records: [] });
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ return (
+ <Grid item xs className={classes.gridContainer}>
+ <CustomizedTables
+ title="Materialized Views"
+ data={data}
+ addLinks
+ baseURL="/materialized-views/"
+ showSearchBox={true}
+ inAccordionFormat={true}
+ />
+ </Grid>
+ );
+};
+
+export default MaterializedViewListingPage;
diff --git a/pinot-controller/src/main/resources/app/requests/index.ts
b/pinot-controller/src/main/resources/app/requests/index.ts
index 27152f2e476..9dd1dfaa736 100644
--- a/pinot-controller/src/main/resources/app/requests/index.ts
+++ b/pinot-controller/src/main/resources/app/requests/index.ts
@@ -418,3 +418,14 @@ export const requestDeleteUser = (userObject: UserObject):
Promise<AxiosResponse
export const requestUpdateUser = (userObject: UserObject, passwordChanged:
boolean): Promise<AxiosResponse<any>> =>
baseApi.put(`/users/${userObject.username}?component=${userObject.component}&passwordChanged=${passwordChanged}`,
JSON.stringify(userObject), {headers});
+
+// Materialized Views
+
+export const getMaterializedViewList = (): Promise<AxiosResponse<any>> =>
+ baseApi.get('/materializedViews');
+
+export const getMaterializedView = (viewTableName: string):
Promise<AxiosResponse<any>> =>
+ baseApi.get(`/materializedViews/${viewTableName}`);
+
+export const deleteMaterializedView = (viewTableName: string):
Promise<AxiosResponse<any>> =>
+ baseApi.delete(`/materializedViews/${viewTableName}`, {headers});
diff --git a/pinot-controller/src/main/resources/app/router.tsx
b/pinot-controller/src/main/resources/app/router.tsx
index 4ec3814f0f5..a5d8e545229 100644
--- a/pinot-controller/src/main/resources/app/router.tsx
+++ b/pinot-controller/src/main/resources/app/router.tsx
@@ -37,10 +37,14 @@ import SchemaPageDetails from './pages/SchemaPageDetails';
import LoginPage from './pages/LoginPage';
import UserPage from "./pages/UserPage";
import LogicalTableDetails from './pages/LogicalTableDetails';
+import MaterializedViewListingPage from './pages/MaterializedViewListingPage';
+import MaterializedViewDetails from './pages/MaterializedViewDetails';
+import DataSourcesPage from './pages/DataSourcesPage';
export default [
// TODO: make async
{ path: '/', Component: HomePage },
+ { path: '/data-sources', Component: DataSourcesPage },
{ path: '/query/timeseries', Component: TimeseriesQueryPageWrapper },
{ path: '/query', Component: QueryPage },
{ path: '/tenants', Component: TenantsListingPage },
@@ -50,6 +54,8 @@ export default [
{ path: '/minions', Component: InstanceListingPage },
{ path: '/tables', Component: TablesListingPage },
{ path: '/logical-tables/:logicalTableName', Component: LogicalTableDetails
},
+ { path: '/materialized-views', Component: MaterializedViewListingPage },
+ { path: '/materialized-views/:materializedViewTableName', Component:
MaterializedViewDetails },
{ path: '/minion-task-manager', Component: MinionTaskManager },
{ path: '/task-queue/:taskType', Component: TaskQueue },
{ path: '/task-queue/:taskType/tables/:queueTableName', Component:
TaskQueueTable },
diff --git a/pinot-controller/src/main/resources/app/utils/Utils.tsx
b/pinot-controller/src/main/resources/app/utils/Utils.tsx
index ee0d6ecae36..49dcdb9e6e6 100644
--- a/pinot-controller/src/main/resources/app/utils/Utils.tsx
+++ b/pinot-controller/src/main/resources/app/utils/Utils.tsx
@@ -481,6 +481,21 @@ const formatTime = (time: number, format?: string): string
=> {
return formatTimeInTimezone(time, format);
}
+// Formats an epoch-millis value as an ISO-8601 string. Returns "—" for null /
zero /
+// negative inputs (which Pinot's MV runtime metadata uses as the "never set"
sentinel for
+// lastRefreshTime and similar fields). Safe to call from any UI component
that surfaces
+// optional timestamps.
+const formatEpochMillis = (ms: number | null | undefined): string => {
+ if (!ms || ms <= 0) {
+ return '—';
+ }
+ try {
+ return new Date(ms).toISOString();
+ } catch {
+ return String(ms);
+ }
+}
+
export default {
sortArray,
tableFormat,
@@ -497,5 +512,6 @@ export default {
pinotTableDetailsFromArray,
getLoadingTableData,
formatTime,
+ formatEpochMillis,
getRebalanceConfigValue
};
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]