This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch feature/pr-9-License-added-forNewfile in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 6d04263be68593b245bc45e2e4ec937aa624612a 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;
