From 20517b43f08623770031e8f9b581612474af9eb0 Mon Sep 17 00:00:00 2001 From: cgivre Date: Thu, 5 Feb 2026 22:17:59 -0500 Subject: [PATCH 01/54] Intial commit for phase 4: Dashboards --- exec/java-exec/pom.xml | 62 + .../exec/server/rest/DashboardResources.java | 547 ++ .../exec/server/rest/DrillRestServer.java | 6 +- .../exec/server/rest/MetadataResources.java | 720 ++ .../exec/server/rest/SavedQueryResources.java | 492 ++ .../exec/server/rest/SqlLabSpaServlet.java | 126 + .../server/rest/VisualizationResources.java | 615 ++ .../drill/exec/server/rest/WebServer.java | 9 +- .../src/main/resources/rest/generic.ftl | 1 + .../src/main/resources/webapp/.gitignore | 31 + .../src/main/resources/webapp/.npmrc | 2 + .../src/main/resources/webapp/index.html | 31 + .../main/resources/webapp/package-lock.json | 6866 +++++++++++++++++ .../src/main/resources/webapp/package.json | 48 + .../src/main/resources/webapp/src/App.tsx | 47 + .../main/resources/webapp/src/api/client.ts | 94 + .../resources/webapp/src/api/dashboards.ts | 64 + .../main/resources/webapp/src/api/metadata.ts | 157 + .../main/resources/webapp/src/api/queries.ts | 69 + .../resources/webapp/src/api/savedQueries.ts | 64 + .../webapp/src/api/visualizations.ts | 64 + .../webapp/src/components/common/Navbar.tsx | 111 + .../dashboard/DashboardPanelCard.tsx | 155 + .../webapp/src/components/dashboard/index.ts | 18 + .../components/query-editor/QueryToolbar.tsx | 276 + .../query-editor/SaveQueryDialog.tsx | 133 + .../src/components/query-editor/SqlEditor.tsx | 209 + .../src/components/results/ResultsGrid.tsx | 258 + .../schema-explorer/SchemaExplorer.tsx | 579 ++ .../components/visualization/ChartPreview.tsx | 361 + .../visualization/ChartTypeSelector.tsx | 169 + .../components/visualization/ColumnMapper.tsx | 194 + .../visualization/VisualizationBuilder.tsx | 303 + .../src/components/visualization/index.ts | 21 + .../resources/webapp/src/hooks/useMetadata.ts | 85 + .../resources/webapp/src/hooks/useQuery.ts | 104 + .../src/main/resources/webapp/src/index.css | 331 + .../src/main/resources/webapp/src/main.tsx | 58 + .../webapp/src/pages/DashboardViewPage.tsx | 346 + .../webapp/src/pages/DashboardsPage.tsx | 428 + .../webapp/src/pages/SavedQueriesPage.tsx | 420 + .../resources/webapp/src/pages/SqlLabPage.tsx | 372 + .../webapp/src/pages/VisualizationsPage.tsx | 584 ++ .../main/resources/webapp/src/store/index.ts | 38 + .../resources/webapp/src/store/querySlice.ts | 169 + .../resources/webapp/src/store/uiSlice.ts | 70 + .../main/resources/webapp/src/types/index.ts | 199 + .../src/main/resources/webapp/tsconfig.json | 25 + .../main/resources/webapp/tsconfig.node.json | 10 + .../src/main/resources/webapp/vite.config.ts | 55 + .../server/rest/TestDashboardResources.java | 396 + .../server/rest/TestMetadataResources.java | 233 + .../server/rest/TestSavedQueryResources.java | 267 + .../rest/TestVisualizationResources.java | 276 + exec/jdbc-all/pom.xml | 1 + 55 files changed, 17367 insertions(+), 2 deletions(-) create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DashboardResources.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/MetadataResources.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SavedQueryResources.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SqlLabSpaServlet.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/VisualizationResources.java create mode 100644 exec/java-exec/src/main/resources/webapp/.gitignore create mode 100644 exec/java-exec/src/main/resources/webapp/.npmrc create mode 100644 exec/java-exec/src/main/resources/webapp/index.html create mode 100644 exec/java-exec/src/main/resources/webapp/package-lock.json create mode 100644 exec/java-exec/src/main/resources/webapp/package.json create mode 100644 exec/java-exec/src/main/resources/webapp/src/App.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/client.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/dashboards.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/metadata.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/queries.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/savedQueries.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/api/visualizations.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/common/Navbar.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/dashboard/DashboardPanelCard.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/dashboard/index.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/query-editor/QueryToolbar.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/query-editor/SaveQueryDialog.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/query-editor/SqlEditor.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/results/ResultsGrid.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/schema-explorer/SchemaExplorer.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/visualization/ChartPreview.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/visualization/ChartTypeSelector.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/visualization/ColumnMapper.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/visualization/VisualizationBuilder.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/components/visualization/index.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/hooks/useMetadata.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/hooks/useQuery.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/index.css create mode 100644 exec/java-exec/src/main/resources/webapp/src/main.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/pages/DashboardViewPage.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/pages/DashboardsPage.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/pages/SavedQueriesPage.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/pages/SqlLabPage.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/pages/VisualizationsPage.tsx create mode 100644 exec/java-exec/src/main/resources/webapp/src/store/index.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/store/querySlice.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/store/uiSlice.ts create mode 100644 exec/java-exec/src/main/resources/webapp/src/types/index.ts create mode 100644 exec/java-exec/src/main/resources/webapp/tsconfig.json create mode 100644 exec/java-exec/src/main/resources/webapp/tsconfig.node.json create mode 100644 exec/java-exec/src/main/resources/webapp/vite.config.ts create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/server/rest/TestDashboardResources.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/server/rest/TestMetadataResources.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/server/rest/TestSavedQueryResources.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/server/rest/TestVisualizationResources.java diff --git a/exec/java-exec/pom.xml b/exec/java-exec/pom.xml index fe2c229a9a0..723938da3b1 100644 --- a/exec/java-exec/pom.xml +++ b/exec/java-exec/pom.xml @@ -849,6 +849,54 @@ org.apache.maven.plugins maven-jar-plugin + + + + com.github.eirslett + frontend-maven-plugin + 1.15.0 + + ${project.basedir}/src/main/resources/webapp + ${project.build.directory} + + + + + install-node-and-npm + generate-resources + + install-node-and-npm + + + v20.10.0 + 10.2.3 + + + + + npm-install + generate-resources + + npm + + + ci --legacy-peer-deps + + + + + npm-build + generate-resources + + npm + + + run build + + + + + maven-resources-plugin @@ -866,6 +914,20 @@ + + copy-webapp-dist + process-resources + copy-resources + + ${project.build.outputDirectory}/webapp/dist + + + ${project.basedir}/src/main/resources/webapp/dist + false + + + + diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DashboardResources.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DashboardResources.java new file mode 100644 index 00000000000..0bcece35c13 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DashboardResources.java @@ -0,0 +1,547 @@ +/* + * 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.drill.exec.server.rest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.drill.common.exceptions.DrillRuntimeException; +import org.apache.drill.exec.exception.StoreException; +import org.apache.drill.exec.server.rest.auth.DrillUserPrincipal; +import org.apache.drill.exec.store.sys.PersistentStore; +import org.apache.drill.exec.store.sys.PersistentStoreConfig; +import org.apache.drill.exec.store.sys.PersistentStoreProvider; +import org.apache.drill.exec.work.WorkManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * REST API for managing dashboards. + * Dashboards combine multiple visualizations into interactive drag-and-drop layouts. + */ +@Path("/api/v1/dashboards") +@Tag(name = "Dashboards", description = "APIs for managing interactive dashboards") +@RolesAllowed(DrillUserPrincipal.AUTHENTICATED_ROLE) +public class DashboardResources { + private static final Logger logger = LoggerFactory.getLogger(DashboardResources.class); + private static final String STORE_NAME = "drill.sqllab.dashboards"; + + @Inject + WorkManager workManager; + + @Inject + DrillUserPrincipal principal; + + @Inject + PersistentStoreProvider storeProvider; + + private static volatile PersistentStore cachedStore; + + // ==================== Model Classes ==================== + + /** + * A panel within a dashboard, referencing a visualization and its layout position. + */ + public static class DashboardPanel { + @JsonProperty + private String id; + @JsonProperty + private String visualizationId; + @JsonProperty + private int x; + @JsonProperty + private int y; + @JsonProperty + private int width; + @JsonProperty + private int height; + + public DashboardPanel() { + } + + @JsonCreator + public DashboardPanel( + @JsonProperty("id") String id, + @JsonProperty("visualizationId") String visualizationId, + @JsonProperty("x") int x, + @JsonProperty("y") int y, + @JsonProperty("width") int width, + @JsonProperty("height") int height) { + this.id = id; + this.visualizationId = visualizationId; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public String getId() { + return id; + } + + public String getVisualizationId() { + return visualizationId; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public void setId(String id) { + this.id = id; + } + + public void setVisualizationId(String visualizationId) { + this.visualizationId = visualizationId; + } + + public void setX(int x) { + this.x = x; + } + + public void setY(int y) { + this.y = y; + } + + public void setWidth(int width) { + this.width = width; + } + + public void setHeight(int height) { + this.height = height; + } + } + + /** + * Dashboard model for persistence. + */ + public static class Dashboard { + @JsonProperty + private String id; + @JsonProperty + private String name; + @JsonProperty + private String description; + @JsonProperty + private List panels; + @JsonProperty + private String owner; + @JsonProperty + private long createdAt; + @JsonProperty + private long updatedAt; + @JsonProperty + private int refreshInterval; + @JsonProperty + private boolean isPublic; + + public Dashboard() { + } + + @JsonCreator + public Dashboard( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("panels") List panels, + @JsonProperty("owner") String owner, + @JsonProperty("createdAt") long createdAt, + @JsonProperty("updatedAt") long updatedAt, + @JsonProperty("refreshInterval") int refreshInterval, + @JsonProperty("isPublic") boolean isPublic) { + this.id = id; + this.name = name; + this.description = description; + this.panels = panels; + this.owner = owner; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.refreshInterval = refreshInterval; + this.isPublic = isPublic; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public List getPanels() { + return panels; + } + + public String getOwner() { + return owner; + } + + public long getCreatedAt() { + return createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public int getRefreshInterval() { + return refreshInterval; + } + + public boolean isPublic() { + return isPublic; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setPanels(List panels) { + this.panels = panels; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public void setRefreshInterval(int refreshInterval) { + this.refreshInterval = refreshInterval; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + } + + /** + * Request body for creating a new dashboard. + */ + public static class CreateDashboardRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public List panels; + @JsonProperty + public int refreshInterval; + @JsonProperty + public boolean isPublic; + } + + /** + * Request body for updating a dashboard. + */ + public static class UpdateDashboardRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public List panels; + @JsonProperty + public Integer refreshInterval; + @JsonProperty + public Boolean isPublic; + } + + /** + * Response containing a list of dashboards. + */ + public static class DashboardsResponse { + @JsonProperty + public List dashboards; + + public DashboardsResponse(List dashboards) { + this.dashboards = dashboards; + } + } + + /** + * Simple message response. + */ + public static class MessageResponse { + @JsonProperty + public String message; + + public MessageResponse(String message) { + this.message = message; + } + } + + // ==================== API Endpoints ==================== + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List dashboards", description = "Returns all dashboards accessible by the current user") + public DashboardsResponse listDashboards() { + logger.debug("Listing dashboards for user: {}", getCurrentUser()); + + List dashboards = new ArrayList<>(); + String currentUser = getCurrentUser(); + + try { + PersistentStore store = getStore(); + Iterator> iterator = store.getAll(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Dashboard dashboard = entry.getValue(); + + // Return dashboards owned by user or public dashboards + if (dashboard.getOwner().equals(currentUser) || dashboard.isPublic()) { + dashboards.add(dashboard); + } + } + } catch (Exception e) { + logger.error("Error listing dashboards", e); + throw new DrillRuntimeException("Failed to list dashboards: " + e.getMessage(), e); + } + + return new DashboardsResponse(dashboards); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create dashboard", description = "Creates a new dashboard") + public Response createDashboard(CreateDashboardRequest request) { + logger.debug("Creating dashboard: {}", request.name); + + if (request.name == null || request.name.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new MessageResponse("Dashboard name is required")) + .build(); + } + + String id = UUID.randomUUID().toString(); + long now = Instant.now().toEpochMilli(); + + Dashboard dashboard = new Dashboard( + id, + request.name.trim(), + request.description, + request.panels != null ? request.panels : new ArrayList<>(), + getCurrentUser(), + now, + now, + request.refreshInterval, + request.isPublic + ); + + try { + PersistentStore store = getStore(); + store.put(id, dashboard); + } catch (Exception e) { + logger.error("Error creating dashboard", e); + throw new DrillRuntimeException("Failed to create dashboard: " + e.getMessage(), e); + } + + return Response.status(Response.Status.CREATED).entity(dashboard).build(); + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get dashboard", description = "Returns a dashboard by ID") + public Response getDashboard( + @Parameter(description = "Dashboard ID") @PathParam("id") String id) { + logger.debug("Getting dashboard: {}", id); + + try { + PersistentStore store = getStore(); + Dashboard dashboard = store.get(id); + + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Dashboard not found")) + .build(); + } + + // Check access permissions + if (!dashboard.getOwner().equals(getCurrentUser()) && !dashboard.isPublic()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Access denied")) + .build(); + } + + return Response.ok(dashboard).build(); + } catch (Exception e) { + logger.error("Error getting dashboard", e); + throw new DrillRuntimeException("Failed to get dashboard: " + e.getMessage(), e); + } + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update dashboard", description = "Updates an existing dashboard") + public Response updateDashboard( + @Parameter(description = "Dashboard ID") @PathParam("id") String id, + UpdateDashboardRequest request) { + logger.debug("Updating dashboard: {}", id); + + try { + PersistentStore store = getStore(); + Dashboard dashboard = store.get(id); + + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Dashboard not found")) + .build(); + } + + // Only owner can update + if (!dashboard.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can update this dashboard")) + .build(); + } + + // Update fields if provided + if (request.name != null) { + dashboard.setName(request.name.trim()); + } + if (request.description != null) { + dashboard.setDescription(request.description); + } + if (request.panels != null) { + dashboard.setPanels(request.panels); + } + if (request.refreshInterval != null) { + dashboard.setRefreshInterval(request.refreshInterval); + } + if (request.isPublic != null) { + dashboard.setPublic(request.isPublic); + } + + dashboard.setUpdatedAt(Instant.now().toEpochMilli()); + + store.put(id, dashboard); + + return Response.ok(dashboard).build(); + } catch (Exception e) { + logger.error("Error updating dashboard", e); + throw new DrillRuntimeException("Failed to update dashboard: " + e.getMessage(), e); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete dashboard", description = "Deletes a dashboard") + public Response deleteDashboard( + @Parameter(description = "Dashboard ID") @PathParam("id") String id) { + logger.debug("Deleting dashboard: {}", id); + + try { + PersistentStore store = getStore(); + Dashboard dashboard = store.get(id); + + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Dashboard not found")) + .build(); + } + + // Only owner can delete + if (!dashboard.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can delete this dashboard")) + .build(); + } + + store.delete(id); + + return Response.ok(new MessageResponse("Dashboard deleted successfully")).build(); + } catch (Exception e) { + logger.error("Error deleting dashboard", e); + throw new DrillRuntimeException("Failed to delete dashboard: " + e.getMessage(), e); + } + } + + // ==================== Helper Methods ==================== + + private PersistentStore getStore() { + if (cachedStore == null) { + synchronized (DashboardResources.class) { + if (cachedStore == null) { + try { + cachedStore = storeProvider.getOrCreateStore( + PersistentStoreConfig.newJacksonBuilder( + workManager.getContext().getLpPersistence().getMapper(), + Dashboard.class + ) + .name(STORE_NAME) + .build() + ); + } catch (StoreException e) { + throw new DrillRuntimeException("Failed to access dashboards store", e); + } + } + } + } + return cachedStore; + } + + private String getCurrentUser() { + return principal.getName(); + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DrillRestServer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DrillRestServer.java index 239936ea8eb..ed9d763fbd6 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DrillRestServer.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/DrillRestServer.java @@ -98,8 +98,12 @@ public DrillRestServer(final WorkManager workManager, final ServletContext servl register(MetricsResources.class); register(ThreadsResources.class); register(LogsResources.class); + register(MetadataResources.class); + register(SavedQueryResources.class); + register(VisualizationResources.class); + register(DashboardResources.class); - logger.info("Registered {} resource classes", 9); + logger.info("Registered {} resource classes", 13); property(FreemarkerMvcFeature.TEMPLATE_OBJECT_FACTORY, getFreemarkerConfiguration(servletContext)); register(FreemarkerMvcFeature.class); diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/MetadataResources.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/MetadataResources.java new file mode 100644 index 00000000000..17979aaf5b5 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/MetadataResources.java @@ -0,0 +1,720 @@ +/* + * 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.drill.exec.server.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.drill.exec.expr.fn.registry.FunctionHolder; +import org.apache.drill.exec.expr.fn.registry.LocalFunctionRegistry; +import org.apache.drill.exec.server.rest.RestQueryRunner.QueryResult; +import org.apache.drill.exec.server.rest.auth.DrillUserPrincipal; +import org.apache.drill.common.logical.StoragePluginConfig; +import org.apache.drill.exec.store.StoragePluginRegistry; +import org.apache.drill.exec.work.WorkManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * REST API for browsing database metadata (schemas, tables, columns). + * Used by the SQL Lab frontend for schema exploration and autocomplete. + */ +@Path("/api/v1/metadata") +@Tag(name = "Metadata", description = "Database metadata exploration APIs") +@RolesAllowed(DrillUserPrincipal.AUTHENTICATED_ROLE) +public class MetadataResources { + private static final Logger logger = LoggerFactory.getLogger(MetadataResources.class); + + @Inject + WorkManager workManager; + + @Inject + WebUserConnection webUserConnection; + + @Inject + StoragePluginRegistry storageRegistry; + + // Plugins to completely exclude from the UI + private static final Set EXCLUDED_PLUGINS = new HashSet<>(Arrays.asList( + "cp", // classpath plugin - internal use only + "sys", // system tables - internal use only + "information_schema" // INFORMATION_SCHEMA - metadata only + )); + + // Plugins that appear but cannot enumerate their tables + private static final Set NON_BROWSABLE_PLUGINS = new HashSet<>(Arrays.asList( + "http" // http plugin - shows endpoints but can't enumerate table schema + )); + + // ==================== Response Models ==================== + + public static class PluginInfo { + @JsonProperty + public String name; + @JsonProperty + public String type; + @JsonProperty + public boolean enabled; + @JsonProperty + public boolean browsable; + + public PluginInfo() {} + + public PluginInfo(String name, String type, boolean enabled, boolean browsable) { + this.name = name; + this.type = type; + this.enabled = enabled; + this.browsable = browsable; + } + } + + public static class PluginsResponse { + @JsonProperty + public List plugins; + + public PluginsResponse(List plugins) { + this.plugins = plugins; + } + } + + public static class SchemaInfo { + @JsonProperty + public String name; + @JsonProperty + public String type = "schema"; + @JsonProperty + public String plugin; + @JsonProperty + public boolean browsable = true; + + public SchemaInfo() {} + + public SchemaInfo(String name) { + this.name = name; + } + + public SchemaInfo(String name, String plugin, boolean browsable) { + this.name = name; + this.plugin = plugin; + this.browsable = browsable; + } + } + + public static class SchemasResponse { + @JsonProperty + public List schemas; + + public SchemasResponse(List schemas) { + this.schemas = schemas; + } + } + + public static class TableInfo { + @JsonProperty + public String name; + @JsonProperty + public String schema; + @JsonProperty + public String type; + + public TableInfo() {} + + public TableInfo(String name, String schema, String type) { + this.name = name; + this.schema = schema; + this.type = type; + } + } + + public static class TablesResponse { + @JsonProperty + public List tables; + + public TablesResponse(List tables) { + this.tables = tables; + } + } + + public static class ColumnInfo { + @JsonProperty + public String name; + @JsonProperty + public String type; + @JsonProperty + public boolean nullable; + @JsonProperty + public String schema; + @JsonProperty + public String table; + + public ColumnInfo() {} + + public ColumnInfo(String name, String type, boolean nullable, String schema, String table) { + this.name = name; + this.type = type; + this.nullable = nullable; + this.schema = schema; + this.table = table; + } + } + + public static class ColumnsResponse { + @JsonProperty + public List columns; + + public ColumnsResponse(List columns) { + this.columns = columns; + } + } + + public static class TablePreviewResponse { + @JsonProperty + public List columns; + @JsonProperty + public List> rows; + + public TablePreviewResponse(List columns, List> rows) { + this.columns = columns; + this.rows = rows; + } + } + + public static class FunctionsResponse { + @JsonProperty + public List functions; + + public FunctionsResponse(List functions) { + this.functions = functions; + } + } + + /** + * File or folder information from SHOW FILES command. + */ + public static class FileInfo { + @JsonProperty + public String name; + @JsonProperty + public boolean isDirectory; + @JsonProperty + public boolean isFile; + @JsonProperty + public long length; + @JsonProperty + public String owner; + @JsonProperty + public String group; + @JsonProperty + public String permissions; + @JsonProperty + public String modificationTime; + + public FileInfo() {} + + public FileInfo(String name, boolean isDirectory, boolean isFile, long length, + String owner, String group, String permissions, String modificationTime) { + this.name = name; + this.isDirectory = isDirectory; + this.isFile = isFile; + this.length = length; + this.owner = owner; + this.group = group; + this.permissions = permissions; + this.modificationTime = modificationTime; + } + } + + public static class FilesResponse { + @JsonProperty + public List files; + @JsonProperty + public String path; + + public FilesResponse(List files, String path) { + this.files = files; + this.path = path; + } + } + + // ==================== API Endpoints ==================== + + @GET + @Path("/plugins") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List storage plugins", description = "Returns a list of enabled storage plugins") + public PluginsResponse getPlugins() { + logger.debug("Fetching storage plugins"); + + List plugins = new ArrayList<>(); + + try { + // Get all enabled storage plugins + Map enabledPlugins = storageRegistry.enabledConfigs(); + for (Map.Entry entry : enabledPlugins.entrySet()) { + String pluginName = entry.getKey(); + + // Skip excluded plugins (cp, sys, information_schema) + if (EXCLUDED_PLUGINS.contains(pluginName.toLowerCase())) { + continue; + } + + StoragePluginConfig pluginConfig = entry.getValue(); + String pluginType = pluginConfig != null ? pluginConfig.getClass().getSimpleName() : "unknown"; + // Remove "Config" suffix if present for cleaner display + if (pluginType.endsWith("Config")) { + pluginType = pluginType.substring(0, pluginType.length() - 6); + } + boolean isBrowsable = !NON_BROWSABLE_PLUGINS.contains(pluginName.toLowerCase()); + plugins.add(new PluginInfo(pluginName, pluginType.toLowerCase(), true, isBrowsable)); + } + } catch (Exception e) { + logger.error("Error fetching plugins", e); + throw new RuntimeException("Failed to fetch plugins: " + e.getMessage(), e); + } + + return new PluginsResponse(plugins); + } + + @GET + @Path("/plugins/{plugin}/schemas") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List schemas for a plugin", description = "Returns a list of schemas for the specified plugin") + public SchemasResponse getPluginSchemas( + @Parameter(description = "Plugin name") @PathParam("plugin") String plugin) { + logger.debug("Fetching schemas for plugin: {}", plugin); + + List schemas = new ArrayList<>(); + boolean isBrowsable = !NON_BROWSABLE_PLUGINS.contains(plugin.toLowerCase()); + + // For non-browsable plugins, just return the plugin name as the only schema + if (!isBrowsable) { + schemas.add(new SchemaInfo(plugin, plugin, false)); + return new SchemasResponse(schemas); + } + + // Try to get sub-schemas from INFORMATION_SCHEMA + String sql = String.format( + "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA " + + "WHERE SCHEMA_NAME = '%s' OR SCHEMA_NAME LIKE '%s.%%' " + + "ORDER BY SCHEMA_NAME", + escapeQuotes(plugin), escapeQuotes(plugin)); + + try { + QueryResult result = executeQuery(sql); + for (Map row : result.rows) { + String schemaName = row.get("SCHEMA_NAME"); + if (schemaName != null) { + schemas.add(new SchemaInfo(schemaName, plugin, isBrowsable)); + } + } + } catch (Exception e) { + logger.warn("Error fetching schemas for plugin: {}, returning plugin as sole schema", plugin, e); + // Return at least the plugin name as a schema + schemas.add(new SchemaInfo(plugin, plugin, isBrowsable)); + } + + // If no schemas found, return the plugin name itself + if (schemas.isEmpty()) { + schemas.add(new SchemaInfo(plugin, plugin, isBrowsable)); + } + + return new SchemasResponse(schemas); + } + + @GET + @Path("/schemas") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all schemas", description = "Returns a list of all available schemas/databases") + public SchemasResponse getSchemas() { + logger.debug("Fetching all schemas"); + + List schemas = new ArrayList<>(); + + // First, add schemas from enabled storage plugins (fast, no connection needed) + try { + Map enabledPlugins = storageRegistry.enabledConfigs(); + for (String pluginName : enabledPlugins.keySet()) { + // Skip excluded plugins + if (EXCLUDED_PLUGINS.contains(pluginName.toLowerCase())) { + continue; + } + boolean isBrowsable = !NON_BROWSABLE_PLUGINS.contains(pluginName.toLowerCase()); + // Add the root plugin as a schema + schemas.add(new SchemaInfo(pluginName, pluginName, isBrowsable)); + } + } catch (Exception e) { + logger.warn("Error fetching plugins for schema list, falling back to INFORMATION_SCHEMA query", e); + } + + // If we got schemas from plugins, return them; otherwise fall back to SQL query + if (!schemas.isEmpty()) { + return new SchemasResponse(schemas); + } + + // Fallback: try INFORMATION_SCHEMA query (may fail if plugins have connection issues) + String sql = "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME"; + try { + QueryResult result = executeQuery(sql); + for (Map row : result.rows) { + String schemaName = row.get("SCHEMA_NAME"); + if (schemaName != null) { + schemas.add(new SchemaInfo(schemaName)); + } + } + } catch (Exception e) { + logger.error("Error fetching schemas from INFORMATION_SCHEMA", e); + // Return empty list rather than failing completely + return new SchemasResponse(schemas); + } + + return new SchemasResponse(schemas); + } + + @GET + @Path("/schemas/{schema}/tables") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List tables in schema", description = "Returns a list of tables in the specified schema") + public TablesResponse getTables( + @Parameter(description = "Schema name") @PathParam("schema") String schema) { + logger.debug("Fetching tables for schema: {}", schema); + + String sql = String.format( + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.`TABLES` " + + "WHERE TABLE_SCHEMA = '%s' ORDER BY TABLE_NAME", + escapeQuotes(schema)); + + List tables = new ArrayList<>(); + + try { + QueryResult result = executeQuery(sql); + for (Map row : result.rows) { + String tableName = row.get("TABLE_NAME"); + String tableType = row.get("TABLE_TYPE"); + if (tableName != null) { + tables.add(new TableInfo(tableName, schema, tableType != null ? tableType : "TABLE")); + } + } + } catch (Exception e) { + logger.error("Error fetching tables for schema: {}", schema, e); + throw new RuntimeException("Failed to fetch tables: " + e.getMessage(), e); + } + + return new TablesResponse(tables); + } + + @GET + @Path("/schemas/{schema}/files") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List files in schema/workspace", + description = "Returns a list of files and folders in the specified schema (for file-based plugins like dfs)") + public FilesResponse getFiles( + @Parameter(description = "Schema name (e.g., dfs.tmp)") @PathParam("schema") String schema, + @Parameter(description = "Subdirectory path") @QueryParam("path") @DefaultValue("") String subPath) { + logger.debug("Fetching files for schema: {}, path: {}", schema, subPath); + + List files = new ArrayList<>(); + String fullPath = schema; + if (subPath != null && !subPath.isEmpty()) { + fullPath = schema + ".`" + subPath + "`"; + } + + // Use SHOW FILES command to list files + String sql = String.format("SHOW FILES IN `%s`", escapeBackticks(fullPath)); + + try { + QueryResult result = executeQuery(sql); + for (Map row : result.rows) { + String name = row.get("name"); + if (name == null) { + continue; + } + + boolean isDirectory = "true".equalsIgnoreCase(row.get("isDirectory")); + boolean isFile = "true".equalsIgnoreCase(row.get("isFile")); + long length = 0; + try { + String lenStr = row.get("length"); + if (lenStr != null) { + length = Long.parseLong(lenStr); + } + } catch (NumberFormatException e) { + // ignore + } + + files.add(new FileInfo( + name, + isDirectory, + isFile, + length, + row.get("owner"), + row.get("group"), + row.get("permissions"), + row.get("modificationTime") + )); + } + } catch (Exception e) { + logger.warn("Error fetching files for schema: {} - this may not be a file-based plugin", schema, e); + // Return empty list for non-file plugins rather than throwing + return new FilesResponse(files, subPath); + } + + return new FilesResponse(files, subPath); + } + + @GET + @Path("/schemas/{schema}/tables/{table}/columns") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List columns in table", description = "Returns a list of columns in the specified table") + public ColumnsResponse getColumns( + @Parameter(description = "Schema name") @PathParam("schema") String schema, + @Parameter(description = "Table name") @PathParam("table") String table) { + logger.debug("Fetching columns for table: {}.{}", schema, table); + + String sql = String.format( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE " + + "FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' " + + "ORDER BY ORDINAL_POSITION", + escapeQuotes(schema), escapeQuotes(table)); + + List columns = new ArrayList<>(); + + try { + QueryResult result = executeQuery(sql); + for (Map row : result.rows) { + String columnName = row.get("COLUMN_NAME"); + String dataType = row.get("DATA_TYPE"); + String isNullable = row.get("IS_NULLABLE"); + if (columnName != null) { + columns.add(new ColumnInfo( + columnName, + dataType != null ? dataType : "ANY", + "YES".equalsIgnoreCase(isNullable), + schema, + table + )); + } + } + } catch (Exception e) { + logger.error("Error fetching columns for table: {}.{}", schema, table, e); + throw new RuntimeException("Failed to fetch columns: " + e.getMessage(), e); + } + + return new ColumnsResponse(columns); + } + + @GET + @Path("/schemas/{schema}/tables/{table}/preview") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Preview table data", description = "Returns a preview of data from the specified table") + public TablePreviewResponse previewTable( + @Parameter(description = "Schema name") @PathParam("schema") String schema, + @Parameter(description = "Table name") @PathParam("table") String table, + @Parameter(description = "Maximum rows to return") @QueryParam("limit") @DefaultValue("100") int limit) { + logger.debug("Previewing table: {}.{} with limit {}", schema, table, limit); + + // Cap the limit to prevent excessive data retrieval + int safeLimit = Math.min(Math.max(1, limit), 1000); + + String sql = String.format( + "SELECT * FROM `%s`.`%s` LIMIT %d", + escapeBackticks(schema), escapeBackticks(table), safeLimit); + + try { + QueryResult result = executeQuery(sql); + return new TablePreviewResponse(new ArrayList<>(result.columns), result.rows); + } catch (Exception e) { + logger.error("Error previewing table: {}.{}", schema, table, e); + throw new RuntimeException("Failed to preview table: " + e.getMessage(), e); + } + } + + @GET + @Path("/schemas/{schema}/files/columns") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get columns from a file", + description = "Returns column names and types by executing SELECT * LIMIT 1 on the file") + public ColumnsResponse getFileColumns( + @Parameter(description = "Schema name (e.g., dfs.tmp)") @PathParam("schema") String schema, + @Parameter(description = "File path within the schema") @QueryParam("path") String filePath) { + logger.debug("Fetching columns for file: {}/{}", schema, filePath); + + if (filePath == null || filePath.isEmpty()) { + throw new IllegalArgumentException("File path is required"); + } + + // Build the fully qualified path + // Handle paths that may contain special characters + String fullPath = String.format("`%s`.`%s`", + escapeBackticks(schema), escapeBackticks(filePath)); + + String sql = String.format("SELECT * FROM %s LIMIT 1", fullPath); + + List columns = new ArrayList<>(); + + try { + QueryResult result = executeQuery(sql); + + // Get column names and infer types from the first row of results + for (String columnName : result.columns) { + // Try to infer type from first row value + String dataType = "ANY"; + if (!result.rows.isEmpty()) { + String value = result.rows.get(0).get(columnName); + dataType = inferDataType(value); + } + columns.add(new ColumnInfo(columnName, dataType, true, schema, filePath)); + } + } catch (Exception e) { + logger.error("Error fetching columns for file: {}/{}", schema, filePath, e); + throw new RuntimeException("Failed to get file columns: " + e.getMessage(), e); + } + + return new ColumnsResponse(columns); + } + + /** + * Infer the data type from a string value. + * This is a simple heuristic for display purposes. + */ + private String inferDataType(String value) { + if (value == null) { + return "ANY"; + } + + // Try integer + try { + Long.parseLong(value); + return "BIGINT"; + } catch (NumberFormatException e) { + // not an integer + } + + // Try floating point + try { + Double.parseDouble(value); + return "DOUBLE"; + } catch (NumberFormatException e) { + // not a number + } + + // Check for boolean + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return "BOOLEAN"; + } + + // Check for date/time patterns + if (value.matches("\\d{4}-\\d{2}-\\d{2}")) { + return "DATE"; + } + if (value.matches("\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}.*")) { + return "TIMESTAMP"; + } + + // Default to VARCHAR + return "VARCHAR"; + } + + @GET + @Path("/functions") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List SQL functions", description = "Returns a list of available SQL functions for autocomplete") + public FunctionsResponse getFunctions() { + logger.debug("Fetching SQL functions"); + + TreeSet functionSet = new TreeSet<>(); + + try { + // Get built-in functions from the function registry + List builtInFunctions = workManager.getContext() + .getFunctionImplementationRegistry() + .getLocalFunctionRegistry() + .getAllJarsWithFunctionsHolders() + .get(LocalFunctionRegistry.BUILT_IN); + + if (builtInFunctions != null) { + for (FunctionHolder holder : builtInFunctions) { + String name = holder.getName(); + // Only include functions that start with a letter and don't contain spaces + if (name != null && !name.contains(" ") && name.matches("([a-z]|[A-Z])\\w+") + && !holder.getHolder().isInternal()) { + functionSet.add(name); + } + } + } + } catch (Exception e) { + logger.error("Error fetching functions", e); + // Return empty list on error rather than failing + } + + return new FunctionsResponse(new ArrayList<>(functionSet)); + } + + // ==================== Helper Methods ==================== + + /** + * Execute a SQL query and return the results + */ + private QueryResult executeQuery(String sql) throws Exception { + QueryWrapper wrapper = new QueryWrapper.RestQueryBuilder() + .query(sql) + .queryType("SQL") + .rowLimit("10000") // Reasonable limit for metadata queries + .build(); + + return new RestQueryRunner(wrapper, workManager, webUserConnection).run(); + } + + /** + * Escape single quotes in SQL strings + */ + private String escapeQuotes(String value) { + if (value == null) { + return ""; + } + return value.replace("'", "''"); + } + + /** + * Escape backticks in SQL identifiers + */ + private String escapeBackticks(String value) { + if (value == null) { + return ""; + } + return value.replace("`", "``"); + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SavedQueryResources.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SavedQueryResources.java new file mode 100644 index 00000000000..c0e145a7de7 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SavedQueryResources.java @@ -0,0 +1,492 @@ +/* + * 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.drill.exec.server.rest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.drill.common.exceptions.DrillRuntimeException; +import org.apache.drill.exec.exception.StoreException; +import org.apache.drill.exec.server.rest.auth.DrillUserPrincipal; +import org.apache.drill.exec.store.sys.PersistentStore; +import org.apache.drill.exec.store.sys.PersistentStoreConfig; +import org.apache.drill.exec.store.sys.PersistentStoreProvider; +import org.apache.drill.exec.work.WorkManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * REST API for managing saved SQL queries. + * Queries are persisted using Drill's PersistentStore mechanism. + */ +@Path("/api/v1/saved-queries") +@Tag(name = "Saved Queries", description = "APIs for managing saved SQL queries") +@RolesAllowed(DrillUserPrincipal.AUTHENTICATED_ROLE) +public class SavedQueryResources { + private static final Logger logger = LoggerFactory.getLogger(SavedQueryResources.class); + private static final String STORE_NAME = "drill.sqllab.saved_queries"; + + @Inject + WorkManager workManager; + + @Inject + DrillUserPrincipal principal; + + @Inject + PersistentStoreProvider storeProvider; + + private static volatile PersistentStore cachedStore; + + // ==================== Model Classes ==================== + + /** + * Saved query model for persistence. + */ + public static class SavedQuery { + @JsonProperty + private String id; + @JsonProperty + private String name; + @JsonProperty + private String description; + @JsonProperty + private String sql; + @JsonProperty + private String defaultSchema; + @JsonProperty + private String owner; + @JsonProperty + private long createdAt; + @JsonProperty + private long updatedAt; + @JsonProperty + private Map tags; + @JsonProperty + private boolean isPublic; + + // Default constructor for Jackson + public SavedQuery() { + } + + @JsonCreator + public SavedQuery( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("sql") String sql, + @JsonProperty("defaultSchema") String defaultSchema, + @JsonProperty("owner") String owner, + @JsonProperty("createdAt") long createdAt, + @JsonProperty("updatedAt") long updatedAt, + @JsonProperty("tags") Map tags, + @JsonProperty("isPublic") boolean isPublic) { + this.id = id; + this.name = name; + this.description = description; + this.sql = sql; + this.defaultSchema = defaultSchema; + this.owner = owner; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.tags = tags; + this.isPublic = isPublic; + } + + // Getters + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getSql() { + return sql; + } + + public String getDefaultSchema() { + return defaultSchema; + } + + public String getOwner() { + return owner; + } + + public long getCreatedAt() { + return createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public Map getTags() { + return tags; + } + + public boolean isPublic() { + return isPublic; + } + + // Setters for updates + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setSql(String sql) { + this.sql = sql; + } + + public void setDefaultSchema(String defaultSchema) { + this.defaultSchema = defaultSchema; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public void setTags(Map tags) { + this.tags = tags; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + } + + /** + * Request body for creating a new saved query. + */ + public static class CreateSavedQueryRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public String sql; + @JsonProperty + public String defaultSchema; + @JsonProperty + public Map tags; + @JsonProperty + public boolean isPublic; + } + + /** + * Request body for updating a saved query. + */ + public static class UpdateSavedQueryRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public String sql; + @JsonProperty + public String defaultSchema; + @JsonProperty + public Map tags; + @JsonProperty + public Boolean isPublic; + } + + /** + * Response containing a list of saved queries. + */ + public static class SavedQueriesResponse { + @JsonProperty + public List queries; + + public SavedQueriesResponse(List queries) { + this.queries = queries; + } + } + + /** + * Simple message response. + */ + public static class MessageResponse { + @JsonProperty + public String message; + + public MessageResponse(String message) { + this.message = message; + } + } + + // ==================== API Endpoints ==================== + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List saved queries", description = "Returns all saved queries accessible by the current user") + public SavedQueriesResponse listSavedQueries() { + logger.debug("Listing saved queries for user: {}", getCurrentUser()); + + List queries = new ArrayList<>(); + String currentUser = getCurrentUser(); + + try { + PersistentStore store = getStore(); + Iterator> iterator = store.getAll(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + SavedQuery query = entry.getValue(); + + // Return queries owned by user or public queries + if (query.getOwner().equals(currentUser) || query.isPublic()) { + queries.add(query); + } + } + } catch (Exception e) { + logger.error("Error listing saved queries", e); + throw new DrillRuntimeException("Failed to list saved queries: " + e.getMessage(), e); + } + + return new SavedQueriesResponse(queries); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create saved query", description = "Creates a new saved query") + public Response createSavedQuery(CreateSavedQueryRequest request) { + logger.debug("Creating saved query: {}", request.name); + + if (request.name == null || request.name.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new MessageResponse("Query name is required")) + .build(); + } + + if (request.sql == null || request.sql.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new MessageResponse("SQL is required")) + .build(); + } + + String id = UUID.randomUUID().toString(); + long now = Instant.now().toEpochMilli(); + + SavedQuery query = new SavedQuery( + id, + request.name.trim(), + request.description, + request.sql, + request.defaultSchema, + getCurrentUser(), + now, + now, + request.tags != null ? request.tags : new HashMap<>(), + request.isPublic + ); + + try { + PersistentStore store = getStore(); + store.put(id, query); + } catch (Exception e) { + logger.error("Error creating saved query", e); + throw new DrillRuntimeException("Failed to create saved query: " + e.getMessage(), e); + } + + return Response.status(Response.Status.CREATED).entity(query).build(); + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get saved query", description = "Returns a saved query by ID") + public Response getSavedQuery( + @Parameter(description = "Query ID") @PathParam("id") String id) { + logger.debug("Getting saved query: {}", id); + + try { + PersistentStore store = getStore(); + SavedQuery query = store.get(id); + + if (query == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Query not found")) + .build(); + } + + // Check access permissions + if (!query.getOwner().equals(getCurrentUser()) && !query.isPublic()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Access denied")) + .build(); + } + + return Response.ok(query).build(); + } catch (Exception e) { + logger.error("Error getting saved query", e); + throw new DrillRuntimeException("Failed to get saved query: " + e.getMessage(), e); + } + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update saved query", description = "Updates an existing saved query") + public Response updateSavedQuery( + @Parameter(description = "Query ID") @PathParam("id") String id, + UpdateSavedQueryRequest request) { + logger.debug("Updating saved query: {}", id); + + try { + PersistentStore store = getStore(); + SavedQuery query = store.get(id); + + if (query == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Query not found")) + .build(); + } + + // Only owner can update + if (!query.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can update this query")) + .build(); + } + + // Update fields if provided + if (request.name != null) { + query.setName(request.name.trim()); + } + if (request.description != null) { + query.setDescription(request.description); + } + if (request.sql != null) { + query.setSql(request.sql); + } + if (request.defaultSchema != null) { + query.setDefaultSchema(request.defaultSchema); + } + if (request.tags != null) { + query.setTags(request.tags); + } + if (request.isPublic != null) { + query.setPublic(request.isPublic); + } + + query.setUpdatedAt(Instant.now().toEpochMilli()); + + store.put(id, query); + + return Response.ok(query).build(); + } catch (Exception e) { + logger.error("Error updating saved query", e); + throw new DrillRuntimeException("Failed to update saved query: " + e.getMessage(), e); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete saved query", description = "Deletes a saved query") + public Response deleteSavedQuery( + @Parameter(description = "Query ID") @PathParam("id") String id) { + logger.debug("Deleting saved query: {}", id); + + try { + PersistentStore store = getStore(); + SavedQuery query = store.get(id); + + if (query == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Query not found")) + .build(); + } + + // Only owner can delete + if (!query.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can delete this query")) + .build(); + } + + store.delete(id); + + return Response.ok(new MessageResponse("Query deleted successfully")).build(); + } catch (Exception e) { + logger.error("Error deleting saved query", e); + throw new DrillRuntimeException("Failed to delete saved query: " + e.getMessage(), e); + } + } + + // ==================== Helper Methods ==================== + + private PersistentStore getStore() { + if (cachedStore == null) { + synchronized (SavedQueryResources.class) { + if (cachedStore == null) { + try { + cachedStore = storeProvider.getOrCreateStore( + PersistentStoreConfig.newJacksonBuilder( + workManager.getContext().getLpPersistence().getMapper(), + SavedQuery.class + ) + .name(STORE_NAME) + .build() + ); + } catch (StoreException e) { + throw new DrillRuntimeException("Failed to access saved queries store", e); + } + } + } + } + return cachedStore; + } + + private String getCurrentUser() { + return principal.getName(); + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SqlLabSpaServlet.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SqlLabSpaServlet.java new file mode 100644 index 00000000000..4ffa7aa44bd --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/SqlLabSpaServlet.java @@ -0,0 +1,126 @@ +/* + * 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.drill.exec.server.rest; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; + +/** + * Servlet for serving the SQL Lab React SPA. + * Handles client-side routing by serving index.html for all non-static routes. + */ +public class SqlLabSpaServlet extends HttpServlet { + private static final Logger logger = LoggerFactory.getLogger(SqlLabSpaServlet.class); + private static final String WEBAPP_PATH = "/webapp/"; + private static final String INDEX_HTML = "index.html"; + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String path = request.getPathInfo(); + if (path == null || path.equals("/")) { + path = "/" + INDEX_HTML; + } + + // Check if this is a static asset request (has file extension) + boolean isStaticAsset = path.lastIndexOf('.') > path.lastIndexOf('/'); + + String resourcePath = WEBAPP_PATH + (isStaticAsset ? "dist" + path : "dist/" + INDEX_HTML); + + // Try to serve from built assets first + URL resource = getClass().getResource(resourcePath); + + // Fallback to development mode (source files) + if (resource == null && isStaticAsset) { + resourcePath = WEBAPP_PATH + path.substring(1); // Remove leading slash + resource = getClass().getResource(resourcePath); + } + + // For SPA routing, always serve index.html for non-static requests + if (resource == null && !isStaticAsset) { + resourcePath = WEBAPP_PATH + "dist/" + INDEX_HTML; + resource = getClass().getResource(resourcePath); + + // Fallback to source index.html + if (resource == null) { + resourcePath = WEBAPP_PATH + INDEX_HTML; + resource = getClass().getResource(resourcePath); + } + } + + if (resource == null) { + logger.debug("Resource not found: {}", path); + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + // Set content type based on file extension + String contentType = getContentType(path); + if (contentType != null) { + response.setContentType(contentType); + } + + // Serve the resource + try (InputStream in = resource.openStream(); + OutputStream out = response.getOutputStream()) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } + } + + private String getContentType(String path) { + if (path.endsWith(".html")) { + return "text/html; charset=UTF-8"; + } else if (path.endsWith(".js")) { + return "application/javascript; charset=UTF-8"; + } else if (path.endsWith(".css")) { + return "text/css; charset=UTF-8"; + } else if (path.endsWith(".json")) { + return "application/json; charset=UTF-8"; + } else if (path.endsWith(".png")) { + return "image/png"; + } else if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { + return "image/jpeg"; + } else if (path.endsWith(".svg")) { + return "image/svg+xml"; + } else if (path.endsWith(".ico")) { + return "image/x-icon"; + } else if (path.endsWith(".woff")) { + return "font/woff"; + } else if (path.endsWith(".woff2")) { + return "font/woff2"; + } else if (path.endsWith(".ttf")) { + return "font/ttf"; + } else if (path.endsWith(".eot")) { + return "application/vnd.ms-fontobject"; + } + return null; + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/VisualizationResources.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/VisualizationResources.java new file mode 100644 index 00000000000..e35879e1eb2 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/VisualizationResources.java @@ -0,0 +1,615 @@ +/* + * 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.drill.exec.server.rest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.drill.common.exceptions.DrillRuntimeException; +import org.apache.drill.exec.exception.StoreException; +import org.apache.drill.exec.server.rest.auth.DrillUserPrincipal; +import org.apache.drill.exec.store.sys.PersistentStore; +import org.apache.drill.exec.store.sys.PersistentStoreConfig; +import org.apache.drill.exec.store.sys.PersistentStoreProvider; +import org.apache.drill.exec.work.WorkManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * REST API for managing visualizations. + * Visualizations are charts created from saved query results. + */ +@Path("/api/v1/visualizations") +@Tag(name = "Visualizations", description = "APIs for managing chart visualizations") +@RolesAllowed(DrillUserPrincipal.AUTHENTICATED_ROLE) +public class VisualizationResources { + private static final Logger logger = LoggerFactory.getLogger(VisualizationResources.class); + private static final String STORE_NAME = "drill.sqllab.visualizations"; + + @Inject + WorkManager workManager; + + @Inject + DrillUserPrincipal principal; + + @Inject + PersistentStoreProvider storeProvider; + + private static volatile PersistentStore cachedStore; + + // ==================== Model Classes ==================== + + /** + * Configuration for visualization axes and metrics. + */ + public static class VisualizationConfig { + @JsonProperty + private String xAxis; + @JsonProperty + private String yAxis; + @JsonProperty + private List metrics; + @JsonProperty + private List dimensions; + @JsonProperty + private Map chartOptions; + @JsonProperty + private String colorScheme; + + public VisualizationConfig() { + } + + @JsonCreator + public VisualizationConfig( + @JsonProperty("xAxis") String xAxis, + @JsonProperty("yAxis") String yAxis, + @JsonProperty("metrics") List metrics, + @JsonProperty("dimensions") List dimensions, + @JsonProperty("chartOptions") Map chartOptions, + @JsonProperty("colorScheme") String colorScheme) { + this.xAxis = xAxis; + this.yAxis = yAxis; + this.metrics = metrics; + this.dimensions = dimensions; + this.chartOptions = chartOptions; + this.colorScheme = colorScheme; + } + + public String getXAxis() { + return xAxis; + } + + public String getYAxis() { + return yAxis; + } + + public List getMetrics() { + return metrics; + } + + public List getDimensions() { + return dimensions; + } + + public Map getChartOptions() { + return chartOptions; + } + + public String getColorScheme() { + return colorScheme; + } + + public void setXAxis(String xAxis) { + this.xAxis = xAxis; + } + + public void setYAxis(String yAxis) { + this.yAxis = yAxis; + } + + public void setMetrics(List metrics) { + this.metrics = metrics; + } + + public void setDimensions(List dimensions) { + this.dimensions = dimensions; + } + + public void setChartOptions(Map chartOptions) { + this.chartOptions = chartOptions; + } + + public void setColorScheme(String colorScheme) { + this.colorScheme = colorScheme; + } + } + + /** + * Visualization model for persistence. + */ + public static class Visualization { + @JsonProperty + private String id; + @JsonProperty + private String name; + @JsonProperty + private String description; + @JsonProperty + private String savedQueryId; + @JsonProperty + private String chartType; + @JsonProperty + private VisualizationConfig config; + @JsonProperty + private String owner; + @JsonProperty + private long createdAt; + @JsonProperty + private long updatedAt; + @JsonProperty + private boolean isPublic; + @JsonProperty + private String sql; + @JsonProperty + private String defaultSchema; + + public Visualization() { + } + + @JsonCreator + public Visualization( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("savedQueryId") String savedQueryId, + @JsonProperty("chartType") String chartType, + @JsonProperty("config") VisualizationConfig config, + @JsonProperty("owner") String owner, + @JsonProperty("createdAt") long createdAt, + @JsonProperty("updatedAt") long updatedAt, + @JsonProperty("isPublic") boolean isPublic, + @JsonProperty("sql") String sql, + @JsonProperty("defaultSchema") String defaultSchema) { + this.id = id; + this.name = name; + this.description = description; + this.savedQueryId = savedQueryId; + this.chartType = chartType; + this.config = config; + this.owner = owner; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.isPublic = isPublic; + this.sql = sql; + this.defaultSchema = defaultSchema; + } + + // Getters + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getSavedQueryId() { + return savedQueryId; + } + + public String getChartType() { + return chartType; + } + + public VisualizationConfig getConfig() { + return config; + } + + public String getOwner() { + return owner; + } + + public long getCreatedAt() { + return createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public boolean isPublic() { + return isPublic; + } + + public String getSql() { + return sql; + } + + public String getDefaultSchema() { + return defaultSchema; + } + + // Setters for updates + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setSavedQueryId(String savedQueryId) { + this.savedQueryId = savedQueryId; + } + + public void setChartType(String chartType) { + this.chartType = chartType; + } + + public void setConfig(VisualizationConfig config) { + this.config = config; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + + public void setSql(String sql) { + this.sql = sql; + } + + public void setDefaultSchema(String defaultSchema) { + this.defaultSchema = defaultSchema; + } + } + + /** + * Request body for creating a new visualization. + */ + public static class CreateVisualizationRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public String savedQueryId; + @JsonProperty + public String chartType; + @JsonProperty + public VisualizationConfig config; + @JsonProperty + public boolean isPublic; + @JsonProperty + public String sql; + @JsonProperty + public String defaultSchema; + } + + /** + * Request body for updating a visualization. + */ + public static class UpdateVisualizationRequest { + @JsonProperty + public String name; + @JsonProperty + public String description; + @JsonProperty + public String savedQueryId; + @JsonProperty + public String chartType; + @JsonProperty + public VisualizationConfig config; + @JsonProperty + public Boolean isPublic; + @JsonProperty + public String sql; + @JsonProperty + public String defaultSchema; + } + + /** + * Response containing a list of visualizations. + */ + public static class VisualizationsResponse { + @JsonProperty + public List visualizations; + + public VisualizationsResponse(List visualizations) { + this.visualizations = visualizations; + } + } + + /** + * Simple message response. + */ + public static class MessageResponse { + @JsonProperty + public String message; + + public MessageResponse(String message) { + this.message = message; + } + } + + // ==================== API Endpoints ==================== + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List visualizations", description = "Returns all visualizations accessible by the current user") + public VisualizationsResponse listVisualizations() { + logger.debug("Listing visualizations for user: {}", getCurrentUser()); + + List visualizations = new ArrayList<>(); + String currentUser = getCurrentUser(); + + try { + PersistentStore store = getStore(); + Iterator> iterator = store.getAll(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Visualization viz = entry.getValue(); + + // Return visualizations owned by user or public visualizations + if (viz.getOwner().equals(currentUser) || viz.isPublic()) { + visualizations.add(viz); + } + } + } catch (Exception e) { + logger.error("Error listing visualizations", e); + throw new DrillRuntimeException("Failed to list visualizations: " + e.getMessage(), e); + } + + return new VisualizationsResponse(visualizations); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create visualization", description = "Creates a new visualization") + public Response createVisualization(CreateVisualizationRequest request) { + logger.debug("Creating visualization: {}", request.name); + + if (request.name == null || request.name.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new MessageResponse("Visualization name is required")) + .build(); + } + + if (request.chartType == null || request.chartType.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new MessageResponse("Chart type is required")) + .build(); + } + + String id = UUID.randomUUID().toString(); + long now = Instant.now().toEpochMilli(); + + Visualization viz = new Visualization( + id, + request.name.trim(), + request.description, + request.savedQueryId, + request.chartType, + request.config != null ? request.config : new VisualizationConfig(), + getCurrentUser(), + now, + now, + request.isPublic, + request.sql, + request.defaultSchema + ); + + try { + PersistentStore store = getStore(); + store.put(id, viz); + } catch (Exception e) { + logger.error("Error creating visualization", e); + throw new DrillRuntimeException("Failed to create visualization: " + e.getMessage(), e); + } + + return Response.status(Response.Status.CREATED).entity(viz).build(); + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get visualization", description = "Returns a visualization by ID") + public Response getVisualization( + @Parameter(description = "Visualization ID") @PathParam("id") String id) { + logger.debug("Getting visualization: {}", id); + + try { + PersistentStore store = getStore(); + Visualization viz = store.get(id); + + if (viz == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Visualization not found")) + .build(); + } + + // Check access permissions + if (!viz.getOwner().equals(getCurrentUser()) && !viz.isPublic()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Access denied")) + .build(); + } + + return Response.ok(viz).build(); + } catch (Exception e) { + logger.error("Error getting visualization", e); + throw new DrillRuntimeException("Failed to get visualization: " + e.getMessage(), e); + } + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update visualization", description = "Updates an existing visualization") + public Response updateVisualization( + @Parameter(description = "Visualization ID") @PathParam("id") String id, + UpdateVisualizationRequest request) { + logger.debug("Updating visualization: {}", id); + + try { + PersistentStore store = getStore(); + Visualization viz = store.get(id); + + if (viz == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Visualization not found")) + .build(); + } + + // Only owner can update + if (!viz.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can update this visualization")) + .build(); + } + + // Update fields if provided + if (request.name != null) { + viz.setName(request.name.trim()); + } + if (request.description != null) { + viz.setDescription(request.description); + } + if (request.savedQueryId != null) { + viz.setSavedQueryId(request.savedQueryId); + } + if (request.chartType != null) { + viz.setChartType(request.chartType); + } + if (request.config != null) { + viz.setConfig(request.config); + } + if (request.isPublic != null) { + viz.setPublic(request.isPublic); + } + if (request.sql != null) { + viz.setSql(request.sql); + } + if (request.defaultSchema != null) { + viz.setDefaultSchema(request.defaultSchema); + } + + viz.setUpdatedAt(Instant.now().toEpochMilli()); + + store.put(id, viz); + + return Response.ok(viz).build(); + } catch (Exception e) { + logger.error("Error updating visualization", e); + throw new DrillRuntimeException("Failed to update visualization: " + e.getMessage(), e); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete visualization", description = "Deletes a visualization") + public Response deleteVisualization( + @Parameter(description = "Visualization ID") @PathParam("id") String id) { + logger.debug("Deleting visualization: {}", id); + + try { + PersistentStore store = getStore(); + Visualization viz = store.get(id); + + if (viz == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new MessageResponse("Visualization not found")) + .build(); + } + + // Only owner can delete + if (!viz.getOwner().equals(getCurrentUser())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new MessageResponse("Only the owner can delete this visualization")) + .build(); + } + + store.delete(id); + + return Response.ok(new MessageResponse("Visualization deleted successfully")).build(); + } catch (Exception e) { + logger.error("Error deleting visualization", e); + throw new DrillRuntimeException("Failed to delete visualization: " + e.getMessage(), e); + } + } + + // ==================== Helper Methods ==================== + + private PersistentStore getStore() { + if (cachedStore == null) { + synchronized (VisualizationResources.class) { + if (cachedStore == null) { + try { + cachedStore = storeProvider.getOrCreateStore( + PersistentStoreConfig.newJacksonBuilder( + workManager.getContext().getLpPersistence().getMapper(), + Visualization.class + ) + .name(STORE_NAME) + .build() + ); + } catch (StoreException e) { + throw new DrillRuntimeException("Failed to access visualizations store", e); + } + } + } + } + return cachedStore; + } + + private String getCurrentUser() { + return principal.getName(); + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/WebServer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/WebServer.java index e4fc2ba2a4b..98190a948de 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/WebServer.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/rest/WebServer.java @@ -101,6 +101,7 @@ public class WebServer implements AutoCloseable { private static final int PORT_HUNT_TRIES = 100; private static final String BASE_STATIC_PATH = "/rest/static/"; private static final String DRILL_ICON_RESOURCE_RELATIVE_PATH = "img/drill.ico"; + private static final String SQLLAB_WEBAPP_PATH = "/webapp/"; private final DrillConfig config; private final MetricRegistry metrics; @@ -214,6 +215,12 @@ private ServletContextHandler createServletContextHandler(final boolean authEnab staticHolder.setInitParameter("pathInfoOnly", "true"); servletContextHandler.addServlet(staticHolder, "/static/*"); + // Add SQL Lab React SPA servlet + // Uses custom servlet for SPA client-side routing support + final ServletHolder sqlLabHolder = new ServletHolder("sqllab", SqlLabSpaServlet.class); + servletContextHandler.addServlet(sqlLabHolder, "/sqllab/*"); + logger.info("SQL Lab React app configured at /sqllab/*"); + // Store the dependencies in the holder BEFORE creating the servlet // When Jersey instantiates DrillRestServerApplication (which extends DrillRestServer), // it will retrieve these dependencies and pass them to the parent constructor @@ -260,7 +267,7 @@ private ServletContextHandler createServletContextHandler(final boolean authEnab holder.setInitParameter(CrossOriginFilter.ALLOW_CREDENTIALS_PARAM, String.valueOf(config.getBoolean(ExecConstants.HTTP_CORS_CREDENTIALS))); - for (String path : new String[]{"*.json", "/storage/*/enable/*", "/status*"}) { + for (String path : new String[]{"*.json", "/storage/*/enable/*", "/status*", "/api/*"}) { servletContextHandler.addFilter(holder, path, EnumSet.of(DispatcherType.REQUEST)); } } diff --git a/exec/java-exec/src/main/resources/rest/generic.ftl b/exec/java-exec/src/main/resources/rest/generic.ftl index f4fd6aae015..957d544b393 100644 --- a/exec/java-exec/src/main/resources/rest/generic.ftl +++ b/exec/java-exec/src/main/resources/rest/generic.ftl @@ -65,6 +65,7 @@ + } + extra={editMode && ( + + ) : ( + + + + )} + + {/* Save Query Button */} + + + + + {/* Schema Selector */} + + Schema: + + onEditorSettingsChange?.({ ...editorSettings, theme: value }) + } + options={[ + { value: 'vs-light', label: 'Light' }, + { value: 'vs-dark', label: 'Dark' }, + { value: 'hc-black', label: 'High Contrast' }, + ]} + /> + +
+ Font Size: {editorSettings.fontSize}px + + onEditorSettingsChange?.({ ...editorSettings, fontSize: value }) + } + /> +
+
+ Tab Size + + + + +