You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by pp...@apache.org on 2026/05/10 15:01:54 UTC
(atlas) branch master updated: ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation (#626)
This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/master by this push:
new 874180f5e ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation (#626)
874180f5e is described below
commit 874180f5edccb7992ede23f726e1b652c46dccd7
Author: Prasad Pawar <49...@users.noreply.github.com>
AuthorDate: Sun May 10 20:31:48 2026 +0530
ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation (#626)
* ATLAS-5286: ATLAS UI: Dashboard - Filter query and shared view helpers changes (#615)
* ATLAS-5287: ATLAS UI: Dashboard- search bar with dropdown (#616)
* ATLAS-5267: ATLAS UI: Create Glossary: Add Import glossary terms tab (template + upload) (#617)
* ATLAS-5256: ATLAS UI: Header - Create Button and Dropdown Menu. (#618)
* ATLAS-5256: ATLAS UI: Header - Create Button and Dropdown Menu
* ATLAS-5256: ATLAS UI: Header - Create Button and Dropdown Menu
* ATLAS-5251: ATLAS UI : Dashboard - Latest Entities List (#619)
* ATLAS-5247, ATLAS-5248, ATLAS-5249, ATLAS-5250, ATLAS-5252, ATLAS-5254, ATLAS-5255: ATLAS UI: Dashboard All cards. (#620)
* ATLAS-5288: ATLAS UI: Dashboard - Classification and Admin Audits (#621)
* ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation
---
dashboard/src/api/apiMethods/searchApiMethod.ts | 62 +-
.../components/CreateDropdown/CreateDropdown.tsx | 176 +++
dashboard/src/components/CreateDropdown/index.ts | 18 +
dashboard/src/components/FilterQuery.tsx | 197 +++-
.../src/components/GlobalSearch/QuickSearch.tsx | 1185 ++++++++++++--------
dashboard/src/components/ImportDialog.tsx | 44 +-
dashboard/src/components/QueryBuilder/Filters.tsx | 123 +-
.../src/components/ShowMore/DrawerBodyChipView.tsx | 1 +
dashboard/src/components/ShowMore/ShowMoreView.tsx | 3 +
dashboard/src/components/Table/TablePagination.tsx | 120 +-
dashboard/src/components/TreeNodeIcons.tsx | 42 +-
.../src/components/TypeDefAuditDetailModal.tsx | 108 ++
dashboard/src/utils/CommonViewFunction.ts | 17 +-
dashboard/src/utils/Enum.ts | 9 +
dashboard/src/utils/apiErrorToastMessage.ts | 43 +
dashboard/src/utils/auditTypeDefUtils.ts | 90 ++
dashboard/src/utils/dashboardSearchUtils.ts | 189 ++++
dashboard/src/utils/filterUrlCriterionRemoval.ts | 81 ++
dashboard/src/utils/glossaryImportFlow.ts | 57 +
dashboard/src/utils/metricsUtils.ts | 241 ++++
dashboard/src/utils/scopedQuickSearchUtils.ts | 466 ++++++++
dashboard/src/utils/typeCatalogUtils.ts | 159 +++
.../views/Administrator/AdministratorLayout.tsx | 22 +-
.../views/Administrator/Audits/AuditResults.tsx | 78 +-
.../BusinessMetadataAtrributeForm.tsx | 1 +
.../BusinessMetadata/BusinessMetadataForm.tsx | 2 +
dashboard/src/views/Classification/AddTag.tsx | 1 +
.../src/views/Classification/AddTagAttributes.tsx | 1 +
.../views/Classification/ClassificationForm.tsx | 5 +-
dashboard/src/views/Classification/DeleteTag.tsx | 1 +
dashboard/src/views/DashBoard.tsx | 83 +-
.../DashboardOverview/ClassificationCoverage.tsx | 394 +++++++
.../ClassificationDistributionCard.tsx | 227 ++++
.../views/DashboardOverview/DashboardOverview.tsx | 149 +++
.../views/DashboardOverview/DashboardSkeleton.tsx | 39 +
.../views/DashboardOverview/EntityStatusDonut.tsx | 173 +++
.../views/DashboardOverview/EntityTypeBarChart.tsx | 327 ++++++
.../EntityTypeBarChartSkeleton.tsx | 57 +
.../DashboardOverview/KafkaTopicSummaryCard.tsx | 402 +++++++
.../views/DashboardOverview/LatestEntitiesList.tsx | 273 +++++
.../DashboardOverview/LatestEntitiesSkeleton.tsx | 53 +
.../DashboardOverview/MessageConsumptionChart.tsx | 257 +++++
.../src/views/DashboardOverview/OverviewCard.tsx | 114 ++
.../src/views/DashboardOverview/RecentActivity.tsx | 408 +++++++
.../DashboardOverview/RecentActivitySkeleton.tsx | 37 +
.../DashboardOverview/dashboardChartPalette.ts | 42 +
.../DashboardOverview/latestEntitiesList.utils.ts | 64 ++
.../BusinessMetadataDetailsLayout.tsx | 2 +
.../EntityDetailTabs/ClassificationsTab.tsx | 6 +
.../EntityDetailTabs/PropagationPropertyModal.tsx | 1 +
.../DetailPage/GlossaryDetails/TermRelation.tsx | 1 +
dashboard/src/views/Entity/EntityForm.tsx | 1 +
.../src/views/Glossary/AddUpdateCategoryForm.tsx | 1 +
.../src/views/Glossary/AddUpdateGlossaryForm.tsx | 450 ++++++--
dashboard/src/views/Glossary/AddUpdateTermForm.tsx | 1 +
dashboard/src/views/Glossary/AssignCategory.tsx | 1 +
dashboard/src/views/Glossary/AssignTerm.tsx | 1 +
dashboard/src/views/Glossary/DeleteGlossary.tsx | 52 +-
dashboard/src/views/SaveFilters/SaveFilters.tsx | 1 +
dashboard/src/views/SearchResult/SearchResult.tsx | 24 +-
.../src/views/SideBar/SideBarTree/SideBarTree.tsx | 23 +-
dashboard/src/views/Statistics/ServerStats.tsx | 7 +-
dashboard/src/views/Statistics/Statistics.tsx | 45 +-
63 files changed, 6330 insertions(+), 928 deletions(-)
diff --git a/dashboard/src/api/apiMethods/searchApiMethod.ts b/dashboard/src/api/apiMethods/searchApiMethod.ts
index 11a061e4f..8cc58f041 100644
--- a/dashboard/src/api/apiMethods/searchApiMethod.ts
+++ b/dashboard/src/api/apiMethods/searchApiMethod.ts
@@ -56,10 +56,62 @@ const getRelationShipV2 = (params: { params: Record<string, unknown> }) => {
return fetchApi(url, { method: "GET" });
};
+/** Atlas `SearchParameters.sortBy` / `sortOrder` (see `SortOrder` enum: DESCENDING). */
+const LATEST_ENTITIES_TIMESTAMP_SORT = "__timestamp" as const;
+
+type LatestEntitiesSearchOptions = {
+ limit: number;
+ includeSubClassifications: boolean;
+};
+
+/**
+ * Request `__timestamp` so sort + “Created … ago” work. Do not set
+ * `excludeHeaderAttributes`: for `_ALL_ENTITY_TYPES`, Atlas validates each
+ * `attributes` entry against `__ENTITY_ROOT` and rejects `name` / `qualifiedName`
+ * / `guid` (see `excludeHeaderAttributesAllEntityType` in Atlas tests). Normal
+ * headers then include name, guid, typeName like the main basic search.
+ */
+const buildLatestEntitiesBasicBody = (opts: LatestEntitiesSearchOptions) => {
+ return {
+ typeName: "_ALL_ENTITY_TYPES",
+ excludeDeletedEntities: true,
+ includeClassificationAttributes: true,
+ includeSubTypes: true,
+ includeSubClassifications: opts.includeSubClassifications,
+ limit: opts.limit,
+ offset: 0,
+ tagFilters: null,
+ entityFilters: null,
+ classification: null,
+ termName: null,
+ relationshipFilters: null,
+ attributes: ["__timestamp"],
+ sortBy: LATEST_ENTITIES_TIMESTAMP_SORT,
+ sortOrder: "DESCENDING",
+ };
+};
+
+/**
+ * Dashboard card only: newest entities by `__timestamp`, no entity filters,
+ * sub-classifications off, full entity headers for name/guid/type.
+ */
+const getLatestEntities = () => {
+ return getBasicSearchResult(
+ {
+ data: buildLatestEntitiesBasicBody({
+ limit: 7,
+ includeSubClassifications: false,
+ }),
+ },
+ "basic"
+ );
+};
+
export {
- getBasicSearchResult,
- getRelationShipResult,
- getGlobalSearchResult,
- getRelationShip,
- getRelationShipV2
+ getBasicSearchResult,
+ getRelationShipResult,
+ getGlobalSearchResult,
+ getRelationShip,
+ getRelationShipV2,
+ getLatestEntities
};
diff --git a/dashboard/src/components/CreateDropdown/CreateDropdown.tsx b/dashboard/src/components/CreateDropdown/CreateDropdown.tsx
new file mode 100644
index 000000000..cb32d4e2a
--- /dev/null
+++ b/dashboard/src/components/CreateDropdown/CreateDropdown.tsx
@@ -0,0 +1,176 @@
+/*
+ * 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 { useState, useCallback } from "react";
+import { Button, Menu, MenuItem } from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import CategoryIcon from "@mui/icons-material/Category";
+import LocalOfferIcon from "@mui/icons-material/LocalOffer";
+import MenuBookIcon from "@mui/icons-material/MenuBook";
+import BusinessCenterIcon from "@mui/icons-material/BusinessCenter";
+import ListIcon from "@mui/icons-material/List";
+import { useNavigate } from "react-router-dom";
+import EntityForm from "@views/Entity/EntityForm";
+import ClassificationForm from "@views/Classification/ClassificationForm";
+import AddUpdateGlossaryForm from "@views/Glossary/AddUpdateGlossaryForm";
+const CreateDropdown = () => {
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
+ const [entityModal, setEntityModal] = useState(false);
+ const [classificationModal, setClassificationModal] = useState(false);
+ const [glossaryModal, setGlossaryModal] = useState(false);
+ const navigate = useNavigate();
+
+ const open = Boolean(anchorEl);
+
+ const handleClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
+ setAnchorEl(event.currentTarget);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setAnchorEl(null);
+ }, []);
+
+ const handleEntityClick = useCallback(() => {
+ handleClose();
+ setEntityModal(true);
+ }, [handleClose]);
+
+ const handleClassificationClick = useCallback(() => {
+ handleClose();
+ setClassificationModal(true);
+ }, [handleClose]);
+
+ const handleGlossaryClick = useCallback(() => {
+ handleClose();
+ setGlossaryModal(true);
+ }, [handleClose]);
+
+ const handleBusinessMetadataClick = useCallback(() => {
+ handleClose();
+ navigate({
+ pathname: "/administrator",
+ search: "tabActive=businessMetadata&create=true"
+ });
+ }, [handleClose, navigate]);
+
+ const handleEnumClick = useCallback(() => {
+ handleClose();
+ navigate({
+ pathname: "/administrator",
+ search: "tabActive=enum"
+ });
+ }, [handleClose, navigate]);
+
+ return (
+ <>
+ <Button
+ variant="contained"
+ color="primary"
+ size="small"
+ onClick={handleClick}
+ endIcon={<ExpandMoreIcon />}
+ startIcon={<AddIcon />}
+ aria-controls={open ? "create-menu" : undefined}
+ aria-haspopup="true"
+ aria-expanded={open ? "true" : undefined}
+ data-cy="create-dropdown"
+ >
+ Create
+ </Button>
+ <Menu
+ id="create-menu"
+ anchorEl={anchorEl}
+ open={open}
+ onClose={handleClose}
+ anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
+ transformOrigin={{ horizontal: "left", vertical: "top" }}
+ slotProps={{
+ paper: {
+ elevation: 2,
+ sx: { mt: 1.5, minWidth: 200 }
+ }
+ }}
+ >
+ <MenuItem
+ onClick={handleEntityClick}
+ data-cy="create-entity"
+ sx={{ gap: 1.5 }}
+ >
+ <CategoryIcon fontSize="small" />
+ Entity
+ </MenuItem>
+ <MenuItem
+ onClick={handleClassificationClick}
+ data-cy="create-classification"
+ sx={{ gap: 1.5 }}
+ >
+ <LocalOfferIcon fontSize="small" />
+ Classification
+ </MenuItem>
+ <MenuItem
+ onClick={handleGlossaryClick}
+ data-cy="create-glossary"
+ sx={{ gap: 1.5 }}
+ >
+ <MenuBookIcon fontSize="small" />
+ Glossary
+ </MenuItem>
+ <MenuItem
+ onClick={handleBusinessMetadataClick}
+ data-cy="create-business-metadata"
+ sx={{ gap: 1.5 }}
+ >
+ <BusinessCenterIcon fontSize="small" />
+ Business Metadata
+ </MenuItem>
+ <MenuItem
+ onClick={handleEnumClick}
+ data-cy="create-enum"
+ sx={{ gap: 1.5 }}
+ >
+ <ListIcon fontSize="small" />
+ Enum
+ </MenuItem>
+ </Menu>
+
+ {entityModal && (
+ <EntityForm
+ open={entityModal}
+ onClose={() => setEntityModal(false)}
+ />
+ )}
+ {classificationModal && (
+ <ClassificationForm
+ open={classificationModal}
+ isAdd={true}
+ onClose={() => setClassificationModal(false)}
+ />
+ )}
+ {glossaryModal && (
+ <AddUpdateGlossaryForm
+ open={glossaryModal}
+ isAdd={true}
+ onClose={() => setGlossaryModal(false)}
+ node={undefined}
+ />
+ )}
+ </>
+ );
+};
+
+export default CreateDropdown;
diff --git a/dashboard/src/components/CreateDropdown/index.ts b/dashboard/src/components/CreateDropdown/index.ts
new file mode 100644
index 000000000..8f1e1ff80
--- /dev/null
+++ b/dashboard/src/components/CreateDropdown/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+export { default } from "./CreateDropdown";
diff --git a/dashboard/src/components/FilterQuery.tsx b/dashboard/src/components/FilterQuery.tsx
index 69ef98fd2..20351c33a 100644
--- a/dashboard/src/components/FilterQuery.tsx
+++ b/dashboard/src/components/FilterQuery.tsx
@@ -22,8 +22,12 @@ import { attributeFilter } from "@utils/CommonViewFunction";
import {
queryBuilderDateRangeUIValueToAPI,
systemAttributes,
- filterQueryValue
+ filterQueryValue,
+ getDisplayOperator
} from "@utils/Enum";
+import { removeCriterionFromFilterUrl } from "@utils/filterUrlCriterionRemoval";
+import type { ParsedApiFilter } from "@utils/filterUrlCriterionRemoval";
+import { dateTimeFormat } from "@utils/Global";
import moment from "moment";
import { useLocation, useNavigate } from "react-router-dom";
import { globalSearchFilterInitialQuery } from "@utils/Utils";
@@ -45,13 +49,18 @@ export const FilterQuery = ({ value }: any) => {
</Typography>
);
} else {
- if (obj.type === "date" || obj.id == "createTime") {
+ const isDateAttr =
+ obj.type === "date" ||
+ obj.id === "createTime" ||
+ obj.id === "__timestamp" ||
+ obj.id === "__modificationTimestamp";
+ if (isDateAttr) {
if (queryBuilderDateRangeUIValueToAPI[obj.value]) {
obj.value = queryBuilderDateRangeUIValueToAPI[obj.value];
+ } else if (/^\d+$/.test(String(obj.value))) {
+ obj.value = `${moment(Number(obj.value)).format(dateTimeFormat)} (${moment.tz(moment.tz.guess()).zoneAbbr()})`;
} else {
- obj.value = `${obj.value} (${moment
- .tz(moment.tz.guess())
- .zoneAbbr()})`;
+ obj.value = `${obj.value} (${moment.tz(moment.tz.guess()).zoneAbbr()})`;
}
}
@@ -60,6 +69,7 @@ export const FilterQuery = ({ value }: any) => {
color="primary"
className="chip-items"
data-type={type}
+ data-rule-key={key}
data-id={`${obj.id}${key}`}
data-cy={`${obj.id}${key}`}
label={
@@ -71,7 +81,7 @@ export const FilterQuery = ({ value }: any) => {
: obj.id}
</Typography>
<Typography className="operator" fontWeight="600">
- {obj.operator}{" "}
+ {getDisplayOperator(obj.operator)}{" "}
</Typography>
<Typography className="searchValue">
{filterQueryValue[obj.id]
@@ -97,9 +107,138 @@ export const FilterQuery = ({ value }: any) => {
const clearQueryAttr = (e: any) => {
const searchParams = new URLSearchParams(location.search);
- const currentType: any = e?.target
- ?.closest("[data-type]")
- ?.getAttribute("data-type");
+ const chipEl = (e?.target as HTMLElement)?.closest?.(
+ "[data-type]"
+ ) as HTMLElement | null;
+ const currentType = chipEl?.getAttribute("data-type");
+ const ruleKeyRaw = chipEl?.getAttribute("data-rule-key");
+
+ const navigateAfterChange = (sp: URLSearchParams) => {
+ const meaningfulFilterParams = [
+ "type",
+ "tag",
+ "query",
+ "term",
+ "relationshipName",
+ "entityFilters",
+ "tagFilters",
+ "relationshipFilters",
+ "excludeST",
+ "excludeSC",
+ "includeDE"
+ ];
+ const hasMeaningfulFilters = meaningfulFilterParams.some((param) =>
+ sp.has(param)
+ );
+ if (!hasMeaningfulFilters) {
+ navigate({
+ pathname: "/search"
+ });
+ } else if ([...sp]?.length <= 1) {
+ navigate({
+ pathname: "/search"
+ });
+ } else {
+ navigate({
+ pathname: "/search/searchResult",
+ search: sp.toString()
+ });
+ }
+ };
+
+ const syncFilterParamGlobalState = (
+ paramKey: "entityFilters" | "tagFilters" | "relationshipFilters",
+ urlValue: string | null
+ ) => {
+ if (!urlValue) {
+ globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] });
+ return;
+ }
+ const api = attributeFilter.extractUrl({
+ value: urlValue,
+ apiObj: true
+ }) as ParsedApiFilter | null;
+ if (!api?.criterion || !Array.isArray(api.criterion)) {
+ globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] });
+ return;
+ }
+ globalSearchFilterInitialQuery.setQuery({
+ [paramKey]: {
+ combinator: String(api.condition || "AND").toLowerCase(),
+ rules: api.criterion.map((rule: any, i: number) => ({
+ id: `url-rule-${i}`,
+ field: rule.attributeName,
+ operator: getDisplayOperator(rule.operator) || rule.operator,
+ value: rule.attributeValue,
+ ...(rule.type ? { type: rule.type } : {})
+ }))
+ }
+ });
+ };
+
+ const isFilterCriterionChip =
+ ruleKeyRaw !== null &&
+ ruleKeyRaw !== "" &&
+ (currentType === "entityFilters" ||
+ currentType === "tagFilters" ||
+ currentType === "relationshipFilters");
+
+ if (isFilterCriterionChip) {
+ const idx = Number.parseInt(ruleKeyRaw as string, 10);
+ if (Number.isNaN(idx)) {
+ return;
+ }
+ const paramKey = currentType as
+ | "entityFilters"
+ | "tagFilters"
+ | "relationshipFilters";
+ const raw = searchParams.get(paramKey);
+ if (!raw) {
+ return;
+ }
+ const allowSimplify = paramKey === "entityFilters";
+ const result = removeCriterionFromFilterUrl(raw, idx, allowSimplify);
+ if (!result) {
+ return;
+ }
+ if (result.kind === "empty") {
+ searchParams.delete(paramKey);
+ globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] });
+ navigateAfterChange(searchParams);
+ return;
+ }
+ if (result.kind === "singleTypeName") {
+ searchParams.set("type", result.typeName);
+ searchParams.delete("entityFilters");
+ if (searchParams.get("includeDE") === "true") {
+ const delUrl = attributeFilter.generateUrl({
+ value: {
+ condition: "AND",
+ criterion: [
+ {
+ attributeName: "__state",
+ operator: "eq",
+ attributeValue: "DELETED"
+ }
+ ]
+ }
+ });
+ if (delUrl) {
+ searchParams.set("entityFilters", delUrl);
+ }
+ }
+ syncFilterParamGlobalState(
+ "entityFilters",
+ searchParams.get("entityFilters")
+ );
+ navigateAfterChange(searchParams);
+ return;
+ }
+ searchParams.set(paramKey, result.url);
+ syncFilterParamGlobalState(paramKey, result.url);
+ navigateAfterChange(searchParams);
+ return;
+ }
if (currentType == "term") {
searchParams.delete("gtype");
@@ -127,43 +266,11 @@ export const FilterQuery = ({ value }: any) => {
globalSearchFilterInitialQuery.setQuery({ [currentType]: [] });
}
- searchParams.delete(currentType);
-
- // Check if there are any meaningful filters left after removal
- const meaningfulFilterParams = [
- "type",
- "tag",
- "query",
- "term",
- "relationshipName",
- "entityFilters",
- "tagFilters",
- "relationshipFilters",
- "excludeST",
- "excludeSC",
- "includeDE"
- ];
-
- const hasMeaningfulFilters = meaningfulFilterParams.some((param) =>
- searchParams.has(param)
- );
-
- // If no meaningful filters remain, navigate to clear all (like Clear button)
- if (!hasMeaningfulFilters) {
- navigate({
- pathname: "/search"
- });
- } else if ([...searchParams]?.length <= 1) {
- // Only searchType or other system params remain
- navigate({
- pathname: "/search"
- });
- } else {
- navigate({
- pathname: "/search/searchResult",
- search: searchParams.toString()
- });
+ if (currentType) {
+ searchParams.delete(currentType);
}
+
+ navigateAfterChange(searchParams);
};
if (value.type) {
diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx
index 272ee107d..c8eba9490 100644
--- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx
+++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx
@@ -16,518 +16,715 @@
*/
import {
- Autocomplete,
- CircularProgress,
- InputAdornment,
- Stack,
- TextField,
- Typography
+ Autocomplete,
+ CircularProgress,
+ FormControl,
+ InputAdornment,
+ MenuItem,
+ Select,
+ Stack,
+ TextField,
+ Typography,
+ type SelectChangeEvent
} from "@mui/material";
-import { useRef, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { getGlobalSearchResult } from "../../api/apiMethods/searchApiMethod";
import DisplayImage from "../EntityDisplayImage";
import SearchIcon from "@mui/icons-material/Search";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { entityStateReadOnly } from "../../utils/Enum";
import {
- extractKeyValueFromEntity,
- isEmpty,
- serverError
+ extractKeyValueFromEntity,
+ isEmpty,
+ serverError
} from "../../utils/Utils";
import parse from "autosuggest-highlight/parse";
import match from "autosuggest-highlight/match";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import AdvancedSearch from "./AdvancedSearch";
import {
- HandleValuesType,
- QuickSearchOptionListType,
- SuggestionDataType
+ HandleValuesType,
+ QuickSearchOptionListType,
+ SuggestionDataType
} from "../../models/globalSearchType";
import { AxiosResponse } from "axios";
import { CustomButton } from "@components/muiComponents";
+import { useAppSelector } from "@hooks/reducerHook";
+import {
+ buildBusinessMetadataScopedOptions,
+ buildClassificationScopedOptions,
+ buildEntitiesTreeForQuickSearch,
+ buildGlossaryTermsTreeForQuickSearch,
+ entityTreeToScopedOptions,
+ glossaryTreeToScopedOptions,
+ type QuickSearchScope,
+ type ScopedQuickSearchOption
+} from "@utils/scopedQuickSearchUtils";
+import {
+ navigateToBasicTextQuery,
+ navigateToBusinessMetadataDetailPage,
+ navigateToClassificationDetailPage,
+ navigateToGlossaryTermDetailPage,
+ navigateToServiceTypeEntitySearch
+} from "@utils/dashboardSearchUtils";
+
+interface GlobalOptionRow {
+ title: string;
+ types: string;
+ entityObj?: any;
+ scoped?: ScopedQuickSearchOption;
+}
+
+const SCOPE_SELECT_ID = "quick-search-scope-select";
+
+const SCOPE_LABELS: Record<QuickSearchScope, string> = {
+ default: "Select All",
+ entity: "Entity",
+ classification: "Classification",
+ glossary: "Glossary / Terms",
+ businessMetadata: "Business Metadata"
+};
const QuickSearch = () => {
- const navigate = useNavigate();
- const location = useLocation();
- const toastId = useRef(null);
- const searchParams = new URLSearchParams(location.search);
- const [options, setOptions] = useState<any>([]);
- const [open, setOpen] = useState<boolean>(false);
- const [openAdvanceSearch, setOpenAdvanceSearch] = useState<boolean>(false);
- const [loading, setLoading] = useState<boolean>(false);
- const [value, setValue] = useState<string>("");
-
- const getData = async (searchTerm: string) => {
- let entities: QuickSearchOptionListType = [];
- let suggestionNames: QuickSearchOptionListType = [];
- let quickSearchResp: AxiosResponse | null = null;
- let suggestionSearchResp: AxiosResponse | null = null;
- try {
- setLoading(true);
- quickSearchResp = await getGlobalSearchResult("quick", {
- params: { query: searchTerm, limit: 5, offset: 0 }
- });
- setLoading(false);
- } catch (error) {
- setLoading(false);
- console.error("Error fetching quick search results:", error);
- serverError(error, toastId);
- }
-
- try {
- setLoading(true);
- suggestionSearchResp = await getGlobalSearchResult("suggestions", {
- params: { prefixString: searchTerm }
- });
- setLoading(false);
- } catch (error) {
- setLoading(false);
- console.error("Error fetching suggestion search results:", error);
- serverError(error, toastId);
- }
- const { searchResults = [] } = quickSearchResp?.data || {};
- const { suggestions = [] }: SuggestionDataType =
- suggestionSearchResp?.data || {};
-
- entities = !isEmpty(searchResults?.entities)
- ? searchResults?.entities?.map((entityDef: any) => {
- const { name }: { name: string; found: boolean; key: any } =
- extractKeyValueFromEntity(entityDef);
- return {
- title: `${name}`,
- parent: entityDef.typeName,
- types: "Entities",
- entityObj: entityDef
- };
- })
- : [{ title: "No Entities Found", types: "Entities" }];
-
- suggestionNames = !isEmpty(suggestions)
- ? suggestions.map((suggestion: any) => {
- return {
- title: `${suggestion}`,
- types: "Suggestions"
- };
- })
- : [{ title: "No Suggestions Found", types: "Suggestions" }];
-
- setOptions([...entities, ...suggestionNames]);
- };
-
- const onInputChange = (_event: any, value: string) => {
- // Sanitize input: remove any potential script tags and validate
- const sanitizedValue = value ? value.trim() : "";
-
- if (sanitizedValue) {
- setOpen(true);
- getData(sanitizedValue);
- } else {
- setOptions([]);
- setValue("");
- setOpen(false);
- }
- };
-
- const handleValues = (option: HandleValuesType | string | null) => {
- // Handle case when option is a string (direct search query)
- if (typeof option === "string") {
- const queryValue = option.trim();
- if (queryValue) {
- setOpen(false);
- setOptions([]);
- // URLSearchParams automatically encodes the value, preventing XSS
- // Additional validation: ensure query is not empty after trim
- const sanitizedQuery = queryValue || "*";
- searchParams.set("query", sanitizedQuery);
- searchParams.set("searchType", "basic");
- navigate(
- {
- pathname: `/search/searchResult`,
- search: searchParams.toString()
- },
- { replace: true }
- );
- }
- return;
- }
-
- // Handle case when option is null or undefined
- if (!option || typeof option !== "object") {
- return;
- }
-
- const { entityObj, title, types } = option;
-
- // Validate that title exists and is not undefined
- if (!title || title === "undefined" || typeof title !== "string") {
- return;
- }
-
- // Sanitize title: trim and validate
- const sanitizedTitle = title.trim();
- if (!sanitizedTitle) {
- return;
- }
-
- setOpen(false);
- setOptions([]);
- // URLSearchParams automatically encodes the value, preventing XSS
- searchParams.set("query", sanitizedTitle);
- searchParams.set("searchType", "basic");
-
- if (types === "Entities" && entityObj && entityObj.guid) {
- navigate(
- {
- pathname: `/detailPage/${entityObj.guid}`
- },
- { replace: true }
- );
- } else {
- navigate(
- {
- pathname: `/search/searchResult`,
- search: searchParams.toString()
- },
- { replace: true }
- );
- }
- };
-
- const handleClickAway = () => {
- setOpen(false);
- };
-
- const handleCloseModal = () => {
- setOpenAdvanceSearch(false);
- };
-
- return (
- <>
- <Stack
- direction="row"
- className="global-search-stack"
- alignItems="center"
- gap="0.5rem"
- >
- <ClickAwayListener onClickAway={handleClickAway}>
- <Autocomplete
- open={open}
- loading={loading}
- onKeyDown={(e) => {
- const code = e.keyCode || e.which;
-
- switch (code) {
- case 13: { // Enter key
- e.preventDefault();
- const inputValue = (e.target as HTMLInputElement).value.trim();
-
- // If input is empty, use "*" for wildcard search (matching classic UI behavior)
- const searchQuery = inputValue === "" ? "*" : inputValue;
-
- // Try to find exact match in options
- const activeOption = options.find(
- (option: { title: any }) =>
- typeof option !== "string" &&
- option.title === searchQuery
- );
-
- if (activeOption) {
- // If exact match found, use that option
- handleValues(activeOption as HandleValuesType);
- } else {
- // If no match found, trigger basic search with typed value (matching classic UI behavior)
- handleValues(searchQuery);
- }
- break;
- }
- case 9: // Tab key
- case 27: // Escape key
- setOpen(false);
- break;
- default:
- break;
- }
- }}
- freeSolo
- id="global-search"
- disablePortal
- className="global-search-autocomplete"
- sx={{
- "& + .MuiAutocomplete-popper .MuiAutocomplete-option": {
- backgroundColor: "white"
- },
- "& + .MuiAutocomplete-popper .MuiAutocomplete-option:hover": {
- backgroundColor: "#c7e3ff"
- }
- }}
- value={value}
- onChange={(_event: any, newValue: any) => {
- if (newValue) {
- // Only update options if newValue is a valid option object
- if (typeof newValue === "object" && newValue.title) {
- setOptions([newValue, ...options]);
- }
- setValue(newValue);
- // Only call handleValues if newValue is a valid option
- if (typeof newValue === "object" && newValue.title && newValue.title !== "undefined") {
- handleValues(newValue);
- }
- } else {
- setValue("");
- }
- }}
- clearOnBlur={false}
- autoComplete={true}
- includeInputInList
- noOptionsText={"No Entities"}
- disableClearable
- onInputChange={onInputChange}
- getOptionLabel={(option: string | QuickSearchOptionListType) => {
- if (typeof option === "string") {
- return option;
- }
- // Safely extract title, defaulting to empty string if undefined
- const title = (option as any)?.title;
- return title && typeof title === "string" ? title : "";
- }}
- renderOption={(props, option, { inputValue }) => {
- const { entityObj, types, parent } =
- typeof option !== "string" &&
- "entityObj" in option &&
- "types" in option &&
- "parent" in option
- ? (option as {
- entityObj: { status?: string; guid?: string };
- types: string;
- parent: string;
- })
- : { entityObj: null, types: "", parent: "" };
- typeof option !== "string" &&
- "entityObj" in option &&
- "types" in option
- ? option
- : { entityObj: null, types: "", parent: "" };
- const title =
- typeof option !== "string" && "title" in option
- ? option.title
- : option;
-
- // Validate and sanitize title to prevent XSS
- const safeTitle = typeof title === "string" ? title : "";
-
- // Validate guid to prevent XSS in URL
- const guid = (entityObj as { guid?: string })?.guid;
- const safeGuid = guid && typeof guid === "string" ? guid : "";
- const href = safeGuid ? `/detailPage/${safeGuid}` : "#";
-
- const { name }: { name: string; found: boolean; key: any } =
- extractKeyValueFromEntity(entityObj);
-
- // Safely handle name extraction
- const safeName = name && typeof name === "string" ? name : "";
-
- const matches = match(
- types === "Entities" ? safeName : safeTitle,
- inputValue || "",
- {
- findAllOccurrences: true,
- insideWords: true
- }
- );
- const parts = parse(
- types === "Entities" ? safeName : safeTitle,
- matches
- );
- return (
- <Stack
- flexDirection="row"
- component="li"
- className="global-search-options"
- sx={{
- "& > span": {
- mr: 2,
- flexShrink: 0
- }
- }}
- {...props}
- onClick={() => {
- if (typeof option !== "string") {
- handleValues(option as unknown as HandleValuesType);
- }
- }}
- >
- {types === "Entities" && !isEmpty(entityObj) ? (
- <Link
- className="entity-name text-decoration-none"
- style={{
- maxWidth: "100%",
- width: "100%",
- color: "black",
- textDecoration: "none",
- display: "inline-flex",
- alignItems: "center",
- flexWrap: "wrap"
- }}
- to={{
- pathname: href
- }}
- color={
- entityObj?.status &&
- entityStateReadOnly[entityObj.status]
- ? "error"
- : "primary"
- }
- >
- {" "}
- {types === "Entities" && !isEmpty(entityObj) && (
- <DisplayImage entity={entityObj} />
- )}{" "}
- {types === "Entities" && !isEmpty(entityObj)
- ? parts.map((part, index) => (
- <Stack
- flexDirection="row"
- key={index}
- style={{
- fontWeight: part.highlight ? "bold" : "regular"
- }}
- >
- {entityObj?.guid !== "-1" && !part.highlight ? (
- <Link
- className="entity-name text-blue text-decoration-none"
- style={{
- color: "black",
- textDecoration: "none",
- maxWidth: "100%",
- width: "100%"
- }}
- to={{
- pathname: href
- }}
- color={
- entityObj?.status &&
- entityStateReadOnly[entityObj.status]
- ? "error"
- : "primary"
- }
- >
- {part.text}
- </Link>
- ) : (
- part.text
- )}
- </Stack>
- ))
- : parts.map((part, index) => (
- <Stack
- flexDirection="row"
- key={index}
- style={{
- fontWeight: part.highlight ? "bold" : "regular"
- }}
- >
- {part.text}
- </Stack>
- ))}
- {types === "Entities" &&
- !isEmpty(entityObj) &&
- ` (${parent})`}
- </Link>
- ) : (
- parts.map((part, index) => (
- <Typography
- component="p"
- key={index}
- className="global-search-options-text"
- sx={{
- fontWeight: part.highlight ? "bold" : "regular"
- }}
- >
- {" "}
- {part.text}
- </Typography>
- ))
- )}
- </Stack>
- );
- }}
- renderInput={(params) => (
- <TextField
- {...params}
- placeholder="Search Entities..."
- fullWidth
- onClick={() => {
- setOpen(true);
- }}
- className="text-black-default"
- InputProps={{
- style: {
- padding: "1px 10px",
- borderRadius: "4px",
- color: "white !important",
- opacity: 1
- },
- ...params.InputProps,
- type: "search",
- endAdornment: (
- <InputAdornment position="start">
- {loading ? (
- <CircularProgress sx={{ color: "gray" }} size={15} />
- ) : (
- <SearchIcon fontSize="small" />
- )}
- </InputAdornment>
- )
- }}
- />
- )}
- groupBy={(option) =>
- typeof option !== "string" && "types" in option
- ? String(option.types)
- : ""
- }
- options={options}
- filterOptions={(options) => options}
- />
- </ClickAwayListener>
-
- <CustomButton
- variant="outlined"
- size="small"
- sx={{
- backgroundColor: "white !important",
- color: "#4a90e2 !important",
- borderColor: "#dddddd !important",
- "&:hover": {
- backgroundColor: "rgba(74, 144, 226, 0.08) !important",
- // borderColor: "#4a90e2 !important",
- color: "#4a90e2 !important"
- }
- }}
- onClick={() => {
- setOpenAdvanceSearch(true);
- }}
- >
- <Typography
- sx={{
- color: "#4a90e2 !important",
- fontWeight: "600 !important",
- fontSize: "0.875rem !important"
- }}
- display="inline"
- >
- Advanced
- </Typography>
- </CustomButton>
- </Stack>
-
- {openAdvanceSearch && (
- <AdvancedSearch
- openAdvanceSearch={openAdvanceSearch}
- handleCloseModal={handleCloseModal}
- />
- )}
- </>
- );
+ const navigate = useNavigate();
+ const location = useLocation();
+ const toastId = useRef(null);
+ const searchParams = new URLSearchParams(location.search);
+ const [options, setOptions] = useState<GlobalOptionRow[]>([]);
+ const [open, setOpen] = useState<boolean>(false);
+ const [openAdvanceSearch, setOpenAdvanceSearch] = useState<boolean>(false);
+ const [loading, setLoading] = useState<boolean>(false);
+ const [inputText, setInputText] = useState<string>("");
+ const [scope, setScope] = useState<QuickSearchScope>("default");
+
+ const { typeHeaderData } = useAppSelector((state: any) => state.typeHeader);
+ const { metricsData } = useAppSelector((state: any) => state.metrics);
+ const { allEntityTypesData } = useAppSelector((state: any) => state.allEntityTypes);
+ const { classificationData } = useAppSelector((state: any) => state.classification);
+ const { glossaryData } = useAppSelector((state: any) => state.glossary);
+ const { businessMetaData } = useAppSelector((state: any) => state.businessMetaData);
+
+ const entityTree = useMemo(
+ () =>
+ buildEntitiesTreeForQuickSearch(
+ typeHeaderData,
+ metricsData?.data?.entity,
+ allEntityTypesData?.category
+ ),
+ [typeHeaderData, metricsData, allEntityTypesData]
+ );
+
+ const rebuildScopedOptions = useCallback(
+ (term: string): GlobalOptionRow[] => {
+ if (scope === "entity") {
+ return entityTreeToScopedOptions(entityTree, term).map((o) => ({
+ title: o.title,
+ types: o.group,
+ scoped: o
+ }));
+ }
+ if (scope === "classification") {
+ return buildClassificationScopedOptions(
+ classificationData,
+ metricsData?.data?.tag?.tagEntities,
+ term
+ ).map((o) => ({
+ title: o.title,
+ types: o.group,
+ scoped: o
+ }));
+ }
+ if (scope === "glossary") {
+ const gTree = buildGlossaryTermsTreeForQuickSearch(glossaryData);
+ return glossaryTreeToScopedOptions(gTree, term).map((o) => ({
+ title: o.title,
+ types: o.group,
+ scoped: o
+ }));
+ }
+ if (scope === "businessMetadata") {
+ return buildBusinessMetadataScopedOptions(
+ businessMetaData?.businessMetadataDefs,
+ term
+ ).map((o) => ({
+ title: o.title,
+ types: o.group,
+ scoped: o
+ }));
+ }
+ return [];
+ },
+ [
+ scope,
+ entityTree,
+ classificationData,
+ metricsData,
+ glossaryData,
+ businessMetaData
+ ]
+ );
+
+ const getGlobalSearchData = async (searchTerm: string) => {
+ let entities: QuickSearchOptionListType = [];
+ let suggestionNames: QuickSearchOptionListType = [];
+ let quickSearchResp: AxiosResponse | null = null;
+ let suggestionSearchResp: AxiosResponse | null = null;
+ try {
+ setLoading(true);
+ quickSearchResp = await getGlobalSearchResult("quick", {
+ params: { query: searchTerm, limit: 5, offset: 0 }
+ });
+ setLoading(false);
+ } catch (error) {
+ setLoading(false);
+ console.error("Error fetching quick search results:", error);
+ serverError(error, toastId);
+ }
+
+ try {
+ setLoading(true);
+ suggestionSearchResp = await getGlobalSearchResult("suggestions", {
+ params: { prefixString: searchTerm }
+ });
+ setLoading(false);
+ } catch (error) {
+ setLoading(false);
+ console.error("Error fetching suggestion search results:", error);
+ serverError(error, toastId);
+ }
+ const { searchResults = [] } = quickSearchResp?.data || {};
+ const { suggestions = [] }: SuggestionDataType =
+ suggestionSearchResp?.data || {};
+
+ entities = !isEmpty(searchResults?.entities)
+ ? searchResults?.entities?.map((entityDef: any) => {
+ const { name }: { name: string; found: boolean; key: any } =
+ extractKeyValueFromEntity(entityDef);
+ return {
+ title: `${name}`,
+ parent: entityDef.typeName,
+ types: "Entities",
+ entityObj: entityDef
+ };
+ })
+ : [{ title: "No Entities Found", types: "Entities" }];
+
+ suggestionNames = !isEmpty(suggestions)
+ ? suggestions.map((suggestion: any) => {
+ return {
+ title: `${suggestion}`,
+ types: "Suggestions"
+ };
+ })
+ : [{ title: "No Suggestions Found", types: "Suggestions" }];
+
+ setOptions([...entities, ...suggestionNames] as GlobalOptionRow[]);
+ };
+
+ const onInputChange = (_event: unknown, value: string) => {
+ const sanitizedValue = value ? value.trim() : "";
+ setInputText(value ?? "");
+ if (!sanitizedValue) {
+ setOptions([]);
+ setOpen(false);
+ return;
+ }
+ setOpen(true);
+ if (scope === "default") {
+ void getGlobalSearchData(sanitizedValue);
+ } else {
+ setOptions(rebuildScopedOptions(sanitizedValue));
+ }
+ };
+
+ const handleScopedSelection = (opt: ScopedQuickSearchOption) => {
+ setOpen(false);
+ setOptions([]);
+ setInputText("");
+ if (opt.kind === "entity-type" && opt.entityTypeName) {
+ navigateToBasicTextQuery(navigate, opt.entityTypeName);
+ return;
+ }
+ if (opt.kind === "entity-service" && opt.serviceUnderlyingTypeNames?.length) {
+ navigateToServiceTypeEntitySearch(
+ navigate,
+ opt.serviceUnderlyingTypeNames,
+ false
+ );
+ return;
+ }
+ if (opt.kind === "classification" && opt.classificationName) {
+ navigateToClassificationDetailPage(navigate, opt.classificationName);
+ return;
+ }
+ if (
+ opt.kind === "glossary-term" &&
+ opt.termGuid &&
+ opt.glossaryGuid &&
+ opt.termId &&
+ opt.termParent
+ ) {
+ navigateToGlossaryTermDetailPage(navigate, {
+ termGuid: opt.termGuid,
+ termId: opt.termId,
+ glossaryGuid: opt.glossaryGuid,
+ parentName: opt.termParent
+ });
+ return;
+ }
+ if (opt.kind === "business-metadata" && opt.bmGuid) {
+ navigateToBusinessMetadataDetailPage(navigate, opt.bmGuid);
+ }
+ };
+
+ const handleValues = (option: HandleValuesType | GlobalOptionRow | string | null) => {
+ if (typeof option === "string") {
+ const queryValue = option.trim();
+ if (!queryValue) return;
+ setOpen(false);
+ setOptions([]);
+ setInputText("");
+ if (scope !== "default") {
+ return;
+ }
+ const sanitizedQuery = queryValue || "*";
+ searchParams.set("query", sanitizedQuery);
+ searchParams.set("searchType", "basic");
+ navigate(
+ {
+ pathname: `/search/searchResult`,
+ search: searchParams.toString()
+ },
+ { replace: true }
+ );
+ return;
+ }
+
+ if (!option || typeof option !== "object") return;
+
+ const row = option as GlobalOptionRow;
+ if (row.scoped) {
+ handleScopedSelection(row.scoped);
+ return;
+ }
+
+ const { entityObj, title, types } = row as HandleValuesType;
+ if (!title || title === "undefined" || typeof title !== "string") return;
+
+ const sanitizedTitle = title.trim();
+ if (!sanitizedTitle) return;
+
+ setOpen(false);
+ setOptions([]);
+ setInputText("");
+
+ searchParams.set("query", sanitizedTitle);
+ searchParams.set("searchType", "basic");
+
+ if (types === "Entities" && entityObj && entityObj.guid) {
+ navigate(
+ {
+ pathname: `/detailPage/${entityObj.guid}`
+ },
+ { replace: true }
+ );
+ } else {
+ navigate(
+ {
+ pathname: `/search/searchResult`,
+ search: searchParams.toString()
+ },
+ { replace: true }
+ );
+ }
+ };
+
+ const handleSubmitSearch = () => {
+ const q = inputText.trim();
+ if (scope !== "default") {
+ const activeOption = options.find((o) => o.title === q);
+ if (activeOption?.scoped) {
+ handleScopedSelection(activeOption.scoped);
+ }
+ return;
+ }
+ if (!q) {
+ handleValues("*");
+ return;
+ }
+ const activeOption = options.find(
+ (o) => typeof o !== "string" && o.title === q
+ );
+ if (activeOption) {
+ handleValues(activeOption as HandleValuesType);
+ } else {
+ handleValues(q);
+ }
+ };
+
+ const handleScopeChange = (e: SelectChangeEvent<QuickSearchScope>) => {
+ const v = e.target.value as QuickSearchScope;
+ setScope(v);
+ setOptions([]);
+ setInputText("");
+ setOpen(false);
+ };
+
+ const handleClickAway = () => {
+ setOpen(false);
+ };
+
+ const handleCloseModal = () => {
+ setOpenAdvanceSearch(false);
+ };
+
+ const inputPlaceholder =
+ scope === "default" ? "Search Entities..." : "Contains text...";
+
+ return (
+ <>
+ <Stack
+ direction="row"
+ className="global-search-stack"
+ alignItems="center"
+ gap="0.5rem"
+ >
+ <FormControl
+ size="small"
+ sx={{ minWidth: 160, backgroundColor: "white", borderRadius: 1 }}
+ >
+ <Select<QuickSearchScope>
+ id={SCOPE_SELECT_ID}
+ value={scope}
+ onChange={handleScopeChange}
+ aria-label="Search scope"
+ displayEmpty
+ renderValue={(v) => SCOPE_LABELS[v as QuickSearchScope]}
+ >
+ <MenuItem value="default">Select All</MenuItem>
+ <MenuItem value="entity">Entity</MenuItem>
+ <MenuItem value="classification">Classification</MenuItem>
+ <MenuItem value="glossary">Glossary / Terms</MenuItem>
+ <MenuItem value="businessMetadata">Business Metadata</MenuItem>
+ </Select>
+ </FormControl>
+
+ <ClickAwayListener onClickAway={handleClickAway}>
+ <Autocomplete
+ open={open}
+ loading={loading}
+ inputValue={inputText}
+ onInputChange={onInputChange}
+ onKeyDown={(e) => {
+ const code = e.keyCode || e.which;
+ switch (code) {
+ case 13: {
+ e.preventDefault();
+ handleSubmitSearch();
+ break;
+ }
+ case 9:
+ case 27:
+ setOpen(false);
+ break;
+ default:
+ break;
+ }
+ }}
+ freeSolo
+ id="global-search"
+ disablePortal
+ className="global-search-autocomplete"
+ sx={{
+ minWidth: 280,
+ flex: 1,
+ "& + .MuiAutocomplete-popper .MuiAutocomplete-option": {
+ backgroundColor: "white"
+ },
+ "& + .MuiAutocomplete-popper .MuiAutocomplete-option:hover": {
+ backgroundColor: "#c7e3ff"
+ }
+ }}
+ value={null}
+ onChange={(_event: unknown, newValue: unknown) => {
+ if (newValue && typeof newValue === "object") {
+ const o = newValue as GlobalOptionRow;
+ if (o.title) {
+ handleValues(o);
+ }
+ }
+ }}
+ clearOnBlur={false}
+ autoComplete
+ includeInputInList
+ noOptionsText={scope === "default" ? "No Entities" : "No matches"}
+ getOptionLabel={(option: string | GlobalOptionRow) => {
+ if (typeof option === "string") return option;
+ return option?.title && typeof option.title === "string"
+ ? option.title
+ : "";
+ }}
+ renderOption={(props, option, { inputValue }) => {
+ const row = option as GlobalOptionRow;
+ if (row.scoped) {
+ const title = row.title;
+ const matches = match(title, inputValue || "", {
+ findAllOccurrences: true,
+ insideWords: true
+ });
+ const parts = parse(title, matches);
+ return (
+ <li {...props} key={row.scoped.id}>
+ <Typography component="span" variant="body2">
+ {parts.map((part, index) => (
+ <span
+ key={index}
+ style={{
+ fontWeight: part.highlight ? 700 : 400
+ }}
+ >
+ {part.text}
+ </span>
+ ))}
+ </Typography>
+ </li>
+ );
+ }
+
+ const { entityObj, types, parent } =
+ typeof option !== "string" &&
+ "entityObj" in option &&
+ "types" in option &&
+ "parent" in option
+ ? (option as {
+ entityObj: { status?: string; guid?: string };
+ types: string;
+ parent: string;
+ })
+ : { entityObj: null, types: "", parent: "" };
+ const title =
+ typeof option !== "string" && "title" in option
+ ? option.title
+ : option;
+ const safeTitle = typeof title === "string" ? title : "";
+ const guid = (entityObj as { guid?: string })?.guid;
+ const safeGuid = guid && typeof guid === "string" ? guid : "";
+ const href = safeGuid ? `/detailPage/${safeGuid}` : "#";
+ const { name }: { name: string; found: boolean; key: any } =
+ extractKeyValueFromEntity(entityObj);
+ const safeName = name && typeof name === "string" ? name : "";
+ const matches = match(
+ types === "Entities" ? safeName : safeTitle,
+ inputValue || "",
+ {
+ findAllOccurrences: true,
+ insideWords: true
+ }
+ );
+ const parts = parse(
+ types === "Entities" ? safeName : safeTitle,
+ matches
+ );
+ return (
+ <Stack
+ flexDirection="row"
+ component="li"
+ className="global-search-options"
+ sx={{
+ "& > span": {
+ mr: 2,
+ flexShrink: 0
+ }
+ }}
+ {...props}
+ onClick={() => {
+ handleValues(option as GlobalOptionRow);
+ }}
+ >
+ {types === "Entities" && !isEmpty(entityObj) ? (
+ <Link
+ className="entity-name text-decoration-none"
+ style={{
+ maxWidth: "100%",
+ width: "100%",
+ color: "black",
+ textDecoration: "none",
+ display: "inline-flex",
+ alignItems: "center",
+ flexWrap: "wrap"
+ }}
+ to={{ pathname: href }}
+ color={
+ entityObj?.status &&
+ entityStateReadOnly[entityObj.status]
+ ? "error"
+ : "primary"
+ }
+ >
+ {types === "Entities" && !isEmpty(entityObj) && (
+ <DisplayImage entity={entityObj} />
+ )}
+ {types === "Entities" && !isEmpty(entityObj)
+ ? parts.map((part, index) => (
+ <Stack
+ flexDirection="row"
+ key={index}
+ style={{
+ fontWeight: part.highlight ? "bold" : "regular"
+ }}
+ >
+ {entityObj?.guid !== "-1" && !part.highlight ? (
+ <Link
+ className="entity-name text-blue text-decoration-none"
+ style={{
+ color: "black",
+ textDecoration: "none",
+ maxWidth: "100%",
+ width: "100%"
+ }}
+ to={{ pathname: href }}
+ color={
+ entityObj?.status &&
+ entityStateReadOnly[entityObj.status]
+ ? "error"
+ : "primary"
+ }
+ >
+ {part.text}
+ </Link>
+ ) : (
+ part.text
+ )}
+ </Stack>
+ ))
+ : parts.map((part, index) => (
+ <Stack
+ flexDirection="row"
+ key={index}
+ style={{
+ fontWeight: part.highlight ? "bold" : "regular"
+ }}
+ >
+ {part.text}
+ </Stack>
+ ))}
+ {types === "Entities" &&
+ !isEmpty(entityObj) &&
+ ` (${parent})`}
+ </Link>
+ ) : (
+ parts.map((part, index) => (
+ <Typography
+ component="p"
+ key={index}
+ className="global-search-options-text"
+ sx={{
+ fontWeight: part.highlight ? "bold" : "regular"
+ }}
+ >
+ {part.text}
+ </Typography>
+ ))
+ )}
+ </Stack>
+ );
+ }}
+ renderInput={(params) => (
+ <TextField
+ {...params}
+ placeholder={inputPlaceholder}
+ fullWidth
+ onClick={() => {
+ setOpen(true);
+ }}
+ className="text-black-default"
+ InputProps={{
+ style: {
+ padding: "1px 10px",
+ borderRadius: "4px",
+ color: "#1a1a1a",
+ backgroundColor: "white"
+ },
+ ...params.InputProps,
+ type: "search",
+ endAdornment: (
+ <InputAdornment position="end">
+ {loading ? (
+ <CircularProgress sx={{ color: "gray" }} size={15} />
+ ) : (
+ <SearchIcon fontSize="small" sx={{ color: "gray" }} />
+ )}
+ </InputAdornment>
+ )
+ }}
+ inputProps={{
+ ...params.inputProps,
+ "aria-label": "Global search"
+ }}
+ />
+ )}
+ groupBy={(option) =>
+ typeof option !== "string" && "types" in option
+ ? String((option as GlobalOptionRow).types)
+ : ""
+ }
+ options={options}
+ filterOptions={(x) => x}
+ />
+ </ClickAwayListener>
+
+ <CustomButton
+ variant="contained"
+ size="small"
+ sx={{
+ backgroundColor: "#4a90e2 !important",
+ color: "#fff !important",
+ textTransform: "none",
+ fontWeight: 600
+ }}
+ onClick={handleSubmitSearch}
+ aria-label="Run search"
+ >
+ Search
+ </CustomButton>
+
+ <CustomButton
+ variant="outlined"
+ size="small"
+ sx={{
+ backgroundColor: "white !important",
+ color: "#4a90e2 !important",
+ borderColor: "#dddddd !important",
+ "&:hover": {
+ backgroundColor: "rgba(74, 144, 226, 0.08) !important",
+ color: "#4a90e2 !important"
+ }
+ }}
+ onClick={() => {
+ setOpenAdvanceSearch(true);
+ }}
+ >
+ <Typography
+ sx={{
+ color: "#4a90e2 !important",
+ fontWeight: "600 !important",
+ fontSize: "0.875rem !important"
+ }}
+ display="inline"
+ >
+ Advanced
+ </Typography>
+ </CustomButton>
+ </Stack>
+
+ {openAdvanceSearch && (
+ <AdvancedSearch
+ openAdvanceSearch={openAdvanceSearch}
+ handleCloseModal={handleCloseModal}
+ />
+ )}
+ </>
+ );
};
export default QuickSearch;
diff --git a/dashboard/src/components/ImportDialog.tsx b/dashboard/src/components/ImportDialog.tsx
index a3adfbb54..10d82aa1d 100644
--- a/dashboard/src/components/ImportDialog.tsx
+++ b/dashboard/src/components/ImportDialog.tsx
@@ -37,7 +37,8 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
-import { getGlossaryImport } from "../api/apiMethods/glossaryApiMethod";
+import { postGlossaryImportFormData } from "@utils/glossaryImportFlow";
+import { getApiErrorToastMessage } from "@utils/apiErrorToastMessage";
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
"& .MuiDialogContent-root": {
@@ -77,22 +78,27 @@ export const ImportDialog: React.FC<CustomModalProps> = ({
const onUpload = async () => {
if (fileData) {
try {
- let apiMethod =
+ const onProgress = (progressValue: number) => {
+ setProgress(progressValue);
+ };
+ const importResp =
title == "Import Business Metadata"
- ? getBusinessMetadataImport
- : getGlossaryImport;
- let formData = new FormData();
- formData.append("file", fileData);
- const importResp = await apiMethod(formData, {
- onUploadProgress: (progressEvent: {
- loaded: number;
- total: number;
- }) => {
- let progressValue =
- (progressEvent.loaded / progressEvent.total) * 100;
- setProgress(progressValue);
- }
- });
+ ? await (async () => {
+ const formData = new FormData();
+ formData.append("file", fileData);
+ return getBusinessMetadataImport(formData, {
+ onUploadProgress: (progressEvent: {
+ loaded: number;
+ total: number;
+ }) => {
+ if (!progressEvent.total) return;
+ onProgress(
+ (progressEvent.loaded / progressEvent.total) * 100
+ );
+ }
+ });
+ })()
+ : await postGlossaryImportFormData(fileData, onProgress);
if (importResp.data.failedImportInfoList == undefined) {
toast.dismiss(toastId.current);
@@ -114,8 +120,12 @@ export const ImportDialog: React.FC<CustomModalProps> = ({
}
setImportData(importResp.data);
} catch (error) {
+ const message = getApiErrorToastMessage(error);
+ if (message === null) {
+ return;
+ }
toast.dismiss(toastId.current);
- toastId.current = toast.error(`Invalid JSON response from server`);
+ toastId.current = toast.error(message);
}
}
};
diff --git a/dashboard/src/components/QueryBuilder/Filters.tsx b/dashboard/src/components/QueryBuilder/Filters.tsx
index 2b305510e..d162f28e6 100644
--- a/dashboard/src/components/QueryBuilder/Filters.tsx
+++ b/dashboard/src/components/QueryBuilder/Filters.tsx
@@ -25,7 +25,7 @@ import {
FormControlLabel
} from "@mui/material";
-import { useState } from "react";
+import { useState, useEffect, useMemo } from "react";
import {
Accordion,
AccordionDetails,
@@ -47,6 +47,7 @@ import { useAppSelector } from "@hooks/reducerHook";
import { cloneDeep } from "@utils/Helper";
import { getObjDef } from "@views/Administrator/Audits/AuditsFilter/AuditFiltersFields";
import { attributeFilter } from "@utils/CommonViewFunction";
+import { getDisplayOperator } from "@utils/Enum";
import moment from "moment";
import RelationshipFilters from "./RelationshipFilters";
import TypeFilters from "./TypeFilters/TypeFilters";
@@ -70,28 +71,66 @@ const Filters = ({
const tagParams = searchParams.get("tag");
const relationshipParams = searchParams.get("relationshipName");
const entityFilterParams = searchParams.get("entityFilters");
- const [checkedEntities, setCheckedEntities] = useState<any>(
- !isEmpty(searchParams.get("includeDE"))
- ? searchParams.get("includeDE")
- : false
+ const [checkedEntities, setCheckedEntities] = useState<boolean>(
+ searchParams.get("includeDE") === "true" || searchParams.get("includeDE") === true
);
- const [checkedSubClassifications, setCheckedSubClassifications] =
- useState<any>(
- !isEmpty(searchParams.get("excludeSC"))
- ? searchParams.get("excludeSC")
- : false
- );
- const [checkedSubTypes, setCheckedSubTypes] = useState<any>(
- !isEmpty(searchParams.get("excludeST"))
- ? searchParams.get("excludeST")
- : false
+ const [checkedSubClassifications, setCheckedSubClassifications] = useState<boolean>(
+ searchParams.get("excludeSC") === "true" || searchParams.get("excludeSC") === true
+ );
+ const [checkedSubTypes, setCheckedSubTypes] = useState<boolean>(
+ searchParams.get("excludeST") === "true" || searchParams.get("excludeST") === true
);
+ const parseFiltersFromUrl = (params: string | null) => {
+ if (isEmpty(params)) return null;
+ const parsed = attributeFilter.extractUrl({
+ value: params,
+ formatDate: true
+ });
+ if (!parsed?.rules) return null;
+ const rulesArr = Array.isArray(parsed.rules)
+ ? parsed.rules
+ : Object.keys(parsed.rules || {}).map((k) => parsed.rules[k]);
+ const mappedRules = rulesArr
+ .filter((r) => r && !r.condition)
+ .map((r, i) => ({
+ field: r.id,
+ operator: getDisplayOperator(r.operator) || r.operator,
+ value: r.value,
+ id: `url-rule-${i}`
+ }));
+ if (mappedRules.length === 0) return null;
+ return {
+ combinator: (parsed.condition || "AND").toLowerCase(),
+ rules: mappedRules
+ };
+ };
+
+ const entityFiltersFromUrl = parseFiltersFromUrl(entityFilterParams);
+ const tagFilterParams = searchParams.get("tagFilters");
+ const relationshipFilterParams = searchParams.get("relationshipFilters");
+ const tagFiltersFromUrl = parseFiltersFromUrl(tagFilterParams);
+ const relationshipFiltersFromUrl = parseFiltersFromUrl(relationshipFilterParams);
+
const [typeQuery, setTypeQuery] = useState(
!isEmpty(globalSearchFilterInitialQuery.getQuery()?.entityFilters) &&
!isEmpty(entityFilterParams)
? globalSearchFilterInitialQuery.getQuery()?.entityFilters
- : initialQuery
+ : !isEmpty(entityFiltersFromUrl)
+ ? entityFiltersFromUrl
+ : initialQuery
);
+
+ useEffect(() => {
+ if (
+ !isEmpty(entityFilterParams) &&
+ isEmpty(globalSearchFilterInitialQuery.getQuery()?.entityFilters) &&
+ !isEmpty(entityFiltersFromUrl)
+ ) {
+ globalSearchFilterInitialQuery.setQuery({
+ entityFilters: entityFiltersFromUrl
+ });
+ }
+ }, [entityFilterParams]);
const [classificationQuery, setClassificationQuery] = useState(
!isEmpty(globalSearchFilterInitialQuery.getQuery()?.tagFilters)
? globalSearchFilterInitialQuery.getQuery()?.tagFilters
@@ -125,28 +164,63 @@ const Filters = ({
businessMetadata: businessMetadataDefs
};
+ const appliedIncludeDE = searchParams.get("includeDE") === "true" || searchParams.get("includeDE") === true;
+ const appliedExcludeSC = searchParams.get("excludeSC") === "true" || searchParams.get("excludeSC") === true;
+ const appliedExcludeST = searchParams.get("excludeST") === "true" || searchParams.get("excludeST") === true;
+
+ const hasChanges = useMemo(() => {
+ const switchChanged =
+ !!checkedEntities !== !!appliedIncludeDE ||
+ !!checkedSubClassifications !== !!appliedExcludeSC ||
+ !!checkedSubTypes !== !!appliedExcludeST;
+
+ const normalizeQuery = (q: RuleGroupType | null) => {
+ if (!q || !q.rules || q.rules.length === 0) return JSON.stringify({ combinator: "and", rules: [] });
+ return JSON.stringify({ combinator: q.combinator || "and", rules: q.rules });
+ };
+
+ const entityQueryChanged =
+ normalizeQuery(typeQuery) !== normalizeQuery(entityFiltersFromUrl || initialQuery);
+ const tagQueryChanged =
+ normalizeQuery(classificationQuery) !== normalizeQuery(tagFiltersFromUrl || initialQuery);
+ const relationshipQueryChanged =
+ normalizeQuery(relationshipQuery) !== normalizeQuery(relationshipFiltersFromUrl || initialQuery);
+
+ return switchChanged || entityQueryChanged || tagQueryChanged || relationshipQueryChanged;
+ }, [
+ checkedEntities,
+ checkedSubClassifications,
+ checkedSubTypes,
+ appliedIncludeDE,
+ appliedExcludeSC,
+ appliedExcludeST,
+ typeQuery,
+ classificationQuery,
+ relationshipQuery,
+ entityFiltersFromUrl,
+ tagFiltersFromUrl,
+ relationshipFiltersFromUrl
+ ]);
+
const handleSwitchChangeEntities = (
event: React.ChangeEvent<HTMLInputElement>
) => {
event.stopPropagation();
- searchParams.set("includeDE", event.target.checked);
- setCheckedEntities(event.target.checked);
+ setCheckedEntities(Boolean(event.target.checked));
};
const handleSwitchChangeSubClassification = (
event: React.ChangeEvent<HTMLInputElement>
) => {
event.stopPropagation();
- searchParams.set("excludeSC", event.target.checked);
- setCheckedSubClassifications(event.target.checked);
+ setCheckedSubClassifications(Boolean(event.target.checked));
};
const handleSwitchChangeSubTypes = (
event: React.ChangeEvent<HTMLInputElement>
) => {
event.stopPropagation();
- searchParams.set("excludeST", event.target.checked);
- setCheckedSubTypes(event.target.checked);
+ setCheckedSubTypes(Boolean(event.target.checked));
};
const paramsObject: Record<string, any> = {};
@@ -187,9 +261,9 @@ const Filters = ({
let rules_widgets = null;
let systemAttrArr;
- if (!isEmpty(paramsObject)) {
+ if (!isEmpty(paramsObject?.entityFilters)) {
rules_widgets = attributeFilter.extractUrl({
- value: undefined,
+ value: paramsObject.entityFilters,
formatDate: true
});
}
@@ -699,6 +773,7 @@ const Filters = ({
<CustomButton
variant="contained"
size="small"
+ disabled={!hasChanges}
onClick={() => {
applyFilter();
}}
diff --git a/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx b/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
index 5a082ef7e..38f702af6 100644
--- a/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
+++ b/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
@@ -388,6 +388,7 @@ const DrawerBodyChipView = ({
button2Label="Remove"
button2Handler={handleRemove}
disableButton2={removeLoader}
+ button2Loading={removeLoader}
>
<Typography fontSize={14}>
Remove:{" "}
diff --git a/dashboard/src/components/ShowMore/ShowMoreView.tsx b/dashboard/src/components/ShowMore/ShowMoreView.tsx
index 5a0f1cfc0..3761d0a4d 100644
--- a/dashboard/src/components/ShowMore/ShowMoreView.tsx
+++ b/dashboard/src/components/ShowMore/ShowMoreView.tsx
@@ -182,6 +182,8 @@ const ShowMoreView = ({
}
} catch (error) {
serverError(error, toastId);
+ } finally {
+ setRemoveLoader(false);
}
};
@@ -379,6 +381,7 @@ const ShowMoreView = ({
button2Label="Remove"
button2Handler={handleRemove}
disableButton2={removeLoader}
+ button2Loading={removeLoader}
>
<Typography fontSize={14}>
Remove:{" "}
diff --git a/dashboard/src/components/Table/TablePagination.tsx b/dashboard/src/components/Table/TablePagination.tsx
index 7577f0007..792d1a711 100644
--- a/dashboard/src/components/Table/TablePagination.tsx
+++ b/dashboard/src/components/Table/TablePagination.tsx
@@ -369,6 +369,18 @@ const TablePagination: React.FC<PaginationProps> = ({
? pageTo
: Math.min((pageIndex + 1) * pageSize, totalDatasetRows);
+ /** Last page may return fewer than `limit` rows; cap "to" at known total. */
+ const displayToCapped =
+ isServerSide &&
+ typeof totalCount === "number" &&
+ totalCount >= 0
+ ? Math.min(displayTo, totalCount)
+ : displayTo;
+
+ const footerRangeStart =
+ totalDatasetRows === 0 ? 0 : Math.min(displayFrom, displayToCapped);
+ const footerRangeEnd = totalDatasetRows === 0 ? 0 : displayToCapped;
+
return (
<Stack
spacing={{ xs: 1, sm: 2 }}
@@ -381,8 +393,16 @@ const TablePagination: React.FC<PaginationProps> = ({
>
<div>
<span className="text-grey">
- Showing <u>{totalDatasetRows.toLocaleString()} records</u> From{" "}
- {displayFrom} - {displayTo}
+ {totalDatasetRows === 0 ? (
+ "No records to display"
+ ) : (
+ <>
+ Showing {footerRangeStart.toLocaleString()}-
+ {footerRangeEnd.toLocaleString()} of{" "}
+ {totalDatasetRows.toLocaleString()}{" "}
+ {totalDatasetRows === 1 ? "record" : "records"}
+ </>
+ )}
</span>
</div>
@@ -491,25 +511,27 @@ const TablePagination: React.FC<PaginationProps> = ({
value={pendingGoToPageVal}
/>
<LightTooltip title="Goto Page">
- <IconButton
- type="button"
- size="small"
- className={`${
- !isEmpty(pendingGoToPageVal)
- ? "cursor-pointer"
- : "cursor-not-allowed"
- } table-pagination-gotopage-button`}
- aria-label="search"
- onClick={() => {
- if (!isEmpty(pendingGoToPageVal)) {
- setGoToPageTrigger(pendingGoToPageVal);
- handleGoToPage();
- }
- }}
- disabled={isEmpty(pendingGoToPageVal)}
- >
- Go
- </IconButton>
+ <span style={{ display: "inline-flex" }}>
+ <IconButton
+ type="button"
+ size="small"
+ className={`${
+ !isEmpty(pendingGoToPageVal)
+ ? "cursor-pointer"
+ : "cursor-not-allowed"
+ } table-pagination-gotopage-button`}
+ aria-label="search"
+ onClick={() => {
+ if (!isEmpty(pendingGoToPageVal)) {
+ setGoToPageTrigger(pendingGoToPageVal);
+ handleGoToPage();
+ }
+ }}
+ disabled={isEmpty(pendingGoToPageVal)}
+ >
+ Go
+ </IconButton>
+ </span>
</LightTooltip>
</Paper>
</Stack>
@@ -517,19 +539,21 @@ const TablePagination: React.FC<PaginationProps> = ({
<Stack flexDirection="row" alignItems="center">
<LightTooltip title="Previous">
- <IconButton
- size="small"
- className="pagination-page-change-btn"
- onClick={handlePreviousPage}
- disabled={isPreviousDisabled}
- aria-label="previous page"
- >
- {theme.direction === "rtl" ? (
- <KeyboardArrowRight />
- ) : (
- <KeyboardArrowLeft />
- )}
- </IconButton>
+ <span style={{ display: "inline-flex" }}>
+ <IconButton
+ size="small"
+ className="pagination-page-change-btn"
+ onClick={handlePreviousPage}
+ disabled={isPreviousDisabled}
+ aria-label="previous page"
+ >
+ {theme.direction === "rtl" ? (
+ <KeyboardArrowRight />
+ ) : (
+ <KeyboardArrowLeft />
+ )}
+ </IconButton>
+ </span>
</LightTooltip>
<LightTooltip title={`Page ${activePage}`}>
@@ -539,19 +563,21 @@ const TablePagination: React.FC<PaginationProps> = ({
</LightTooltip>
<LightTooltip title="Next">
- <IconButton
- size="small"
- className="pagination-page-change-btn"
- onClick={handleNextPage}
- disabled={isNextDisabled}
- aria-label="next page"
- >
- {theme.direction === "rtl" ? (
- <KeyboardArrowLeft />
- ) : (
- <KeyboardArrowRight />
- )}
- </IconButton>
+ <span style={{ display: "inline-flex" }}>
+ <IconButton
+ size="small"
+ className="pagination-page-change-btn"
+ onClick={handleNextPage}
+ disabled={isNextDisabled}
+ aria-label="next page"
+ >
+ {theme.direction === "rtl" ? (
+ <KeyboardArrowLeft />
+ ) : (
+ <KeyboardArrowRight />
+ )}
+ </IconButton>
+ </span>
</LightTooltip>
</Stack>
</>
diff --git a/dashboard/src/components/TreeNodeIcons.tsx b/dashboard/src/components/TreeNodeIcons.tsx
index 3e56f70e8..5403a2a9d 100644
--- a/dashboard/src/components/TreeNodeIcons.tsx
+++ b/dashboard/src/components/TreeNodeIcons.tsx
@@ -16,6 +16,7 @@
*/
import { useRef, useState } from "react";
+import { useAsyncPending } from "../hooks/useAsyncPending";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import Stack from "@mui/material/Stack";
@@ -72,6 +73,9 @@ const TreeNodeIcons = (props: {
const [glossaryModal, setGlossaryModal] = useState<boolean>(false);
const [termModal, setTermModal] = useState(false);
const [categoryModal, setCategoryModal] = useState(false);
+ const [savedSearchDeleteLoading, setSavedSearchDeleteLoading] =
+ useState(false);
+ const { pending: renameSubmitting, run: runRenameAction } = useAsyncPending();
const openNode = Boolean(expandNode);
@@ -128,6 +132,7 @@ const TreeNodeIcons = (props: {
const handleRemove = async () => {
try {
+ setSavedSearchDeleteLoading(true);
await removeSavedSearch(selectedSearchData.guid);
setDeleteModal(false);
setExpandNode(null);
@@ -142,23 +147,27 @@ const TreeNodeIcons = (props: {
toastId.current = toast.success(`${node.id} was deleted successfully`);
} catch (error) {
serverError(error, toastId);
+ } finally {
+ setSavedSearchDeleteLoading(false);
}
};
- const handleEdit = async () => {
- try {
- let filterData = { ...selectedSearchData, name: value };
- await editSavedSearch(filterData as CustomFiltersNodeType, "PUT");
- updatedData();
- setRenameModal(false);
- setExpandNode(null);
- toast.dismiss(toastId.current);
- toastId.current = toast.success(
- `${filterData.name} was updated successfully`
- );
- } catch (error) {
- serverError(error, toastId);
- }
+ const handleEdit = () => {
+ void runRenameAction(async () => {
+ try {
+ let filterData = { ...selectedSearchData, name: value };
+ await editSavedSearch(filterData as CustomFiltersNodeType, "PUT");
+ updatedData();
+ setRenameModal(false);
+ setExpandNode(null);
+ toast.dismiss(toastId.current);
+ toastId.current = toast.success(
+ `${filterData.name} was updated successfully`
+ );
+ } catch (error) {
+ serverError(error, toastId);
+ }
+ });
};
return (
<>
@@ -483,7 +492,8 @@ const TreeNodeIcons = (props: {
button1Handler={handleCloseRenameModal}
button2Label="Update"
button2Handler={handleEdit}
- // disableButton2={value}
+ disableButton2={renameSubmitting}
+ button2Loading={renameSubmitting}
>
<Stack
sx={{
@@ -518,6 +528,8 @@ const TreeNodeIcons = (props: {
button1Handler={handleCloseDeleteModal}
button2Label="Ok"
button2Handler={handleRemove}
+ disableButton2={savedSearchDeleteLoading}
+ button2Loading={savedSearchDeleteLoading}
>
<Typography fontSize={15}>
Are you sure you want to delete <strong>{node.id}</strong> ?{""}
diff --git a/dashboard/src/components/TypeDefAuditDetailModal.tsx b/dashboard/src/components/TypeDefAuditDetailModal.tsx
new file mode 100644
index 000000000..1e780d6fb
--- /dev/null
+++ b/dashboard/src/components/TypeDefAuditDetailModal.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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 { Divider, Stack } from "@mui/material";
+import CustomModal from "@components/Modal";
+import { getValues } from "@components/commonComponents";
+import { StyledPaper } from "@utils/Muiutils";
+import { category } from "@utils/Enum";
+import { isArray, isEmpty } from "@utils/Utils";
+
+export interface TypeDefAuditDetail {
+ category?: string;
+ name?: string;
+ [key: string]: unknown;
+}
+
+interface TypeDefAuditDetailModalProps {
+ open: boolean;
+ onClose: () => void;
+ /** Full type-definition payload from audit `result` (same shape as audit tab click). */
+ detailObject: TypeDefAuditDetail | Record<string, unknown> | null;
+ maxWidth?: "xs" | "sm" | "md" | "lg" | "xl";
+}
+
+const buildTitle = (detailObject: TypeDefAuditDetailModalProps["detailObject"]): string => {
+ if (isEmpty(detailObject)) return "Type Details";
+ const cat = detailObject?.category;
+ const name = detailObject?.name;
+ if (cat != null && name != null && category[String(cat)]) {
+ return `${category[String(cat)]} Type Details: ${String(name)}`;
+ }
+ if (name != null) return `Type Details: ${String(name)}`;
+ return "Type Details";
+};
+
+export const TypeDefAuditDetailModal = ({
+ open,
+ onClose,
+ detailObject,
+ maxWidth = "md",
+}: TypeDefAuditDetailModalProps) => {
+ return (
+ <CustomModal
+ open={open}
+ onClose={onClose}
+ title={buildTitle(detailObject)}
+ footer={false}
+ button1Handler={undefined}
+ button2Handler={undefined}
+ maxWidth={maxWidth}
+ >
+ <StyledPaper variant="outlined">
+ {!isEmpty(detailObject)
+ ? Object.entries(detailObject as Record<string, unknown>)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([keys, value]) => (
+ <div key={keys}>
+ <Stack direction="row" spacing={4} marginBottom={1} marginTop={1}>
+ <div
+ style={{
+ flex: 1,
+ wordBreak: "break-all",
+ textAlign: "left",
+ fontWeight: "600",
+ }}
+ >
+ {`${keys} ${isArray(value) ? `(${(value as unknown[]).length})` : ""}`}
+ </div>
+ <div
+ style={{
+ flex: 1,
+ wordBreak: "break-all",
+ textAlign: "left",
+ }}
+ >
+ {getValues(
+ value,
+ undefined,
+ undefined,
+ undefined,
+ "properties"
+ )}
+ </div>
+ </Stack>
+ <Divider />
+ </div>
+ ))
+ : "No Record Found"}
+ </StyledPaper>
+ </CustomModal>
+ );
+};
+
+export default TypeDefAuditDetailModal;
diff --git a/dashboard/src/utils/CommonViewFunction.ts b/dashboard/src/utils/CommonViewFunction.ts
index 3760de5a5..d58357bfd 100644
--- a/dashboard/src/utils/CommonViewFunction.ts
+++ b/dashboard/src/utils/CommonViewFunction.ts
@@ -193,13 +193,19 @@ export const attributeFilter = {
let temp = obj.split("::") || obj.split("|" + spliter + "|");
let rule = {};
if (apiObj) {
+ const attrName = temp[0];
+ const isDateAttr =
+ temp[3] === "date" ||
+ attrName === "__timestamp" ||
+ attrName === "__modificationTimestamp" ||
+ attrName === "createTime";
rule = {
- attributeName: temp[0],
+ attributeName: attrName,
operator: mapUiOperatorToAPI(temp[1]),
attributeValue: temp[2]?.trim()
};
rule.attributeValue =
- rule.type === "date" && formatDate && rule.attributeValue.length
+ isDateAttr && formatDate && rule.attributeValue?.length
? formatedDate({
date: parseInt(rule.attributeValue),
zone: false
@@ -231,9 +237,12 @@ export const attributeFilter = {
rule.value;
}
} else if (
- (rule.type === "date" || rule.attributeName == "createTime") &&
+ (rule.type === "date" ||
+ rule.attributeName === "createTime" ||
+ rule.id === "__timestamp" ||
+ rule.id === "__modificationTimestamp") &&
formatDate &&
- rule.value.length
+ rule.value?.length
) {
rule.value = formatedDate({
date: parseInt(rule.value),
diff --git a/dashboard/src/utils/Enum.ts b/dashboard/src/utils/Enum.ts
index 76ee0712d..50644400b 100644
--- a/dashboard/src/utils/Enum.ts
+++ b/dashboard/src/utils/Enum.ts
@@ -319,6 +319,15 @@ export const queryBuilderUIOperatorToAPI = {
export const queryBuilderApiOperatorToUI = invert(queryBuilderUIOperatorToAPI);
+/**
+ * Converts API operator (eq, lte, gte, etc.) to display symbol (=, <=, >=, etc.)
+ * Used when rendering filter query chips above search results table
+ */
+export const getDisplayOperator = (operator: string): string => {
+ const mapped = (queryBuilderApiOperatorToUI as Map<string, string>).get(operator);
+ return mapped ?? operator;
+};
+
export const queryBuilderDateRangeUIValueToAPI: Record<string, string> = {
Today: "TODAY",
Yesterday: "YESTERDAY",
diff --git a/dashboard/src/utils/apiErrorToastMessage.ts b/dashboard/src/utils/apiErrorToastMessage.ts
new file mode 100644
index 000000000..7afa98d0b
--- /dev/null
+++ b/dashboard/src/utils/apiErrorToastMessage.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+/**
+ * Resolves user-visible toast text from an axios/fetchApi error.
+ * @returns null when the caller must not toast (403 is handled in fetchApi).
+ */
+export const getApiErrorToastMessage = (error: unknown): string | null => {
+ const er = error as {
+ response?: { status?: number; data?: unknown };
+ };
+ if (er?.response?.status === 403) {
+ return null;
+ }
+ const data = er?.response?.data;
+ if (data !== null && data !== undefined && typeof data === "object") {
+ const o = data as Record<string, unknown>;
+ if (o.errorMessage != null && String(o.errorMessage).trim() !== "") {
+ return String(o.errorMessage);
+ }
+ if (o.msgDesc != null && String(o.msgDesc).trim() !== "") {
+ return String(o.msgDesc);
+ }
+ }
+ if (typeof data === "string" && data.trim() !== "") {
+ return data;
+ }
+ return "Invalid JSON response from server";
+};
diff --git a/dashboard/src/utils/auditTypeDefUtils.ts b/dashboard/src/utils/auditTypeDefUtils.ts
new file mode 100644
index 000000000..c23097578
--- /dev/null
+++ b/dashboard/src/utils/auditTypeDefUtils.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 { jsonParse } from "@utils/Utils";
+
+/** TYPE_DEF_* audit params that use the same detail modal as the Administrator Audits tab */
+export const TYPE_DEF_DETAIL_MODAL_PARAMS = new Set([
+ "ENUM",
+ "ENTITY",
+ "STRUCT",
+ "RELATIONSHIP",
+]);
+
+export interface AuditRecordLike {
+ operation?: string;
+ params?: string;
+ result?: string;
+}
+
+/**
+ * Pulls the full typedef object from an audit `result` JSON for ENUM / ENTITY /
+ * STRUCT / RELATIONSHIP rows (matches expanded audit row click payload).
+ */
+export const extractTypeDefDetailObject = (
+ record: AuditRecordLike,
+ resolvedName: string,
+ resolvedGuid: string
+): Record<string, unknown> | null => {
+ const op = record.operation;
+ if (
+ op !== "TYPE_DEF_CREATE" &&
+ op !== "TYPE_DEF_UPDATE" &&
+ op !== "TYPE_DEF_DELETE"
+ ) {
+ return null;
+ }
+ const paramKeys = (record.params ?? "")
+ .split(",")
+ .map((k) => k.trim())
+ .filter(Boolean);
+
+ try {
+ const resultObj =
+ typeof record.result === "string" ? jsonParse(record.result) : record.result;
+ if (!resultObj || typeof resultObj !== "object") return null;
+
+ const keysToScan =
+ paramKeys.length > 0 ? paramKeys : Object.keys(resultObj as object);
+
+ for (const key of keysToScan) {
+ if (!TYPE_DEF_DETAIL_MODAL_PARAMS.has(key)) continue;
+ const list = (resultObj as Record<string, unknown>)[key];
+ if (!Array.isArray(list) || list.length === 0) continue;
+ const match = list.find((item: { name?: string; guid?: string }) => {
+ if (!item || typeof item !== "object") return false;
+ if (resolvedName && item.name === resolvedName) return true;
+ if (resolvedGuid && item.guid === resolvedGuid) return true;
+ return false;
+ });
+ if (match && typeof match === "object") {
+ return match as Record<string, unknown>;
+ }
+ }
+
+ const firstKey = paramKeys.find((k) => TYPE_DEF_DETAIL_MODAL_PARAMS.has(k));
+ if (firstKey) {
+ const list = (resultObj as Record<string, unknown>)[firstKey];
+ if (Array.isArray(list) && list[0] && typeof list[0] === "object") {
+ return list[0] as Record<string, unknown>;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+};
diff --git a/dashboard/src/utils/dashboardSearchUtils.ts b/dashboard/src/utils/dashboardSearchUtils.ts
new file mode 100644
index 000000000..15ebdf699
--- /dev/null
+++ b/dashboard/src/utils/dashboardSearchUtils.ts
@@ -0,0 +1,189 @@
+/*
+ * 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 type { NavigateFunction } from "react-router-dom";
+import { attributeFilter } from "@utils/CommonViewFunction";
+
+export type DashboardSearchType =
+ | "all_entities"
+ | "all_classifications"
+ | "entity_status"
+ | "entity_type";
+
+export interface DashboardSearchParams {
+ type?: string;
+ tag?: string;
+ includeDE?: boolean;
+ entityFilters?: {
+ condition: string;
+ criterion: Array<{ attributeName: string; operator: string; attributeValue: string }>;
+ };
+}
+
+const buildSearchParams = (
+ type: DashboardSearchType,
+ extra?: Partial<DashboardSearchParams>
+): URLSearchParams => {
+ const params = new URLSearchParams();
+ params.set("searchType", "basic");
+
+ if (type === "all_classifications") {
+ params.set("tag", extra?.tag ?? "_ALL_CLASSIFICATION_TYPES");
+ params.set("type", "");
+ } else {
+ params.set("type", extra?.type ?? "_ALL_ENTITY_TYPES");
+ }
+ if (extra?.includeDE) {
+ params.set("includeDE", "true");
+ }
+ if (extra?.entityFilters) {
+ const urlStr = attributeFilter.generateUrl({ value: extra.entityFilters });
+ if (urlStr) params.set("entityFilters", urlStr);
+ }
+ return params;
+};
+
+export const navigateToTaggedSearch = (navigate: NavigateFunction): void => {
+ navigateToSearch(navigate, "all_classifications");
+};
+
+export const navigateToClassificationSearch = (
+ navigate: NavigateFunction,
+ tagName: string
+): void => {
+ navigateToSearch(navigate, "all_classifications", { tag: tagName });
+};
+
+/** "View All" from Latest Entities card: basic all-types search, newest first. */
+export const navigateToLatestEntitiesSearch = (navigate: NavigateFunction): void => {
+ const params = buildSearchParams("all_entities");
+ params.set("pageLimit", "25");
+ params.set("pageOffset", "0");
+ params.set("sortBy", "__timestamp");
+ params.set("sortOrder", "DESCENDING");
+ /* Same as dashboard card: request system create time in the basic-search body. */
+ params.set("attributes", "__timestamp");
+ navigate({ pathname: "/search/searchResult", search: params.toString() });
+};
+
+export const navigateToEntityTypeSearch = (
+ navigate: NavigateFunction,
+ typeName: string,
+ includeDeleted: boolean
+): void => {
+ const extra: Partial<DashboardSearchParams> = {
+ type: typeName,
+ includeDE: includeDeleted
+ };
+ if (includeDeleted) {
+ extra.entityFilters = {
+ condition: "AND",
+ criterion: [{ attributeName: "__state", operator: "eq", attributeValue: "DELETED" }]
+ };
+ }
+ navigateToSearch(navigate, "entity_type", extra);
+};
+
+/** Search all entity typedefs that belong to one service-type bucket (sidebar grouping). */
+export const navigateToServiceTypeEntitySearch = (
+ navigate: NavigateFunction,
+ typeNames: string[],
+ includeDeleted: boolean
+): void => {
+ const cleaned = [...new Set(typeNames.filter(Boolean))].sort((a, b) => a.localeCompare(b));
+ if (cleaned.length === 0) {
+ navigateToSearch(navigate, "all_entities");
+ return;
+ }
+ if (cleaned.length === 1) {
+ navigateToEntityTypeSearch(navigate, cleaned[0], includeDeleted);
+ return;
+ }
+ const criterion = cleaned.map((typeName) => ({
+ attributeName: "__typeName",
+ operator: "eq",
+ attributeValue: typeName,
+ }));
+ navigateToSearch(navigate, "all_entities", {
+ type: "_ALL_ENTITY_TYPES",
+ includeDE: includeDeleted,
+ entityFilters: { condition: "OR", criterion },
+ });
+};
+
+export const navigateToSearch = (
+ navigate: NavigateFunction,
+ type: DashboardSearchType,
+ extra?: Partial<DashboardSearchParams>
+): void => {
+ const search = buildSearchParams(type, extra).toString();
+ navigate({ pathname: "/search/searchResult", search });
+};
+
+/** Basic search using free-text `query` (e.g. entity typedef name). */
+export const navigateToBasicTextQuery = (
+ navigate: NavigateFunction,
+ query: string
+): void => {
+ const params = new URLSearchParams();
+ params.set("searchType", "basic");
+ params.set("query", query.trim());
+ params.set("pageLimit", "25");
+ params.set("pageOffset", "0");
+ navigate({ pathname: "/search/searchResult", search: params.toString() });
+};
+
+export const navigateToClassificationDetailPage = (
+ navigate: NavigateFunction,
+ classificationName: string
+): void => {
+ const sp = new URLSearchParams();
+ sp.set("tag", classificationName);
+ navigate({
+ pathname: `/tag/tagAttribute/${encodeURIComponent(classificationName)}`,
+ search: sp.toString()
+ });
+};
+
+export const navigateToGlossaryTermDetailPage = (
+ navigate: NavigateFunction,
+ args: {
+ termGuid: string;
+ termId: string;
+ glossaryGuid: string;
+ parentName: string;
+ }
+): void => {
+ const sp = new URLSearchParams();
+ sp.set("gid", args.glossaryGuid);
+ sp.set("term", `${args.termId}@${args.parentName}`);
+ sp.set("gtype", "term");
+ sp.set("viewType", "term");
+ sp.set("guid", args.termGuid);
+ sp.set("searchType", "basic");
+ navigate({
+ pathname: `/glossary/${args.termGuid}`,
+ search: sp.toString()
+ });
+};
+
+export const navigateToBusinessMetadataDetailPage = (
+ navigate: NavigateFunction,
+ bmGuid: string
+): void => {
+ navigate({ pathname: `/administrator/businessMetadata/${bmGuid}` });
+};
diff --git a/dashboard/src/utils/filterUrlCriterionRemoval.ts b/dashboard/src/utils/filterUrlCriterionRemoval.ts
new file mode 100644
index 000000000..746fe7998
--- /dev/null
+++ b/dashboard/src/utils/filterUrlCriterionRemoval.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 { attributeFilter } from "./CommonViewFunction";
+
+export type RemoveCriterionResult =
+ | { kind: "empty" }
+ | { kind: "url"; url: string }
+ | { kind: "singleTypeName"; typeName: string };
+
+type ApiFilterCriterionRow = {
+ attributeName: string;
+ operator: string;
+ attributeValue: string;
+ type?: string;
+};
+
+/** Narrowing shape for `attributeFilter.extractUrl(..., { apiObj: true })`. */
+export type ParsedApiFilter = {
+ condition: string;
+ criterion: ApiFilterCriterionRow[];
+};
+
+/**
+ * Drops one criterion from a serialized filter URL (entity / tag / relationship).
+ * When allowTypeParamSimplification is true and exactly one __typeName eq remains,
+ * returns singleTypeName so the UI can use type=<typedef> (matches sidebar search).
+ */
+export const removeCriterionFromFilterUrl = (
+ filterUrl: string,
+ index: number,
+ allowTypeParamSimplification = false
+): RemoveCriterionResult | null => {
+ if (!filterUrl || index < 0) {
+ return null;
+ }
+ const parsed = attributeFilter.extractUrl({
+ value: filterUrl,
+ apiObj: true
+ }) as ParsedApiFilter | null;
+ if (!parsed?.criterion || !Array.isArray(parsed.criterion)) {
+ return null;
+ }
+ const crit = [...parsed.criterion];
+ if (index >= crit.length) {
+ return null;
+ }
+ crit.splice(index, 1);
+ if (crit.length === 0) {
+ return { kind: "empty" };
+ }
+ if (
+ allowTypeParamSimplification &&
+ crit.length === 1 &&
+ crit[0].attributeName === "__typeName" &&
+ String(crit[0].operator).toLowerCase() === "eq"
+ ) {
+ return { kind: "singleTypeName", typeName: String(crit[0].attributeValue) };
+ }
+ const url = attributeFilter.generateUrl({
+ value: { condition: parsed.condition, criterion: crit }
+ });
+ if (!url) {
+ return { kind: "empty" };
+ }
+ return { kind: "url", url };
+};
diff --git a/dashboard/src/utils/glossaryImportFlow.ts b/dashboard/src/utils/glossaryImportFlow.ts
new file mode 100644
index 000000000..a3f564050
--- /dev/null
+++ b/dashboard/src/utils/glossaryImportFlow.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 {
+ getGlossaryImport,
+ getGlossaryImportTmpl
+} from '@api/apiMethods/glossaryApiMethod';
+
+/**
+ * Same behavior as glossary branch of SideBarTree.downloadFile.
+ */
+export const downloadGlossaryImportTemplate = async (): Promise<void> => {
+ const apiResp = await getGlossaryImportTmpl({});
+ const text =
+ apiResp && typeof apiResp.data !== 'undefined'
+ ? String(apiResp.data)
+ : '';
+ const blob = new Blob([text], { type: 'text/plain' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', 'template');
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+};
+
+export const postGlossaryImportFormData = (
+ file: File,
+ onUploadProgress?: (progressPercent: number) => void
+) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ return getGlossaryImport(formData, {
+ onUploadProgress: (progressEvent: { loaded: number; total: number }) => {
+ if (!onUploadProgress || !progressEvent.total) return;
+ const progressValue =
+ (progressEvent.loaded / progressEvent.total) * 100;
+ onUploadProgress(progressValue);
+ }
+ });
+};
diff --git a/dashboard/src/utils/metricsUtils.ts b/dashboard/src/utils/metricsUtils.ts
new file mode 100644
index 000000000..2b6b6bcf2
--- /dev/null
+++ b/dashboard/src/utils/metricsUtils.ts
@@ -0,0 +1,241 @@
+/*
+ * 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.
+ */
+
+export const sumEntityMetrics = (obj: Record<string, number> | undefined): number =>
+ Object.values(obj || {}).reduce((a, b) => a + (Number(b) || 0), 0);
+
+export const getEntityStatusTotals = (entity: Record<string, unknown> | undefined) => ({
+ active: sumEntityMetrics(entity?.entityActive as Record<string, number>),
+ shell: sumEntityMetrics(entity?.entityShell as Record<string, number>),
+ deleted: sumEntityMetrics(entity?.entityDeleted as Record<string, number>)
+});
+
+export interface EntityTypeDistributionItem {
+ name: string;
+ count: number;
+ active: number;
+ deleted: number;
+ /** Entity typedef names rolled into this bar (e.g. service type buckets). */
+ underlyingTypeNames?: string[];
+}
+
+export const getTaggedCount = (tag: Record<string, unknown> | undefined): number =>
+ sumEntityMetrics(tag?.tagEntities as Record<string, number>);
+
+/** tagEntities values summed — counts tag–entity associations, not unique entities */
+export const getTagEntityAssociationTotal = (tag: Record<string, unknown> | undefined): number =>
+ getTaggedCount(tag);
+
+/**
+ * Number of classification types that have at least one active entity (each key in
+ * tagEntities with count > 0). Does not double-count the same entity across types.
+ */
+export const getClassificationTypesInUseCount = (tag: Record<string, unknown> | undefined): number => {
+ const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
+ if (!tagEntities || typeof tagEntities !== "object") return 0;
+ return Object.values(tagEntities).filter((c) => (Number(c) || 0) > 0).length;
+};
+
+/** Classification names that have at least one entity assignment per tag metrics. */
+export const getClassificationNamesInUseFromTag = (
+ tag: Record<string, unknown> | undefined
+): Set<string> => {
+ const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
+ const names = new Set<string>();
+ if (!tagEntities || typeof tagEntities !== "object") return names;
+ for (const [name, count] of Object.entries(tagEntities)) {
+ if ((Number(count) || 0) > 0) {
+ names.add(name);
+ }
+ }
+ return names;
+};
+
+/**
+ * Defined classification names (from type defs) with no entity assignments in metrics.
+ */
+export const getUnusedClassificationNames = (
+ definedNames: string[],
+ tag: Record<string, unknown> | undefined
+): string[] => {
+ const inUse = getClassificationNamesInUseFromTag(tag);
+ return definedNames.filter((n) => !inUse.has(n)).sort((a, b) => a.localeCompare(b));
+};
+
+/** Parse stats from metrics general.stats (keys like "Notification:currentDay" -> { Notification: { currentDay } }) */
+export const parseMetricsStats = (stats: Record<string, unknown> | undefined): Record<string, Record<string, unknown>> => {
+ const result: Record<string, Record<string, unknown>> = {};
+ if (!stats || typeof stats !== "object") return result;
+ for (const key of Object.keys(stats)) {
+ const parts = key.split(":");
+ const group = parts[0];
+ const subKey = parts[1];
+ if (!group || !subKey) continue;
+ if (!result[group]) result[group] = {};
+ result[group][subKey] = stats[key];
+ }
+ return result;
+};
+
+export interface MessageConsumptionItem {
+ period: string;
+ count: number;
+ creates: number;
+ updates: number;
+ deletes: number;
+ failed: number;
+ avgTime: number;
+}
+
+const getNotificationValue = (
+ notification: Record<string, unknown>,
+ key: string,
+ field: "creates" | "updates" | "deletes" | "failed" | "avgTime"
+): number => {
+ const keyMap: Record<string, Record<string, string>> = {
+ total: { creates: "totalCreates", updates: "totalUpdates", deletes: "totalDeletes", failed: "totalFailed", avgTime: "totalAvgTime" },
+ currentHour: { creates: "currentHourEntityCreates", updates: "currentHourEntityUpdates", deletes: "currentHourEntityDeletes", failed: "currentHourFailed", avgTime: "currentHourAvgTime" },
+ previousHour: { creates: "previousHourEntityCreates", updates: "previousHourEntityUpdates", deletes: "previousHourEntityDeletes", failed: "previousHourFailed", avgTime: "previousHourAvgTime" },
+ currentDay: { creates: "currentDayEntityCreates", updates: "currentDayEntityUpdates", deletes: "currentDayEntityDeletes", failed: "currentDayFailed", avgTime: "currentDayAvgTime" },
+ previousDay: { creates: "previousDayEntityCreates", updates: "previousDayEntityUpdates", deletes: "previousDayEntityDeletes", failed: "previousDayFailed", avgTime: "previousDayAvgTime" }
+ };
+ const fullKey = keyMap[key]?.[field] ?? "";
+ return Number(notification[fullKey] ?? 0);
+};
+
+export interface GetMessageConsumptionDataOptions {
+ /** When false, omits the Total period (e.g. topic rows where total is shown as Processed). */
+ includeTotal?: boolean;
+}
+
+/** Extract message consumption data for bar chart from Notification stats */
+export const getMessageConsumptionData = (
+ notification: Record<string, unknown> | undefined,
+ options?: GetMessageConsumptionDataOptions
+): MessageConsumptionItem[] => {
+ if (!notification) return [];
+ const includeTotal = options?.includeTotal !== false;
+ const periods = [
+ { key: "total", label: "Total" },
+ { key: "currentHour", label: "Current Hour" },
+ { key: "previousHour", label: "Previous Hour" },
+ { key: "currentDay", label: "Current Day" },
+ { key: "previousDay", label: "Previous Day" }
+ ];
+ const selected = includeTotal ? periods : periods.filter((p) => p.key !== "total");
+ return selected.map(({ key, label }) => ({
+ period: label,
+ count: Number(notification[key] ?? 0),
+ creates: getNotificationValue(notification, key, "creates"),
+ updates: getNotificationValue(notification, key, "updates"),
+ deletes: getNotificationValue(notification, key, "deletes"),
+ failed: getNotificationValue(notification, key, "failed"),
+ avgTime: getNotificationValue(notification, key, "avgTime")
+ }));
+};
+
+/**
+ * Topic partition maps sometimes repeat `Notification:*` keys from the wire format.
+ * Strip the prefix so getMessageConsumptionData finds `currentHour`, `totalCreates`, etc.
+ */
+export const normalizeTopicMetricsRecord = (
+ topicDetail: Record<string, unknown> | undefined
+): Record<string, unknown> => {
+ if (!topicDetail || typeof topicDetail !== "object") return {};
+ const out: Record<string, unknown> = { ...topicDetail };
+ for (const key of Object.keys(topicDetail)) {
+ if (!key.startsWith("Notification:")) continue;
+ const shortKey = key.slice("Notification:".length);
+ if (shortKey && out[shortKey] === undefined) {
+ out[shortKey] = topicDetail[key];
+ }
+ }
+ return out;
+};
+
+/**
+ * True when the payload includes at least one period bucket count (hour/day series).
+ * `total` alone is not enough — legacy rows often only mirror processedMessageCount.
+ */
+export const topicRowHasPeriodMetrics = (topicDetail: Record<string, unknown> | undefined): boolean => {
+ const n = normalizeTopicMetricsRecord(topicDetail);
+ return (
+ n.currentHour !== undefined ||
+ n.previousHour !== undefined ||
+ n.currentDay !== undefined ||
+ n.previousDay !== undefined
+ );
+};
+
+const omitTopicDetailsFromNotification = (
+ notification: Record<string, unknown> | undefined
+): Record<string, unknown> => {
+ if (!notification || typeof notification !== "object") return {};
+ const { topicDetails: _omit, ...rest } = notification;
+ return rest;
+};
+
+/**
+ * One entry from `Notification.topicDetails` for getMessageConsumptionData.
+ * When the server omits per-period fields on partitions (legacy payload), merge in
+ * aggregate `Notification` (excluding `topicDetails`) so the chart matches /admin/metrics.
+ */
+export const buildTopicNotificationRecord = (
+ topicDetail: Record<string, unknown> | undefined,
+ options?: {
+ aggregateNotification?: Record<string, unknown> | undefined;
+ /** When true, fill period keys from aggregate if the topic row lacks them. */
+ useAggregateFallback?: boolean;
+ }
+): Record<string, unknown> | undefined => {
+ if (!topicDetail || typeof topicDetail !== "object") {
+ if (options?.useAggregateFallback && options?.aggregateNotification) {
+ return omitTopicDetailsFromNotification(options.aggregateNotification);
+ }
+ return undefined;
+ }
+ const normalized = normalizeTopicMetricsRecord(topicDetail);
+ if (!options?.useAggregateFallback || !options?.aggregateNotification) {
+ return normalized;
+ }
+ const base = omitTopicDetailsFromNotification(options.aggregateNotification);
+ return { ...base, ...normalized };
+};
+
+/** Chart periods without Total (Processed column shows the total). */
+export const getMessageConsumptionDataExcludingTotal = (
+ notification: Record<string, unknown> | undefined
+): MessageConsumptionItem[] =>
+ getMessageConsumptionData(notification, { includeTotal: false });
+
+export interface ClassificationDistributionItem {
+ name: string;
+ count: number;
+}
+
+/** Top classifications by entity count from tag.tagEntities */
+export const getClassificationDistribution = (
+ tag: Record<string, unknown> | undefined,
+ topN = 5
+): ClassificationDistributionItem[] => {
+ const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
+ if (!tagEntities) return [];
+ return Object.entries(tagEntities)
+ .map(([name, count]) => ({ name, count: Number(count) || 0 }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, topN);
+};
diff --git a/dashboard/src/utils/scopedQuickSearchUtils.ts b/dashboard/src/utils/scopedQuickSearchUtils.ts
new file mode 100644
index 000000000..81a97c44e
--- /dev/null
+++ b/dashboard/src/utils/scopedQuickSearchUtils.ts
@@ -0,0 +1,466 @@
+/*
+ * 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 type { TreeNode } from "@models/treeStructureType";
+import type {
+ ChildrenInterface,
+ ServiceTypeInterface,
+ TypeHeaderInterface
+} from "@models/entityTreeType";
+import { addOnEntities } from "./Enum";
+import { customSortBy, customSortByObjectKeys, isEmpty } from "./Utils";
+
+export type QuickSearchScope =
+ | "default"
+ | "entity"
+ | "classification"
+ | "glossary"
+ | "businessMetadata";
+
+export type ScopedOptionKind =
+ | "entity-service"
+ | "entity-type"
+ | "classification"
+ | "glossary-term"
+ | "business-metadata";
+
+export interface ScopedQuickSearchOption {
+ id: string;
+ title: string;
+ group: string;
+ kind: ScopedOptionKind;
+ entityTypeName?: string;
+ serviceUnderlyingTypeNames?: string[];
+ classificationName?: string;
+ termGuid?: string;
+ glossaryGuid?: string;
+ termId?: string;
+ termParent?: string;
+ bmGuid?: string;
+}
+
+interface MetricsEntitySnapshot {
+ entityActive?: Record<string, number>;
+ entityDeleted?: Record<string, number>;
+}
+
+interface GlossaryTermCategoryRow {
+ parentCategoryGuid?: string;
+ categoryGuid?: string;
+ displayText?: string;
+ termGuid?: string;
+}
+
+interface GlossaryApiRow {
+ name: string;
+ guid: string;
+ categories?: GlossaryTermCategoryRow[];
+ terms?: GlossaryTermCategoryRow[];
+}
+
+const DEFAULT_SERVICE = "other_types";
+
+const generateServiceTypeArr = (
+ entityCountArr: ServiceTypeInterface[],
+ serviceType: string,
+ children: ChildrenInterface,
+ entityCount: number
+) => {
+ const existing = entityCountArr.find(
+ (obj): obj is ServiceTypeInterface =>
+ typeof obj === "object" && obj !== null && serviceType in obj
+ );
+ if (existing) {
+ const bucket = (existing as Record<string, {
+ children: ChildrenInterface[];
+ totalCount: number;
+ }>)[serviceType];
+ if (bucket) {
+ bucket.children.push(children);
+ bucket.totalCount += entityCount;
+ }
+ } else {
+ entityCountArr.push({
+ [serviceType]: {
+ children: [children],
+ name: serviceType,
+ totalCount: entityCount
+ }
+ } as ServiceTypeInterface);
+ }
+};
+
+const pushRootEntityToTree = (
+ entities: ServiceTypeInterface[],
+ allEntityCategory: string | undefined
+) => {
+ const rootEntityChildren: ChildrenInterface = {
+ gType: "Entity",
+ guid: addOnEntities[0],
+ id: addOnEntities[0],
+ name: addOnEntities[0],
+ type: allEntityCategory,
+ text: addOnEntities[0]
+ };
+ const hasOther = entities.some((obj) => obj["other_types"] !== undefined);
+ if (hasOther) {
+ const idx = entities.findIndex((obj) => "other_types" in obj);
+ (entities[idx] as ServiceTypeInterface)["other_types"].children.push(
+ rootEntityChildren
+ );
+ } else {
+ entities.push({
+ other_types: {
+ name: "other_types",
+ children: [rootEntityChildren],
+ totalCount: 0
+ }
+ } as ServiceTypeInterface);
+ }
+ return entities;
+};
+
+/**
+ * Mirrors {@link EntitiesTree} group view (service type → entity typedefs).
+ */
+export const buildEntitiesTreeForQuickSearch = (
+ typeHeaderData: TypeHeaderInterface[] | null | undefined,
+ metricsEntity: MetricsEntitySnapshot | null | undefined,
+ allEntityCategory: string | undefined
+): TreeNode[] => {
+ if (!Array.isArray(typeHeaderData) || !typeHeaderData.length || !metricsEntity) {
+ return [];
+ }
+ const active = metricsEntity.entityActive || {};
+ const deleted = metricsEntity.entityDeleted || {};
+ const newArr: ServiceTypeInterface[] = [];
+
+ typeHeaderData.forEach((entity) => {
+ let { serviceType = DEFAULT_SERVICE, category, name, guid } = entity;
+ if (category !== "ENTITY") return;
+ const entityCount =
+ Number(active[name] ?? 0) + Number(deleted[name] ?? 0);
+ const modelName = entityCount ? `${name} (${entityCount})` : name;
+ const children: ChildrenInterface = {
+ text: modelName,
+ name,
+ type: category,
+ gType: "Entity",
+ guid,
+ id: guid
+ };
+ generateServiceTypeArr(newArr, serviceType, children, entityCount);
+ });
+
+ pushRootEntityToTree(newArr, allEntityCategory);
+
+ const child = (childs: ChildrenInterface[]) => {
+ if (!childs?.length) return [];
+ return customSortBy(
+ childs.map((obj) => ({
+ id: obj.name as string,
+ label: obj.text as string,
+ types: "child"
+ })),
+ ["label"]
+ );
+ };
+
+ const sorted = customSortByObjectKeys(newArr);
+ return sorted.map((entity: ServiceTypeInterface) => {
+ const key = Object.keys(entity)[0];
+ const entityData = entity[key];
+ return {
+ id: entityData.name,
+ label:
+ entityData.totalCount === 0
+ ? entityData.name
+ : `${entityData.name} (${entityData.totalCount})`,
+ children: child(entityData.children),
+ types: "parent"
+ };
+ });
+};
+
+const filterTreeNodes = (treeData: TreeNode[], searchTerm: string): TreeNode[] => {
+ const q = searchTerm.trim().toLowerCase();
+ if (!q) return treeData;
+ return treeData
+ .filter(
+ (node) =>
+ node.label?.toLowerCase().includes(q) ||
+ node.children?.some((child) => child.label?.toLowerCase().includes(q))
+ )
+ .map((node) => {
+ const parentMatches = node.label?.toLowerCase().includes(q) ?? false;
+ const kids = node.children?.length
+ ? parentMatches
+ ? node.children
+ : node.children.filter((c) => c.label?.toLowerCase().includes(q))
+ : undefined;
+ return { ...node, children: kids };
+ });
+};
+
+export const entityTreeToScopedOptions = (
+ tree: TreeNode[],
+ searchTerm: string
+): ScopedQuickSearchOption[] => {
+ const filtered = filterTreeNodes(tree, searchTerm);
+ const out: ScopedQuickSearchOption[] = [];
+ for (const parent of filtered) {
+ const underlying =
+ parent.children?.map((c) => c.id).filter(Boolean) ?? [];
+ out.push({
+ id: `svc:${parent.id}`,
+ title: parent.label,
+ group: "Entity",
+ kind: "entity-service",
+ serviceUnderlyingTypeNames: underlying
+ });
+ for (const child of parent.children ?? []) {
+ out.push({
+ id: `type:${child.id}`,
+ title: child.label,
+ group: "Entity",
+ kind: "entity-type",
+ entityTypeName: child.id
+ });
+ }
+ }
+ return out;
+};
+
+interface ClassificationDefRow {
+ name: string;
+ subTypes: string[];
+ guid: string;
+ superTypes: string[];
+}
+
+/** Depth-first classification list (same traversal as sidebar tree). */
+export const buildClassificationScopedOptions = (
+ classificationData: { classificationDefs: ClassificationDefRow[] } | null,
+ tagEntities: Record<string, number> | undefined,
+ searchTerm: string
+): ScopedQuickSearchOption[] => {
+ if (!classificationData?.classificationDefs?.length) return [];
+ const defs = classificationData.classificationDefs;
+ const byName = new Map(defs.map((d) => [d.name, d]));
+ const q = searchTerm.trim().toLowerCase();
+ const out: ScopedQuickSearchOption[] = [];
+ const seen = new Set<string>();
+
+ const visit = (typeName: string) => {
+ if (seen.has(typeName)) return;
+ seen.add(typeName);
+ const def = byName.get(typeName);
+ if (!def) return;
+ const count = tagEntities?.[typeName];
+ const label =
+ count !== undefined ? `${typeName} (${count})` : typeName;
+ if (!q || label.toLowerCase().includes(q) || typeName.toLowerCase().includes(q)) {
+ out.push({
+ id: `cls:${def.guid}:${typeName}`,
+ title: label,
+ group: "Classification",
+ kind: "classification",
+ classificationName: typeName
+ });
+ }
+ (def.subTypes ?? []).forEach((st) => visit(st));
+ };
+
+ defs
+ .filter((d) => isEmpty(d.superTypes))
+ .forEach((d) => visit(d.name));
+
+ return customSortBy(out, ["title"]);
+};
+
+/** Build glossary tree for “terms” mode (see {@link GlossaryTree} glossaryType true). */
+export const buildGlossaryTermsTreeForQuickSearch = (
+ glossaryData: GlossaryApiRow[] | null | undefined
+): TreeNode[] => {
+ if (!glossaryData?.length) return [];
+ const glossaryType = true;
+
+ const toServiceRows: Record<string, {
+ name: string;
+ children: ChildrenInterface[];
+ id: string;
+ types: string;
+ parent: string;
+ guid: string;
+ }>[] = glossaryData.map((glossary) => {
+ const categoryRelation =
+ glossary.categories?.filter((o) => o.parentCategoryGuid !== undefined) ??
+ [];
+
+ const getChildren = (glossaries: {
+ children: NonNullable<GlossaryApiRow["terms"]>;
+ parent: string;
+ }) => {
+ if (isEmpty(glossaries.children)) return [];
+ return glossaries.children
+ .map((glossariesType) => {
+ const getChild = () =>
+ categoryRelation
+ .map((obj) => {
+ if (obj.parentCategoryGuid === glossariesType.categoryGuid) {
+ return {
+ name: obj.displayText,
+ id: obj.displayText,
+ children: [] as ChildrenInterface[],
+ types: "child",
+ parent: glossaries.parent,
+ cGuid: glossaryType ? obj.termGuid : obj.categoryGuid,
+ guid: glossary.guid
+ };
+ }
+ return undefined;
+ })
+ .filter(Boolean) as ChildrenInterface[];
+ if (glossariesType.parentCategoryGuid === undefined) {
+ return {
+ name: glossariesType.displayText,
+ id: glossariesType.displayText,
+ children: getChild(),
+ types: "child",
+ parent: glossaries.parent,
+ cGuid: glossaryType
+ ? glossariesType.termGuid
+ : glossariesType.categoryGuid,
+ guid: glossary.guid
+ } as ChildrenInterface;
+ }
+ return undefined;
+ })
+ .filter(Boolean) as ChildrenInterface[];
+ };
+
+ const children = getChildren({
+ children: glossary?.terms ?? [],
+ parent: glossary.name
+ });
+
+ return {
+ [glossary.name]: {
+ name: glossary.name,
+ children: children || [],
+ id: glossary.guid,
+ types: "parent",
+ parent: glossary.name,
+ guid: glossary.guid
+ }
+ };
+ });
+
+ const child = (childs: ChildrenInterface[]): TreeNode[] => {
+ if (!childs?.length) return [];
+ return customSortBy(
+ childs.map((obj) => ({
+ id: obj?.name as string,
+ label: obj?.name as string,
+ children:
+ obj?.children !== undefined
+ ? child(obj.children.filter(Boolean) as ChildrenInterface[])
+ : [],
+ types: obj?.types,
+ parent: obj?.parent,
+ guid: obj?.guid,
+ cGuid: obj?.cGuid
+ })),
+ ["label"]
+ );
+ };
+
+ const sorted = customSortByObjectKeys(toServiceRows as ServiceTypeInterface[]);
+ return sorted.map((entity: Record<string, {
+ name: string;
+ children: ChildrenInterface[];
+ types: string;
+ parent: string;
+ guid: string;
+ }>) => {
+ const g = entity[Object.keys(entity)[0]];
+ return {
+ id: g.name,
+ label: g.name,
+ children: child((g.children ?? []) as ChildrenInterface[]),
+ types: g.types,
+ parent: g.parent,
+ guid: g.guid
+ };
+ });
+};
+
+const collectGlossaryTermOptions = (
+ nodes: TreeNode[],
+ glossaryLabel: string,
+ out: ScopedQuickSearchOption[]
+) => {
+ for (const n of nodes) {
+ if (n.cGuid && n.types === "child" && n.id) {
+ out.push({
+ id: `term:${n.cGuid}`,
+ title: `${n.label} (${glossaryLabel})`,
+ group: "Glossary / Terms",
+ kind: "glossary-term",
+ termGuid: n.cGuid as string,
+ glossaryGuid: (n.guid as string) ?? "",
+ termId: n.id,
+ termParent: (n.parent as string) ?? glossaryLabel
+ });
+ }
+ if (n.children?.length) {
+ collectGlossaryTermOptions(n.children, glossaryLabel, out);
+ }
+ }
+};
+
+export const glossaryTreeToScopedOptions = (
+ tree: TreeNode[],
+ searchTerm: string
+): ScopedQuickSearchOption[] => {
+ const filtered = filterTreeNodes(tree, searchTerm);
+ const out: ScopedQuickSearchOption[] = [];
+ for (const parent of filtered) {
+ collectGlossaryTermOptions(parent.children ?? [], parent.label, out);
+ }
+ return customSortBy(out, ["title"]);
+};
+
+export const buildBusinessMetadataScopedOptions = (
+ businessMetadataDefs: Array<{ name: string; guid: string }> | undefined,
+ searchTerm: string
+): ScopedQuickSearchOption[] => {
+ if (!businessMetadataDefs?.length) return [];
+ const q = searchTerm.trim().toLowerCase();
+ return customSortBy(
+ businessMetadataDefs
+ .filter((d) => !q || d.name.toLowerCase().includes(q))
+ .map((d) => ({
+ id: `bm:${d.guid}`,
+ title: d.name,
+ group: "Business Metadata",
+ kind: "business-metadata" as const,
+ bmGuid: d.guid
+ })),
+ ["title"]
+ );
+};
diff --git a/dashboard/src/utils/typeCatalogUtils.ts b/dashboard/src/utils/typeCatalogUtils.ts
new file mode 100644
index 000000000..078a36c79
--- /dev/null
+++ b/dashboard/src/utils/typeCatalogUtils.ts
@@ -0,0 +1,159 @@
+/*
+ * 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 type { EntityTypeDistributionItem } from "./metricsUtils";
+
+/** Minimal typedef row from `/api/atlas/types/typedefs/headers` (see EntitiesTree). */
+export interface TypeHeaderCatalogRow {
+ name: string;
+ category: string;
+ serviceType?: string;
+ guid?: string;
+}
+
+export interface DashboardTypeCatalog {
+ classificationNames: string[];
+ /** Glossary-related entity typedef names (Atlas glossary model). */
+ glossaryTermRelatedTypeNames: string[];
+ businessMetadataNames: string[];
+ /** All ENTITY typedef names (sorted). */
+ entityTypeNames: string[];
+}
+
+const DEFAULT_SERVICE_TYPE = "other_types";
+
+const countForEntityType = (
+ entity: Record<string, unknown> | undefined,
+ typeName: string
+): { active: number; deleted: number } => {
+ const active = (entity?.entityActive as Record<string, number>) || {};
+ const deleted = (entity?.entityDeleted as Record<string, number>) || {};
+ return {
+ active: Number(active[typeName] ?? 0) || 0,
+ deleted: Number(deleted[typeName] ?? 0) || 0,
+ };
+};
+
+/**
+ * Service-type buckets aligned with the Entities sidebar (`serviceType` on each ENTITY typedef).
+ * Falls back to `{typeName}_` prefix grouping when headers are missing (e.g. hive_table → hive).
+ */
+export const getServiceTypeDistribution = (
+ entity: Record<string, unknown> | undefined,
+ typeHeaders: TypeHeaderCatalogRow[] | null | undefined,
+ topN = 5
+): EntityTypeDistributionItem[] => {
+ const active = (entity?.entityActive as Record<string, number>) || {};
+ const deleted = (entity?.entityDeleted as Record<string, number>) || {};
+ const map = new Map<
+ string,
+ { active: number; deleted: number; typeNames: Set<string> }
+ >();
+
+ const add = (serviceKey: string, typeName: string) => {
+ const { active: a, deleted: d } = countForEntityType(entity, typeName);
+ const cur = map.get(serviceKey) ?? {
+ active: 0,
+ deleted: 0,
+ typeNames: new Set<string>(),
+ };
+ cur.active += a;
+ cur.deleted += d;
+ cur.typeNames.add(typeName);
+ map.set(serviceKey, cur);
+ };
+
+ if (Array.isArray(typeHeaders) && typeHeaders.length > 0) {
+ for (const row of typeHeaders) {
+ if (row.category !== "ENTITY") continue;
+ const st = (row.serviceType?.trim() || DEFAULT_SERVICE_TYPE) as string;
+ add(st, row.name);
+ }
+ } else {
+ const allTypes = new Set([...Object.keys(active), ...Object.keys(deleted)]);
+ allTypes.forEach((typeName) => {
+ const i = typeName.indexOf("_");
+ const serviceKey = i === -1 ? typeName : typeName.slice(0, i);
+ add(serviceKey, typeName);
+ });
+ }
+
+ const rows: EntityTypeDistributionItem[] = [...map.entries()].map(
+ ([name, { active: a, deleted: d, typeNames }]) => ({
+ name,
+ active: a,
+ deleted: d,
+ count: a + d,
+ underlyingTypeNames: [...typeNames].sort(),
+ })
+ );
+
+ rows.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name));
+
+ const top = rows.slice(0, topN);
+ if (top.length === topN) return top;
+
+ const used = new Set(top.map((r) => r.name));
+ const pad = rows
+ .filter((r) => !used.has(r.name))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ while (top.length < topN && pad.length > 0) {
+ const next = pad.shift();
+ if (next) top.push(next);
+ }
+ return top;
+};
+
+/** Lists derived from type headers for search / future features. */
+export const buildDashboardTypeCatalog = (
+ typeHeaders: TypeHeaderCatalogRow[] | null | undefined
+): DashboardTypeCatalog => {
+ const empty: DashboardTypeCatalog = {
+ classificationNames: [],
+ glossaryTermRelatedTypeNames: [],
+ businessMetadataNames: [],
+ entityTypeNames: [],
+ };
+ if (!Array.isArray(typeHeaders) || typeHeaders.length === 0) return empty;
+
+ const classificationNames: string[] = [];
+ const businessMetadataNames: string[] = [];
+ const entityTypeNames: string[] = [];
+ const glossaryTermRelatedTypeNames: string[] = [];
+
+ for (const row of typeHeaders) {
+ const { name, category } = row;
+ if (!name) continue;
+ if (category === "CLASSIFICATION") classificationNames.push(name);
+ else if (category === "BUSINESS_METADATA") businessMetadataNames.push(name);
+ else if (category === "ENTITY") {
+ entityTypeNames.push(name);
+ if (name.toLowerCase().includes("glossary")) {
+ glossaryTermRelatedTypeNames.push(name);
+ }
+ }
+ }
+
+ const sortUnique = (arr: string[]) => [...new Set(arr)].sort((a, b) => a.localeCompare(b));
+
+ return {
+ classificationNames: sortUnique(classificationNames),
+ businessMetadataNames: sortUnique(businessMetadataNames),
+ entityTypeNames: sortUnique(entityTypeNames),
+ glossaryTermRelatedTypeNames: sortUnique(glossaryTermRelatedTypeNames),
+ };
+};
diff --git a/dashboard/src/views/Administrator/AdministratorLayout.tsx b/dashboard/src/views/Administrator/AdministratorLayout.tsx
index 302da4313..7e1e2abc8 100644
--- a/dashboard/src/views/Administrator/AdministratorLayout.tsx
+++ b/dashboard/src/views/Administrator/AdministratorLayout.tsx
@@ -19,7 +19,7 @@ import { LinkTab } from "@components/muiComponents";
import { Stack, Tabs } from "@mui/material";
import { Item, samePageLinkNavigation } from "@utils/Muiutils";
import { isEmpty } from "@utils/Utils";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import BusinessMetadataTab from "./BusinessMetadataTab";
import Enumerations from "./Enumerations";
@@ -44,6 +44,26 @@ const AdministratorLayout = () => {
!isEmpty(activeTab) ? allTabs.findIndex((val) => val === activeTab) : 0
);
+ useEffect(() => {
+ const tabIndex = !isEmpty(activeTab)
+ ? allTabs.findIndex((val) => val === activeTab)
+ : 0;
+ const resolvedIndex = tabIndex >= 0 ? tabIndex : 0;
+ setValue(resolvedIndex);
+
+ if (activeTab && activeTab !== "businessMetadata") {
+ setForm(false);
+ }
+ const createParam = searchParams.get("create");
+ if (createParam === "true" && activeTab === "businessMetadata") {
+ setForm(true);
+ setBMAttribute({});
+ const newParams = new URLSearchParams(location.search);
+ newParams.delete("create");
+ navigate({ pathname: "/administrator", search: newParams.toString() }, { replace: true });
+ }
+ }, [activeTab, location.search, navigate]);
+
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
if (
event.type !== "click" ||
diff --git a/dashboard/src/views/Administrator/Audits/AuditResults.tsx b/dashboard/src/views/Administrator/Audits/AuditResults.tsx
index 9deef0525..a287456f7 100644
--- a/dashboard/src/views/Administrator/Audits/AuditResults.tsx
+++ b/dashboard/src/views/Administrator/Audits/AuditResults.tsx
@@ -15,22 +15,13 @@
* limitations under the License.
*/
-import {
- Divider,
- Grid,
- Link,
- List,
- ListItem,
- ListItemText,
- Stack,
- Typography,
-} from "@mui/material";
+import { Grid, Link, List, ListItem, ListItemText, Typography } from "@mui/material";
import { auditAction, category } from "@utils/Enum";
-import { isArray, isEmpty, jsonParse } from "@utils/Utils";
+import { isEmpty, jsonParse } from "@utils/Utils";
import CustomModal from "@components/Modal";
+import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal";
import { useState } from "react";
-import { getValues } from "@components/commonComponents";
-import { Item, StyledPaper } from "@utils/Muiutils";
+import { Item } from "@utils/Muiutils";
import AuditsTab from "@views/DetailPage/EntityDetailTabs/AuditsTab";
import ImportExportAudits from "./ImportExportAudits";
@@ -196,65 +187,12 @@ const AuditResults = ({ componentProps, row }: any) => {
<ImportExportAudits auditObj={auditObj} />
)}
- <CustomModal
+ <TypeDefAuditDetailModal
open={openModal}
onClose={handleCloseModal}
- title={`${category[currentResultObj.category]} Type Details: ${
- currentResultObj.name
- }`}
- footer={false}
- button1Handler={undefined}
- button2Handler={undefined}
- >
- <StyledPaper variant="outlined">
- {" "}
- {!isEmpty(currentResultObj)
- ? Object.entries(currentResultObj)
- .sort()
- .map(([keys, value]: [string, any]) => {
- return (
- <>
- <Stack
- direction="row"
- spacing={4}
- marginBottom={1}
- marginTop={1}
- >
- <div
- style={{
- flex: 1,
- wordBreak: "break-all",
- textAlign: "left",
- fontWeight: "600",
- }}
- >
- {`${keys} ${
- isArray(value) ? `(${value.length})` : ""
- }`}
- </div>
- <div
- style={{
- flex: 1,
- wordBreak: "break-all",
- textAlign: "left",
- }}
- >
- {getValues(
- value,
- undefined,
- undefined,
- undefined,
- "properties"
- )}
- </div>
- </Stack>
- <Divider />
- </>
- );
- })
- : "No Record Found"}
- </StyledPaper>
- </CustomModal>
+ detailObject={currentResultObj}
+ maxWidth="sm"
+ />
{(operation == "PURGE" || operation == "AUTO_PURGE") && (
<CustomModal
diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
index 6e161f68f..d32eb7017 100644
--- a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
+++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
@@ -898,6 +898,7 @@ const BusinessMetadataAttributeForm = ({
maxWidth="sm"
button2Handler={handleSubmit(onSubmit)}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
>
<Stack gap={2} paddingTop="2rem" paddingBottom="2rem">
<EnumCreateUpdate
diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx
index 950a6b3ff..9d31691e5 100644
--- a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx
+++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx
@@ -42,6 +42,7 @@ import BusinessMetadataAttributeForm from "./BusinessMetadataAtrributeForm";
import { setEditBMAttribute } from "@redux/slice/createBMSlice";
import { cloneDeep } from "@utils/Helper";
import { fetchBusinessMetaData } from "@redux/slice/typeDefSlices/typedefBusinessMetadataSlice";
+import { fetchEntityData } from "@redux/slice/typeDefSlices/typedefEntitySlice";
import { defaultType } from "@utils/Enum";
import { getTypeName } from "@utils/CommonViewFunction";
@@ -340,6 +341,7 @@ const BusinessMetaDataForm = ({
let bmName = response?.data?.businessMetadataDefs?.[0]?.name;
toastMssg(bmName);
dispatchState(fetchBusinessMetaData());
+ dispatchState(fetchEntityData());
setBMAttribute({});
setForm(false);
} catch (error) {
diff --git a/dashboard/src/views/Classification/AddTag.tsx b/dashboard/src/views/Classification/AddTag.tsx
index f06fbc6bb..1ec08ac9f 100644
--- a/dashboard/src/views/Classification/AddTag.tsx
+++ b/dashboard/src/views/Classification/AddTag.tsx
@@ -381,6 +381,7 @@ const AddTag = (props: {
disableButton2={
isEmpty(classificationData) ? true : isSubmitting
}
+ button2Loading={isSubmitting}
isDirty={
isEmpty(classificationData)
? false
diff --git a/dashboard/src/views/Classification/AddTagAttributes.tsx b/dashboard/src/views/Classification/AddTagAttributes.tsx
index 107045a17..34d4392e8 100644
--- a/dashboard/src/views/Classification/AddTagAttributes.tsx
+++ b/dashboard/src/views/Classification/AddTagAttributes.tsx
@@ -145,6 +145,7 @@ const AddTagAttributes = ({ open, onClose }: any) => {
button2Label="Add"
button2Handler={handleSubmit(onSubmit)}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
>
<form onSubmit={handleSubmit(onSubmit)}>
{/* <TagAtrributes control={control} /> */}
diff --git a/dashboard/src/views/Classification/ClassificationForm.tsx b/dashboard/src/views/Classification/ClassificationForm.tsx
index 170f9f16c..943cabdb6 100644
--- a/dashboard/src/views/Classification/ClassificationForm.tsx
+++ b/dashboard/src/views/Classification/ClassificationForm.tsx
@@ -48,6 +48,7 @@ import { paramsType } from "@models/detailPageType";
import TagAtrributes from "./TagAttributes";
import { fetchClassificationData } from "@redux/slice/typeDefSlices/typedefClassificationSlice";
import { AntSwitch } from "@utils/Muiutils";
+import { refreshDashboardHomeData } from "@utils/refreshDashboardHome";
const ClassificationForm = ({
open,
@@ -202,7 +203,8 @@ const ClassificationForm = ({
isAdd ? "created" : "updated"
} successfully`
);
- fetchInitialData();
+ await fetchInitialData();
+ refreshDashboardHomeData(dispatchApi);
} catch (error) {
console.error(
`Error while ${isAdd ? "creating" : "updating"} classification`,
@@ -223,6 +225,7 @@ const ClassificationForm = ({
button1Handler={onClose}
button2Label={isAdd ? "Create" : "Save"}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
isDirty={isDirty}
maxWidth="sm"
button2Handler={handleSubmit(onSubmit)}
diff --git a/dashboard/src/views/Classification/DeleteTag.tsx b/dashboard/src/views/Classification/DeleteTag.tsx
index 71b4448f6..4bc2d7a19 100644
--- a/dashboard/src/views/Classification/DeleteTag.tsx
+++ b/dashboard/src/views/Classification/DeleteTag.tsx
@@ -77,6 +77,7 @@ const DeleteTag = (props: {
maxWidth="sm"
button2Handler={handleRemove}
disableButton2={loader}
+ button2Loading={loader}
>
<Typography fontSize={15}>
Are you sure you want to delete classification
diff --git a/dashboard/src/views/DashBoard.tsx b/dashboard/src/views/DashBoard.tsx
index 9b8dde430..c0b5a201e 100644
--- a/dashboard/src/views/DashBoard.tsx
+++ b/dashboard/src/views/DashBoard.tsx
@@ -15,65 +15,40 @@
* limitations under the License.
*/
+import { Stack } from "@mui/material";
import QuickSearch from "@components/GlobalSearch/QuickSearch";
-import { CustomButton } from "@components/muiComponents";
+import DashboardOverview from "./DashboardOverview/DashboardOverview";
import { useAppSelector } from "@hooks/reducerHook";
-import { Stack } from "@mui/material";
-import { useState } from "react";
-import EntityForm from "./Entity/EntityForm";
-import AddIcon from "@mui/icons-material/Add";
const DashBoard = () => {
- const { sessionObj = "" }: any = useAppSelector(
- (state: any) => state.session
- );
- const [entityModal, setEntityModal] = useState<boolean>(false);
- const { data } = sessionObj || {};
- const key = "atlas.entity.create.allowed";
- const entityCreate = data?.[key] || "";
+ const dashboardRefreshVersion = useAppSelector((state) => state.dashboardRefresh.version);
- const handleOpenEntityModal = () => {
- setEntityModal(true);
- };
- const handleCloseEntityModal = () => {
- setEntityModal(false);
- };
- return (
- <Stack
- alignItems="flex-start"
- justifyContent="space-between"
- position="relative"
- height="100%"
- flex="1"
- padding="0"
- >
- <Stack direction="row" justifyContent="flex-end" width={"100%"}>
- {entityCreate && (
- <CustomButton
- variant="contained"
- size="small"
- onClick={(_e: any) => handleOpenEntityModal()}
- startIcon={<AddIcon />}
- >
- Create Entity
- </CustomButton>
- )}
- </Stack>
- <Stack
- justifyContent="center"
- flex="1"
- alignItems={"center"}
- height={"100%"}
- width={"100%"}
- className="dashboard-quick-search"
- >
- <QuickSearch />
- </Stack>
- {entityModal && (
- <EntityForm open={entityModal} onClose={handleCloseEntityModal} />
- )}
- </Stack>
- );
+ return (
+ <Stack
+ width="100%"
+ maxWidth="100%"
+ alignItems="stretch"
+ justifyContent="flex-start"
+ position="relative"
+ height="100%"
+ flex="1"
+ padding={0}
+ spacing={2}
+ sx={{ boxSizing: "border-box", overflow: "hidden" }}
+ >
+ <Stack
+ direction="row"
+ width="100%"
+ justifyContent="center"
+ sx={{ mb: 2, flexShrink: 0 }}
+ >
+ <QuickSearch key={dashboardRefreshVersion} />
+ </Stack>
+ <Stack width="100%" flex={1} sx={{ minWidth: 0 }}>
+ <DashboardOverview />
+ </Stack>
+ </Stack>
+ );
};
export default DashBoard;
diff --git a/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
new file mode 100644
index 000000000..21fc18588
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
@@ -0,0 +1,394 @@
+/*
+ * 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 { memo, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Paper,
+ Stack,
+ Typography,
+ Box,
+ List,
+ ListItem,
+ Link,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import CustomModal from "@components/Modal";
+import { numberFormatWithComma } from "@utils/Helper";
+import {
+ getClassificationTypesInUseCount,
+ getUnusedClassificationNames,
+} from "@utils/metricsUtils";
+import { navigateToTaggedSearch } from "@utils/dashboardSearchUtils";
+import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
+import { fetchClassificationData } from "@redux/slice/typeDefSlices/typedefClassificationSlice";
+
+interface ClassificationCoverageProps {
+ /** Defined classification types in the type system (from general.tagCount) */
+ classificationTypeDefinitions: number;
+ tag: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const ClassificationCoverage = memo(
+ ({
+ classificationTypeDefinitions,
+ tag,
+ isLoading,
+ }: ClassificationCoverageProps) => {
+ const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const { classificationData, loadingClassification } = useAppSelector(
+ (state: { classification?: { classificationData?: { classificationDefs?: { name: string }[] } | null; loadingClassification?: boolean } }) =>
+ state.classification ?? {}
+ );
+
+ useEffect(() => {
+ if (!classificationData && !loadingClassification) {
+ void dispatch(fetchClassificationData());
+ }
+ }, [classificationData, loadingClassification, dispatch]);
+
+ const definedNames = useMemo(() => {
+ const defs =
+ classificationData?.classificationDefs as { name?: string }[] | undefined;
+ if (!Array.isArray(defs)) return [];
+ return defs.map((d) => d.name).filter((n): n is string => Boolean(n));
+ }, [classificationData]);
+
+ const typesInUse = useMemo(
+ () => getClassificationTypesInUseCount(tag),
+ [tag]
+ );
+ const unusedNames = useMemo(
+ () => getUnusedClassificationNames(definedNames, tag),
+ [definedNames, tag]
+ );
+ const unusedCount = unusedNames.length;
+
+ const typeUsageProgress =
+ classificationTypeDefinitions > 0
+ ? (typesInUse / classificationTypeDefinitions) * 100
+ : 0;
+ const usagePercentRounded = Math.min(
+ 100,
+ Math.round(Number.isFinite(typeUsageProgress) ? typeUsageProgress : 0)
+ );
+
+ const [unusedModalOpen, setUnusedModalOpen] = useState(false);
+ const [unusedListSearchQuery, setUnusedListSearchQuery] = useState("");
+
+ const handleClassificationsClick = useCallback(() => {
+ navigateToTaggedSearch(navigate);
+ }, [navigate]);
+
+ const handleOpenUnusedModal = useCallback(() => {
+ if (unusedCount > 0 && definedNames.length > 0) {
+ setUnusedListSearchQuery("");
+ setUnusedModalOpen(true);
+ }
+ }, [unusedCount, definedNames.length]);
+
+ const handleCloseUnusedModal = useCallback(() => {
+ setUnusedListSearchQuery("");
+ setUnusedModalOpen(false);
+ }, []);
+
+ const handleUnusedBarSegmentClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ handleOpenUnusedModal();
+ },
+ [handleOpenUnusedModal]
+ );
+
+ const unusedNamesFiltered = useMemo(() => {
+ const q = unusedListSearchQuery.trim().toLowerCase();
+ if (!q) return unusedNames;
+ return unusedNames.filter((name) => name.toLowerCase().includes(q));
+ }, [unusedNames, unusedListSearchQuery]);
+
+ const showUnusedListSearch = unusedNames.length > 5;
+
+ if (isLoading) return null;
+
+ const usedBarPct = Math.min(Math.max(typeUsageProgress, 0), 100);
+ const defsReady = definedNames.length > 0 || !!classificationData;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Classification Types In Use
+ </Typography>
+ </Box>
+ <Stack spacing={1.5} sx={{ pt: 2 }}>
+ <Stack
+ direction="row"
+ justifyContent="space-between"
+ alignItems="center"
+ sx={{ width: "100%" }}
+ >
+ <Typography
+ sx={{
+ fontSize: "0.875rem",
+ fontWeight: 600,
+ color: "#1a1a1a",
+ }}
+ >
+ Classifications in use
+ </Typography>
+ <Typography
+ sx={{
+ fontSize: "0.875rem",
+ color: "#868e96",
+ }}
+ >
+ {usagePercentRounded}%
+ </Typography>
+ </Stack>
+ <Stack
+ direction="row"
+ sx={{
+ height: 10,
+ borderRadius: 5,
+ overflow: "hidden",
+ width: "100%",
+ backgroundColor: "#e9ecef",
+ }}
+ aria-label="Classification usage: green is in use, grey is not in use"
+ >
+ {usedBarPct > 0 ? (
+ <Box
+ component="button"
+ type="button"
+ onClick={handleClassificationsClick}
+ sx={{
+ width: `${usedBarPct}%`,
+ minWidth: 0,
+ border: "none",
+ padding: 0,
+ cursor: "pointer",
+ backgroundColor: "#16a34a",
+ }}
+ aria-label="Open classification search for types in use"
+ />
+ ) : null}
+ <Box
+ component="button"
+ type="button"
+ onClick={handleUnusedBarSegmentClick}
+ disabled={unusedCount === 0 || !defsReady}
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ border: "none",
+ padding: 0,
+ cursor:
+ unusedCount > 0 && defsReady ? "pointer" : "default",
+ backgroundColor: "#e9ecef",
+ }}
+ aria-label={
+ unusedCount > 0 && defsReady
+ ? `View ${unusedCount} classification types not in use`
+ : "No unused classification types to show"
+ }
+ />
+ </Stack>
+ {defsReady && (
+ <Typography
+ component="p"
+ sx={{
+ fontSize: "0.875rem",
+ color: "#6c757d",
+ m: 0,
+ }}
+ >
+ <strong>Not in use:</strong>{" "}
+ {unusedCount > 0 ? (
+ <Link
+ component="button"
+ type="button"
+ onClick={handleOpenUnusedModal}
+ sx={{
+ fontSize: "0.875rem",
+ color: "primary.main",
+ cursor: "pointer",
+ textDecoration: "underline",
+ background: "none",
+ border: "none",
+ padding: 0,
+ verticalAlign: "baseline",
+ fontFamily: "inherit",
+ }}
+ aria-label={`Show ${unusedCount} classification types not in use`}
+ >
+ {numberFormatWithComma(unusedCount)}{" "}
+ {unusedCount === 1
+ ? "classification type"
+ : "classification types"}
+ </Link>
+ ) : (
+ <>
+ {numberFormatWithComma(unusedCount)}{" "}
+ {classificationTypeDefinitions > 0
+ ? unusedCount === 1
+ ? "classification type"
+ : "classification types"
+ : ""}
+ </>
+ )}
+ </Typography>
+ )}
+ {!defsReady && loadingClassification ? (
+ <Typography variant="caption" sx={{ color: "#868e96" }}>
+ Loading classification definitions…
+ </Typography>
+ ) : null}
+ <Typography
+ component="button"
+ type="button"
+ onClick={handleClassificationsClick}
+ sx={{
+ fontSize: "0.875rem",
+ color: "#6c757d",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ textAlign: "left",
+ "&:hover": { color: "primary.main", textDecoration: "underline" },
+ }}
+ aria-label="Open classification search"
+ >
+ {numberFormatWithComma(typesInUse)} of{" "}
+ {numberFormatWithComma(classificationTypeDefinitions)}
+ classification types are in use (have at least one entity).
+ </Typography>
+ </Stack>
+
+ <CustomModal
+ open={unusedModalOpen}
+ onClose={handleCloseUnusedModal}
+ title="Classification types not in use"
+ maxWidth="sm"
+ button1Label="Close"
+ button1Handler={handleCloseUnusedModal}
+ button2Label=""
+ button2Handler={handleCloseUnusedModal}
+ hideButton2={true}
+ >
+ <Stack spacing={2} sx={{ pt: 0.5 }}>
+ <Typography
+ variant="body2"
+ sx={{ color: "#6c757d", lineHeight: 1.5 }}
+ >
+ These types are defined in Atlas but have no entity assignments in
+ the current metrics snapshot.
+ </Typography>
+ {showUnusedListSearch ? (
+ <TextField
+ size="small"
+ fullWidth
+ placeholder="Search classification name"
+ value={unusedListSearchQuery}
+ onChange={(e) => setUnusedListSearchQuery(e.target.value)}
+ inputProps={{
+ "aria-label": "Filter unused classifications by name",
+ }}
+ InputProps={{
+ startAdornment: (
+ <InputAdornment position="start">
+ <SearchIcon
+ sx={{ color: "#868e96", fontSize: "1.25rem" }}
+ aria-hidden
+ />
+ </InputAdornment>
+ ),
+ }}
+ />
+ ) : null}
+ <Box
+ sx={{
+ maxHeight: "min(45vh, 320px)",
+ overflowY: "auto",
+ }}
+ >
+ {unusedNamesFiltered.length === 0 ? (
+ <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
+ {unusedListSearchQuery.trim()
+ ? "No classifications match your search."
+ : "No unused classifications to list."}
+ </Typography>
+ ) : (
+ <List disablePadding>
+ {unusedNamesFiltered.map((name) => (
+ <ListItem
+ key={name}
+ disablePadding
+ sx={{
+ py: 1.25,
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ "&:last-child": { borderBottom: "none" },
+ }}
+ >
+ <Link
+ component={RouterLink}
+ to={{
+ pathname: `/tag/tagAttribute/${encodeURIComponent(name)}`,
+ search: new URLSearchParams({ tag: name }).toString(),
+ }}
+ onClick={handleCloseUnusedModal}
+ underline="hover"
+ color="primary"
+ sx={{
+ fontSize: "0.875rem",
+ fontWeight: 500,
+ cursor: "pointer",
+ }}
+ >
+ {name}
+ </Link>
+ </ListItem>
+ ))}
+ </List>
+ )}
+ </Box>
+ </Stack>
+ </CustomModal>
+ </Paper>
+ );
+ }
+);
+
+ClassificationCoverage.displayName = "ClassificationCoverage";
+
+export default ClassificationCoverage;
diff --git a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx
new file mode 100644
index 000000000..6da4704c7
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx
@@ -0,0 +1,227 @@
+/*
+ * 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, { memo, useCallback, useMemo } from "react";
+import { Paper, Stack, Typography, Box, Link } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList
+} from "recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import {
+ getClassificationDistribution,
+ getTagEntityAssociationTotal,
+} from "@utils/metricsUtils";
+import { navigateToSearch, navigateToClassificationSearch } from "@utils/dashboardSearchUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ CLASSIFICATION_DISTRIBUTION_CHART_MARGIN,
+} from "./dashboardChartPalette";
+
+const BAR_COLOR = CHART_BAR_ACTIVE_BLUE;
+
+interface ClassificationDistributionCardProps {
+ tag: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationDistributionCardProps) => {
+ const navigate = useNavigate();
+ const data = getClassificationDistribution(tag, 5);
+ const associationTotal = useMemo(() => getTagEntityAssociationTotal(tag), [tag]);
+
+ const handleBarClick = useCallback(
+ (entry: { name: string }) => {
+ navigateToClassificationSearch(navigate, entry.name);
+ },
+ [navigate]
+ );
+
+ const handleViewAll = useCallback(() => {
+ navigateToSearch(navigate, "all_classifications");
+ }, [navigate]);
+
+ const handleLabelClick = useCallback(
+ (tagName: string) => {
+ navigateToClassificationSearch(navigate, tagName);
+ },
+ [navigate]
+ );
+
+ const renderTooltip = useCallback((props: unknown) => {
+ const p = props as { active?: boolean; payload?: Array<{ payload?: { name: string; count: number } }> };
+ if (!p?.active || !p?.payload?.length) return null;
+ const row = p.payload[0]?.payload;
+ if (!row) return null;
+ return (
+ <Box sx={{ p: 1.5, bgcolor: "background.paper", borderRadius: 1, boxShadow: 2, minWidth: 140 }}>
+ <Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
+ {row.name}
+ </Typography>
+ <Typography variant="caption" display="block">
+ Entities: {numberFormatWithComma(row.count)}
+ </Typography>
+ </Box>
+ );
+ }, []);
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Classification Distribution
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{ fontSize: "0.875rem", cursor: "pointer", textDecoration: "none", color: "primary.main" }}
+ aria-label="View all classifications"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ <Typography variant="body2" sx={{ color: "#6c757d", mt: 2, lineHeight: 1.4 }}>
+ <strong>Tag–entity associations (total):</strong>{" "}
+ {numberFormatWithComma(associationTotal)}
+ </Typography>
+ <Typography variant="caption" sx={{ color: "#868e96", display: "block", mt: 0.75, lineHeight: 1.4 }}>
+ The chart shows the top 5 classifications by number of entities in use.
+ </Typography>
+ {data.length === 0 ? (
+ <Stack alignItems="center" justifyContent="center" height={200}>
+ <Typography variant="body2" color="text.secondary">
+ No classification data available
+ </Typography>
+ </Stack>
+ ) : (
+ <Box sx={{ mt: 2, minHeight: 260, height: 260, width: "100%", minWidth: 280 }}>
+ <ResponsiveContainer width="100%" height="100%" style={{ cursor: "pointer" }}>
+ <BarChart
+ data={data}
+ layout="vertical"
+ margin={{ ...CLASSIFICATION_DISTRIBUTION_CHART_MARGIN }}
+ >
+ <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+ type="number"
+ tickFormatter={(v) => numberFormatWithComma(v)}
+ height={36}
+ label={{
+ value: "Entity Count",
+ position: "bottom",
+ offset: 12,
+ style: { fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+ type="category"
+ dataKey="name"
+ width={52}
+ label={{
+ value: "Classification",
+ angle: -90,
+ position: "left",
+ offset: 2,
+ style: { fontSize: 10, fill: "#6c757d", textAnchor: "middle" },
+ }}
+ tick={(props: Record<string, unknown>) => {
+ const { x = 0, y = 0, payload } = props;
+ const p = payload as { value?: string; name?: string } | undefined;
+ const value = p?.value ?? p?.name ?? (typeof payload === "string" ? payload : "");
+ return (
+ <g
+ transform={`translate(${x},${y})`}
+ onClick={() => (value ? handleLabelClick(value) : undefined)}
+ style={{ cursor: value ? "pointer" : "default" }}
+ role={value ? "button" : undefined}
+ tabIndex={value ? 0 : undefined}
+ onKeyDown={
+ value
+ ? (e: React.KeyboardEvent<SVGGElement>) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleLabelClick(value);
+ }
+ }
+ : undefined
+ }
+ >
+ <text x={0} y={0} dy={4} textAnchor="end" fill="#333" fontSize={12}>
+ {value}
+ </text>
+ </g>
+ );
+ }}
+ />
+ <Tooltip content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+ dataKey="count"
+ name="Entities"
+ fill={BAR_COLOR}
+ radius={[0, 4, 4, 0]}
+ onClick={(entry) => handleBarClick(entry)}
+ cursor="pointer"
+ >
+ <LabelList
+ dataKey="count"
+ position="right"
+ offset={10}
+ formatter={(v: number) => numberFormatWithComma(v)}
+ style={{
+ fontSize: 12,
+ fontWeight: 500,
+ fill: BAR_COLOR,
+ }}
+ />
+ {data.map((_, index) => <Cell key={index} fill={BAR_COLOR} />)}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ )}
+ </Paper>
+ );
+});
+
+ClassificationDistributionCard.displayName = "ClassificationDistributionCard";
+
+export default ClassificationDistributionCard;
diff --git a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx
new file mode 100644
index 000000000..c7bfbd551
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx
@@ -0,0 +1,149 @@
+/*
+ * 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 { useEffect, useState, useMemo, useCallback } from "react";
+import { Grid, Stack } from "@mui/material";
+import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
+import { fetchTypeHeaderData } from "@redux/slice/typeDefSlices/typeDefHeaderSlice";
+import { getLatestEntities } from "@api/apiMethods/searchApiMethod";
+import type { TypeHeaderInterface } from "@models/entityTreeType";
+import DashboardSkeleton from "./DashboardSkeleton";
+import EntityTypeBarChartSkeleton from "./EntityTypeBarChartSkeleton";
+import LatestEntitiesSkeleton from "./LatestEntitiesSkeleton";
+import OverviewCard from "./OverviewCard";
+import EntityStatusDonut from "./EntityStatusDonut";
+import ClassificationCoverage from "./ClassificationCoverage";
+import EntityTypeBarChart from "./EntityTypeBarChart";
+import LatestEntitiesList from "./LatestEntitiesList";
+import RecentActivity from "./RecentActivity";
+import KafkaTopicSummaryCard from "./KafkaTopicSummaryCard";
+import ClassificationDistributionCard from "./ClassificationDistributionCard";
+
+const DashboardOverview = () => {
+ const dispatch = useAppDispatch();
+ const { metricsData, loading: metricsLoading } = useAppSelector((state: { metrics: { metricsData: unknown; loading: boolean } }) => state.metrics);
+ const typeHeaderData = useAppSelector(
+ (state: { typeHeader?: { typeHeaderData?: TypeHeaderInterface[] | null } }) =>
+ state.typeHeader?.typeHeaderData ?? null
+ );
+ const dashboardRefreshVersion = useAppSelector((state) => state.dashboardRefresh.version);
+ const [latestEntities, setLatestEntities] = useState<unknown[]>([]);
+ const [latestLoading, setLatestLoading] = useState(true);
+ const [latestError, setLatestError] = useState<string | null>(null);
+
+ const metrics = metricsData as {
+ data?: {
+ general?: { entityCount?: number; tagCount?: number; stats?: Record<string, unknown> };
+ entity?: Record<string, unknown>;
+ tag?: Record<string, unknown>;
+ };
+ } | null;
+ const general = metrics?.data?.general;
+ const entity = metrics?.data?.entity;
+ const tag = metrics?.data?.tag;
+ const stats = general?.stats;
+ const entityCount = general?.entityCount ?? 0;
+ const tagCount = general?.tagCount ?? 0;
+
+ const isLoading = metricsLoading;
+
+ const fetchLatestEntities = useCallback(async () => {
+ setLatestLoading(true);
+ setLatestError(null);
+ try {
+ const resp = await getLatestEntities();
+ const entities = (resp as { data?: { entities?: unknown[] } })?.data?.entities ?? [];
+ setLatestEntities(Array.isArray(entities) ? entities : []);
+ } catch (err) {
+ setLatestError(err instanceof Error ? err.message : "Failed to load latest entities");
+ setLatestEntities([]);
+ } finally {
+ setLatestLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchLatestEntities();
+ }, [dashboardRefreshVersion, fetchLatestEntities]);
+
+ useEffect(() => {
+ dispatch(fetchTypeHeaderData());
+ }, [dispatch]);
+
+ const latestEntitiesList = useMemo(() => {
+ if (!Array.isArray(latestEntities)) return [];
+ return latestEntities.slice(0, 7) as { guid?: string; typeName?: string; attributes?: { name?: string; qualifiedName?: string; __timestamp?: number } }[];
+ }, [latestEntities]);
+
+ return (
+ <Stack
+ spacing={3}
+ width="100%"
+ sx={{
+ maxWidth: "100%",
+ boxSizing: "border-box",
+ backgroundColor: "#f5f7f9",
+ padding: 3,
+ borderRadius: 2
+ }}
+ >
+ <Grid container spacing={3} sx={{ width: "100%", alignItems: "stretch" }}>
+ <Grid item xs={12} md={4}>
+ {isLoading ? <DashboardSkeleton /> : <OverviewCard entityCount={entityCount} tagCount={tagCount} />}
+ </Grid>
+ <Grid item xs={12} md={4}>
+ {isLoading ? <DashboardSkeleton /> : <EntityStatusDonut entity={entity} />}
+ </Grid>
+ <Grid item xs={12} md={4}>
+ {isLoading ? (
+ <DashboardSkeleton />
+ ) : (
+ <ClassificationCoverage
+ classificationTypeDefinitions={tagCount}
+ tag={tag}
+ />
+ )}
+ </Grid>
+ <Grid item xs={12} md={8} sx={{ display: "flex", minWidth: 0 }}>
+ {isLoading ? (
+ <EntityTypeBarChartSkeleton />
+ ) : (
+ <EntityTypeBarChart entity={entity} typeHeaderData={typeHeaderData} />
+ )}
+ </Grid>
+ <Grid item xs={12} md={4} sx={{ display: "flex", minWidth: 0 }}>
+ {latestLoading ? (
+ <LatestEntitiesSkeleton />
+ ) : (
+ <LatestEntitiesList entities={latestEntitiesList} error={latestError} />
+ )}
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display: "flex", minWidth: 0 }}>
+ <RecentActivity />
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display: "flex", minWidth: 0 }}>
+ {isLoading ? <EntityTypeBarChartSkeleton /> : <ClassificationDistributionCard tag={tag} isLoading={isLoading} />}
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display: "flex", minWidth: 0 }}>
+ {isLoading ? <EntityTypeBarChartSkeleton /> : <KafkaTopicSummaryCard stats={stats} isLoading={isLoading} />}
+ </Grid>
+ </Grid>
+ </Stack>
+ );
+};
+
+export default DashboardOverview;
diff --git a/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx b/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx
new file mode 100644
index 000000000..b37ec59d4
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx
@@ -0,0 +1,39 @@
+/*
+ * 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 { Paper, Stack } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+const DashboardSkeleton = () => (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Stack spacing={1}>
+ <SkeletonLoader animation="wave" variant="text" count={1} width="40%" height={28} />
+ <SkeletonLoader animation="wave" variant="rectangular" count={1} height={60} />
+ </Stack>
+ </Paper>
+);
+
+export default DashboardSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx
new file mode 100644
index 000000000..9eb93dc1e
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx
@@ -0,0 +1,173 @@
+/*
+ * 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 { memo, useCallback, useState } from "react";
+import { Paper, Stack, Typography, Box } from "@mui/material";
+import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Sector } from "recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import { getEntityStatusTotals } from "@utils/metricsUtils";
+import { navigateToSearch } from "@utils/dashboardSearchUtils";
+import { ENTITY_STATUS_DONUT_COLORS as COLORS } from "./dashboardChartPalette";
+
+interface EntityStatusDonutProps {
+ entity: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const EntityStatusDonut = memo(({ entity, isLoading }: EntityStatusDonutProps) => {
+ const [activeIndex, setActiveIndex] = useState<number>(-1);
+ const navigate = useNavigate();
+ const totals = getEntityStatusTotals(entity);
+ const total = totals.active + totals.shell + totals.deleted;
+
+ const chartData = [
+ { name: "Active", value: totals.active, color: COLORS.Active },
+ { name: "Shell", value: totals.shell, color: COLORS.Shell },
+ { name: "Deleted", value: totals.deleted, color: COLORS.Deleted }
+ ].filter((d) => d.value > 0);
+
+ const getPercent = (val: number) => (total > 0 ? Math.round((val / total) * 100) : 0);
+
+ const handleStatusClick = useCallback(
+ (status: "Active" | "Shell" | "Deleted") => {
+ if (status === "Active") {
+ navigateToSearch(navigate, "entity_status");
+ } else if (status === "Deleted") {
+ navigateToSearch(navigate, "entity_status", {
+ includeDE: true,
+ entityFilters: {
+ condition: "AND",
+ criterion: [{ attributeName: "__state", operator: "eq", attributeValue: "DELETED" }]
+ }
+ });
+ } else if (status === "Shell") {
+ navigateToSearch(navigate, "entity_status", {
+ entityFilters: {
+ condition: "AND",
+ criterion: [{ attributeName: "__isIncomplete", operator: "eq", attributeValue: "true" }]
+ }
+ });
+ }
+ },
+ [navigate]
+ );
+
+ if (isLoading) return null;
+
+ const renderActiveShape = (props: unknown) => {
+ const p = props as { outerRadius?: number; innerRadius?: number; [k: string]: unknown };
+ return (
+ <Sector
+ {...p}
+ outerRadius={(p.outerRadius ?? 60) * 1.08}
+ innerRadius={p.innerRadius ?? 40}
+ />
+ );
+ };
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Entity Status Overview
+ </Typography>
+ </Box>
+ <Stack direction="row" spacing={2} alignItems="center" height={160} sx={{ pt: 2 }}>
+ <Stack spacing={1.5} flex={1}>
+ {(["Active", "Shell", "Deleted"] as const).map((status) => (
+ <Box
+ key={status}
+ component="button"
+ type="button"
+ onClick={() => handleStatusClick(status)}
+ aria-label={`View ${status} entities`}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ gap: 1.5,
+ cursor: "pointer",
+ background: "none",
+ border: "none",
+ padding: 0,
+ margin: 0,
+ font: "inherit",
+ textAlign: "left",
+ "&:hover": { opacity: 0.85 }
+ }}
+ >
+ <Box
+ sx={{
+ width: 12,
+ height: 12,
+ borderRadius: "50%",
+ backgroundColor: COLORS[status],
+ flexShrink: 0
+ }}
+ />
+ <Typography component="span" sx={{ fontSize: "0.875rem", color: "#374151" }}>
+ {status} {getPercent(totals[status.toLowerCase() as keyof typeof totals])}%
+ </Typography>
+ </Box>
+ ))}
+ </Stack>
+ <ResponsiveContainer width="50%" height="100%" style={{ cursor: "pointer" }}>
+ <PieChart>
+ <Pie
+ data={chartData}
+ cx="50%"
+ cy="50%"
+ innerRadius={40}
+ outerRadius={60}
+ paddingAngle={2}
+ dataKey="value"
+ isAnimationActive
+ animationDuration={800}
+ animationEasing="ease-out"
+ activeIndex={activeIndex}
+ activeShape={renderActiveShape}
+ onMouseEnter={(_, index) => setActiveIndex(index)}
+ onMouseLeave={() => setActiveIndex(-1)}
+ onClick={(data) => handleStatusClick(data.name as "Active" | "Shell" | "Deleted")}
+ >
+ {chartData.map((entry, index) => (
+ <Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
+ ))}
+ </Pie>
+ <Tooltip
+ formatter={(value: number) => numberFormatWithComma(value)}
+ contentStyle={{ borderRadius: 8 }}
+ />
+ </PieChart>
+ </ResponsiveContainer>
+ </Stack>
+ </Paper>
+ );
+});
+
+EntityStatusDonut.displayName = "EntityStatusDonut";
+
+export default EntityStatusDonut;
diff --git a/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx
new file mode 100644
index 000000000..433cf228f
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx
@@ -0,0 +1,327 @@
+/*
+ * 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, { memo, useCallback, useMemo } from "react";
+import { Paper, Stack, Typography, Link, Box } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList,
+} from "recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import type { EntityTypeDistributionItem } from "@utils/metricsUtils";
+import {
+ getServiceTypeDistribution,
+ type TypeHeaderCatalogRow,
+} from "@utils/typeCatalogUtils";
+import {
+ navigateToSearch,
+ navigateToServiceTypeEntitySearch,
+} from "@utils/dashboardSearchUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+ HORIZONTAL_BAR_CHART_MARGIN,
+} from "./dashboardChartPalette";
+
+const ACTIVE_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETED_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface EntityTypeBarChartProps {
+ entity: Record<string, unknown> | undefined;
+ typeHeaderData?: TypeHeaderCatalogRow[] | null;
+ isLoading?: boolean;
+}
+
+const payloadFromBarEvent = (item: unknown): EntityTypeDistributionItem | undefined => {
+ if (!item || typeof item !== "object") return undefined;
+ const rec = item as { payload?: EntityTypeDistributionItem };
+ return rec.payload;
+};
+
+const EntityTypeBarChart = memo(
+ ({ entity, typeHeaderData, isLoading }: EntityTypeBarChartProps) => {
+ const navigate = useNavigate();
+ const data = useMemo(
+ () => getServiceTypeDistribution(entity, typeHeaderData, 5),
+ [entity, typeHeaderData]
+ );
+
+ const navigateForRow = useCallback(
+ (row: EntityTypeDistributionItem, includeDeleted: boolean) => {
+ const targets =
+ row.underlyingTypeNames?.length && row.underlyingTypeNames.length > 0
+ ? row.underlyingTypeNames
+ : [row.name];
+ navigateToServiceTypeEntitySearch(navigate, targets, includeDeleted);
+ },
+ [navigate]
+ );
+
+ const handleActiveBarClick = useCallback(
+ (barProps: unknown) => {
+ const row = payloadFromBarEvent(barProps);
+ if (row) navigateForRow(row, false);
+ },
+ [navigateForRow]
+ );
+
+ const handleDeletedBarClick = useCallback(
+ (barProps: unknown) => {
+ const row = payloadFromBarEvent(barProps);
+ if (row) navigateForRow(row, true);
+ },
+ [navigateForRow]
+ );
+
+ const handleViewAll = useCallback(() => {
+ navigateToSearch(navigate, "all_entities");
+ }, [navigate]);
+
+ const handleLabelClick = useCallback(
+ (serviceLabel: string) => {
+ const row = data.find((d) => d.name === serviceLabel);
+ if (row) navigateForRow(row, false);
+ },
+ [data, navigateForRow]
+ );
+
+ const renderTooltip = useCallback((props: unknown) => {
+ const p = props as {
+ active?: boolean;
+ payload?: Array<{
+ payload?: EntityTypeDistributionItem;
+ }>;
+ };
+ if (!p?.active || !p?.payload?.length) return null;
+ const row = p.payload[0]?.payload;
+ if (!row) return null;
+ return (
+ <Box sx={{ p: 1.5, bgcolor: "background.paper", borderRadius: 1, boxShadow: 2, minWidth: 140 }}>
+ <Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
+ {row.name}
+ </Typography>
+ <Typography variant="caption" display="block" color="primary">
+ Active: {numberFormatWithComma(row.active)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: DELETED_COLOR }}>
+ Deleted: {numberFormatWithComma(row.deleted)}
+ </Typography>
+ <Typography variant="caption" display="block" fontWeight={600}>
+ Total: {numberFormatWithComma(row.count)}
+ </Typography>
+ </Box>
+ );
+ }, []);
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Service Type Distribution
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{
+ fontSize: "0.875rem",
+ cursor: "pointer",
+ textDecoration: "none",
+ color: "primary.main",
+ }}
+ aria-label="View all entities"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ {data.length === 0 ? (
+ <Stack alignItems="center" justifyContent="center" height={200}>
+ <Typography variant="body2" color="text.secondary">
+ No service type data available
+ </Typography>
+ </Stack>
+ ) : (
+ <Box sx={{ mt: 2, minHeight: 260, height: 260, width: "100%", minWidth: 280 }}>
+ <Stack direction="row" spacing={2} sx={{ mb: 1, flexWrap: "wrap" }} aria-label="Chart legend">
+ <Stack direction="row" alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ backgroundColor: ACTIVE_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography variant="caption" sx={{ color: "#6c757d", fontSize: "0.8125rem" }}>
+ Active
+ </Typography>
+ </Stack>
+ <Stack direction="row" alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ backgroundColor: DELETED_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography variant="caption" sx={{ color: "#6c757d", fontSize: "0.8125rem" }}>
+ Deleted
+ </Typography>
+ </Stack>
+ </Stack>
+ <ResponsiveContainer width="100%" height="100%" style={{ cursor: "pointer" }}>
+ <BarChart data={data} layout="vertical" margin={{ ...HORIZONTAL_BAR_CHART_MARGIN }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+ type="number"
+ tickFormatter={(v) => numberFormatWithComma(v)}
+ height={36}
+ label={{
+ value: "Entity Count",
+ position: "bottom",
+ offset: 12,
+ style: { fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+ type="category"
+ dataKey="name"
+ width={88}
+ label={{
+ value: "Service Type",
+ angle: -90,
+ position: "left",
+ offset: 4,
+ style: { fontSize: 11, fill: "#6c757d", textAnchor: "middle" },
+ }}
+ tick={(props: Record<string, unknown>) => {
+ const { x = 0, y = 0, payload } = props;
+ const p = payload as { value?: string; name?: string } | undefined;
+ const value =
+ p?.value ??
+ p?.name ??
+ (typeof payload === "string" ? payload : "");
+ return (
+ <g
+ transform={`translate(${x},${y})`}
+ onClick={() => (value ? handleLabelClick(value) : undefined)}
+ style={{ cursor: value ? "pointer" : "default" }}
+ role={value ? "button" : undefined}
+ tabIndex={value ? 0 : undefined}
+ onKeyDown={
+ value
+ ? (e: React.KeyboardEvent<SVGGElement>) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleLabelClick(value);
+ }
+ }
+ : undefined
+ }
+ >
+ <text x={0} y={0} dy={4} textAnchor="end" fill="#333" fontSize={12}>
+ {value}
+ </text>
+ </g>
+ );
+ }}
+ />
+ <Tooltip content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+ dataKey="active"
+ name="Active"
+ stackId="a"
+ fill={ACTIVE_COLOR}
+ radius={[0, 0, 0, 0]}
+ isAnimationActive
+ animationDuration={800}
+ animationEasing="ease-out"
+ onClick={handleActiveBarClick}
+ cursor="pointer"
+ activeBar={{ fill: ACTIVE_COLOR }}
+ >
+ {data.map((_, index) => (
+ <Cell key={`active-${index}`} fill={ACTIVE_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+ dataKey="deleted"
+ name="Deleted"
+ stackId="a"
+ fill={DELETED_COLOR}
+ radius={[0, 4, 4, 0]}
+ isAnimationActive
+ animationDuration={800}
+ animationEasing="ease-out"
+ onClick={handleDeletedBarClick}
+ cursor="pointer"
+ activeBar={{ fill: DELETED_COLOR }}
+ >
+ <LabelList
+ dataKey="count"
+ position="right"
+ offset={10}
+ formatter={(v: number) => numberFormatWithComma(v)}
+ style={{
+ fontSize: 12,
+ fontWeight: 500,
+ fill: ACTIVE_COLOR,
+ }}
+ />
+ {data.map((_, index) => (
+ <Cell key={`deleted-${index}`} fill={DELETED_COLOR} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ )}
+ </Paper>
+ );
+ }
+);
+
+EntityTypeBarChart.displayName = "EntityTypeBarChart";
+
+export default EntityTypeBarChart;
diff --git a/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx b/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx
new file mode 100644
index 000000000..3f2950b0b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx
@@ -0,0 +1,57 @@
+/*
+ * 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 { Paper, Stack, Box } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+const EntityTypeBarChartSkeleton = () => (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ flex: 1,
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <SkeletonLoader animation="wave" variant="text" count={1} width="50%" height={24} />
+ <SkeletonLoader animation="wave" variant="text" count={1} width={60} height={20} />
+ </Stack>
+ </Box>
+ <Stack direction="row" spacing={2} sx={{ mt: 2, mb: 1 }}>
+ <SkeletonLoader animation="wave" variant="text" count={1} width={40} height={16} />
+ <SkeletonLoader animation="wave" variant="text" count={1} width={50} height={16} />
+ </Stack>
+ <Stack spacing={1.5} sx={{ mt: 2 }}>
+ {[1, 2, 3, 4, 5].map((i) => (
+ <Stack key={i} direction="row" alignItems="center" spacing={2}>
+ <SkeletonLoader animation="wave" variant="text" count={1} width={100} height={20} />
+ <SkeletonLoader animation="wave" variant="rectangular" count={1} width={`${30 + i * 12}%`} height={24} sx={{ borderRadius: 1 }} />
+ </Stack>
+ ))}
+ </Stack>
+ </Paper>
+);
+
+export default EntityTypeBarChartSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx b/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx
new file mode 100644
index 000000000..93c00f50b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx
@@ -0,0 +1,402 @@
+/*
+ * 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 { Fragment, memo, useMemo, useState, useCallback } from "react";
+import {
+ Paper,
+ Stack,
+ Typography,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Box,
+ IconButton,
+ Tooltip,
+ Collapse,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import ExpandLessIcon from "@mui/icons-material/ExpandLess";
+import { numberFormatWithComma } from "@utils/Helper";
+import { formatedDate } from "@utils/Utils";
+import {
+ parseMetricsStats,
+ getMessageConsumptionData,
+ getMessageConsumptionDataExcludingTotal,
+ buildTopicNotificationRecord,
+ topicRowHasPeriodMetrics,
+ type MessageConsumptionItem,
+} from "@utils/metricsUtils";
+import MessageConsumptionChart from "./MessageConsumptionChart";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+} from "./dashboardChartPalette";
+
+const CREATES_COLOR = ENTITY_STATUS_DONUT_COLORS.Active;
+const UPDATES_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETES_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface TopicDetail {
+ topic: string;
+ processed: number;
+ failed: number;
+ lastProcessed: string | number;
+ /** Partition stats — same shape as aggregate Notification (per AtlasMetricsUtil). */
+ topicStats: Record<string, unknown>;
+}
+
+interface KafkaTopicSummaryCardProps {
+ stats: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const getTopicConsumptionPanelId = (topic: string): string =>
+ `kafka-topic-msg-panel-${topic.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
+
+const TotalProcessedTooltip = ({
+ total,
+}: {
+ total: MessageConsumptionItem | undefined;
+}) => {
+ if (!total) {
+ return (
+ <Typography variant="caption" component="span">
+ No breakdown available
+ </Typography>
+ );
+ }
+ return (
+ <Box sx={{ p: 1, minWidth: 160, bgcolor: "#fff" }}>
+ <Typography
+ variant="body2"
+ fontWeight={600}
+ sx={{ mb: 0.5, color: "#1a1a1a" }}
+ >
+ {total.period}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: CREATES_COLOR }}>
+ Creates: {numberFormatWithComma(total.creates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: UPDATES_COLOR }}>
+ Updates: {numberFormatWithComma(total.updates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: DELETES_COLOR }}>
+ Deletes: {numberFormatWithComma(total.deletes)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: "#6c757d", mt: 0.5 }}>
+ Messages processed: {numberFormatWithComma(total.count)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: "#6c757d" }}>
+ Avg time (ms): {numberFormatWithComma(total.avgTime)}
+ </Typography>
+ </Box>
+ );
+};
+
+const KafkaTopicSummaryCard = memo(({ stats, isLoading }: KafkaTopicSummaryCardProps) => {
+ const [sortKey, setSortKey] = useState<"topic" | "processed" | "failed">("topic");
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+ /** One expanded topic at a time to limit vertical growth. */
+ const [expandedTopic, setExpandedTopic] = useState<string | null>(null);
+
+ const parsed = useMemo(() => parseMetricsStats(stats), [stats]);
+ const notification = parsed?.Notification as Record<string, unknown> | undefined;
+ const topicDetails = notification?.topicDetails as
+ | Record<string, Record<string, unknown>>
+ | undefined;
+
+ const rows = useMemo((): TopicDetail[] => {
+ if (!topicDetails || typeof topicDetails !== "object") return [];
+ return Object.entries(topicDetails).map(([topic, data]) => {
+ const lp = data?.lastMessageProcessedTime;
+ const lastProcessed: string | number =
+ lp != null && typeof lp !== "object" ? (lp as string | number) : "-";
+ return {
+ topic,
+ processed: Number(data?.processedMessageCount ?? 0),
+ failed: Number(data?.failedMessageCount ?? 0),
+ lastProcessed,
+ topicStats: data ?? {},
+ };
+ });
+ }, [topicDetails]);
+
+ const sortedRows = useMemo(() => {
+ const list = [...rows];
+ const dir = sortOrder === "asc" ? 1 : -1;
+ return list.sort((a, b) => {
+ if (sortKey === "topic") {
+ const av = String(a.topic).toLowerCase();
+ const bv = String(b.topic).toLowerCase();
+ return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
+ }
+ const av = a[sortKey];
+ const bv = b[sortKey];
+ return ((av as number) - (bv as number)) * dir;
+ });
+ }, [rows, sortKey, sortOrder]);
+
+ const consumptionByTopic = useMemo(() => {
+ const m = new Map<
+ string,
+ { totalRow: MessageConsumptionItem | undefined; chartData: MessageConsumptionItem[] }
+ >();
+ for (const row of rows) {
+ const record = buildTopicNotificationRecord(row.topicStats, {
+ aggregateNotification: notification,
+ useAggregateFallback: !topicRowHasPeriodMetrics(row.topicStats),
+ });
+ const full = getMessageConsumptionData(record);
+ m.set(row.topic, {
+ totalRow: full.find((d) => d.period === "Total"),
+ chartData: getMessageConsumptionDataExcludingTotal(record),
+ });
+ }
+ return m;
+ }, [rows, notification]);
+
+ const handleSort = useCallback((key: "topic" | "processed" | "failed") => {
+ setSortKey(key);
+ setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
+ }, []);
+
+ const handleToggleExpand = useCallback((topic: string) => {
+ setExpandedTopic((prev) => (prev === topic ? null : topic));
+ }, []);
+
+ const formatLastProcessed = (val: string | number): string => {
+ if (val === "-" || val == null) return "-";
+ if (typeof val === "number") return formatedDate({ date: val });
+ return String(val);
+ };
+
+ const hasExpanded = expandedTopic !== null;
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 280,
+ width: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Kafka Topic Summary
+ </Typography>
+ </Box>
+ {rows.length === 0 ? (
+ <Stack alignItems="center" justifyContent="center" height={180}>
+ <Typography variant="body2" color="text.secondary">
+ No topic data available
+ </Typography>
+ </Stack>
+ ) : (
+ <TableContainer sx={{ maxHeight: hasExpanded ? 560 : 220, mt: 1 }}>
+ <Table size="small" stickyHeader>
+ <TableHead>
+ <TableRow>
+ <TableCell
+ onClick={() => handleSort("topic")}
+ sx={{ cursor: "pointer", fontWeight: 600 }}
+ aria-label="Sort by topic"
+ >
+ Topic{" "}
+ {sortKey === "topic"
+ ? sortOrder === "asc"
+ ? "▲"
+ : "▼"
+ : ""}
+ </TableCell>
+ <TableCell
+ align="right"
+ onClick={() => handleSort("processed")}
+ sx={{ cursor: "pointer", fontWeight: 600 }}
+ aria-label="Sort by processed"
+ >
+ Processed{" "}
+ {sortKey === "processed"
+ ? sortOrder === "asc"
+ ? "▲"
+ : "▼"
+ : ""}
+ </TableCell>
+ <TableCell
+ align="right"
+ onClick={() => handleSort("failed")}
+ sx={{ cursor: "pointer", fontWeight: 600 }}
+ aria-label="Sort by failed"
+ >
+ Failed{" "}
+ {sortKey === "failed"
+ ? sortOrder === "asc"
+ ? "▲"
+ : "▼"
+ : ""}
+ </TableCell>
+ <TableCell align="right" sx={{ fontWeight: 600 }}>
+ Last Processed
+ </TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {sortedRows.map((row) => {
+ const isExpanded = expandedTopic === row.topic;
+ const panelId = getTopicConsumptionPanelId(row.topic);
+ const cons = consumptionByTopic.get(row.topic);
+ const totalForHover = cons?.totalRow;
+ const chartData = cons?.chartData ?? [];
+ return (
+ <Fragment key={row.topic}>
+ <TableRow hover>
+ <TableCell sx={{ fontSize: "0.8125rem" }}>
+ <Stack direction="row" alignItems="center" spacing={0.5}>
+ <IconButton
+ size="small"
+ aria-label={
+ isExpanded
+ ? `Collapse message consumption for ${row.topic}`
+ : `Expand message consumption for ${row.topic}`
+ }
+ aria-expanded={isExpanded}
+ aria-controls={panelId}
+ id={`${panelId}-trigger`}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleToggleExpand(row.topic);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === " " || e.key === "Enter") {
+ e.stopPropagation();
+ }
+ }}
+ >
+ {isExpanded ? (
+ <ExpandLessIcon fontSize="small" />
+ ) : (
+ <ExpandMoreIcon fontSize="small" />
+ )}
+ </IconButton>
+ <Typography
+ component="span"
+ variant="body2"
+ sx={{ fontSize: "0.8125rem" }}
+ >
+ {row.topic}
+ </Typography>
+ </Stack>
+ </TableCell>
+ <TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+ <Tooltip
+ title={<TotalProcessedTooltip total={totalForHover} />}
+ arrow
+ enterDelay={200}
+ placement="top"
+ componentsProps={{
+ tooltip: {
+ sx: {
+ bgcolor: "#fff",
+ color: "#1a1a1a",
+ border: "1px solid",
+ borderColor: "rgba(0, 0, 0, 0.12)",
+ boxShadow: 2,
+ },
+ },
+ arrow: {
+ sx: {
+ color: "#fff",
+ },
+ },
+ }}
+ >
+ <Box
+ component="span"
+ tabIndex={0}
+ sx={{
+ cursor: "help",
+ borderBottom: "1px dotted",
+ borderColor: "text.secondary",
+ }}
+ aria-label="Total processed — hover for creates, updates, deletes"
+ >
+ {numberFormatWithComma(row.processed)}
+ </Box>
+ </Tooltip>
+ </TableCell>
+ <TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+ {numberFormatWithComma(row.failed)}
+ </TableCell>
+ <TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+ {formatLastProcessed(row.lastProcessed)}
+ </TableCell>
+ </TableRow>
+ {isExpanded ? (
+ <TableRow>
+ <TableCell
+ colSpan={4}
+ sx={{ p: 0, borderBottom: "none" }}
+ >
+ <Collapse in={isExpanded} timeout="auto" unmountOnExit>
+ <Box
+ id={panelId}
+ sx={{
+ py: 2,
+ px: 2,
+ bgcolor: "action.hover",
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ }}
+ >
+ <Typography
+ variant="subtitle2"
+ sx={{ mb: 1, fontWeight: 600, color: "#1a1a1a" }}
+ >
+ Message consumption
+ </Typography>
+ <MessageConsumptionChart
+ data={chartData}
+ chartAriaLabel={`Message consumption for ${row.topic}`}
+ />
+ </Box>
+ </Collapse>
+ </TableCell>
+ </TableRow>
+ ) : null}
+ </Fragment>
+ );
+ })}
+ </TableBody>
+ </Table>
+ </TableContainer>
+ )}
+ </Paper>
+ );
+});
+
+KafkaTopicSummaryCard.displayName = "KafkaTopicSummaryCard";
+
+export default KafkaTopicSummaryCard;
diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx
new file mode 100644
index 000000000..2d80a5a1f
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx
@@ -0,0 +1,273 @@
+/*
+ * 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 { memo, useCallback } from "react";
+import { Paper, Stack, Typography, Link, List, ListItem, Box } from "@mui/material";
+import { Link as RouterLink } from "react-router-dom";
+import moment from "moment";
+import { useNavigate } from "react-router-dom";
+import { navigateToLatestEntitiesSearch } from "@utils/dashboardSearchUtils";
+import type { LatestEntityRowModel } from "./latestEntitiesList.utils";
+import {
+ resolveLatestEntityDisplayName,
+ resolveLatestEntityGuid,
+ resolveLatestEntityTypeName
+} from "./latestEntitiesList.utils";
+
+interface EntityItem extends LatestEntityRowModel {
+ createTime?: number | Date | string;
+ attributes?: LatestEntityRowModel["attributes"] & {
+ __timestamp?: number | string | Record<string, unknown>;
+ createTime?: number | string;
+ };
+}
+
+interface LatestEntitiesListProps {
+ entities: EntityItem[];
+ isLoading?: boolean;
+ error?: string | null;
+}
+
+const INVALID_TS_LABEL = "Created today";
+
+const unwrapLongLike = (raw: unknown): unknown => {
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return raw;
+ const o = raw as Record<string, unknown>;
+ if (typeof o.$numberLong === "string" || typeof o.$numberLong === "number") {
+ return o.$numberLong;
+ }
+ if (typeof o.longValue === "string" || typeof o.longValue === "number") {
+ return o.longValue;
+ }
+ return raw;
+};
+
+/**
+ * Milliseconds since epoch, or null only when missing / unusable.
+ * Rejects 0 (epoch) to avoid "56 years ago". Accepts sec or ms numbers, ISO strings.
+ */
+const normalizeEntityTimestampMs = (raw: unknown): number | null => {
+ const v = unwrapLongLike(raw);
+ if (v == null) return null;
+ if (v instanceof Date) {
+ const t = v.getTime();
+ if (!Number.isFinite(t) || t <= 0) return null;
+ return moment(t).isValid() ? t : null;
+ }
+ if (typeof v === "string") {
+ const trimmed = v.trim();
+ if (trimmed === "") return null;
+ const n = Number(trimmed);
+ if (Number.isFinite(n) && n > 0) {
+ const ms = n < 1e12 ? n * 1000 : n;
+ if (moment(ms).isValid() && ms > 0) return ms;
+ }
+ const parsed = moment(trimmed);
+ if (parsed.isValid()) {
+ const ms = parsed.valueOf();
+ if (ms > 0) return ms;
+ }
+ return null;
+ }
+ if (typeof v === "number") {
+ if (!Number.isFinite(v) || v <= 0) return null;
+ const ms = v < 1e12 ? v * 1000 : v;
+ return moment(ms).isValid() && ms > 0 ? ms : null;
+ }
+ return null;
+};
+
+/** Use only `__timestamp` for relative "Created … ago" (not `createTime`, often 0 in API). */
+const getEntityTimestampRawForDisplay = (entity: EntityItem): unknown => {
+ const a = entity.attributes;
+ const top = entity as EntityItem & { __timestamp?: unknown };
+ return a?.__timestamp ?? top.__timestamp;
+};
+
+/** Valid timestamp only: seconds / minutes / hours for last 24h, then moment relative. */
+const formatCreatedRelativeFromMs = (ms: number): string => {
+ const now = Date.now();
+ const deltaMs = now - ms;
+ if (!Number.isFinite(deltaMs)) return INVALID_TS_LABEL;
+ if (deltaMs < 0) {
+ return `Created ${moment(ms).fromNow()}`;
+ }
+ const totalSec = Math.floor(deltaMs / 1000);
+ if (totalSec < 1) {
+ return "Created just now";
+ }
+ if (totalSec < 60) {
+ return totalSec === 1
+ ? "Created 1 second ago"
+ : `Created ${totalSec} seconds ago`;
+ }
+ const totalMin = Math.floor(totalSec / 60);
+ if (totalMin < 60) {
+ return totalMin === 1
+ ? "Created 1 minute ago"
+ : `Created ${totalMin} minutes ago`;
+ }
+ const totalHr = Math.floor(totalMin / 60);
+ if (totalHr < 24) {
+ return totalHr === 1
+ ? "Created 1 hour ago"
+ : `Created ${totalHr} hours ago`;
+ }
+ return `Created ${moment(ms).fromNow()}`;
+};
+
+const formatRelativeTime = (raw: unknown): string => {
+ const ms = normalizeEntityTimestampMs(raw);
+ if (ms == null) return INVALID_TS_LABEL;
+ return formatCreatedRelativeFromMs(ms);
+};
+
+const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesListProps) => {
+ const navigate = useNavigate();
+
+ const handleViewAll = useCallback(() => {
+ navigateToLatestEntitiesSearch(navigate);
+ }, [navigate]);
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ flex: 1,
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Latest Entities Created
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{
+ fontSize: "0.875rem",
+ cursor: "pointer",
+ textDecoration: "none",
+ color: "primary.main"
+ }}
+ aria-label="View all entities"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ {error ? (
+ <Stack alignItems="center" justifyContent="center" height={200} sx={{ pt: 2 }}>
+ <Typography variant="body2" color="error">
+ {error}
+ </Typography>
+ </Stack>
+ ) : !entities || entities.length === 0 ? (
+ <Stack alignItems="center" justifyContent="center" height={200} sx={{ pt: 2 }}>
+ <Typography variant="body2" color="text.secondary">
+ No recent entities
+ </Typography>
+ </Stack>
+ ) : (
+ <List disablePadding sx={{ pt: 2 }}>
+ {entities.slice(0, 7).map((entity) => {
+ const displayName = resolveLatestEntityDisplayName(entity);
+ const entityGuid = resolveLatestEntityGuid(entity);
+ const typeName = resolveLatestEntityTypeName(entity);
+ const timestamp = getEntityTimestampRawForDisplay(entity);
+ const detailHref = entityGuid
+ ? `/detailPage/${entityGuid}`
+ : undefined;
+
+ return (
+ <ListItem
+ key={entityGuid || displayName}
+ disablePadding
+ sx={{
+ py: 1,
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ "&:last-child": { borderBottom: "none" }
+ }}
+ >
+ <Stack width="100%" direction="row" justifyContent="space-between" alignItems="center">
+ <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" flex={1} minWidth={0} mr={1}>
+ {detailHref ? (
+ <Link
+ component={RouterLink}
+ to={detailHref}
+ underline="hover"
+ color="primary"
+ sx={{
+ fontSize: "0.875rem",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ cursor: "pointer",
+ maxWidth: "100%"
+ }}
+ >
+ {displayName}
+ </Link>
+ ) : (
+ <Typography
+ component="span"
+ sx={{
+ fontSize: "0.875rem",
+ fontWeight: 500,
+ color: "text.primary"
+ }}
+ >
+ {displayName}
+ </Typography>
+ )}
+ <Typography
+ component="span"
+ sx={{
+ fontSize: "0.875rem",
+ color: "#6c757d",
+ flexShrink: 0
+ }}
+ >
+ ({typeName})
+ </Typography>
+ </Stack>
+ <Typography sx={{ fontSize: "0.8125rem", color: "#6c757d", flexShrink: 0, ml: 1 }}>
+ {formatRelativeTime(timestamp)}
+ </Typography>
+ </Stack>
+ </ListItem>
+ );
+ })}
+ </List>
+ )}
+ </Paper>
+ );
+});
+
+LatestEntitiesList.displayName = "LatestEntitiesList";
+
+export default LatestEntitiesList;
diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx
new file mode 100644
index 000000000..75e4f5960
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 { Paper, Stack, Box } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+const LatestEntitiesSkeleton = () => (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ flex: 1,
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <SkeletonLoader animation="wave" variant="text" count={1} width="60%" height={24} />
+ <SkeletonLoader animation="wave" variant="text" count={1} width={60} height={20} />
+ </Stack>
+ </Box>
+ <Stack spacing={1} sx={{ pt: 2 }}>
+ {[1, 2, 3, 4, 5, 6, 7].map((i) => (
+ <Stack key={i} direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5 }}>
+ <SkeletonLoader animation="wave" variant="text" count={1} width={`${70 + (i % 3) * 10}%`} height={20} />
+ <SkeletonLoader animation="wave" variant="text" count={1} width={80} height={18} />
+ </Stack>
+ ))}
+ </Stack>
+ </Paper>
+);
+
+export default LatestEntitiesSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx
new file mode 100644
index 000000000..f2b98f687
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx
@@ -0,0 +1,257 @@
+/*
+ * 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 { memo, useCallback } from "react";
+import { Stack, Typography, Box } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList,
+} from "recharts";
+import { numberFormatWithComma } from "@utils/Helper";
+import { type MessageConsumptionItem } from "@utils/metricsUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+} from "./dashboardChartPalette";
+
+const CREATES_COLOR = ENTITY_STATUS_DONUT_COLORS.Active;
+const UPDATES_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETES_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface MessageConsumptionChartProps {
+ /** Rows must already exclude the Total period when used from Kafka Topic Summary. */
+ data: MessageConsumptionItem[];
+ /** Optional accessible name for the chart region (embedded context). */
+ chartAriaLabel?: string;
+}
+
+const MessageConsumptionChart = memo(
+ ({ data, chartAriaLabel }: MessageConsumptionChartProps) => {
+ const renderTooltip = useCallback(
+ (props: unknown) => {
+ const p = props as {
+ active?: boolean;
+ label?: string | number;
+ payload?: Array<{ payload?: MessageConsumptionItem }>;
+ };
+ if (!p?.active) return null;
+ const periodLabel =
+ p.label !== undefined && p.label !== null ? String(p.label) : "";
+ const rowFromLabel = periodLabel
+ ? data.find((d) => d.period === periodLabel)
+ : undefined;
+ const row =
+ rowFromLabel ?? p.payload?.[0]?.payload ?? undefined;
+ if (!row) return null;
+ return (
+ <Box
+ sx={{
+ p: 1.5,
+ bgcolor: "background.paper",
+ borderRadius: 1,
+ boxShadow: 2,
+ minWidth: 160,
+ }}
+ >
+ <Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
+ {row.period}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: CREATES_COLOR }}>
+ Creates: {numberFormatWithComma(row.creates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: UPDATES_COLOR }}>
+ Updates: {numberFormatWithComma(row.updates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: DELETES_COLOR }}>
+ Deletes: {numberFormatWithComma(row.deletes)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: "#6c757d", mt: 0.5 }}>
+ Messages processed: {numberFormatWithComma(row.count)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{ color: "#6c757d" }}>
+ Avg time (ms): {numberFormatWithComma(row.avgTime)}
+ </Typography>
+ </Box>
+ );
+ },
+ [data]
+ );
+
+ if (data.length === 0) {
+ return (
+ <Stack alignItems="center" justifyContent="center" minHeight={200}>
+ <Typography variant="body2" color="text.secondary">
+ No message consumption data available
+ </Typography>
+ </Stack>
+ );
+ }
+
+ return (
+ <Box
+ role={chartAriaLabel ? "region" : undefined}
+ aria-label={chartAriaLabel}
+ sx={{ minHeight: 260, height: 260, width: "100%", minWidth: 280 }}
+ >
+ <Stack
+ direction="row"
+ spacing={2}
+ sx={{ mb: 1, flexWrap: "wrap" }}
+ aria-label="Chart legend"
+ >
+ <Stack direction="row" alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ backgroundColor: CREATES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d", fontSize: "0.8125rem" }}
+ >
+ Creates
+ </Typography>
+ </Stack>
+ <Stack direction="row" alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ backgroundColor: UPDATES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d", fontSize: "0.8125rem" }}
+ >
+ Updates
+ </Typography>
+ </Stack>
+ <Stack direction="row" alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ backgroundColor: DELETES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d", fontSize: "0.8125rem" }}
+ >
+ Deletes
+ </Typography>
+ </Stack>
+ </Stack>
+ <ResponsiveContainer width="100%" height="100%">
+ <BarChart
+ data={data}
+ margin={{ top: 36, right: 28, left: 52, bottom: 36 }}
+ >
+ <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+ dataKey="period"
+ tick={{ fontSize: 11 }}
+ height={36}
+ label={{
+ value: "Period",
+ position: "bottom",
+ offset: 12,
+ style: { fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+ tickFormatter={(v) => numberFormatWithComma(v)}
+ width={48}
+ label={{
+ value: "Count",
+ angle: -90,
+ position: "left",
+ offset: 6,
+ style: { fontSize: 11, fill: "#6c757d", textAnchor: "middle" },
+ }}
+ />
+ <Tooltip content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+ dataKey="creates"
+ name="Creates"
+ stackId="a"
+ fill={CREATES_COLOR}
+ radius={[0, 0, 0, 0]}
+ >
+ {data.map((_, index) => (
+ <Cell key={`creates-${index}`} fill={CREATES_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+ dataKey="updates"
+ name="Updates"
+ stackId="a"
+ fill={UPDATES_COLOR}
+ radius={[0, 0, 0, 0]}
+ >
+ {data.map((_, index) => (
+ <Cell key={`updates-${index}`} fill={UPDATES_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+ dataKey="deletes"
+ name="Deletes"
+ stackId="a"
+ fill={DELETES_COLOR}
+ radius={[0, 4, 4, 0]}
+ >
+ <LabelList
+ dataKey="count"
+ position="top"
+ offset={8}
+ formatter={(v: number) => numberFormatWithComma(v)}
+ style={{
+ fontSize: 11,
+ fontWeight: 600,
+ fill: "#374151",
+ }}
+ />
+ {data.map((_, index) => (
+ <Cell key={`deletes-${index}`} fill={DELETES_COLOR} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ );
+ }
+);
+
+MessageConsumptionChart.displayName = "MessageConsumptionChart";
+
+export default MessageConsumptionChart;
diff --git a/dashboard/src/views/DashboardOverview/OverviewCard.tsx b/dashboard/src/views/DashboardOverview/OverviewCard.tsx
new file mode 100644
index 000000000..41018055b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/OverviewCard.tsx
@@ -0,0 +1,114 @@
+/*
+ * 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 { Paper, Stack, Typography, Box } from "@mui/material";
+import { numberFormatWithComma } from "@utils/Helper";
+import { useNavigate } from "react-router-dom";
+import { navigateToSearch } from "@utils/dashboardSearchUtils";
+
+interface OverviewCardProps {
+ entityCount: number;
+ tagCount: number;
+ isLoading?: boolean;
+}
+
+const OverviewCard = ({ entityCount, tagCount, isLoading }: OverviewCardProps) => {
+ const navigate = useNavigate();
+
+ const handleEntitiesClick = () => {
+ navigateToSearch(navigate, "all_entities");
+ };
+
+ const handleClassificationsClick = () => {
+ navigateToSearch(navigate, "all_classifications");
+ };
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Overview
+ </Typography>
+ </Box>
+ <Stack direction="column" spacing={3} sx={{ pt: 2 }}>
+ <Stack alignItems="flex-start" spacing={0.5}>
+ <Typography
+ component="button"
+ sx={{
+ fontSize: "1.75rem",
+ fontWeight: 700,
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "#1a1a1a",
+ textAlign: "left",
+ lineHeight: 1.2,
+ "&:hover": { textDecoration: "underline", color: "primary.main" }
+ }}
+ onClick={handleEntitiesClick}
+ aria-label="View all entities"
+ >
+ {numberFormatWithComma(entityCount)}
+ </Typography>
+ <Typography sx={{ fontSize: "0.875rem", color: "#6c757d", textTransform: "capitalize" }}>
+ Entities
+ </Typography>
+ </Stack>
+ <Stack alignItems="flex-start" spacing={0.5}>
+ <Typography
+ component="button"
+ sx={{
+ fontSize: "1.75rem",
+ fontWeight: 700,
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "#1a1a1a",
+ textAlign: "left",
+ lineHeight: 1.2,
+ "&:hover": { textDecoration: "underline", color: "primary.main" }
+ }}
+ onClick={handleClassificationsClick}
+ aria-label="View all classifications"
+ >
+ {numberFormatWithComma(tagCount)}
+ </Typography>
+ <Typography sx={{ fontSize: "0.875rem", color: "#6c757d", textTransform: "capitalize" }}>
+ Classifications
+ </Typography>
+ </Stack>
+ </Stack>
+ </Paper>
+ );
+};
+
+export default OverviewCard;
diff --git a/dashboard/src/views/DashboardOverview/RecentActivity.tsx b/dashboard/src/views/DashboardOverview/RecentActivity.tsx
new file mode 100644
index 000000000..fdd5be150
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/RecentActivity.tsx
@@ -0,0 +1,408 @@
+/*
+ * 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 { memo, useCallback, useEffect, useState } from "react";
+import { useAppSelector } from "@hooks/reducerHook";
+import { Paper, Stack, Typography, Link, Box, Chip, List, ListItem } from "@mui/material";
+import { RecentActivityListSkeleton } from "./RecentActivitySkeleton";
+import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import { getAuditData } from "@api/apiMethods/detailpageApiMethod";
+import { category } from "@utils/Enum";
+import { extractTypeDefDetailObject } from "@utils/auditTypeDefUtils";
+import { dateFormat, isEmpty, jsonParse } from "@utils/Utils";
+
+export interface AuditRecord {
+ guid?: string;
+ userName?: string;
+ operation?: string;
+ params?: string;
+ result?: string;
+ startTime?: number;
+ endTime?: number;
+}
+
+const AUDIT_TABS = [
+ { id: "typeCreated", label: "Created", operations: ["TYPE_DEF_CREATE"] },
+ { id: "typeUpdated", label: "Updated", operations: ["TYPE_DEF_UPDATE"] },
+ { id: "typeDeleted", label: "Deleted", operations: ["TYPE_DEF_DELETE"] },
+ { id: "purge", label: "Purge", operations: ["PURGE", "AUTO_PURGE"] },
+ { id: "import", label: "Import", operations: ["IMPORT"] },
+ { id: "export", label: "Export", operations: ["EXPORT"] }
+] as const;
+
+interface ParsedAuditResult {
+ typeLabel: string;
+ entityName: string;
+ entityGuid?: string;
+ actionPhrase: string;
+ user: string;
+ dateStr: string;
+}
+
+const parseAuditRecord = (record: AuditRecord): ParsedAuditResult => {
+ const { operation, params, result, userName, endTime } = record;
+ const user = userName || "Unknown";
+ const dateStr = endTime ? dateFormat(endTime) : "";
+ let typeLabel = category[params as keyof typeof category] || params || "Type";
+ let actionPhrase = "";
+ let entityName = "";
+ let entityGuid = "";
+
+ if (operation === "PURGE" || operation === "AUTO_PURGE") {
+ try {
+ const guidStr = typeof result === "string" ? result.replace("[", "").replace("]", "").split(",")[0]?.trim() : "";
+ entityGuid = guidStr ?? "";
+ if (entityGuid) entityName = "View entity";
+ } catch {
+ /* ignore */
+ }
+ actionPhrase = "operation was performed";
+ return { typeLabel: operation === "AUTO_PURGE" ? "Auto purge" : "Purge", entityName, entityGuid, actionPhrase, user, dateStr };
+ }
+ if (operation === "EXPORT") {
+ return { typeLabel: "Export", entityName: "", entityGuid: "", actionPhrase: "operation was performed", user, dateStr };
+ }
+ if (operation === "IMPORT") {
+ return { typeLabel: "Import", entityName: "", entityGuid: "", actionPhrase: "operation was performed", user, dateStr };
+ }
+
+ if (operation === "TYPE_DEF_CREATE") {
+ actionPhrase = "was created";
+ } else if (operation === "TYPE_DEF_UPDATE") {
+ actionPhrase = "was updated";
+ } else if (operation === "TYPE_DEF_DELETE") {
+ actionPhrase = "was deleted";
+ } else {
+ return { typeLabel: operation || "", entityName: "", entityGuid: "", actionPhrase: "", user, dateStr };
+ }
+
+ try {
+ const resultObj = typeof result === "string" ? jsonParse(result) : result;
+ if (resultObj?.name) {
+ entityName = resultObj.name;
+ entityGuid = resultObj.guid ?? "";
+ } else if (resultObj && typeof resultObj === "object") {
+ const paramsKey = params?.split(",")[0]?.trim();
+ const arr = paramsKey ? resultObj[paramsKey] : resultObj[Object.keys(resultObj)[0]];
+ if (Array.isArray(arr) && arr[0]) {
+ entityName = arr[0].name ?? "";
+ entityGuid = arr[0].guid ?? "";
+ } else if (!Array.isArray(arr) && arr?.name) {
+ entityName = arr.name;
+ entityGuid = arr.guid ?? "";
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ return { typeLabel, entityName, entityGuid, actionPhrase, user, dateStr };
+};
+
+const getDetailUrl = (
+ operation: string | undefined,
+ params: string,
+ entityName: string,
+ entityGuid: string
+): string | null => {
+ if ((operation === "PURGE" || operation === "AUTO_PURGE") && entityGuid) {
+ return `/detailPage/${entityGuid}`;
+ }
+ if (!entityName && !entityGuid) return null;
+ /* Type Created/Updated/Deleted tabs - link to type details */
+ const param = params?.split(",")[0]?.trim();
+ if (param === "CLASSIFICATION") {
+ return `/tag/tagAttribute/${entityName}`;
+ }
+ if (param === "BUSINESS_METADATA" && entityGuid) {
+ return `/administrator/businessMetadata/${entityGuid}`;
+ }
+ if (["ENTITY", "ENUM", "RELATIONSHIP", "STRUCT"].includes(param ?? "")) {
+ return null;
+ }
+ return `/administrator?tabActive=typeSystem`;
+};
+
+const RecentActivity = memo(() => {
+ const dashboardRefreshVersion = useAppSelector((state) => state.dashboardRefresh.version);
+ const navigate = useNavigate();
+ const [activeTab, setActiveTab] = useState(0);
+ const [tabData, setTabData] = useState<Record<string, AuditRecord[]>>({
+ typeCreated: [],
+ typeUpdated: [],
+ typeDeleted: [],
+ purge: [],
+ import: [],
+ export: []
+ });
+ const [loading, setLoading] = useState<Record<string, boolean>>({
+ typeCreated: false,
+ typeUpdated: false,
+ typeDeleted: false,
+ purge: false,
+ import: false,
+ export: false
+ });
+
+ const fetchAudits = useCallback(async (tabId: string, operations: string[]) => {
+ setLoading((prev) => ({ ...prev, [tabId]: true }));
+ try {
+ const allResults: AuditRecord[] = [];
+ for (const op of operations) {
+ const auditFilters = {
+ condition: "AND" as const,
+ criterion: [
+ {
+ attributeName: "operation",
+ operator: "eq" as const,
+ attributeValue: op
+ }
+ ]
+ };
+ const resp = await getAuditData({
+ auditFilters,
+ limit: 5,
+ offset: 0,
+ sortBy: "startTime",
+ sortOrder: "DESCENDING"
+ });
+ const data = (resp as { data?: AuditRecord[] })?.data ?? [];
+ if (Array.isArray(data)) allResults.push(...data);
+ }
+ allResults.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0));
+ setTabData((prev) => ({ ...prev, [tabId]: allResults.slice(0, 5) }));
+ } catch {
+ setTabData((prev) => ({ ...prev, [tabId]: [] }));
+ } finally {
+ setLoading((prev) => ({ ...prev, [tabId]: false }));
+ }
+ }, []);
+
+ useEffect(() => {
+ AUDIT_TABS.forEach((tab) => {
+ fetchAudits(tab.id, [...tab.operations]);
+ });
+ }, [fetchAudits, dashboardRefreshVersion]);
+
+ const handleViewAll = useCallback(() => {
+ navigate("/administrator?tabActive=audit");
+ }, [navigate]);
+
+ const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: number) => {
+ setActiveTab(newValue);
+ }, []);
+
+ const currentTab = AUDIT_TABS[activeTab];
+ const records = tabData[currentTab?.id] ?? [];
+ const isLoading = loading[currentTab?.id] ?? false;
+
+ const [typeDetailOpen, setTypeDetailOpen] = useState(false);
+ const [typeDetailObject, setTypeDetailObject] = useState<Record<string, unknown> | null>(null);
+
+ const handleCloseTypeDetail = useCallback(() => {
+ setTypeDetailOpen(false);
+ setTypeDetailObject(null);
+ }, []);
+
+ const handleOpenTypeDetail = useCallback((obj: Record<string, unknown>) => {
+ setTypeDetailObject(obj);
+ setTypeDetailOpen(true);
+ }, []);
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 280,
+ width: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Recent Activity
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{
+ fontSize: "0.875rem",
+ cursor: "pointer",
+ textDecoration: "none",
+ color: "primary.main"
+ }}
+ aria-label="View all audits"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ <Stack direction="row" spacing={1} sx={{ mt: 1.5, flexWrap: "wrap", gap: 1 }}>
+ {AUDIT_TABS.map((tab, idx) => (
+ <Chip
+ key={tab.id}
+ label={tab.label}
+ onClick={(e) => handleTabChange(e as unknown as React.SyntheticEvent, idx)}
+ color={activeTab === idx ? "primary" : "default"}
+ variant={activeTab === idx ? "filled" : "outlined"}
+ sx={{
+ fontSize: "0.8125rem",
+ height: 32,
+ "&.MuiChip-filled": { fontWeight: 600 }
+ }}
+ aria-pressed={activeTab === idx}
+ aria-label={`${tab.label} tab`}
+ />
+ ))}
+ </Stack>
+ <Box sx={{ pt: 1 }}>
+ {isLoading ? (
+ <RecentActivityListSkeleton />
+ ) : isEmpty(records) ? (
+ <Stack alignItems="center" justifyContent="center" height={180}>
+ <Typography variant="body2" color="text.secondary">
+ No records present
+ </Typography>
+ </Stack>
+ ) : (
+ <List disablePadding>
+ {records.map((record, idx) => {
+ const parsed = parseAuditRecord(record);
+ const { typeLabel, entityName, entityGuid, actionPhrase, user, dateStr } = parsed;
+ const typeDefPayload = extractTypeDefDetailObject(
+ record,
+ entityName ?? "",
+ entityGuid ?? ""
+ );
+ const detailUrl = getDetailUrl(
+ record.operation ?? "",
+ record.params ?? "",
+ entityName ?? "",
+ entityGuid ?? ""
+ );
+
+ const openTypeModalIfNeeded = () => {
+ if (typeDefPayload) {
+ handleOpenTypeDetail(typeDefPayload);
+ }
+ };
+
+ const handleEntityNameKeyDown = (
+ e: React.KeyboardEvent
+ ) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ openTypeModalIfNeeded();
+ };
+
+ return (
+ <ListItem
+ key={record.guid ?? idx}
+ disablePadding
+ sx={{
+ py: 1,
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ "&:last-child": { borderBottom: "none" }
+ }}
+ >
+ <Typography component="span" sx={{ fontSize: "0.875rem", color: "#333" }}>
+ {entityName ? (
+ <>
+ {typeDefPayload ? (
+ <Link
+ component="button"
+ type="button"
+ onClick={openTypeModalIfNeeded}
+ onKeyDown={handleEntityNameKeyDown}
+ tabIndex={0}
+ aria-label={`Open ${typeLabel} type details for ${entityName}`}
+ sx={{
+ color: "primary.main",
+ textDecoration: "none",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ font: "inherit",
+ "&:hover": { textDecoration: "underline" },
+ }}
+ >
+ {entityName}
+ </Link>
+ ) : detailUrl ? (
+ <Link
+ component={RouterLink}
+ to={detailUrl}
+ sx={{
+ color: "primary.main",
+ textDecoration: "none",
+ "&:hover": { textDecoration: "underline" }
+ }}
+ >
+ {entityName}
+ </Link>
+ ) : (
+ entityName
+ )}
+ {" "}
+ {typeLabel} {actionPhrase} by{" "}
+ <Box component="span" sx={{ fontWeight: 700 }}>
+ {user}
+ </Box>{" "}
+ on{" "}
+ <Box component="span" sx={{ color: "#6c757d" }}>
+ {dateStr}
+ </Box>
+ </>
+ ) : (
+ <>
+ {typeLabel} {actionPhrase} by{" "}
+ <Box component="span" sx={{ fontWeight: 700 }}>
+ {user}
+ </Box>{" "}
+ on{" "}
+ <Box component="span" sx={{ color: "#6c757d" }}>
+ {dateStr}
+ </Box>
+ </>
+ )}
+ </Typography>
+ </ListItem>
+ );
+ })}
+ </List>
+ )}
+ </Box>
+ <TypeDefAuditDetailModal
+ open={typeDetailOpen}
+ onClose={handleCloseTypeDetail}
+ detailObject={typeDetailObject}
+ />
+ </Paper>
+ );
+});
+
+RecentActivity.displayName = "RecentActivity";
+
+export default RecentActivity;
diff --git a/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx b/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx
new file mode 100644
index 000000000..8ca10811f
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { Stack } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+/** Shown inside Recent Activity while the active tab's audits are loading. */
+export const RecentActivityListSkeleton = () => (
+ <Stack spacing={1.5} sx={{ pt: 1 }}>
+ {[1, 2, 3, 4, 5].map((i) => (
+ <SkeletonLoader
+ key={i}
+ animation="wave"
+ variant="text"
+ count={1}
+ width={`${85 - i * 5}%`}
+ height={20}
+ />
+ ))}
+ </Stack>
+);
+
+export default RecentActivityListSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
new file mode 100644
index 000000000..fb012a445
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+/** Matches Entity Status Overview donut — single source for dashboard charts */
+export const ENTITY_STATUS_DONUT_COLORS = {
+ Active: "#10b981",
+ Shell: "#f59e0b",
+ Deleted: "#ef4444",
+} as const;
+
+/** Active primary series / bar fill (aligned with Classification Distribution bars) */
+export const CHART_BAR_ACTIVE_BLUE = "#1976d2";
+
+/** Horizontal bar charts: Y-axis title + ticks; keep left tight to reduce card gutter */
+export const HORIZONTAL_BAR_CHART_MARGIN = {
+ top: 8,
+ right: 72,
+ left: 44,
+ bottom: 48,
+} as const;
+
+/** Tighter layout: short classification names — minimize dead space left of Y ticks */
+export const CLASSIFICATION_DISTRIBUTION_CHART_MARGIN = {
+ top: 8,
+ right: 72,
+ left: 12,
+ bottom: 48,
+} as const;
diff --git a/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts b/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts
new file mode 100644
index 000000000..118be6b15
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+/** Shape of basic-search entity headers used by Latest Entities card. */
+export interface LatestEntityRowModel {
+ guid?: string;
+ name?: string;
+ typeName?: string;
+ displayText?: string;
+ attributes?: {
+ name?: string;
+ qualifiedName?: string;
+ __guid?: string;
+ };
+}
+
+/**
+ * Match Search name column: top-level name/displayText and attributes.name /
+ * qualifiedName (Atlas may return any of these).
+ */
+export const resolveLatestEntityDisplayName = (
+ entity: LatestEntityRowModel
+): string => {
+ const n =
+ entity.name ??
+ entity.attributes?.name ??
+ entity.attributes?.qualifiedName ??
+ entity.displayText ??
+ entity.guid;
+ return typeof n === "string" && n.trim() !== "" ? n.trim() : "Unknown";
+};
+
+export const resolveLatestEntityGuid = (
+ entity: LatestEntityRowModel
+): string | undefined => {
+ const g =
+ entity.guid ??
+ (typeof entity.attributes?.__guid === "string"
+ ? entity.attributes.__guid
+ : undefined);
+ if (typeof g !== "string" || g.trim() === "") return undefined;
+ return g.trim();
+};
+
+export const resolveLatestEntityTypeName = (
+ entity: LatestEntityRowModel
+): string => {
+ const t = entity.typeName;
+ return typeof t === "string" && t.trim() !== "" ? t.trim() : "Entity";
+};
diff --git a/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx b/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx
index bb6f8f138..214c9faad 100644
--- a/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx
+++ b/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx
@@ -45,6 +45,7 @@ import { createEditBusinessMetadata } from "@api/apiMethods/typeDefApiMethods";
import { cloneDeep } from "@utils/Helper";
import { defaultAttrObj, defaultType } from "@utils/Enum";
import { fetchBusinessMetaData } from "@redux/slice/typeDefSlices/typedefBusinessMetadataSlice";
+import { fetchEntityData } from "@redux/slice/typeDefSlices/typedefEntitySlice";
import { getTypeName } from "@utils/CommonViewFunction";
const BusinessMetadataDetailsLayout = () => {
@@ -241,6 +242,7 @@ const BusinessMetadataDetailsLayout = () => {
try {
await createEditBusinessMetadata("business_metadata", "PUT", data);
dispatchState(fetchBusinessMetaData());
+ dispatchState(fetchEntityData());
toast.success(
"One or more Business Metadata attributes were updated successfully"
);
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
index c898692c5..0fdd395f6 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
@@ -78,6 +78,7 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
);
const [openModal, setOpenModal] = useState<boolean>(false);
+ const [removeAssignmentLoading, setRemoveAssignmentLoading] = useState(false);
const [tagModal, setTagModal] = useState<boolean>(false);
const [updateTable, setUpdateTable] = useState(moment.now());
const [rowdata, setRowData] = useState();
@@ -129,6 +130,7 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
const handleRemove = async () => {
try {
+ setRemoveAssignmentLoading(true);
await removeClassification(guid, currentValue.selectedValue);
if (!isEmpty(guid)) {
dispatchApi(fetchDetailPageData(guid as string));
@@ -145,6 +147,8 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
setOpenModal(false);
} catch (error) {
serverError(error, toastId);
+ } finally {
+ setRemoveAssignmentLoading(false);
}
};
@@ -381,6 +385,8 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
button1Handler={handleCloseModal}
button2Label="Remove"
button2Handler={handleRemove}
+ disableButton2={removeAssignmentLoading}
+ button2Loading={removeAssignmentLoading}
>
<Typography fontSize={15}>
Remove: <b>{currentValue.selectedValue}</b> assignment from{" "}
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropagationPropertyModal.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropagationPropertyModal.tsx
index 5ab64aef6..1ea05c701 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropagationPropertyModal.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropagationPropertyModal.tsx
@@ -421,6 +421,7 @@ const PropagationPropertyModal = ({
button2Label="Update"
button2Handler={() => onSubmit()}
disableButton2={loading}
+ button2Loading={loading}
>
<Stack gap={2}>
<Stack direction="row" alignItems="center" gap="0.5rem">
diff --git a/dashboard/src/views/DetailPage/GlossaryDetails/TermRelation.tsx b/dashboard/src/views/DetailPage/GlossaryDetails/TermRelation.tsx
index ba1381528..68d069ea7 100644
--- a/dashboard/src/views/DetailPage/GlossaryDetails/TermRelation.tsx
+++ b/dashboard/src/views/DetailPage/GlossaryDetails/TermRelation.tsx
@@ -219,6 +219,7 @@ const TermRelation = ({ glossaryTypeData }: any) => {
button2Label={editModal ? "Update" : "Close"}
button2Handler={editModal ? handleSubmit(onSubmit) : handleCloseModal}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
maxWidth="md"
>
<form onSubmit={handleSubmit(onSubmit)}>
diff --git a/dashboard/src/views/Entity/EntityForm.tsx b/dashboard/src/views/Entity/EntityForm.tsx
index f075b943f..e6a66fa99 100644
--- a/dashboard/src/views/Entity/EntityForm.tsx
+++ b/dashboard/src/views/Entity/EntityForm.tsx
@@ -521,6 +521,7 @@ const EntityForm = ({
button2Label={guid ? "Update" : "Create"}
button2Handler={handleSubmit(onSubmit)}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
isDirty={isDirty}
maxWidth="md"
>
diff --git a/dashboard/src/views/Glossary/AddUpdateCategoryForm.tsx b/dashboard/src/views/Glossary/AddUpdateCategoryForm.tsx
index 12a80b6de..bd9084861 100644
--- a/dashboard/src/views/Glossary/AddUpdateCategoryForm.tsx
+++ b/dashboard/src/views/Glossary/AddUpdateCategoryForm.tsx
@@ -146,6 +146,7 @@ const AddUpdateCategoryForm = (props: {
button1Handler={onClose}
button2Label={isAdd ? "Create" : "Update"}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
maxWidth="sm"
button2Handler={handleSubmit(onSubmit)}
>
diff --git a/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx b/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx
index 4aa2248d1..5f4a22854 100644
--- a/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx
+++ b/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx
@@ -19,108 +19,370 @@ import CustomModal from "@components/Modal";
import GlossaryForm from "./GlossaryForm";
import { useForm } from "react-hook-form";
import {
- createGlossary,
- editGlossary
+ createGlossary,
+ editGlossary
} from "@api/apiMethods/glossaryApiMethod";
import { isEmpty, serverError } from "@utils/Utils";
+import {
+ downloadGlossaryImportTemplate,
+ postGlossaryImportFormData
+} from "@utils/glossaryImportFlow";
+import { getApiErrorToastMessage } from "@utils/apiErrorToastMessage";
import { toast } from "react-toastify";
-import { useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { MouseEvent } from "react";
import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
import { fetchGlossaryData } from "@redux/slice/glossarySlice";
+import {
+ Button,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ Stack,
+ ToggleButton,
+ ToggleButtonGroup
+} from "@mui/material";
+import FileDownloadIcon from "@mui/icons-material/FileDownload";
+import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
+import ImportLayout from "@views/SideBar/Import/ImportLayout";
+import { LightTooltip } from "@components/muiComponents";
+
+type GlossaryCreateTab = "create" | "import";
+
+const hasSelectedImportFile = (fileData: unknown): boolean => {
+ if (fileData == null) return false;
+ if (fileData instanceof File) return true;
+ if (typeof fileData === "object" && Object.keys(fileData as object).length > 0)
+ return true;
+ return false;
+};
const AddUpdateGlossaryForm = (props: {
- open: any;
- onClose: any;
- isAdd: any;
- node: Record<string, any> | undefined;
+ open: any;
+ onClose: any;
+ isAdd: any;
+ node: Record<string, any> | undefined;
}) => {
- const { open, onClose, isAdd, node } = props;
- const dispatch = useAppDispatch();
- const toastId: any = useRef(null);
- const { glossaryData }: any = useAppSelector((state: any) => state.glossary);
- const { id } = node || {};
- let defaultValue: Record<string, string> = {};
- let glossaryObj: Record<string, string> = {};
- if (!isAdd) {
- glossaryObj = glossaryData.find((obj: { name: string }) => {
- return obj.name == id;
- });
- const { name, shortDescription, longDescription } = glossaryObj || {};
-
- defaultValue["name"] = name;
- defaultValue["shortDescription"] = shortDescription;
- defaultValue["longDescription"] = longDescription;
- }
- const {
- control,
- handleSubmit,
- setValue,
- formState: { isSubmitting }
- } = useForm({
- defaultValues: isAdd ? {} : defaultValue,
- mode: "onChange",
- shouldUnregister: true
- });
-
- const onSubmit = async (formValues: any) => {
- let formData = { ...formValues };
- const { guid, qualifiedName } = glossaryObj;
- const {
- name,
- shortDescription,
- longDescription
- }: { name: string; shortDescription: string; longDescription: string } =
- formData;
- let data: Record<string, string> = {};
- if (!isAdd) {
- data["guid"] = guid;
- data["qualifiedName"] = qualifiedName;
- }
- data["name"] = name;
- data["shortDescription"] = !isEmpty(shortDescription)
- ? shortDescription
- : "";
- data["longDescription"] = !isEmpty(longDescription) ? longDescription : "";
-
- try {
- if (isAdd) {
- await createGlossary(data);
- } else {
- await editGlossary(guid, data);
- }
-
- await dispatch(fetchGlossaryData());
- toast.dismiss(toastId.current);
- toastId.current = toast.success(
- `Glossary ${name} was ${isAdd ? "created" : "updated"} successfully`
- );
- onClose();
- } catch (error) {
- serverError(error, toastId);
- }
- };
-
- return (
- <>
- <CustomModal
- open={open}
- onClose={onClose}
- title={isAdd ? "Create Glossary" : "Edit Glossary"}
- button1Label="Cancel"
- button1Handler={onClose}
- button2Label={isAdd ? "Create" : "Update"}
- maxWidth="sm"
- button2Handler={handleSubmit(onSubmit)}
- disableButton2={isSubmitting}
- >
- <GlossaryForm
- control={control}
- handleSubmit={handleSubmit(onSubmit)}
- setValue={setValue}
- />
- </CustomModal>
- </>
- );
+ const { open, onClose, isAdd, node } = props;
+ const dispatch = useAppDispatch();
+ const toastId: any = useRef(null);
+ const { glossaryData }: any = useAppSelector((state: any) => state.glossary);
+ const { id } = node || {};
+ let defaultValue: Record<string, string> = {};
+ let glossaryObj: Record<string, string> = {};
+ if (!isAdd) {
+ glossaryObj = glossaryData.find((obj: { name: string }) => {
+ return obj.name == id;
+ });
+ const { name, shortDescription, longDescription } = glossaryObj || {};
+
+ defaultValue["name"] = name;
+ defaultValue["shortDescription"] = shortDescription;
+ defaultValue["longDescription"] = longDescription;
+ }
+ const {
+ control,
+ handleSubmit,
+ setValue,
+ formState: { isSubmitting }
+ } = useForm({
+ defaultValues: isAdd ? {} : defaultValue,
+ mode: "onChange",
+ shouldUnregister: true
+ });
+
+ const [glossaryCreateTab, setGlossaryCreateTab] =
+ useState<GlossaryCreateTab>("create");
+ const [importFileData, setImportFileData] = useState<any>([]);
+ const [importProgress, setImportProgress] = useState(0);
+ const [importErrorDetails, setImportErrorDetails] = useState(false);
+ const [importData, setImportData] = useState<any>(null);
+ const [importUploading, setImportUploading] = useState(false);
+
+ const resetImportUi = useCallback(() => {
+ setGlossaryCreateTab("create");
+ setImportFileData([]);
+ setImportProgress(0);
+ setImportErrorDetails(false);
+ setImportData(null);
+ setImportUploading(false);
+ }, []);
+
+ const handleModalClose = useCallback(() => {
+ resetImportUi();
+ onClose();
+ }, [onClose, resetImportUi]);
+
+ useEffect(() => {
+ if (!open) {
+ resetImportUi();
+ }
+ }, [open, resetImportUi]);
+
+ const handleGlossaryCreateTabChange = (
+ _event: MouseEvent<HTMLElement>,
+ next: GlossaryCreateTab | null
+ ) => {
+ if (next == null) return;
+ setGlossaryCreateTab(next);
+ if (next === "create") {
+ setImportFileData([]);
+ setImportProgress(0);
+ setImportErrorDetails(false);
+ setImportData(null);
+ }
+ };
+
+ const handleDownloadTemplate = async () => {
+ try {
+ await downloadGlossaryImportTemplate();
+ } catch {
+ toast.dismiss(toastId.current);
+ toastId.current = toast.error("Could not download template");
+ }
+ };
+
+ const handleImportUpload = async () => {
+ if (!hasSelectedImportFile(importFileData)) return;
+ setImportUploading(true);
+ try {
+ const importResp = await postGlossaryImportFormData(
+ importFileData,
+ (pct) => setImportProgress(pct)
+ );
+ if (importResp.data.failedImportInfoList == undefined) {
+ toast.dismiss(toastId.current);
+ toastId.current = toast.success(
+ `File: ${importFileData.name} imported successfully`
+ );
+ await dispatch(fetchGlossaryData());
+ handleModalClose();
+ return;
+ }
+ if (importResp.data.failedImportInfoList != undefined) {
+ toast.dismiss(toastId.current);
+ toastId.current = toast.error(
+ importResp.data.failedImportInfoList[0].remarks
+ );
+ setImportErrorDetails(true);
+ }
+ setImportData(importResp.data);
+ } catch (error) {
+ const message = getApiErrorToastMessage(error);
+ if (message === null) {
+ return;
+ }
+ toast.dismiss(toastId.current);
+ toastId.current = toast.error(message);
+ } finally {
+ setImportUploading(false);
+ }
+ };
+
+ const onSubmit = async (formValues: any) => {
+ let formData = { ...formValues };
+ const { guid, qualifiedName } = glossaryObj;
+ const {
+ name,
+ shortDescription,
+ longDescription
+ }: { name: string; shortDescription: string; longDescription: string } =
+ formData;
+ let data: Record<string, string> = {};
+ if (!isAdd) {
+ data["guid"] = guid;
+ data["qualifiedName"] = qualifiedName;
+ }
+ data["name"] = name;
+ data["shortDescription"] = !isEmpty(shortDescription)
+ ? shortDescription
+ : "";
+ data["longDescription"] = !isEmpty(longDescription) ? longDescription : "";
+
+ try {
+ if (isAdd) {
+ await createGlossary(data);
+ } else {
+ await editGlossary(guid, data);
+ }
+
+ await dispatch(fetchGlossaryData());
+ toast.dismiss(toastId.current);
+ toastId.current = toast.success(
+ `Glossary ${name} was ${isAdd ? "created" : "updated"} successfully`
+ );
+ handleModalClose();
+ } catch (error) {
+ serverError(error, toastId);
+ }
+ };
+
+ const showImportError = isAdd && glossaryCreateTab === "import" && importErrorDetails;
+
+ const modalTitle = showImportError
+ ? "Error Details"
+ : isAdd
+ ? "Create Glossary"
+ : "Edit Glossary";
+
+ const titleIconNode =
+ showImportError ? (
+ <IconButton
+ aria-label="Back to import file"
+ onClick={(e) => {
+ e.stopPropagation();
+ setImportErrorDetails(false);
+ }}
+ size="small"
+ sx={{ color: (theme) => theme.palette.grey[500] }}
+ >
+ <LightTooltip title="Back to import file">
+ <ArrowBackIosNewIcon sx={{ fontSize: "1.25rem" }} />
+ </LightTooltip>
+ </IconButton>
+ ) : undefined;
+
+ const isImportMode = isAdd && glossaryCreateTab === "import" && !importErrorDetails;
+
+ const button2Label = (() => {
+ if (!isAdd) return "Update";
+ if (glossaryCreateTab === "create") return "Create";
+ if (importErrorDetails) return "";
+ return "Upload";
+ })();
+
+ const button2Handler = (() => {
+ if (!isAdd || glossaryCreateTab === "create") return handleSubmit(onSubmit);
+ if (importErrorDetails) return () => {};
+ return handleImportUpload;
+ })();
+
+ const disableButton2 = (() => {
+ if (!isAdd) return isSubmitting;
+ if (glossaryCreateTab === "create") return isSubmitting;
+ if (importErrorDetails) return true;
+ return !hasSelectedImportFile(importFileData);
+ })();
+
+ const button2Loading =
+ isAdd && glossaryCreateTab === "import" && !importErrorDetails
+ ? importUploading
+ : isSubmitting;
+
+ const hideButton2 = Boolean(
+ isAdd && glossaryCreateTab === "import" && importErrorDetails
+ );
+
+ return (
+ <>
+ <CustomModal
+ open={open}
+ onClose={handleModalClose}
+ title={modalTitle}
+ titleIcon={titleIconNode}
+ button1Label="Cancel"
+ button1Handler={handleModalClose}
+ button2Label={button2Label}
+ maxWidth="sm"
+ button2Handler={button2Handler}
+ disableButton2={disableButton2}
+ button2Loading={button2Loading}
+ hideButton2={hideButton2}
+ >
+ <Stack>
+ {isAdd && (
+ <Stack marginBottom="1rem">
+ <ToggleButtonGroup
+ exclusive
+ value={glossaryCreateTab}
+ onChange={handleGlossaryCreateTabChange}
+ size="small"
+ color="primary"
+ aria-label="Choose create glossary or import glossary terms"
+ >
+ <ToggleButton
+ value="create"
+ className="entity-form-toggle-btn"
+ data-cy="create-glossary-tab"
+ >
+ Create glossary
+ </ToggleButton>
+ <ToggleButton
+ value="import"
+ className="entity-form-toggle-btn"
+ data-cy="import-glossary-terms-tab"
+ >
+ Import glossary terms
+ </ToggleButton>
+ </ToggleButtonGroup>
+ </Stack>
+ )}
+ {(!isAdd || glossaryCreateTab === "create") && (
+ <GlossaryForm
+ control={control}
+ handleSubmit={handleSubmit(onSubmit)}
+ setValue={setValue}
+ />
+ )}
+ {isImportMode && (
+ <Stack gap={2}>
+ <Button
+ variant="outlined"
+ color="primary"
+ startIcon={<FileDownloadIcon />}
+ onClick={handleDownloadTemplate}
+ aria-label="Download import template"
+ >
+ Download import template
+ </Button>
+ <ImportLayout
+ setFileData={setImportFileData}
+ progressVal={importProgress}
+ setProgress={setImportProgress}
+ selectedFile={
+ importFileData !== undefined &&
+ hasSelectedImportFile(importFileData)
+ ? [importFileData]
+ : []
+ }
+ errorDetails={importErrorDetails}
+ />
+ </Stack>
+ )}
+ {showImportError && importData?.failedImportInfoList && (
+ <Stack
+ sx={{
+ width: "100%",
+ minHeight: 200,
+ maxHeight: 400,
+ bgcolor: "background.paper"
+ }}
+ >
+ <List>
+ {importData.failedImportInfoList.map(
+ (
+ value: {
+ index: number;
+ remarks: string;
+ },
+ index: number
+ ) => (
+ <ListItem key={value.index} disableGutters disablePadding>
+ <ListItemText
+ className="dropzone-listitem"
+ primary={`${index + 1}. ${value.remarks}`}
+ />
+ </ListItem>
+ )
+ )}
+ </List>
+ </Stack>
+ )}
+ </Stack>
+ </CustomModal>
+ </>
+ );
};
export default AddUpdateGlossaryForm;
diff --git a/dashboard/src/views/Glossary/AddUpdateTermForm.tsx b/dashboard/src/views/Glossary/AddUpdateTermForm.tsx
index feea62032..f5ece0289 100644
--- a/dashboard/src/views/Glossary/AddUpdateTermForm.tsx
+++ b/dashboard/src/views/Glossary/AddUpdateTermForm.tsx
@@ -142,6 +142,7 @@ const AddUpdateTermForm = (props: {
button1Handler={onClose}
button2Label={isAdd ? "Create" : "Update"}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
maxWidth="sm"
button2Handler={handleSubmit(onSubmit)}
>
diff --git a/dashboard/src/views/Glossary/AssignCategory.tsx b/dashboard/src/views/Glossary/AssignCategory.tsx
index 9e588ae3c..5a6f8f8b9 100644
--- a/dashboard/src/views/Glossary/AssignCategory.tsx
+++ b/dashboard/src/views/Glossary/AssignCategory.tsx
@@ -288,6 +288,7 @@ const AssignCategory = ({
maxWidth="sm"
button2Handler={assignCatgeory}
disableButton2={loading}
+ button2Loading={loading}
>
<Stack>
<TextField
diff --git a/dashboard/src/views/Glossary/AssignTerm.tsx b/dashboard/src/views/Glossary/AssignTerm.tsx
index 14eceb720..e4c2fc0ed 100644
--- a/dashboard/src/views/Glossary/AssignTerm.tsx
+++ b/dashboard/src/views/Glossary/AssignTerm.tsx
@@ -452,6 +452,7 @@ const AssignTerm = ({
maxWidth="sm"
button2Handler={relatedTerm ? handleSubmit(onSubmit) : assignTerm}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
isDirty={!isEmpty(selectedNode)}
>
{relatedTerm ? (
diff --git a/dashboard/src/views/Glossary/DeleteGlossary.tsx b/dashboard/src/views/Glossary/DeleteGlossary.tsx
index 744447494..c4b5dcf60 100644
--- a/dashboard/src/views/Glossary/DeleteGlossary.tsx
+++ b/dashboard/src/views/Glossary/DeleteGlossary.tsx
@@ -26,6 +26,7 @@ import { Typography } from "@mui/material";
import { fetchGlossaryData } from "@redux/slice/glossarySlice";
import { isEmpty, serverError } from "@utils/Utils";
import { useRef } from "react";
+import { useAsyncPending } from "@hooks/useAsyncPending";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
@@ -48,36 +49,39 @@ const DeleteGlossary = (props: {
const navigate = useNavigate();
const dispatchApi = useAppDispatch();
const toastId: any = useRef(null);
+ const { pending: deleteInProgress, run: runDelete } = useAsyncPending();
const fetchCurrentData = async () => {
await dispatchApi(fetchGlossaryData());
};
- const handleRemove = async () => {
- try {
- gtype == "term"
- ? await deleteGlossaryorType(cGuid)
- : await deleteGlossaryorTerm(guid);
- updatedData();
- fetchCurrentData();
- toast.success(
- `${
- gtype == "term" ? "Term" : "Glossary"
- } ${id} was deleted successfully`
- );
- if (!isEmpty(glossaryGuid) || !isEmpty(glossaryType)) {
- navigate(
- {
- pathname: "/"
- },
- { replace: true }
+ const handleRemove = () => {
+ void runDelete(async () => {
+ try {
+ gtype == "term"
+ ? await deleteGlossaryorType(cGuid)
+ : await deleteGlossaryorTerm(guid);
+ updatedData();
+ await fetchCurrentData();
+ toast.success(
+ `${
+ gtype == "term" ? "Term" : "Glossary"
+ } ${id} was deleted successfully`
);
+ if (!isEmpty(glossaryGuid) || !isEmpty(glossaryType)) {
+ navigate(
+ {
+ pathname: "/"
+ },
+ { replace: true }
+ );
+ }
+ onClose();
+ setExpandNode(null);
+ } catch (error) {
+ serverError(error, toastId);
}
- onClose();
- setExpandNode(null);
- } catch (error) {
- serverError(error, toastId);
- }
+ });
};
return (
@@ -92,6 +96,8 @@ const DeleteGlossary = (props: {
button2Label="Ok"
maxWidth="sm"
button2Handler={handleRemove}
+ disableButton2={deleteInProgress}
+ button2Loading={deleteInProgress}
>
<Typography fontSize={15}>
Are you sure you want to delete{" "}
diff --git a/dashboard/src/views/SaveFilters/SaveFilters.tsx b/dashboard/src/views/SaveFilters/SaveFilters.tsx
index fed2fe27f..ac65237ec 100644
--- a/dashboard/src/views/SaveFilters/SaveFilters.tsx
+++ b/dashboard/src/views/SaveFilters/SaveFilters.tsx
@@ -206,6 +206,7 @@ const SaveFilters = ({
maxWidth="sm"
button2Handler={handleSubmit(onSubmit)}
disableButton2={isSubmitting}
+ button2Loading={isSubmitting}
>
{" "}
<form onSubmit={handleSubmit(onSubmit)}>
diff --git a/dashboard/src/views/SearchResult/SearchResult.tsx b/dashboard/src/views/SearchResult/SearchResult.tsx
index a026e1175..5d850d93a 100644
--- a/dashboard/src/views/SearchResult/SearchResult.tsx
+++ b/dashboard/src/views/SearchResult/SearchResult.tsx
@@ -71,6 +71,9 @@ interface Params {
classification: any;
termName: string | null;
relationshipFilters?: string | null;
+ sortBy?: string;
+ sortOrder?: string;
+ excludeHeaderAttributes?: boolean;
}
let defaultColumnsName: Array<string> = [
@@ -155,6 +158,15 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
? !searchParams.get("excludeST")
: true,
includeClassificationAttributes: true,
+ ...(!isEmpty(searchParams.get("sortBy")) && {
+ sortBy: searchParams.get("sortBy")
+ }),
+ ...(!isEmpty(searchParams.get("sortOrder")) && {
+ sortOrder: searchParams.get("sortOrder")
+ }),
+ ...(searchParams.get("excludeHeaderAttributes") === "true" && {
+ excludeHeaderAttributes: true
+ }),
...(isEmpty(classificationParams || glossaryTypeParams) && {
entityFilters: !isEmpty(entityFilterParams)
? searchParamsAPiQuery(entityFilterParams)
@@ -1000,12 +1012,20 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
return hideColumns;
};
+ const latestEntitiesSortBy = searchParams.get("sortBy");
+ const latestEntitiesSortOrder = searchParams.get("sortOrder");
const getDefaultSort = useMemo(() => {
if (isDslAggregate) {
return [] as any[]; // no default sorting for DSL aggregates
}
- return [{ id: "name", asc: true }];
- }, [isDslAggregate]);
+ if (
+ latestEntitiesSortBy === "__timestamp" &&
+ latestEntitiesSortOrder === "DESCENDING"
+ ) {
+ return [{ id: "__timestamp", desc: true }];
+ }
+ return [{ id: "name", desc: false }];
+ }, [isDslAggregate, latestEntitiesSortBy, latestEntitiesSortOrder]);
return (
<Stack position="relative" gap={"1rem"}>
diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx
index 02f5e27b6..bc9e444c2 100644
--- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx
+++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx
@@ -64,7 +64,7 @@ import { globalSearchFilterInitialQuery, isEmpty } from "@utils/Utils";
import { attributeFilter } from "@utils/CommonViewFunction";
import { cloneDeep } from "@utils/Helper";
import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined";
-import { getGlossaryImportTmpl } from "@api/apiMethods/glossaryApiMethod";
+import { downloadGlossaryImportTemplate } from "@utils/glossaryImportFlow";
import { toast } from "react-toastify";
import { EnumTypeDefData, TreeNode } from "@models/treeStructureType";
import ImportDialog from "@components/ImportDialog";
@@ -969,33 +969,26 @@ const BarTreeView: FC<{
const downloadFile = async () => {
try {
- let apiResp: any = {};
- if (treeName == "Entities") {
- apiResp = await getBusinessMetadataImportTmpl({});
- } else if (treeName == "Glossary") {
- apiResp = await getGlossaryImportTmpl({});
- }
- let text: string = "";
- if (apiResp) {
- text = apiResp.data;
+ if (treeName == "Glossary") {
+ await downloadGlossaryImportTemplate();
+ return;
}
+ const apiResp: any = await getBusinessMetadataImportTmpl({});
+ const text: string = apiResp ? apiResp.data : "";
const blob = new Blob([text], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
- if (treeName == "Entities") {
- link.setAttribute("download", "template_business_metadata");
- } else if (treeName == "Glossary") {
- link.setAttribute("download", "template");
- }
+ link.setAttribute("download", "template_business_metadata");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
} catch {
/* ignore download error */
}
diff --git a/dashboard/src/views/Statistics/ServerStats.tsx b/dashboard/src/views/Statistics/ServerStats.tsx
index 9f6974614..5f131c41c 100644
--- a/dashboard/src/views/Statistics/ServerStats.tsx
+++ b/dashboard/src/views/Statistics/ServerStats.tsx
@@ -118,12 +118,11 @@ const ServerStats = ({ selectedValue, currentMetricsData }: any) => {
let notificationTableHeader = [
"Count",
- "Avg",
- "Time (ms)",
+ "Avg time (ms)",
"Creates",
"Updates",
- "Deletes"
- // "Failed"
+ "Deletes",
+ "Failed"
];
let topciOffsetTableHeader = [
diff --git a/dashboard/src/views/Statistics/Statistics.tsx b/dashboard/src/views/Statistics/Statistics.tsx
index 6204a6d08..51bb42077 100644
--- a/dashboard/src/views/Statistics/Statistics.tsx
+++ b/dashboard/src/views/Statistics/Statistics.tsx
@@ -23,6 +23,7 @@ import ClassificationStats from "./ClassificationStats";
import {
Autocomplete,
Badge,
+ CircularProgress,
IconButton,
Stack,
TextField
@@ -72,6 +73,7 @@ const Statistics = ({
value: "Current"
});
const [loading, setLoading] = useState(true);
+ const [refreshBusy, setRefreshBusy] = useState(false);
useEffect(() => {
fetchMetricsStatsDetails();
@@ -117,12 +119,20 @@ const Statistics = ({
};
const handleRefresh = async () => {
- await fetchMetricsStatsDetails();
- await dispatch(fetchMetricEntity());
- if (toastId.current) {
- toast.dismiss(toastId.current);
+ if (refreshBusy) {
+ return;
+ }
+ setRefreshBusy(true);
+ try {
+ await fetchMetricsStatsDetails();
+ await dispatch(fetchMetricEntity());
+ if (toastId.current) {
+ toast.dismiss(toastId.current);
+ }
+ toastId.current = toast.success("Metric data is refreshed");
+ } finally {
+ setRefreshBusy(false);
}
- toastId.current = toast.success("Metric data is refreshed");
};
return (
@@ -137,14 +147,23 @@ const Statistics = ({
postTitleIcon={
<Stack justifyContent="center" alignItems="center">
<LightTooltip title="Refresh Data">
- <IconButton
- size="small"
- onClick={() => {
- handleRefresh();
- }}
- >
- <Refresh />
- </IconButton>
+ <span>
+ <IconButton
+ size="small"
+ onClick={() => {
+ void handleRefresh();
+ }}
+ disabled={refreshBusy || loading}
+ aria-busy={refreshBusy}
+ aria-label="Refresh metrics data"
+ >
+ {refreshBusy ? (
+ <CircularProgress size={20} thickness={4} color="inherit" />
+ ) : (
+ <Refresh />
+ )}
+ </IconButton>
+ </span>
</LightTooltip>
</Stack>
}