This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch ATLAS-5246-v1
in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/ATLAS-5246-v1 by this push:
new ec6b3c907 ATLAS-5247, ATLAS-5248, ATLAS-5249, ATLAS-5250, ATLAS-5252,
ATLAS-5254, ATLAS-5255: ATLAS UI: Dashboard All cards. (#620)
ec6b3c907 is described below
commit ec6b3c907bbe059edb285f27b5af8764730fdc4f
Author: Prasad Pawar <[email protected]>
AuthorDate: Fri May 8 12:59:40 2026 +0530
ATLAS-5247, ATLAS-5248, ATLAS-5249, ATLAS-5250, ATLAS-5252, ATLAS-5254,
ATLAS-5255: ATLAS UI: Dashboard All cards. (#620)
---
dashboard/src/utils/metricsUtils.ts | 241 ++++++++++++
dashboard/src/utils/typeCatalogUtils.ts | 159 ++++++++
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 ++++++++++++++++++++
.../DashboardOverview/MessageConsumptionChart.tsx | 257 +++++++++++++
.../src/views/DashboardOverview/OverviewCard.tsx | 114 ++++++
.../src/views/DashboardOverview/RecentActivity.tsx | 408 +++++++++++++++++++++
.../DashboardOverview/RecentActivitySkeleton.tsx | 37 ++
.../DashboardOverview/dashboardChartPalette.ts | 42 +++
16 files changed, 3055 insertions(+), 54 deletions(-)
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/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/DashBoard.tsx
b/dashboard/src/views/DashBoard.tsx
index 9b8dde430..c0b5a201e 100644
--- a/dashboard/src/views/DashBoard.tsx
+++ b/dashboard/src/views/DashBoard.tsx
@@ -15,65 +15,40 @@
* limitations under the License.
*/
+import { Stack } from "@mui/material";
import QuickSearch from "@components/GlobalSearch/QuickSearch";
-import { CustomButton } from "@components/muiComponents";
+import DashboardOverview from "./DashboardOverview/DashboardOverview";
import { useAppSelector } from "@hooks/reducerHook";
-import { Stack } from "@mui/material";
-import { useState } from "react";
-import EntityForm from "./Entity/EntityForm";
-import AddIcon from "@mui/icons-material/Add";
const DashBoard = () => {
- const { sessionObj = "" }: any = useAppSelector(
- (state: any) => state.session
- );
- const [entityModal, setEntityModal] = useState<boolean>(false);
- const { data } = sessionObj || {};
- const key = "atlas.entity.create.allowed";
- const entityCreate = data?.[key] || "";
+ const dashboardRefreshVersion = useAppSelector((state) =>
state.dashboardRefresh.version);
- const handleOpenEntityModal = () => {
- setEntityModal(true);
- };
- const handleCloseEntityModal = () => {
- setEntityModal(false);
- };
- return (
- <Stack
- alignItems="flex-start"
- justifyContent="space-between"
- position="relative"
- height="100%"
- flex="1"
- padding="0"
- >
- <Stack direction="row" justifyContent="flex-end" width={"100%"}>
- {entityCreate && (
- <CustomButton
- variant="contained"
- size="small"
- onClick={(_e: any) => handleOpenEntityModal()}
- startIcon={<AddIcon />}
- >
- Create Entity
- </CustomButton>
- )}
- </Stack>
- <Stack
- justifyContent="center"
- flex="1"
- alignItems={"center"}
- height={"100%"}
- width={"100%"}
- className="dashboard-quick-search"
- >
- <QuickSearch />
- </Stack>
- {entityModal && (
- <EntityForm open={entityModal} onClose={handleCloseEntityModal} />
- )}
- </Stack>
- );
+ return (
+ <Stack
+ width="100%"
+ maxWidth="100%"
+ alignItems="stretch"
+ justifyContent="flex-start"
+ position="relative"
+ height="100%"
+ flex="1"
+ padding={0}
+ spacing={2}
+ sx={{ boxSizing: "border-box", overflow: "hidden" }}
+ >
+ <Stack
+ direction="row"
+ width="100%"
+ justifyContent="center"
+ sx={{ mb: 2, flexShrink: 0 }}
+ >
+ <QuickSearch key={dashboardRefreshVersion} />
+ </Stack>
+ <Stack width="100%" flex={1} sx={{ minWidth: 0 }}>
+ <DashboardOverview />
+ </Stack>
+ </Stack>
+ );
};
export default DashBoard;
diff --git a/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
new file mode 100644
index 000000000..21fc18588
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
@@ -0,0 +1,394 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { memo, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Paper,
+ Stack,
+ Typography,
+ Box,
+ List,
+ ListItem,
+ Link,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import CustomModal from "@components/Modal";
+import { numberFormatWithComma } from "@utils/Helper";
+import {
+ getClassificationTypesInUseCount,
+ getUnusedClassificationNames,
+} from "@utils/metricsUtils";
+import { navigateToTaggedSearch } from "@utils/dashboardSearchUtils";
+import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
+import { fetchClassificationData } from
"@redux/slice/typeDefSlices/typedefClassificationSlice";
+
+interface ClassificationCoverageProps {
+ /** Defined classification types in the type system (from
general.tagCount) */
+ classificationTypeDefinitions: number;
+ tag: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const ClassificationCoverage = memo(
+ ({
+ classificationTypeDefinitions,
+ tag,
+ isLoading,
+ }: ClassificationCoverageProps) => {
+ const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const { classificationData, loadingClassification } =
useAppSelector(
+ (state: { classification?: { classificationData?: {
classificationDefs?: { name: string }[] } | null; loadingClassification?:
boolean } }) =>
+ state.classification ?? {}
+ );
+
+ useEffect(() => {
+ if (!classificationData && !loadingClassification) {
+ void dispatch(fetchClassificationData());
+ }
+ }, [classificationData, loadingClassification, dispatch]);
+
+ const definedNames = useMemo(() => {
+ const defs =
+ classificationData?.classificationDefs as {
name?: string }[] | undefined;
+ if (!Array.isArray(defs)) return [];
+ return defs.map((d) => d.name).filter((n): n is string
=> Boolean(n));
+ }, [classificationData]);
+
+ const typesInUse = useMemo(
+ () => getClassificationTypesInUseCount(tag),
+ [tag]
+ );
+ const unusedNames = useMemo(
+ () => getUnusedClassificationNames(definedNames, tag),
+ [definedNames, tag]
+ );
+ const unusedCount = unusedNames.length;
+
+ const typeUsageProgress =
+ classificationTypeDefinitions > 0
+ ? (typesInUse / classificationTypeDefinitions)
* 100
+ : 0;
+ const usagePercentRounded = Math.min(
+ 100,
+ Math.round(Number.isFinite(typeUsageProgress) ?
typeUsageProgress : 0)
+ );
+
+ const [unusedModalOpen, setUnusedModalOpen] = useState(false);
+ const [unusedListSearchQuery, setUnusedListSearchQuery] =
useState("");
+
+ const handleClassificationsClick = useCallback(() => {
+ navigateToTaggedSearch(navigate);
+ }, [navigate]);
+
+ const handleOpenUnusedModal = useCallback(() => {
+ if (unusedCount > 0 && definedNames.length > 0) {
+ setUnusedListSearchQuery("");
+ setUnusedModalOpen(true);
+ }
+ }, [unusedCount, definedNames.length]);
+
+ const handleCloseUnusedModal = useCallback(() => {
+ setUnusedListSearchQuery("");
+ setUnusedModalOpen(false);
+ }, []);
+
+ const handleUnusedBarSegmentClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ handleOpenUnusedModal();
+ },
+ [handleOpenUnusedModal]
+ );
+
+ const unusedNamesFiltered = useMemo(() => {
+ const q = unusedListSearchQuery.trim().toLowerCase();
+ if (!q) return unusedNames;
+ return unusedNames.filter((name) =>
name.toLowerCase().includes(q));
+ }, [unusedNames, unusedListSearchQuery]);
+
+ const showUnusedListSearch = unusedNames.length > 5;
+
+ if (isLoading) return null;
+
+ const usedBarPct = Math.min(Math.max(typeUsageProgress, 0),
100);
+ const defsReady = definedNames.length > 0 ||
!!classificationData;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem",
fontWeight: 600, color: "#1a1a1a" }}>
+ Classification Types In Use
+ </Typography>
+ </Box>
+ <Stack spacing={1.5} sx={{ pt: 2 }}>
+ <Stack
+ direction="row"
+ justifyContent="space-between"
+ alignItems="center"
+ sx={{ width: "100%" }}
+ >
+ <Typography
+ sx={{
+ fontSize:
"0.875rem",
+ fontWeight: 600,
+ color:
"#1a1a1a",
+ }}
+ >
+ Classifications in use
+ </Typography>
+ <Typography
+ sx={{
+ fontSize:
"0.875rem",
+ color:
"#868e96",
+ }}
+ >
+ {usagePercentRounded}%
+ </Typography>
+ </Stack>
+ <Stack
+ direction="row"
+ sx={{
+ height: 10,
+ borderRadius: 5,
+ overflow: "hidden",
+ width: "100%",
+ backgroundColor:
"#e9ecef",
+ }}
+ aria-label="Classification
usage: green is in use, grey is not in use"
+ >
+ {usedBarPct > 0 ? (
+ <Box
+
component="button"
+ type="button"
+
onClick={handleClassificationsClick}
+ sx={{
+ width:
`${usedBarPct}%`,
+
minWidth: 0,
+ border:
"none",
+
padding: 0,
+ cursor:
"pointer",
+
backgroundColor: "#16a34a",
+ }}
+
aria-label="Open classification search for types in use"
+ />
+ ) : null}
+ <Box
+ component="button"
+ type="button"
+
onClick={handleUnusedBarSegmentClick}
+ disabled={unusedCount
=== 0 || !defsReady}
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ border: "none",
+ padding: 0,
+ cursor:
+
unusedCount > 0 && defsReady ? "pointer" : "default",
+
backgroundColor: "#e9ecef",
+ }}
+ aria-label={
+ unusedCount > 0
&& defsReady
+ ? `View
${unusedCount} classification types not in use`
+ : "No
unused classification types to show"
+ }
+ />
+ </Stack>
+ {defsReady && (
+ <Typography
+ component="p"
+ sx={{
+ fontSize:
"0.875rem",
+ color:
"#6c757d",
+ m: 0,
+ }}
+ >
+ <strong>Not in
use:</strong>{" "}
+ {unusedCount > 0 ? (
+ <Link
+
component="button"
+
type="button"
+
onClick={handleOpenUnusedModal}
+ sx={{
+
fontSize: "0.875rem",
+
color: "primary.main",
+
cursor: "pointer",
+
textDecoration: "underline",
+
background: "none",
+
border: "none",
+
padding: 0,
+
verticalAlign: "baseline",
+
fontFamily: "inherit",
+ }}
+
aria-label={`Show ${unusedCount} classification types not in use`}
+ >
+
{numberFormatWithComma(unusedCount)}{" "}
+
{unusedCount === 1
+
? "classification type"
+
: "classification types"}
+ </Link>
+ ) : (
+ <>
+
{numberFormatWithComma(unusedCount)}{" "}
+
{classificationTypeDefinitions > 0
+
? unusedCount === 1
+
? "classification type"
+
: "classification types"
+
: ""}
+ </>
+ )}
+ </Typography>
+ )}
+ {!defsReady && loadingClassification ? (
+ <Typography variant="caption"
sx={{ color: "#868e96" }}>
+ Loading classification
definitions…
+ </Typography>
+ ) : null}
+ <Typography
+ component="button"
+ type="button"
+
onClick={handleClassificationsClick}
+ sx={{
+ fontSize: "0.875rem",
+ color: "#6c757d",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ textAlign: "left",
+ "&:hover": { color:
"primary.main", textDecoration: "underline" },
+ }}
+ aria-label="Open classification
search"
+ >
+
{numberFormatWithComma(typesInUse)} of{" "}
+
{numberFormatWithComma(classificationTypeDefinitions)}
+ classification types are in use
(have at least one entity).
+ </Typography>
+ </Stack>
+
+ <CustomModal
+ open={unusedModalOpen}
+ onClose={handleCloseUnusedModal}
+ title="Classification types not in use"
+ maxWidth="sm"
+ button1Label="Close"
+ button1Handler={handleCloseUnusedModal}
+ button2Label=""
+ button2Handler={handleCloseUnusedModal}
+ hideButton2={true}
+ >
+ <Stack spacing={2} sx={{ pt: 0.5 }}>
+ <Typography
+ variant="body2"
+ sx={{ color: "#6c757d",
lineHeight: 1.5 }}
+ >
+ These types are defined
in Atlas but have no entity assignments in
+ the current metrics
snapshot.
+ </Typography>
+ {showUnusedListSearch ? (
+ <TextField
+ size="small"
+ fullWidth
+
placeholder="Search classification name"
+
value={unusedListSearchQuery}
+ onChange={(e)
=> setUnusedListSearchQuery(e.target.value)}
+ inputProps={{
+
"aria-label": "Filter unused classifications by name",
+ }}
+ InputProps={{
+
startAdornment: (
+
<InputAdornment position="start">
+
<SearchIcon
+
sx={{ color: "#868e96", fontSize: "1.25rem" }}
+
aria-hidden
+
/>
+
</InputAdornment>
+ ),
+ }}
+ />
+ ) : null}
+ <Box
+ sx={{
+ maxHeight:
"min(45vh, 320px)",
+ overflowY:
"auto",
+ }}
+ >
+
{unusedNamesFiltered.length === 0 ? (
+ <Typography
variant="body2" color="text.secondary" sx={{ py: 2 }}>
+
{unusedListSearchQuery.trim()
+
? "No classifications match your search."
+
: "No unused classifications to list."}
+ </Typography>
+ ) : (
+ <List
disablePadding>
+
{unusedNamesFiltered.map((name) => (
+
<ListItem
+
key={name}
+
disablePadding
+
sx={{
+
py: 1.25,
+
borderBottom: "1px solid",
+
borderColor: "divider",
+
"&:last-child": { borderBottom: "none" },
+
}}
+
>
+
<Link
+
component={RouterLink}
+
to={{
+
pathname:
`/tag/tagAttribute/${encodeURIComponent(name)}`,
+
search: new URLSearchParams({ tag: name }).toString(),
+
}}
+
onClick={handleCloseUnusedModal}
+
underline="hover"
+
color="primary"
+
sx={{
+
fontSize: "0.875rem",
+
fontWeight: 500,
+
cursor: "pointer",
+
}}
+
>
+
{name}
+
</Link>
+
</ListItem>
+ ))}
+ </List>
+ )}
+ </Box>
+ </Stack>
+ </CustomModal>
+ </Paper>
+ );
+ }
+);
+
+ClassificationCoverage.displayName = "ClassificationCoverage";
+
+export default ClassificationCoverage;
diff --git
a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx
b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx
new file mode 100644
index 000000000..6da4704c7
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { memo, useCallback, useMemo } from "react";
+import { Paper, Stack, Typography, Box, Link } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList
+} from "recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import {
+ getClassificationDistribution,
+ getTagEntityAssociationTotal,
+} from "@utils/metricsUtils";
+import { navigateToSearch, navigateToClassificationSearch } from
"@utils/dashboardSearchUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ CLASSIFICATION_DISTRIBUTION_CHART_MARGIN,
+} from "./dashboardChartPalette";
+
+const BAR_COLOR = CHART_BAR_ACTIVE_BLUE;
+
+interface ClassificationDistributionCardProps {
+ tag: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const ClassificationDistributionCard = memo(({ tag, isLoading }:
ClassificationDistributionCardProps) => {
+ const navigate = useNavigate();
+ const data = getClassificationDistribution(tag, 5);
+ const associationTotal = useMemo(() =>
getTagEntityAssociationTotal(tag), [tag]);
+
+ const handleBarClick = useCallback(
+ (entry: { name: string }) => {
+ navigateToClassificationSearch(navigate, entry.name);
+ },
+ [navigate]
+ );
+
+ const handleViewAll = useCallback(() => {
+ navigateToSearch(navigate, "all_classifications");
+ }, [navigate]);
+
+ const handleLabelClick = useCallback(
+ (tagName: string) => {
+ navigateToClassificationSearch(navigate, tagName);
+ },
+ [navigate]
+ );
+
+ const renderTooltip = useCallback((props: unknown) => {
+ const p = props as { active?: boolean; payload?: Array<{
payload?: { name: string; count: number } }> };
+ if (!p?.active || !p?.payload?.length) return null;
+ const row = p.payload[0]?.payload;
+ if (!row) return null;
+ return (
+ <Box sx={{ p: 1.5, bgcolor: "background.paper",
borderRadius: 1, boxShadow: 2, minWidth: 140 }}>
+ <Typography variant="body2" fontWeight={600}
sx={{ mb: 0.5 }}>
+ {row.name}
+ </Typography>
+ <Typography variant="caption" display="block">
+ Entities:
{numberFormatWithComma(row.count)}
+ </Typography>
+ </Box>
+ );
+ }, []);
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Stack direction="row"
justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem",
fontWeight: 600, color: "#1a1a1a" }}>
+ Classification Distribution
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{ fontSize: "0.875rem",
cursor: "pointer", textDecoration: "none", color: "primary.main" }}
+ aria-label="View all
classifications"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ <Typography variant="body2" sx={{ color: "#6c757d", mt:
2, lineHeight: 1.4 }}>
+ <strong>Tag–entity associations
(total):</strong>{" "}
+ {numberFormatWithComma(associationTotal)}
+ </Typography>
+ <Typography variant="caption" sx={{ color: "#868e96",
display: "block", mt: 0.75, lineHeight: 1.4 }}>
+ The chart shows the top 5 classifications by
number of entities in use.
+ </Typography>
+ {data.length === 0 ? (
+ <Stack alignItems="center"
justifyContent="center" height={200}>
+ <Typography variant="body2"
color="text.secondary">
+ No classification data available
+ </Typography>
+ </Stack>
+ ) : (
+ <Box sx={{ mt: 2, minHeight: 260, height: 260,
width: "100%", minWidth: 280 }}>
+ <ResponsiveContainer width="100%"
height="100%" style={{ cursor: "pointer" }}>
+ <BarChart
+ data={data}
+ layout="vertical"
+ margin={{
...CLASSIFICATION_DISTRIBUTION_CHART_MARGIN }}
+ >
+ <CartesianGrid
strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+ type="number"
+
tickFormatter={(v) => numberFormatWithComma(v)}
+ height={36}
+ label={{
+ value:
"Entity Count",
+
position: "bottom",
+ offset:
12,
+ style:
{ fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+ type="category"
+ dataKey="name"
+ width={52}
+ label={{
+ value:
"Classification",
+ angle:
-90,
+
position: "left",
+ offset:
2,
+ style:
{ fontSize: 10, fill: "#6c757d", textAnchor: "middle" },
+ }}
+ tick={(props:
Record<string, unknown>) => {
+ const {
x = 0, y = 0, payload } = props;
+ const p
= payload as { value?: string; name?: string } | undefined;
+ const
value = p?.value ?? p?.name ?? (typeof payload === "string" ? payload : "");
+ return (
+
<g
+
transform={`translate(${x},${y})`}
+
onClick={() => (value ? handleLabelClick(value) : undefined)}
+
style={{ cursor: value ? "pointer" : "default" }}
+
role={value ? "button" : undefined}
+
tabIndex={value ? 0 : undefined}
+
onKeyDown={
+
value
+
? (e: React.KeyboardEvent<SVGGElement>) => {
+
if (e.key === "Enter" || e.key === " ")
{
+
e.preventDefault();
+
handleLabelClick(value);
+
}
+
}
+
: undefined
+
}
+
>
+
<text x={0} y={0} dy={4} textAnchor="end" fill="#333" fontSize={12}>
+
{value}
+
</text>
+
</g>
+ );
+ }}
+ />
+ <Tooltip
content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+ dataKey="count"
+ name="Entities"
+ fill={BAR_COLOR}
+ radius={[0, 4,
4, 0]}
+
onClick={(entry) => handleBarClick(entry)}
+ cursor="pointer"
+ >
+ <LabelList
+
dataKey="count"
+
position="right"
+
offset={10}
+
formatter={(v: number) => numberFormatWithComma(v)}
+ style={{
+
fontSize: 12,
+
fontWeight: 500,
+
fill: BAR_COLOR,
+ }}
+ />
+ {data.map((_,
index) => <Cell key={index} fill={BAR_COLOR} />)}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ )}
+ </Paper>
+ );
+});
+
+ClassificationDistributionCard.displayName = "ClassificationDistributionCard";
+
+export default ClassificationDistributionCard;
diff --git a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx
b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx
new file mode 100644
index 000000000..c7bfbd551
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useState, useMemo, useCallback } from "react";
+import { Grid, Stack } from "@mui/material";
+import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
+import { fetchTypeHeaderData } from
"@redux/slice/typeDefSlices/typeDefHeaderSlice";
+import { getLatestEntities } from "@api/apiMethods/searchApiMethod";
+import type { TypeHeaderInterface } from "@models/entityTreeType";
+import DashboardSkeleton from "./DashboardSkeleton";
+import EntityTypeBarChartSkeleton from "./EntityTypeBarChartSkeleton";
+import LatestEntitiesSkeleton from "./LatestEntitiesSkeleton";
+import OverviewCard from "./OverviewCard";
+import EntityStatusDonut from "./EntityStatusDonut";
+import ClassificationCoverage from "./ClassificationCoverage";
+import EntityTypeBarChart from "./EntityTypeBarChart";
+import LatestEntitiesList from "./LatestEntitiesList";
+import RecentActivity from "./RecentActivity";
+import KafkaTopicSummaryCard from "./KafkaTopicSummaryCard";
+import ClassificationDistributionCard from "./ClassificationDistributionCard";
+
+const DashboardOverview = () => {
+ const dispatch = useAppDispatch();
+ const { metricsData, loading: metricsLoading } = useAppSelector((state:
{ metrics: { metricsData: unknown; loading: boolean } }) => state.metrics);
+ const typeHeaderData = useAppSelector(
+ (state: { typeHeader?: { typeHeaderData?: TypeHeaderInterface[]
| null } }) =>
+ state.typeHeader?.typeHeaderData ?? null
+ );
+ const dashboardRefreshVersion = useAppSelector((state) =>
state.dashboardRefresh.version);
+ const [latestEntities, setLatestEntities] = useState<unknown[]>([]);
+ const [latestLoading, setLatestLoading] = useState(true);
+ const [latestError, setLatestError] = useState<string | null>(null);
+
+ const metrics = metricsData as {
+ data?: {
+ general?: { entityCount?: number; tagCount?: number;
stats?: Record<string, unknown> };
+ entity?: Record<string, unknown>;
+ tag?: Record<string, unknown>;
+ };
+ } | null;
+ const general = metrics?.data?.general;
+ const entity = metrics?.data?.entity;
+ const tag = metrics?.data?.tag;
+ const stats = general?.stats;
+ const entityCount = general?.entityCount ?? 0;
+ const tagCount = general?.tagCount ?? 0;
+
+ const isLoading = metricsLoading;
+
+ const fetchLatestEntities = useCallback(async () => {
+ setLatestLoading(true);
+ setLatestError(null);
+ try {
+ const resp = await getLatestEntities();
+ const entities = (resp as { data?: { entities?:
unknown[] } })?.data?.entities ?? [];
+ setLatestEntities(Array.isArray(entities) ? entities :
[]);
+ } catch (err) {
+ setLatestError(err instanceof Error ? err.message :
"Failed to load latest entities");
+ setLatestEntities([]);
+ } finally {
+ setLatestLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchLatestEntities();
+ }, [dashboardRefreshVersion, fetchLatestEntities]);
+
+ useEffect(() => {
+ dispatch(fetchTypeHeaderData());
+ }, [dispatch]);
+
+ const latestEntitiesList = useMemo(() => {
+ if (!Array.isArray(latestEntities)) return [];
+ return latestEntities.slice(0, 7) as { guid?: string;
typeName?: string; attributes?: { name?: string; qualifiedName?: string;
__timestamp?: number } }[];
+ }, [latestEntities]);
+
+ return (
+ <Stack
+ spacing={3}
+ width="100%"
+ sx={{
+ maxWidth: "100%",
+ boxSizing: "border-box",
+ backgroundColor: "#f5f7f9",
+ padding: 3,
+ borderRadius: 2
+ }}
+ >
+ <Grid container spacing={3} sx={{ width: "100%",
alignItems: "stretch" }}>
+ <Grid item xs={12} md={4}>
+ {isLoading ? <DashboardSkeleton /> :
<OverviewCard entityCount={entityCount} tagCount={tagCount} />}
+ </Grid>
+ <Grid item xs={12} md={4}>
+ {isLoading ? <DashboardSkeleton /> :
<EntityStatusDonut entity={entity} />}
+ </Grid>
+ <Grid item xs={12} md={4}>
+ {isLoading ? (
+ <DashboardSkeleton />
+ ) : (
+ <ClassificationCoverage
+
classificationTypeDefinitions={tagCount}
+ tag={tag}
+ />
+ )}
+ </Grid>
+ <Grid item xs={12} md={8} sx={{ display:
"flex", minWidth: 0 }}>
+ {isLoading ? (
+ <EntityTypeBarChartSkeleton />
+ ) : (
+ <EntityTypeBarChart
entity={entity} typeHeaderData={typeHeaderData} />
+ )}
+ </Grid>
+ <Grid item xs={12} md={4} sx={{ display:
"flex", minWidth: 0 }}>
+ {latestLoading ? (
+ <LatestEntitiesSkeleton />
+ ) : (
+ <LatestEntitiesList
entities={latestEntitiesList} error={latestError} />
+ )}
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display:
"flex", minWidth: 0 }}>
+ <RecentActivity />
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display:
"flex", minWidth: 0 }}>
+ {isLoading ?
<EntityTypeBarChartSkeleton /> : <ClassificationDistributionCard tag={tag}
isLoading={isLoading} />}
+ </Grid>
+ <Grid item xs={12} md={12} sx={{ display:
"flex", minWidth: 0 }}>
+ {isLoading ?
<EntityTypeBarChartSkeleton /> : <KafkaTopicSummaryCard stats={stats}
isLoading={isLoading} />}
+ </Grid>
+ </Grid>
+ </Stack>
+ );
+};
+
+export default DashboardOverview;
diff --git a/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx
b/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx
new file mode 100644
index 000000000..b37ec59d4
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/DashboardSkeleton.tsx
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Paper, Stack } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+const DashboardSkeleton = () => (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Stack spacing={1}>
+ <SkeletonLoader animation="wave" variant="text"
count={1} width="40%" height={28} />
+ <SkeletonLoader animation="wave" variant="rectangular"
count={1} height={60} />
+ </Stack>
+ </Paper>
+);
+
+export default DashboardSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx
b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx
new file mode 100644
index 000000000..9eb93dc1e
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { memo, useCallback, useState } from "react";
+import { Paper, Stack, Typography, Box } from "@mui/material";
+import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Sector } from
"recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import { getEntityStatusTotals } from "@utils/metricsUtils";
+import { navigateToSearch } from "@utils/dashboardSearchUtils";
+import { ENTITY_STATUS_DONUT_COLORS as COLORS } from "./dashboardChartPalette";
+
+interface EntityStatusDonutProps {
+ entity: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const EntityStatusDonut = memo(({ entity, isLoading }: EntityStatusDonutProps)
=> {
+ const [activeIndex, setActiveIndex] = useState<number>(-1);
+ const navigate = useNavigate();
+ const totals = getEntityStatusTotals(entity);
+ const total = totals.active + totals.shell + totals.deleted;
+
+ const chartData = [
+ { name: "Active", value: totals.active, color: COLORS.Active },
+ { name: "Shell", value: totals.shell, color: COLORS.Shell },
+ { name: "Deleted", value: totals.deleted, color: COLORS.Deleted
}
+ ].filter((d) => d.value > 0);
+
+ const getPercent = (val: number) => (total > 0 ? Math.round((val /
total) * 100) : 0);
+
+ const handleStatusClick = useCallback(
+ (status: "Active" | "Shell" | "Deleted") => {
+ if (status === "Active") {
+ navigateToSearch(navigate, "entity_status");
+ } else if (status === "Deleted") {
+ navigateToSearch(navigate, "entity_status", {
+ includeDE: true,
+ entityFilters: {
+ condition: "AND",
+ criterion: [{ attributeName:
"__state", operator: "eq", attributeValue: "DELETED" }]
+ }
+ });
+ } else if (status === "Shell") {
+ navigateToSearch(navigate, "entity_status", {
+ entityFilters: {
+ condition: "AND",
+ criterion: [{ attributeName:
"__isIncomplete", operator: "eq", attributeValue: "true" }]
+ }
+ });
+ }
+ },
+ [navigate]
+ );
+
+ if (isLoading) return null;
+
+ const renderActiveShape = (props: unknown) => {
+ const p = props as { outerRadius?: number; innerRadius?:
number; [k: string]: unknown };
+ return (
+ <Sector
+ {...p}
+ outerRadius={(p.outerRadius ?? 60) * 1.08}
+ innerRadius={p.innerRadius ?? 40}
+ />
+ );
+ };
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight:
600, color: "#1a1a1a" }}>
+ Entity Status Overview
+ </Typography>
+ </Box>
+ <Stack direction="row" spacing={2} alignItems="center"
height={160} sx={{ pt: 2 }}>
+ <Stack spacing={1.5} flex={1}>
+ {(["Active", "Shell", "Deleted"] as
const).map((status) => (
+ <Box
+ key={status}
+ component="button"
+ type="button"
+ onClick={() =>
handleStatusClick(status)}
+ aria-label={`View
${status} entities`}
+ sx={{
+ display: "flex",
+ alignItems:
"center",
+ gap: 1.5,
+ cursor:
"pointer",
+ background:
"none",
+ border: "none",
+ padding: 0,
+ margin: 0,
+ font: "inherit",
+ textAlign:
"left",
+ "&:hover": {
opacity: 0.85 }
+ }}
+ >
+ <Box
+ sx={{
+ width:
12,
+ height:
12,
+
borderRadius: "50%",
+
backgroundColor: COLORS[status],
+
flexShrink: 0
+ }}
+ />
+ <Typography
component="span" sx={{ fontSize: "0.875rem", color: "#374151" }}>
+ {status}
{getPercent(totals[status.toLowerCase() as keyof typeof totals])}%
+ </Typography>
+ </Box>
+ ))}
+ </Stack>
+ <ResponsiveContainer width="50%" height="100%"
style={{ cursor: "pointer" }}>
+ <PieChart>
+ <Pie
+ data={chartData}
+ cx="50%"
+ cy="50%"
+ innerRadius={40}
+ outerRadius={60}
+ paddingAngle={2}
+ dataKey="value"
+ isAnimationActive
+ animationDuration={800}
+
animationEasing="ease-out"
+
activeIndex={activeIndex}
+
activeShape={renderActiveShape}
+ onMouseEnter={(_,
index) => setActiveIndex(index)}
+ onMouseLeave={() =>
setActiveIndex(-1)}
+ onClick={(data) =>
handleStatusClick(data.name as "Active" | "Shell" | "Deleted")}
+ >
+ {chartData.map((entry,
index) => (
+ <Cell
key={`cell-${index}`} fill={entry.color} stroke="none" />
+ ))}
+ </Pie>
+ <Tooltip
+ formatter={(value:
number) => numberFormatWithComma(value)}
+ contentStyle={{
borderRadius: 8 }}
+ />
+ </PieChart>
+ </ResponsiveContainer>
+ </Stack>
+ </Paper>
+ );
+});
+
+EntityStatusDonut.displayName = "EntityStatusDonut";
+
+export default EntityStatusDonut;
diff --git a/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx
b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx
new file mode 100644
index 000000000..433cf228f
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { memo, useCallback, useMemo } from "react";
+import { Paper, Stack, Typography, Link, Box } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList,
+} from "recharts";
+import { useNavigate } from "react-router-dom";
+import { numberFormatWithComma } from "@utils/Helper";
+import type { EntityTypeDistributionItem } from "@utils/metricsUtils";
+import {
+ getServiceTypeDistribution,
+ type TypeHeaderCatalogRow,
+} from "@utils/typeCatalogUtils";
+import {
+ navigateToSearch,
+ navigateToServiceTypeEntitySearch,
+} from "@utils/dashboardSearchUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+ HORIZONTAL_BAR_CHART_MARGIN,
+} from "./dashboardChartPalette";
+
+const ACTIVE_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETED_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface EntityTypeBarChartProps {
+ entity: Record<string, unknown> | undefined;
+ typeHeaderData?: TypeHeaderCatalogRow[] | null;
+ isLoading?: boolean;
+}
+
+const payloadFromBarEvent = (item: unknown): EntityTypeDistributionItem |
undefined => {
+ if (!item || typeof item !== "object") return undefined;
+ const rec = item as { payload?: EntityTypeDistributionItem };
+ return rec.payload;
+};
+
+const EntityTypeBarChart = memo(
+ ({ entity, typeHeaderData, isLoading }: EntityTypeBarChartProps) => {
+ const navigate = useNavigate();
+ const data = useMemo(
+ () => getServiceTypeDistribution(entity,
typeHeaderData, 5),
+ [entity, typeHeaderData]
+ );
+
+ const navigateForRow = useCallback(
+ (row: EntityTypeDistributionItem, includeDeleted:
boolean) => {
+ const targets =
+ row.underlyingTypeNames?.length &&
row.underlyingTypeNames.length > 0
+ ? row.underlyingTypeNames
+ : [row.name];
+ navigateToServiceTypeEntitySearch(navigate,
targets, includeDeleted);
+ },
+ [navigate]
+ );
+
+ const handleActiveBarClick = useCallback(
+ (barProps: unknown) => {
+ const row = payloadFromBarEvent(barProps);
+ if (row) navigateForRow(row, false);
+ },
+ [navigateForRow]
+ );
+
+ const handleDeletedBarClick = useCallback(
+ (barProps: unknown) => {
+ const row = payloadFromBarEvent(barProps);
+ if (row) navigateForRow(row, true);
+ },
+ [navigateForRow]
+ );
+
+ const handleViewAll = useCallback(() => {
+ navigateToSearch(navigate, "all_entities");
+ }, [navigate]);
+
+ const handleLabelClick = useCallback(
+ (serviceLabel: string) => {
+ const row = data.find((d) => d.name ===
serviceLabel);
+ if (row) navigateForRow(row, false);
+ },
+ [data, navigateForRow]
+ );
+
+ const renderTooltip = useCallback((props: unknown) => {
+ const p = props as {
+ active?: boolean;
+ payload?: Array<{
+ payload?: EntityTypeDistributionItem;
+ }>;
+ };
+ if (!p?.active || !p?.payload?.length) return null;
+ const row = p.payload[0]?.payload;
+ if (!row) return null;
+ return (
+ <Box sx={{ p: 1.5, bgcolor: "background.paper",
borderRadius: 1, boxShadow: 2, minWidth: 140 }}>
+ <Typography variant="body2"
fontWeight={600} sx={{ mb: 0.5 }}>
+ {row.name}
+ </Typography>
+ <Typography variant="caption"
display="block" color="primary">
+ Active:
{numberFormatWithComma(row.active)}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: DELETED_COLOR }}>
+ Deleted:
{numberFormatWithComma(row.deleted)}
+ </Typography>
+ <Typography variant="caption"
display="block" fontWeight={600}>
+ Total:
{numberFormatWithComma(row.count)}
+ </Typography>
+ </Box>
+ );
+ }, []);
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Stack direction="row"
justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize:
"1rem", fontWeight: 600, color: "#1a1a1a" }}>
+ Service Type
Distribution
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{
+ fontSize:
"0.875rem",
+ cursor:
"pointer",
+ textDecoration:
"none",
+ color:
"primary.main",
+ }}
+ aria-label="View all
entities"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ {data.length === 0 ? (
+ <Stack alignItems="center"
justifyContent="center" height={200}>
+ <Typography variant="body2"
color="text.secondary">
+ No service type data
available
+ </Typography>
+ </Stack>
+ ) : (
+ <Box sx={{ mt: 2, minHeight: 260,
height: 260, width: "100%", minWidth: 280 }}>
+ <Stack direction="row"
spacing={2} sx={{ mb: 1, flexWrap: "wrap" }} aria-label="Chart legend">
+ <Stack direction="row"
alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+
width: 10,
+
height: 10,
+
borderRadius: "50%",
+
backgroundColor: ACTIVE_COLOR,
+ }}
+
aria-hidden
+ />
+ <Typography
variant="caption" sx={{ color: "#6c757d", fontSize: "0.8125rem" }}>
+ Active
+ </Typography>
+ </Stack>
+ <Stack direction="row"
alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+
width: 10,
+
height: 10,
+
borderRadius: "50%",
+
backgroundColor: DELETED_COLOR,
+ }}
+
aria-hidden
+ />
+ <Typography
variant="caption" sx={{ color: "#6c757d", fontSize: "0.8125rem" }}>
+ Deleted
+ </Typography>
+ </Stack>
+ </Stack>
+ <ResponsiveContainer
width="100%" height="100%" style={{ cursor: "pointer" }}>
+ <BarChart data={data}
layout="vertical" margin={{ ...HORIZONTAL_BAR_CHART_MARGIN }}>
+ <CartesianGrid
strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+
type="number"
+
tickFormatter={(v) => numberFormatWithComma(v)}
+
height={36}
+ label={{
+
value: "Entity Count",
+
position: "bottom",
+
offset: 12,
+
style: { fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+
type="category"
+
dataKey="name"
+
width={88}
+ label={{
+
value: "Service Type",
+
angle: -90,
+
position: "left",
+
offset: 4,
+
style: { fontSize: 11, fill: "#6c757d", textAnchor: "middle" },
+ }}
+
tick={(props: Record<string, unknown>) => {
+
const { x = 0, y = 0, payload } = props;
+
const p = payload as { value?: string; name?: string } | undefined;
+
const value =
+
p?.value ??
+
p?.name ??
+
(typeof payload === "string" ? payload : "");
+
return (
+
<g
+
transform={`translate(${x},${y})`}
+
onClick={() => (value ? handleLabelClick(value) : undefined)}
+
style={{ cursor: value ? "pointer" : "default" }}
+
role={value ? "button" : undefined}
+
tabIndex={value ? 0 : undefined}
+
onKeyDown={
+
value
+
? (e: React.KeyboardEvent<SVGGElement>) => {
+
if (e.key === "Enter" || e.key
=== " ") {
+
e.preventDefault();
+
handleLabelClick(value);
+
}
+
}
+
: undefined
+
}
+
>
+
<text x={0} y={0} dy={4} textAnchor="end" fill="#333"
fontSize={12}>
+
{value}
+
</text>
+
</g>
+
);
+ }}
+ />
+ <Tooltip
content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+
dataKey="active"
+
name="Active"
+
stackId="a"
+
fill={ACTIVE_COLOR}
+
radius={[0, 0, 0, 0]}
+
isAnimationActive
+
animationDuration={800}
+
animationEasing="ease-out"
+
onClick={handleActiveBarClick}
+
cursor="pointer"
+
activeBar={{ fill: ACTIVE_COLOR }}
+ >
+
{data.map((_, index) => (
+
<Cell key={`active-${index}`} fill={ACTIVE_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+
dataKey="deleted"
+
name="Deleted"
+
stackId="a"
+
fill={DELETED_COLOR}
+
radius={[0, 4, 4, 0]}
+
isAnimationActive
+
animationDuration={800}
+
animationEasing="ease-out"
+
onClick={handleDeletedBarClick}
+
cursor="pointer"
+
activeBar={{ fill: DELETED_COLOR }}
+ >
+
<LabelList
+
dataKey="count"
+
position="right"
+
offset={10}
+
formatter={(v: number) => numberFormatWithComma(v)}
+
style={{
+
fontSize: 12,
+
fontWeight: 500,
+
fill: ACTIVE_COLOR,
+
}}
+ />
+
{data.map((_, index) => (
+
<Cell key={`deleted-${index}`} fill={DELETED_COLOR} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ )}
+ </Paper>
+ );
+ }
+);
+
+EntityTypeBarChart.displayName = "EntityTypeBarChart";
+
+export default EntityTypeBarChart;
diff --git
a/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx
b/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx
new file mode 100644
index 000000000..3f2950b0b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/EntityTypeBarChartSkeleton.tsx
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Paper, Stack, Box } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+const EntityTypeBarChartSkeleton = () => (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 340,
+ minWidth: 0,
+ width: "100%",
+ flex: 1,
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor:
"divider" }}>
+ <Stack direction="row" justifyContent="space-between"
alignItems="center">
+ <SkeletonLoader animation="wave" variant="text"
count={1} width="50%" height={24} />
+ <SkeletonLoader animation="wave" variant="text"
count={1} width={60} height={20} />
+ </Stack>
+ </Box>
+ <Stack direction="row" spacing={2} sx={{ mt: 2, mb: 1 }}>
+ <SkeletonLoader animation="wave" variant="text"
count={1} width={40} height={16} />
+ <SkeletonLoader animation="wave" variant="text"
count={1} width={50} height={16} />
+ </Stack>
+ <Stack spacing={1.5} sx={{ mt: 2 }}>
+ {[1, 2, 3, 4, 5].map((i) => (
+ <Stack key={i} direction="row"
alignItems="center" spacing={2}>
+ <SkeletonLoader animation="wave"
variant="text" count={1} width={100} height={20} />
+ <SkeletonLoader animation="wave"
variant="rectangular" count={1} width={`${30 + i * 12}%`} height={24} sx={{
borderRadius: 1 }} />
+ </Stack>
+ ))}
+ </Stack>
+ </Paper>
+);
+
+export default EntityTypeBarChartSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx
b/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx
new file mode 100644
index 000000000..93c00f50b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/KafkaTopicSummaryCard.tsx
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Fragment, memo, useMemo, useState, useCallback } from "react";
+import {
+ Paper,
+ Stack,
+ Typography,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Box,
+ IconButton,
+ Tooltip,
+ Collapse,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import ExpandLessIcon from "@mui/icons-material/ExpandLess";
+import { numberFormatWithComma } from "@utils/Helper";
+import { formatedDate } from "@utils/Utils";
+import {
+ parseMetricsStats,
+ getMessageConsumptionData,
+ getMessageConsumptionDataExcludingTotal,
+ buildTopicNotificationRecord,
+ topicRowHasPeriodMetrics,
+ type MessageConsumptionItem,
+} from "@utils/metricsUtils";
+import MessageConsumptionChart from "./MessageConsumptionChart";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+} from "./dashboardChartPalette";
+
+const CREATES_COLOR = ENTITY_STATUS_DONUT_COLORS.Active;
+const UPDATES_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETES_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface TopicDetail {
+ topic: string;
+ processed: number;
+ failed: number;
+ lastProcessed: string | number;
+ /** Partition stats — same shape as aggregate Notification (per
AtlasMetricsUtil). */
+ topicStats: Record<string, unknown>;
+}
+
+interface KafkaTopicSummaryCardProps {
+ stats: Record<string, unknown> | undefined;
+ isLoading?: boolean;
+}
+
+const getTopicConsumptionPanelId = (topic: string): string =>
+ `kafka-topic-msg-panel-${topic.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
+
+const TotalProcessedTooltip = ({
+ total,
+}: {
+ total: MessageConsumptionItem | undefined;
+}) => {
+ if (!total) {
+ return (
+ <Typography variant="caption" component="span">
+ No breakdown available
+ </Typography>
+ );
+ }
+ return (
+ <Box sx={{ p: 1, minWidth: 160, bgcolor: "#fff" }}>
+ <Typography
+ variant="body2"
+ fontWeight={600}
+ sx={{ mb: 0.5, color: "#1a1a1a" }}
+ >
+ {total.period}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{
color: CREATES_COLOR }}>
+ Creates: {numberFormatWithComma(total.creates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{
color: UPDATES_COLOR }}>
+ Updates: {numberFormatWithComma(total.updates)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{
color: DELETES_COLOR }}>
+ Deletes: {numberFormatWithComma(total.deletes)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{
color: "#6c757d", mt: 0.5 }}>
+ Messages processed:
{numberFormatWithComma(total.count)}
+ </Typography>
+ <Typography variant="caption" display="block" sx={{
color: "#6c757d" }}>
+ Avg time (ms):
{numberFormatWithComma(total.avgTime)}
+ </Typography>
+ </Box>
+ );
+};
+
+const KafkaTopicSummaryCard = memo(({ stats, isLoading }:
KafkaTopicSummaryCardProps) => {
+ const [sortKey, setSortKey] = useState<"topic" | "processed" |
"failed">("topic");
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+ /** One expanded topic at a time to limit vertical growth. */
+ const [expandedTopic, setExpandedTopic] = useState<string | null>(null);
+
+ const parsed = useMemo(() => parseMetricsStats(stats), [stats]);
+ const notification = parsed?.Notification as Record<string, unknown> |
undefined;
+ const topicDetails = notification?.topicDetails as
+ | Record<string, Record<string, unknown>>
+ | undefined;
+
+ const rows = useMemo((): TopicDetail[] => {
+ if (!topicDetails || typeof topicDetails !== "object") return
[];
+ return Object.entries(topicDetails).map(([topic, data]) => {
+ const lp = data?.lastMessageProcessedTime;
+ const lastProcessed: string | number =
+ lp != null && typeof lp !== "object" ? (lp as
string | number) : "-";
+ return {
+ topic,
+ processed: Number(data?.processedMessageCount
?? 0),
+ failed: Number(data?.failedMessageCount ?? 0),
+ lastProcessed,
+ topicStats: data ?? {},
+ };
+ });
+ }, [topicDetails]);
+
+ const sortedRows = useMemo(() => {
+ const list = [...rows];
+ const dir = sortOrder === "asc" ? 1 : -1;
+ return list.sort((a, b) => {
+ if (sortKey === "topic") {
+ const av = String(a.topic).toLowerCase();
+ const bv = String(b.topic).toLowerCase();
+ return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
+ }
+ const av = a[sortKey];
+ const bv = b[sortKey];
+ return ((av as number) - (bv as number)) * dir;
+ });
+ }, [rows, sortKey, sortOrder]);
+
+ const consumptionByTopic = useMemo(() => {
+ const m = new Map<
+ string,
+ { totalRow: MessageConsumptionItem | undefined;
chartData: MessageConsumptionItem[] }
+ >();
+ for (const row of rows) {
+ const record =
buildTopicNotificationRecord(row.topicStats, {
+ aggregateNotification: notification,
+ useAggregateFallback:
!topicRowHasPeriodMetrics(row.topicStats),
+ });
+ const full = getMessageConsumptionData(record);
+ m.set(row.topic, {
+ totalRow: full.find((d) => d.period ===
"Total"),
+ chartData:
getMessageConsumptionDataExcludingTotal(record),
+ });
+ }
+ return m;
+ }, [rows, notification]);
+
+ const handleSort = useCallback((key: "topic" | "processed" | "failed")
=> {
+ setSortKey(key);
+ setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
+ }, []);
+
+ const handleToggleExpand = useCallback((topic: string) => {
+ setExpandedTopic((prev) => (prev === topic ? null : topic));
+ }, []);
+
+ const formatLastProcessed = (val: string | number): string => {
+ if (val === "-" || val == null) return "-";
+ if (typeof val === "number") return formatedDate({ date: val });
+ return String(val);
+ };
+
+ const hasExpanded = expandedTopic !== null;
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 280,
+ width: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight:
600, color: "#1a1a1a" }}>
+ Kafka Topic Summary
+ </Typography>
+ </Box>
+ {rows.length === 0 ? (
+ <Stack alignItems="center"
justifyContent="center" height={180}>
+ <Typography variant="body2"
color="text.secondary">
+ No topic data available
+ </Typography>
+ </Stack>
+ ) : (
+ <TableContainer sx={{ maxHeight: hasExpanded ?
560 : 220, mt: 1 }}>
+ <Table size="small" stickyHeader>
+ <TableHead>
+ <TableRow>
+ <TableCell
+
onClick={() => handleSort("topic")}
+ sx={{
cursor: "pointer", fontWeight: 600 }}
+
aria-label="Sort by topic"
+ >
+ Topic{"
"}
+
{sortKey === "topic"
+
? sortOrder === "asc"
+
? "▲"
+
: "▼"
+
: ""}
+ </TableCell>
+ <TableCell
+
align="right"
+
onClick={() => handleSort("processed")}
+ sx={{
cursor: "pointer", fontWeight: 600 }}
+
aria-label="Sort by processed"
+ >
+
Processed{" "}
+
{sortKey === "processed"
+
? sortOrder === "asc"
+
? "▲"
+
: "▼"
+
: ""}
+ </TableCell>
+ <TableCell
+
align="right"
+
onClick={() => handleSort("failed")}
+ sx={{
cursor: "pointer", fontWeight: 600 }}
+
aria-label="Sort by failed"
+ >
+
Failed{" "}
+
{sortKey === "failed"
+
? sortOrder === "asc"
+
? "▲"
+
: "▼"
+
: ""}
+ </TableCell>
+ <TableCell
align="right" sx={{ fontWeight: 600 }}>
+ Last
Processed
+ </TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {sortedRows.map((row)
=> {
+ const
isExpanded = expandedTopic === row.topic;
+ const panelId =
getTopicConsumptionPanelId(row.topic);
+ const cons =
consumptionByTopic.get(row.topic);
+ const
totalForHover = cons?.totalRow;
+ const chartData
= cons?.chartData ?? [];
+ return (
+
<Fragment key={row.topic}>
+
<TableRow hover>
+
<TableCell sx={{ fontSize: "0.8125rem" }}>
+
<Stack direction="row" alignItems="center" spacing={0.5}>
+
<IconButton
+
size="small"
+
aria-label={
+
isExpanded
+
? `Collapse message consumption
for ${row.topic}`
+
: `Expand message consumption
for ${row.topic}`
+
}
+
aria-expanded={isExpanded}
+
aria-controls={panelId}
+
id={`${panelId}-trigger`}
+
onClick={(e) => {
+
e.stopPropagation();
+
handleToggleExpand(row.topic);
+
}}
+
onKeyDown={(e) => {
+
if (e.key === " " || e.key === "Enter")
{
+
e.stopPropagation();
+
}
+
}}
+
>
+
{isExpanded ? (
+
<ExpandLessIcon fontSize="small" />
+
) : (
+
<ExpandMoreIcon fontSize="small" />
+
)}
+
</IconButton>
+
<Typography
+
component="span"
+
variant="body2"
+
sx={{ fontSize: "0.8125rem" }}
+
>
+
{row.topic}
+
</Typography>
+
</Stack>
+
</TableCell>
+
<TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+
<Tooltip
+
title={<TotalProcessedTooltip total={totalForHover} />}
+
arrow
+
enterDelay={200}
+
placement="top"
+
componentsProps={{
+
tooltip: {
+
sx: {
+
bgcolor: "#fff",
+
color: "#1a1a1a",
+
border: "1px solid",
+
borderColor: "rgba(0, 0, 0,
0.12)",
+
boxShadow: 2,
+
},
+
},
+
arrow: {
+
sx: {
+
color: "#fff",
+
},
+
},
+
}}
+
>
+
<Box
+
component="span"
+
tabIndex={0}
+
sx={{
+
cursor: "help",
+
borderBottom: "1px dotted",
+
borderColor: "text.secondary",
+
}}
+
aria-label="Total processed — hover for
creates, updates, deletes"
+
>
+
{numberFormatWithComma(row.processed)}
+
</Box>
+
</Tooltip>
+
</TableCell>
+
<TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+
{numberFormatWithComma(row.failed)}
+
</TableCell>
+
<TableCell align="right" sx={{ fontSize: "0.8125rem" }}>
+
{formatLastProcessed(row.lastProcessed)}
+
</TableCell>
+
</TableRow>
+
{isExpanded ? (
+
<TableRow>
+
<TableCell
+
colSpan={4}
+
sx={{ p: 0, borderBottom: "none" }}
+
>
+
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
+
<Box
+
id={panelId}
+
sx={{
+
py: 2,
+
px: 2,
+
bgcolor: "action.hover",
+
borderBottom: "1px solid",
+
borderColor: "divider",
+
}}
+
>
+
<Typography
+
variant="subtitle2"
+
sx={{ mb: 1, fontWeight: 600,
color: "#1a1a1a" }}
+
>
+
Message consumption
+
</Typography>
+
<MessageConsumptionChart
+
data={chartData}
+
chartAriaLabel={`Message
consumption for ${row.topic}`}
+
/>
+
</Box>
+
</Collapse>
+
</TableCell>
+
</TableRow>
+
) : null}
+
</Fragment>
+ );
+ })}
+ </TableBody>
+ </Table>
+ </TableContainer>
+ )}
+ </Paper>
+ );
+});
+
+KafkaTopicSummaryCard.displayName = "KafkaTopicSummaryCard";
+
+export default KafkaTopicSummaryCard;
diff --git a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx
b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx
new file mode 100644
index 000000000..f2b98f687
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { memo, useCallback } from "react";
+import { Stack, Typography, Box } from "@mui/material";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ LabelList,
+} from "recharts";
+import { numberFormatWithComma } from "@utils/Helper";
+import { type MessageConsumptionItem } from "@utils/metricsUtils";
+import {
+ CHART_BAR_ACTIVE_BLUE,
+ ENTITY_STATUS_DONUT_COLORS,
+} from "./dashboardChartPalette";
+
+const CREATES_COLOR = ENTITY_STATUS_DONUT_COLORS.Active;
+const UPDATES_COLOR = CHART_BAR_ACTIVE_BLUE;
+const DELETES_COLOR = ENTITY_STATUS_DONUT_COLORS.Deleted;
+
+interface MessageConsumptionChartProps {
+ /** Rows must already exclude the Total period when used from Kafka
Topic Summary. */
+ data: MessageConsumptionItem[];
+ /** Optional accessible name for the chart region (embedded context). */
+ chartAriaLabel?: string;
+}
+
+const MessageConsumptionChart = memo(
+ ({ data, chartAriaLabel }: MessageConsumptionChartProps) => {
+ const renderTooltip = useCallback(
+ (props: unknown) => {
+ const p = props as {
+ active?: boolean;
+ label?: string | number;
+ payload?: Array<{ payload?:
MessageConsumptionItem }>;
+ };
+ if (!p?.active) return null;
+ const periodLabel =
+ p.label !== undefined && p.label !==
null ? String(p.label) : "";
+ const rowFromLabel = periodLabel
+ ? data.find((d) => d.period ===
periodLabel)
+ : undefined;
+ const row =
+ rowFromLabel ?? p.payload?.[0]?.payload
?? undefined;
+ if (!row) return null;
+ return (
+ <Box
+ sx={{
+ p: 1.5,
+ bgcolor:
"background.paper",
+ borderRadius: 1,
+ boxShadow: 2,
+ minWidth: 160,
+ }}
+ >
+ <Typography variant="body2"
fontWeight={600} sx={{ mb: 0.5 }}>
+ {row.period}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: CREATES_COLOR }}>
+ Creates:
{numberFormatWithComma(row.creates)}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: UPDATES_COLOR }}>
+ Updates:
{numberFormatWithComma(row.updates)}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: DELETES_COLOR }}>
+ Deletes:
{numberFormatWithComma(row.deletes)}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: "#6c757d", mt: 0.5 }}>
+ Messages processed:
{numberFormatWithComma(row.count)}
+ </Typography>
+ <Typography variant="caption"
display="block" sx={{ color: "#6c757d" }}>
+ Avg time (ms):
{numberFormatWithComma(row.avgTime)}
+ </Typography>
+ </Box>
+ );
+ },
+ [data]
+ );
+
+ if (data.length === 0) {
+ return (
+ <Stack alignItems="center"
justifyContent="center" minHeight={200}>
+ <Typography variant="body2"
color="text.secondary">
+ No message consumption data
available
+ </Typography>
+ </Stack>
+ );
+ }
+
+ return (
+ <Box
+ role={chartAriaLabel ? "region" : undefined}
+ aria-label={chartAriaLabel}
+ sx={{ minHeight: 260, height: 260, width:
"100%", minWidth: 280 }}
+ >
+ <Stack
+ direction="row"
+ spacing={2}
+ sx={{ mb: 1, flexWrap: "wrap" }}
+ aria-label="Chart legend"
+ >
+ <Stack direction="row"
alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius:
"50%",
+
backgroundColor: CREATES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d",
fontSize: "0.8125rem" }}
+ >
+ Creates
+ </Typography>
+ </Stack>
+ <Stack direction="row"
alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius:
"50%",
+
backgroundColor: UPDATES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d",
fontSize: "0.8125rem" }}
+ >
+ Updates
+ </Typography>
+ </Stack>
+ <Stack direction="row"
alignItems="center" spacing={0.75}>
+ <Box
+ sx={{
+ width: 10,
+ height: 10,
+ borderRadius:
"50%",
+
backgroundColor: DELETES_COLOR,
+ }}
+ aria-hidden
+ />
+ <Typography
+ variant="caption"
+ sx={{ color: "#6c757d",
fontSize: "0.8125rem" }}
+ >
+ Deletes
+ </Typography>
+ </Stack>
+ </Stack>
+ <ResponsiveContainer width="100%" height="100%">
+ <BarChart
+ data={data}
+ margin={{ top: 36, right: 28,
left: 52, bottom: 36 }}
+ >
+ <CartesianGrid
strokeDasharray="3 3" stroke="#f0f0f0" />
+ <XAxis
+ dataKey="period"
+ tick={{ fontSize: 11 }}
+ height={36}
+ label={{
+ value: "Period",
+ position:
"bottom",
+ offset: 12,
+ style: {
fontSize: 11, fill: "#6c757d" },
+ }}
+ />
+ <YAxis
+ tickFormatter={(v) =>
numberFormatWithComma(v)}
+ width={48}
+ label={{
+ value: "Count",
+ angle: -90,
+ position:
"left",
+ offset: 6,
+ style: {
fontSize: 11, fill: "#6c757d", textAnchor: "middle" },
+ }}
+ />
+ <Tooltip
content={renderTooltip} cursor={{ fill: "transparent" }} />
+ <Bar
+ dataKey="creates"
+ name="Creates"
+ stackId="a"
+ fill={CREATES_COLOR}
+ radius={[0, 0, 0, 0]}
+ >
+ {data.map((_, index) =>
(
+ <Cell
key={`creates-${index}`} fill={CREATES_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+ dataKey="updates"
+ name="Updates"
+ stackId="a"
+ fill={UPDATES_COLOR}
+ radius={[0, 0, 0, 0]}
+ >
+ {data.map((_, index) =>
(
+ <Cell
key={`updates-${index}`} fill={UPDATES_COLOR} />
+ ))}
+ </Bar>
+ <Bar
+ dataKey="deletes"
+ name="Deletes"
+ stackId="a"
+ fill={DELETES_COLOR}
+ radius={[0, 4, 4, 0]}
+ >
+ <LabelList
+ dataKey="count"
+ position="top"
+ offset={8}
+ formatter={(v:
number) => numberFormatWithComma(v)}
+ style={{
+
fontSize: 11,
+
fontWeight: 600,
+ fill:
"#374151",
+ }}
+ />
+ {data.map((_, index) =>
(
+ <Cell
key={`deletes-${index}`} fill={DELETES_COLOR} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </Box>
+ );
+ }
+);
+
+MessageConsumptionChart.displayName = "MessageConsumptionChart";
+
+export default MessageConsumptionChart;
diff --git a/dashboard/src/views/DashboardOverview/OverviewCard.tsx
b/dashboard/src/views/DashboardOverview/OverviewCard.tsx
new file mode 100644
index 000000000..41018055b
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/OverviewCard.tsx
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Paper, Stack, Typography, Box } from "@mui/material";
+import { numberFormatWithComma } from "@utils/Helper";
+import { useNavigate } from "react-router-dom";
+import { navigateToSearch } from "@utils/dashboardSearchUtils";
+
+interface OverviewCardProps {
+ entityCount: number;
+ tagCount: number;
+ isLoading?: boolean;
+}
+
+const OverviewCard = ({ entityCount, tagCount, isLoading }: OverviewCardProps)
=> {
+ const navigate = useNavigate();
+
+ const handleEntitiesClick = () => {
+ navigateToSearch(navigate, "all_entities");
+ };
+
+ const handleClassificationsClick = () => {
+ navigateToSearch(navigate, "all_classifications");
+ };
+
+ if (isLoading) return null;
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 200,
+ height: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Typography sx={{ fontSize: "1rem", fontWeight:
600, color: "#1a1a1a" }}>
+ Overview
+ </Typography>
+ </Box>
+ <Stack direction="column" spacing={3} sx={{ pt: 2 }}>
+ <Stack alignItems="flex-start" spacing={0.5}>
+ <Typography
+ component="button"
+ sx={{
+ fontSize: "1.75rem",
+ fontWeight: 700,
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "#1a1a1a",
+ textAlign: "left",
+ lineHeight: 1.2,
+ "&:hover": {
textDecoration: "underline", color: "primary.main" }
+ }}
+ onClick={handleEntitiesClick}
+ aria-label="View all entities"
+ >
+
{numberFormatWithComma(entityCount)}
+ </Typography>
+ <Typography sx={{ fontSize: "0.875rem",
color: "#6c757d", textTransform: "capitalize" }}>
+ Entities
+ </Typography>
+ </Stack>
+ <Stack alignItems="flex-start" spacing={0.5}>
+ <Typography
+ component="button"
+ sx={{
+ fontSize: "1.75rem",
+ fontWeight: 700,
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "#1a1a1a",
+ textAlign: "left",
+ lineHeight: 1.2,
+ "&:hover": {
textDecoration: "underline", color: "primary.main" }
+ }}
+
onClick={handleClassificationsClick}
+ aria-label="View all
classifications"
+ >
+
{numberFormatWithComma(tagCount)}
+ </Typography>
+ <Typography sx={{ fontSize: "0.875rem",
color: "#6c757d", textTransform: "capitalize" }}>
+ Classifications
+ </Typography>
+ </Stack>
+ </Stack>
+ </Paper>
+ );
+};
+
+export default OverviewCard;
diff --git a/dashboard/src/views/DashboardOverview/RecentActivity.tsx
b/dashboard/src/views/DashboardOverview/RecentActivity.tsx
new file mode 100644
index 000000000..fdd5be150
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/RecentActivity.tsx
@@ -0,0 +1,408 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { memo, useCallback, useEffect, useState } from "react";
+import { useAppSelector } from "@hooks/reducerHook";
+import { Paper, Stack, Typography, Link, Box, Chip, List, ListItem } from
"@mui/material";
+import { RecentActivityListSkeleton } from "./RecentActivitySkeleton";
+import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import { getAuditData } from "@api/apiMethods/detailpageApiMethod";
+import { category } from "@utils/Enum";
+import { extractTypeDefDetailObject } from "@utils/auditTypeDefUtils";
+import { dateFormat, isEmpty, jsonParse } from "@utils/Utils";
+
+export interface AuditRecord {
+ guid?: string;
+ userName?: string;
+ operation?: string;
+ params?: string;
+ result?: string;
+ startTime?: number;
+ endTime?: number;
+}
+
+const AUDIT_TABS = [
+ { id: "typeCreated", label: "Created", operations: ["TYPE_DEF_CREATE"]
},
+ { id: "typeUpdated", label: "Updated", operations: ["TYPE_DEF_UPDATE"]
},
+ { id: "typeDeleted", label: "Deleted", operations: ["TYPE_DEF_DELETE"]
},
+ { id: "purge", label: "Purge", operations: ["PURGE", "AUTO_PURGE"] },
+ { id: "import", label: "Import", operations: ["IMPORT"] },
+ { id: "export", label: "Export", operations: ["EXPORT"] }
+] as const;
+
+interface ParsedAuditResult {
+ typeLabel: string;
+ entityName: string;
+ entityGuid?: string;
+ actionPhrase: string;
+ user: string;
+ dateStr: string;
+}
+
+const parseAuditRecord = (record: AuditRecord): ParsedAuditResult => {
+ const { operation, params, result, userName, endTime } = record;
+ const user = userName || "Unknown";
+ const dateStr = endTime ? dateFormat(endTime) : "";
+ let typeLabel = category[params as keyof typeof category] || params ||
"Type";
+ let actionPhrase = "";
+ let entityName = "";
+ let entityGuid = "";
+
+ if (operation === "PURGE" || operation === "AUTO_PURGE") {
+ try {
+ const guidStr = typeof result === "string" ?
result.replace("[", "").replace("]", "").split(",")[0]?.trim() : "";
+ entityGuid = guidStr ?? "";
+ if (entityGuid) entityName = "View entity";
+ } catch {
+ /* ignore */
+ }
+ actionPhrase = "operation was performed";
+ return { typeLabel: operation === "AUTO_PURGE" ? "Auto purge" :
"Purge", entityName, entityGuid, actionPhrase, user, dateStr };
+ }
+ if (operation === "EXPORT") {
+ return { typeLabel: "Export", entityName: "", entityGuid: "",
actionPhrase: "operation was performed", user, dateStr };
+ }
+ if (operation === "IMPORT") {
+ return { typeLabel: "Import", entityName: "", entityGuid: "",
actionPhrase: "operation was performed", user, dateStr };
+ }
+
+ if (operation === "TYPE_DEF_CREATE") {
+ actionPhrase = "was created";
+ } else if (operation === "TYPE_DEF_UPDATE") {
+ actionPhrase = "was updated";
+ } else if (operation === "TYPE_DEF_DELETE") {
+ actionPhrase = "was deleted";
+ } else {
+ return { typeLabel: operation || "", entityName: "",
entityGuid: "", actionPhrase: "", user, dateStr };
+ }
+
+ try {
+ const resultObj = typeof result === "string" ?
jsonParse(result) : result;
+ if (resultObj?.name) {
+ entityName = resultObj.name;
+ entityGuid = resultObj.guid ?? "";
+ } else if (resultObj && typeof resultObj === "object") {
+ const paramsKey = params?.split(",")[0]?.trim();
+ const arr = paramsKey ? resultObj[paramsKey] :
resultObj[Object.keys(resultObj)[0]];
+ if (Array.isArray(arr) && arr[0]) {
+ entityName = arr[0].name ?? "";
+ entityGuid = arr[0].guid ?? "";
+ } else if (!Array.isArray(arr) && arr?.name) {
+ entityName = arr.name;
+ entityGuid = arr.guid ?? "";
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ return { typeLabel, entityName, entityGuid, actionPhrase, user, dateStr
};
+};
+
+const getDetailUrl = (
+ operation: string | undefined,
+ params: string,
+ entityName: string,
+ entityGuid: string
+): string | null => {
+ if ((operation === "PURGE" || operation === "AUTO_PURGE") &&
entityGuid) {
+ return `/detailPage/${entityGuid}`;
+ }
+ if (!entityName && !entityGuid) return null;
+ /* Type Created/Updated/Deleted tabs - link to type details */
+ const param = params?.split(",")[0]?.trim();
+ if (param === "CLASSIFICATION") {
+ return `/tag/tagAttribute/${entityName}`;
+ }
+ if (param === "BUSINESS_METADATA" && entityGuid) {
+ return `/administrator/businessMetadata/${entityGuid}`;
+ }
+ if (["ENTITY", "ENUM", "RELATIONSHIP", "STRUCT"].includes(param ?? ""))
{
+ return null;
+ }
+ return `/administrator?tabActive=typeSystem`;
+};
+
+const RecentActivity = memo(() => {
+ const dashboardRefreshVersion = useAppSelector((state) =>
state.dashboardRefresh.version);
+ const navigate = useNavigate();
+ const [activeTab, setActiveTab] = useState(0);
+ const [tabData, setTabData] = useState<Record<string, AuditRecord[]>>({
+ typeCreated: [],
+ typeUpdated: [],
+ typeDeleted: [],
+ purge: [],
+ import: [],
+ export: []
+ });
+ const [loading, setLoading] = useState<Record<string, boolean>>({
+ typeCreated: false,
+ typeUpdated: false,
+ typeDeleted: false,
+ purge: false,
+ import: false,
+ export: false
+ });
+
+ const fetchAudits = useCallback(async (tabId: string, operations:
string[]) => {
+ setLoading((prev) => ({ ...prev, [tabId]: true }));
+ try {
+ const allResults: AuditRecord[] = [];
+ for (const op of operations) {
+ const auditFilters = {
+ condition: "AND" as const,
+ criterion: [
+ {
+ attributeName:
"operation",
+ operator: "eq" as const,
+ attributeValue: op
+ }
+ ]
+ };
+ const resp = await getAuditData({
+ auditFilters,
+ limit: 5,
+ offset: 0,
+ sortBy: "startTime",
+ sortOrder: "DESCENDING"
+ });
+ const data = (resp as { data?: AuditRecord[]
})?.data ?? [];
+ if (Array.isArray(data))
allResults.push(...data);
+ }
+ allResults.sort((a, b) => (b.endTime ?? 0) - (a.endTime
?? 0));
+ setTabData((prev) => ({ ...prev, [tabId]:
allResults.slice(0, 5) }));
+ } catch {
+ setTabData((prev) => ({ ...prev, [tabId]: [] }));
+ } finally {
+ setLoading((prev) => ({ ...prev, [tabId]: false }));
+ }
+ }, []);
+
+ useEffect(() => {
+ AUDIT_TABS.forEach((tab) => {
+ fetchAudits(tab.id, [...tab.operations]);
+ });
+ }, [fetchAudits, dashboardRefreshVersion]);
+
+ const handleViewAll = useCallback(() => {
+ navigate("/administrator?tabActive=audit");
+ }, [navigate]);
+
+ const handleTabChange = useCallback((_: React.SyntheticEvent, newValue:
number) => {
+ setActiveTab(newValue);
+ }, []);
+
+ const currentTab = AUDIT_TABS[activeTab];
+ const records = tabData[currentTab?.id] ?? [];
+ const isLoading = loading[currentTab?.id] ?? false;
+
+ const [typeDetailOpen, setTypeDetailOpen] = useState(false);
+ const [typeDetailObject, setTypeDetailObject] = useState<Record<string,
unknown> | null>(null);
+
+ const handleCloseTypeDetail = useCallback(() => {
+ setTypeDetailOpen(false);
+ setTypeDetailObject(null);
+ }, []);
+
+ const handleOpenTypeDetail = useCallback((obj: Record<string, unknown>)
=> {
+ setTypeDetailObject(obj);
+ setTypeDetailOpen(true);
+ }, []);
+
+ return (
+ <Paper
+ elevation={1}
+ sx={{
+ padding: 2,
+ borderRadius: 2,
+ minHeight: 280,
+ width: "100%",
+ boxSizing: "border-box",
+ transition: "box-shadow 0.3s ease",
+ "&:hover": { boxShadow: 4 }
+ }}
+ >
+ <Box sx={{ pb: 2, borderBottom: "1px solid",
borderColor: "divider" }}>
+ <Stack direction="row"
justifyContent="space-between" alignItems="center">
+ <Typography sx={{ fontSize: "1rem",
fontWeight: 600, color: "#1a1a1a" }}>
+ Recent Activity
+ </Typography>
+ <Link
+ component="button"
+ onClick={handleViewAll}
+ sx={{
+ fontSize: "0.875rem",
+ cursor: "pointer",
+ textDecoration: "none",
+ color: "primary.main"
+ }}
+ aria-label="View all audits"
+ >
+ View All
+ </Link>
+ </Stack>
+ </Box>
+ <Stack direction="row" spacing={1} sx={{ mt: 1.5,
flexWrap: "wrap", gap: 1 }}>
+ {AUDIT_TABS.map((tab, idx) => (
+ <Chip
+ key={tab.id}
+ label={tab.label}
+ onClick={(e) =>
handleTabChange(e as unknown as React.SyntheticEvent, idx)}
+ color={activeTab === idx ?
"primary" : "default"}
+ variant={activeTab === idx ?
"filled" : "outlined"}
+ sx={{
+ fontSize: "0.8125rem",
+ height: 32,
+ "&.MuiChip-filled": {
fontWeight: 600 }
+ }}
+ aria-pressed={activeTab === idx}
+ aria-label={`${tab.label} tab`}
+ />
+ ))}
+ </Stack>
+ <Box sx={{ pt: 1 }}>
+ {isLoading ? (
+ <RecentActivityListSkeleton />
+ ) : isEmpty(records) ? (
+ <Stack alignItems="center"
justifyContent="center" height={180}>
+ <Typography variant="body2"
color="text.secondary">
+ No records present
+ </Typography>
+ </Stack>
+ ) : (
+ <List disablePadding>
+ {records.map((record, idx) => {
+ const parsed =
parseAuditRecord(record);
+ const { typeLabel,
entityName, entityGuid, actionPhrase, user, dateStr } = parsed;
+ const typeDefPayload =
extractTypeDefDetailObject(
+ record,
+ entityName ??
"",
+ entityGuid ?? ""
+ );
+ const detailUrl =
getDetailUrl(
+
record.operation ?? "",
+ record.params
?? "",
+ entityName ??
"",
+ entityGuid ?? ""
+ );
+
+ const
openTypeModalIfNeeded = () => {
+ if
(typeDefPayload) {
+
handleOpenTypeDetail(typeDefPayload);
+ }
+ };
+
+ const
handleEntityNameKeyDown = (
+ e:
React.KeyboardEvent
+ ) => {
+ if (e.key !==
"Enter" && e.key !== " ") return;
+
e.preventDefault();
+
openTypeModalIfNeeded();
+ };
+
+ return (
+ <ListItem
+
key={record.guid ?? idx}
+
disablePadding
+ sx={{
+
py: 1,
+
borderBottom: "1px solid",
+
borderColor: "divider",
+
"&:last-child": { borderBottom: "none" }
+ }}
+ >
+
<Typography component="span" sx={{ fontSize: "0.875rem", color: "#333" }}>
+
{entityName ? (
+
<>
+
{typeDefPayload ? (
+
<Link
+
component="button"
+
type="button"
+
onClick={openTypeModalIfNeeded}
+
onKeyDown={handleEntityNameKeyDown}
+
tabIndex={0}
+
aria-label={`Open ${typeLabel} type details for
${entityName}`}
+
sx={{
+
color: "primary.main",
+
textDecoration: "none",
+
background: "none",
+
border: "none",
+
cursor: "pointer",
+
padding: 0,
+
font: "inherit",
+
"&:hover": { textDecoration:
"underline" },
+
}}
+
>
+
{entityName}
+
</Link>
+
) : detailUrl ? (
+
<Link
+
component={RouterLink}
+
to={detailUrl}
+
sx={{
+
color: "primary.main",
+
textDecoration: "none",
+
"&:hover": { textDecoration:
"underline" }
+
}}
+
>
+
{entityName}
+
</Link>
+
) : (
+
entityName
+
)}
+
{" "}
+
{typeLabel} {actionPhrase} by{" "}
+
<Box component="span" sx={{ fontWeight: 700 }}>
+
{user}
+
</Box>{" "}
+
on{" "}
+
<Box component="span" sx={{ color: "#6c757d" }}>
+
{dateStr}
+
</Box>
+
</>
+
) : (
+
<>
+
{typeLabel} {actionPhrase} by{" "}
+
<Box component="span" sx={{ fontWeight: 700 }}>
+
{user}
+
</Box>{" "}
+
on{" "}
+
<Box component="span" sx={{ color: "#6c757d" }}>
+
{dateStr}
+
</Box>
+
</>
+
)}
+
</Typography>
+ </ListItem>
+ );
+ })}
+ </List>
+ )}
+ </Box>
+ <TypeDefAuditDetailModal
+ open={typeDetailOpen}
+ onClose={handleCloseTypeDetail}
+ detailObject={typeDetailObject}
+ />
+ </Paper>
+ );
+});
+
+RecentActivity.displayName = "RecentActivity";
+
+export default RecentActivity;
diff --git a/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx
b/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx
new file mode 100644
index 000000000..8ca10811f
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/RecentActivitySkeleton.tsx
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Stack } from "@mui/material";
+import SkeletonLoader from "@components/SkeletonLoader";
+
+/** Shown inside Recent Activity while the active tab's audits are loading. */
+export const RecentActivityListSkeleton = () => (
+ <Stack spacing={1.5} sx={{ pt: 1 }}>
+ {[1, 2, 3, 4, 5].map((i) => (
+ <SkeletonLoader
+ key={i}
+ animation="wave"
+ variant="text"
+ count={1}
+ width={`${85 - i * 5}%`}
+ height={20}
+ />
+ ))}
+ </Stack>
+);
+
+export default RecentActivityListSkeleton;
diff --git a/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
new file mode 100644
index 000000000..fb012a445
--- /dev/null
+++ b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Matches Entity Status Overview donut — single source for dashboard charts
*/
+export const ENTITY_STATUS_DONUT_COLORS = {
+ Active: "#10b981",
+ Shell: "#f59e0b",
+ Deleted: "#ef4444",
+} as const;
+
+/** Active primary series / bar fill (aligned with Classification Distribution
bars) */
+export const CHART_BAR_ACTIVE_BLUE = "#1976d2";
+
+/** Horizontal bar charts: Y-axis title + ticks; keep left tight to reduce
card gutter */
+export const HORIZONTAL_BAR_CHART_MARGIN = {
+ top: 8,
+ right: 72,
+ left: 44,
+ bottom: 48,
+} as const;
+
+/** Tighter layout: short classification names — minimize dead space left of Y
ticks */
+export const CLASSIFICATION_DISTRIBUTION_CHART_MARGIN = {
+ top: 8,
+ right: 72,
+ left: 12,
+ bottom: 48,
+} as const;