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/04 08:42:17 UTC
(atlas) branch ATLAS-5246 created (now 91110d9d1)
This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a change to branch ATLAS-5246
in repository https://gitbox.apache.org/repos/asf/atlas.git
at 91110d9d1 ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation.
This branch includes the following new commits:
new 91110d9d1 ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(atlas) 01/01: ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation.
Posted by pp...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch ATLAS-5246
in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 91110d9d1fc86b1592025ee4ac731889609de088
Author: Prasad Pawar <pr...@freestoneinfotech.com>
AuthorDate: Mon May 4 14:11:23 2026 +0530
ATLAS-5246: ATLAS UI: Dashboard Overview - React UI Dashboard Implementation.
---
.../src/api/apiMethods/entityFormApiMethod.ts | 2 +-
dashboard/src/api/apiMethods/searchApiMethod.ts | 50 +-
.../components/CreateDropdown/CreateDropdown.tsx | 176 +++
dashboard/src/components/CreateDropdown/index.ts | 1 +
dashboard/src/components/DialogShowMoreLess.tsx | 1 +
dashboard/src/components/FilterQuery.tsx | 197 +++-
.../src/components/GlobalSearch/QuickSearch.tsx | 1185 ++++++++++++--------
dashboard/src/components/ImportDialog.tsx | 44 +-
dashboard/src/components/Modal.tsx | 110 +-
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 | 96 +-
dashboard/src/components/TreeNodeIcons.tsx | 42 +-
.../src/components/TypeDefAuditDetailModal.tsx | 108 ++
dashboard/src/hooks/useAsyncPending.ts | 43 +
dashboard/src/redux/reducers/reducers.ts | 2 +
dashboard/src/redux/slice/dashboardRefreshSlice.ts | 39 +
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 | 195 ++++
dashboard/src/utils/filterUrlCriterionRemoval.ts | 81 ++
dashboard/src/utils/glossaryImportFlow.ts | 57 +
dashboard/src/utils/metricsUtils.ts | 241 ++++
dashboard/src/utils/refreshDashboardHome.ts | 30 +
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 | 260 +++++
.../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 +
.../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/Layout/Header.tsx | 23 +-
dashboard/src/views/SaveFilters/SaveFilters.tsx | 1 +
dashboard/src/views/SideBar/SideBarBody.tsx | 71 +-
.../src/views/SideBar/SideBarTree/SideBarTree.tsx | 23 +-
dashboard/src/views/Statistics/ServerStats.tsx | 7 +-
dashboard/src/views/Statistics/Statistics.tsx | 45 +-
70 files changed, 6444 insertions(+), 987 deletions(-)
diff --git a/dashboard/src/api/apiMethods/entityFormApiMethod.ts b/dashboard/src/api/apiMethods/entityFormApiMethod.ts
index 36d17764e..ff2066b16 100644
--- a/dashboard/src/api/apiMethods/entityFormApiMethod.ts
+++ b/dashboard/src/api/apiMethods/entityFormApiMethod.ts
@@ -20,7 +20,7 @@ import {
getEntityUrl,
getTypedefUrl,
} from "@api/apiUrlLinks/entityFormApiUrl";
-import { _get } from "./apiMethod";
+import { _delete, _get } from "./apiMethod";
import { entitiesApiUrl } from "@api/apiUrlLinks/entitiesApiUrl";
interface importTmplConfigs {
diff --git a/dashboard/src/api/apiMethods/searchApiMethod.ts b/dashboard/src/api/apiMethods/searchApiMethod.ts
index 11a061e4f..1d387001f 100644
--- a/dashboard/src/api/apiMethods/searchApiMethod.ts
+++ b/dashboard/src/api/apiMethods/searchApiMethod.ts
@@ -56,10 +56,50 @@ const getRelationShipV2 = (params: { params: Record<string, unknown> }) => {
return fetchApi(url, { method: "GET" });
};
+/**
+ * Dashboard: latest entities via basic search.
+ * Request `__timestamp` only in `attributes` so responses include system created time
+ * (Atlas often returns `createTime: 0`; `__timestamp` holds the real ms value).
+ */
+const getLatestEntities = (limit: number) => {
+ const timestampMs = Date.now();
+ return getBasicSearchResult(
+ {
+ data: {
+ typeName: "_ALL_ENTITY_TYPES",
+ excludeDeletedEntities: true,
+ includeClassificationAttributes: true,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ limit,
+ offset: 0,
+ tagFilters: null,
+ entityFilters: {
+ condition: "AND",
+ criterion: [
+ {
+ attributeName: "__timestamp",
+ operator: "lte",
+ attributeValue: String(timestampMs),
+ type: "date"
+ }
+ ]
+ },
+ classification: null,
+ termName: null,
+ relationshipFilters: null,
+ attributes: ["__timestamp"]
+ }
+ },
+ "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..48cc7382b
--- /dev/null
+++ b/dashboard/src/components/CreateDropdown/index.ts
@@ -0,0 +1 @@
+export { default } from "./CreateDropdown";
diff --git a/dashboard/src/components/DialogShowMoreLess.tsx b/dashboard/src/components/DialogShowMoreLess.tsx
index d782222e8..03492d077 100644
--- a/dashboard/src/components/DialogShowMoreLess.tsx
+++ b/dashboard/src/components/DialogShowMoreLess.tsx
@@ -576,6 +576,7 @@ const DialogShowMoreLess = ({
button2Label="Remove"
button2Handler={handleRemove}
disableButton2={removeLoader}
+ button2Loading={removeLoader}
isDirty={true}
>
{relatedTerm ? (
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/Modal.tsx b/dashboard/src/components/Modal.tsx
index 75682d277..373e5fee2 100644
--- a/dashboard/src/components/Modal.tsx
+++ b/dashboard/src/components/Modal.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { styled } from "@mui/material/styles";
+import { styled, type Theme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
@@ -54,9 +54,13 @@ interface CustomModalProps {
button2Label?: string | undefined;
button2Handler: any;
disableButton2?: string | object | boolean;
+ /** Shows spinner on primary action and blocks both footer buttons while an async action runs */
+ button2Loading?: boolean;
maxWidth?: any;
footer?: boolean;
isDirty?: boolean;
+ /** When true, only the first footer button is rendered (e.g. import error state). */
+ hideButton2?: boolean;
}
export const CustomModal: React.FC<CustomModalProps> = ({
@@ -71,10 +75,18 @@ export const CustomModal: React.FC<CustomModalProps> = ({
button2Label,
button2Handler,
disableButton2,
+ button2Loading,
maxWidth,
isDirty,
- footer
+ footer,
+ hideButton2
}) => {
+ const isLoading = Boolean(button2Loading);
+ const primaryDisabled =
+ isLoading ||
+ Boolean(disableButton2) ||
+ (isDirty !== undefined && !isDirty);
+
const handleClick = (event: { stopPropagation: () => void }) => {
event.stopPropagation();
};
@@ -84,6 +96,7 @@ export const CustomModal: React.FC<CustomModalProps> = ({
<BootstrapDialog
maxWidth={maxWidth || "sm"}
aria-labelledby="customized-dialog-title"
+ aria-busy={isLoading}
open={open}
sx={{
"& .MuiDialog-paper": {
@@ -124,8 +137,12 @@ export const CustomModal: React.FC<CustomModalProps> = ({
}}
onClick={(e) => {
e.stopPropagation();
+ if (isLoading) {
+ return;
+ }
onClose();
}}
+ disabled={isLoading}
>
<CloseIcon sx={{ width: "0.75em", height: "0.75em" }} />
</IconButton>
@@ -145,8 +162,12 @@ export const CustomModal: React.FC<CustomModalProps> = ({
<CustomButton
variant="outlined"
color="primary"
+ disabled={isLoading}
onClick={(e: Event) => {
e.stopPropagation();
+ if (isLoading) {
+ return;
+ }
button1Handler();
}}
>
@@ -154,37 +175,62 @@ export const CustomModal: React.FC<CustomModalProps> = ({
</CustomButton>
)}
- <CustomButton
- variant="contained"
- color="primary"
- aria-label="close"
- primary={true}
- disabled={
- disableButton2 || (isDirty != undefined ? !isDirty : false)
- }
- sx={{
- ...(disableButton2 && {
- "&.Mui-disabled": {
- pointerEvents: "unset",
- cursor: "not-allowed"
+ {!hideButton2 && (
+ <CustomButton
+ variant="contained"
+ color="primary"
+ aria-label={
+ isLoading
+ ? "Action in progress, please wait"
+ : "Confirm dialog action"
+ }
+ primary={true}
+ disabled={primaryDisabled}
+ sx={{
+ minWidth: isLoading ? 88 : undefined,
+ ...(isLoading && {
+ "&.Mui-disabled": {
+ opacity: 1,
+ pointerEvents: "none",
+ cursor: "progress",
+ backgroundColor: (theme: Theme) =>
+ theme.palette.primary.main,
+ color: (theme: Theme) =>
+ theme.palette.primary.contrastText
+ }
+ }),
+ ...(primaryDisabled &&
+ !isLoading && {
+ "&.Mui-disabled": {
+ pointerEvents: "unset",
+ cursor: "not-allowed"
+ }
+ })
+ }}
+ startIcon={
+ isLoading ? (
+ <CircularProgress
+ aria-hidden
+ size={18}
+ thickness={4}
+ sx={{
+ color: (theme: Theme) =>
+ theme.palette.primary.contrastText
+ }}
+ />
+ ) : undefined
+ }
+ onClick={(e: Event) => {
+ e.stopPropagation();
+ if (isLoading) {
+ return;
}
- })
- }}
- startIcon={
- disableButton2 && isDirty ? (
- <CircularProgress
- sx={{ color: "white", fontWeight: "600" }}
- size="14px"
- />
- ) : undefined
- }
- onClick={(e: Event) => {
- e.stopPropagation();
- button2Handler();
- }}
- >
- {button2Label}
- </CustomButton>
+ button2Handler();
+ }}
+ >
+ {button2Label}
+ </CustomButton>
+ )}
</DialogActions>
</Stack>
)}
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..dfe8f468f 100644
--- a/dashboard/src/components/Table/TablePagination.tsx
+++ b/dashboard/src/components/Table/TablePagination.tsx
@@ -491,25 +491,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 +519,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 +543,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/hooks/useAsyncPending.ts b/dashboard/src/hooks/useAsyncPending.ts
new file mode 100644
index 000000000..b058f4392
--- /dev/null
+++ b/dashboard/src/hooks/useAsyncPending.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.
+ */
+
+import { useCallback, useRef, useState } from "react";
+
+/**
+ * Single-flight async guard + pending flag for primary actions (modals, forms).
+ * Reuse wherever a button triggers an API call and should show loading state.
+ */
+export const useAsyncPending = () => {
+ const [pending, setPending] = useState(false);
+ const busyRef = useRef(false);
+
+ const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
+ if (busyRef.current) {
+ return undefined;
+ }
+ busyRef.current = true;
+ setPending(true);
+ try {
+ return await fn();
+ } finally {
+ busyRef.current = false;
+ setPending(false);
+ }
+ }, []);
+
+ return { pending, run };
+};
diff --git a/dashboard/src/redux/reducers/reducers.ts b/dashboard/src/redux/reducers/reducers.ts
index 205006341..425fd9f80 100644
--- a/dashboard/src/redux/reducers/reducers.ts
+++ b/dashboard/src/redux/reducers/reducers.ts
@@ -32,6 +32,7 @@ import { relationshipsReducer } from "@redux/slice/typeDefSlices/typedefRelation
import { metricsReducer } from "@redux/slice/metricsSlice";
import { savedSearchReducer } from "@redux/slice/savedSearchSlice";
import { drawerSliceReducer } from "@redux/slice/drawerSlice";
+import { dashboardRefreshReducer } from "@redux/slice/dashboardRefreshSlice";
const rootReducer = combineReducers({
session: sessionReducer,
@@ -39,6 +40,7 @@ const rootReducer = combineReducers({
typeHeader: typeHeaderReducer,
allEntityTypes: allEntityTypesReducer,
metrics: metricsReducer,
+ dashboardRefresh: dashboardRefreshReducer,
classification: classificationReducer,
businessMetaData: businessMetadataReducer,
glossary: glossaryReducer,
diff --git a/dashboard/src/redux/slice/dashboardRefreshSlice.ts b/dashboard/src/redux/slice/dashboardRefreshSlice.ts
new file mode 100644
index 000000000..cb21a5c9d
--- /dev/null
+++ b/dashboard/src/redux/slice/dashboardRefreshSlice.ts
@@ -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 { createSlice } from "@reduxjs/toolkit";
+
+interface DashboardRefreshState {
+ version: number;
+}
+
+const initialState: DashboardRefreshState = {
+ version: 0,
+};
+
+const dashboardRefreshSlice = createSlice({
+ name: "dashboardRefresh",
+ initialState,
+ reducers: {
+ requestDashboardRefresh: (state) => {
+ state.version += 1;
+ },
+ },
+});
+
+export const { requestDashboardRefresh } = dashboardRefreshSlice.actions;
+export const dashboardRefreshReducer = dashboardRefreshSlice.reducer;
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..2b4ccd87f
--- /dev/null
+++ b/dashboard/src/utils/dashboardSearchUtils.ts
@@ -0,0 +1,195 @@
+/*
+ * 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 });
+};
+
+export const navigateToLatestEntitiesSearch = (
+ navigate: NavigateFunction,
+ timestampMs: number
+): void => {
+ const entityFilters = {
+ condition: "AND",
+ criterion: [
+ {
+ attributeName: "__timestamp",
+ operator: "lte",
+ attributeValue: String(timestampMs),
+ type: "date"
+ }
+ ]
+ };
+ navigateToSearch(navigate, "all_entities", { entityFilters });
+};
+
+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/refreshDashboardHome.ts b/dashboard/src/utils/refreshDashboardHome.ts
new file mode 100644
index 000000000..1a2a6571c
--- /dev/null
+++ b/dashboard/src/utils/refreshDashboardHome.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { AppDispatch } from "@redux/store/store";
+import { requestDashboardRefresh } from "@redux/slice/dashboardRefreshSlice";
+import { fetchMetricEntity } from "@redux/slice/metricsSlice";
+
+/**
+ * Soft-refresh dashboard home: bumps subscribers (latest entities, activity,
+ * QuickSearch) and refetches Redux metrics — same net calls as a full reload
+ * for those areas, without remounting the whole app.
+ */
+export const refreshDashboardHomeData = (dispatch: AppDispatch): void => {
+ dispatch(requestDashboardRefresh());
+ dispatch(fetchMetricEntity());
+};
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..c272307bc
--- /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(7);
+ 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..df14ded1d
--- /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={132}
+ label={{
+ value: "Service Type",
+ angle: -90,
+ position: "left",
+ offset: 10,
+ 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..6a12f2ff0
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx
@@ -0,0 +1,260 @@
+/*
+ * 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";
+
+interface EntityItem {
+ guid?: string;
+ typeName?: string;
+ displayText?: string;
+ createTime?: number | Date | string;
+ attributes?: {
+ name?: string;
+ qualifiedName?: string;
+ __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, Date.now());
+ }, [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 name =
+ entity.attributes?.name ||
+ entity.attributes?.qualifiedName ||
+ entity.displayText ||
+ entity.guid ||
+ "Unknown";
+ const typeName = entity.typeName || "Entity";
+ const timestamp = getEntityTimestampRawForDisplay(entity);
+
+ return (
+ <ListItem
+ key={entity.guid || name}
+ 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} flex={1} overflow="hidden">
+ <Link
+ component={RouterLink}
+ to={`/detailPage/${entity.guid}`}
+ underline="hover"
+ color="primary"
+ sx={{
+ fontSize: "0.875rem",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ cursor: "pointer",
+ flexShrink: 0
+ }}
+ >
+ {name}
+ </Link>
+ <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..c9821e667
--- /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: room for Y label, long category names, X title, right count labels */
+export const HORIZONTAL_BAR_CHART_MARGIN = {
+ top: 8,
+ right: 72,
+ left: 112,
+ 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/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/Layout/Header.tsx b/dashboard/src/views/Layout/Header.tsx
index 9e00e7eea..80fc2dc06 100644
--- a/dashboard/src/views/Layout/Header.tsx
+++ b/dashboard/src/views/Layout/Header.tsx
@@ -29,7 +29,13 @@ import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
import AccountCircleRoundedIcon from "@mui/icons-material/AccountCircleRounded";
import { useLocation, useNavigate } from "react-router-dom";
import QuickSearch from "@components/GlobalSearch/QuickSearch";
-import { Logout } from "@mui/icons-material";
+import CreateDropdown from "@components/CreateDropdown";
+import {
+ AdminPanelSettingsOutlined,
+ HelpOutlineOutlined,
+ Logout,
+ SwapHorizOutlined
+} from "@mui/icons-material";
import { useAppSelector } from "@hooks/reducerHook";
import {
Button,
@@ -174,15 +180,17 @@ const Header: React.FC<Header> = ({
</Button>
</LightTooltip>
)}
- {location.pathname != "/" &&
- location.pathname != "/search" &&
- location.pathname != "/!" &&
- (!location.pathname.includes("!") ) && (
+ {location.pathname !== "/" &&
+ location.pathname !== "/search" && (
<div style={{ display: "flex", justifyContent: "center", flex: "1" }}>
<QuickSearch />
</div>
)}
- <div className="header-menu">
+ {(location.pathname === "/" || location.pathname === "/search") && (
+ <div style={{ flex: "1" }} />
+ )}
+ <div className="header-menu" style={{ display: "flex", alignItems: "center", gap: 8 }}>
+ <CreateDropdown />
<LightTooltip title="Downloads">
<IconButton
size="small"
@@ -260,9 +268,11 @@ const Header: React.FC<Header> = ({
}}
data-cy="administrator"
>
+ <AdminPanelSettingsOutlined sx={{ marginRight: "4px" }} fontSize="inherit" />
Administration
</MenuItem>
<MenuItem dense onClick={handleNestedMenuClick} data-cy="help">
+ <HelpOutlineOutlined sx={{ marginRight: "4px" }} fontSize="inherit" />
Help
</MenuItem>
<Divider />
@@ -275,6 +285,7 @@ const Header: React.FC<Header> = ({
handleClose();
}}
>
+ <SwapHorizOutlined sx={{ marginRight: "4px" }} fontSize="inherit" />
Switch to Classic
</MenuItem>
<MenuItem
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/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx
index 65d8861e5..91b5bda09 100644
--- a/dashboard/src/views/SideBar/SideBarBody.tsx
+++ b/dashboard/src/views/SideBar/SideBarBody.tsx
@@ -18,9 +18,11 @@
import { styled } from "@mui/material/styles";
import {
Suspense,
+ useCallback,
useEffect,
useState,
ChangeEvent,
+ KeyboardEvent,
lazy,
useRef,
} from "react";
@@ -50,6 +52,7 @@ import { fetchRootClassification } from "@redux/slice/rootClassificationSlice";
import { fetchTypeHeaderData } from "@redux/slice/typeDefSlices/typeDefHeaderSlice";
import { fetchRootEntity } from "@redux/slice/allEntityTypesSlice";
import { fetchMetricEntity } from "@redux/slice/metricsSlice";
+import { refreshDashboardHomeData } from "@utils/refreshDashboardHome";
import ErrorPage from "@views/ErrorPage";
import AppRoutes from "@views/AppRoutes";
import ErrorBoundaryWithNavigate from "../../ErrorBoundary";
@@ -153,7 +156,28 @@ const SideBarBody = (props: {
dispatch(fetchRootClassification());
dispatch(fetchEnumData());
dispatch(fetchMetricEntity());
- }, []);
+ }, [dispatch]);
+
+ const handleAtlasLogoClick = useCallback(() => {
+ refreshDashboardHomeData(dispatch);
+ navigate(
+ {
+ pathname: "/search",
+ },
+ { replace: true }
+ );
+ }, [dispatch, navigate]);
+
+ const handleAtlasLogoKeyDown = useCallback(
+ (e: KeyboardEvent<HTMLElement>) => {
+ if (e.key !== "Enter" && e.key !== " ") {
+ return;
+ }
+ e.preventDefault();
+ handleAtlasLogoClick();
+ },
+ [handleAtlasLogoClick]
+ );
useEffect(() => {
const draggerElement = draggerRef.current;
@@ -236,14 +260,11 @@ const SideBarBody = (props: {
cursor: "pointer",
boxSizing: "border-box",
}}
- onClick={() => {
- navigate(
- {
- pathname: "/search",
- },
- { replace: true }
- );
- }}
+ role="button"
+ tabIndex={0}
+ aria-label="Atlas home — refresh dashboard"
+ onClick={handleAtlasLogoClick}
+ onKeyDown={handleAtlasLogoKeyDown}
data-cy="apache-atlas-logo-collapsed"
>
<img
@@ -271,20 +292,22 @@ const SideBarBody = (props: {
}}
>
<Stack gap="1.5rem" width="100%" marginTop="1rem">
- <img
- src={atlasLogo}
- alt="Atlas logo"
- onClick={() => {
- navigate(
- {
- pathname: "/search",
- },
- { replace: true }
- );
- }}
- className="header-logo cursor-pointer"
- data-cy="atlas-logo"
- />
+ <span
+ role="button"
+ tabIndex={0}
+ aria-label="Atlas home — refresh dashboard"
+ onClick={handleAtlasLogoClick}
+ onKeyDown={handleAtlasLogoKeyDown}
+ className="inline-block cursor-pointer"
+ >
+ <img
+ src={atlasLogo}
+ alt=""
+ aria-hidden
+ className="header-logo"
+ data-cy="atlas-logo"
+ />
+ </span>
<Paper
sx={{
width: "100%",
@@ -493,7 +516,7 @@ const SideBarBody = (props: {
}),
margin: "0",
overflowX: "auto",
- background: "#f5f5f5",
+ background: "#f5f7f9",
padding: "0",
}}
>
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>
}