diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.interface.ts index fe2803eb7865..91b3fdd24bb4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.interface.ts @@ -14,7 +14,10 @@ import { Glossary } from '../../generated/entity/data/glossary'; import { EntityReference } from '../../generated/entity/type'; import { GlossaryTermRelationType } from '../../rest/settingConfigAPI'; -import type { LayoutEngineType } from './OntologyExplorer.constants'; +import { + LayoutType, + type LayoutEngineType, +} from './OntologyExplorer.constants'; export type OntologyScope = 'global' | 'glossary' | 'term'; @@ -60,10 +63,8 @@ export interface OntologyGraphData { edges: OntologyEdge[]; } -import { LayoutType } from './OntologyExplorer.constants'; import type { GraphSearchHighlightInput } from './utils/graphSearchHighlight'; -export type LayoutAlgorithm = LayoutType; export type { LayoutEngineType } from './OntologyExplorer.constants'; export type { GraphSearchHighlightInput } from './utils/graphSearchHighlight'; export type GraphViewMode = 'overview' | 'hierarchy' | 'crossGlossary'; @@ -144,7 +145,6 @@ export interface FilterToolbarProps { export interface GraphSettingsPanelProps { settings: GraphSettings; onSettingsChange: (settings: GraphSettings) => void; - isDataMode?: boolean; } export interface NodeContextMenuProps { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.tsx index 8f05f5c3bfe8..2219139c0a98 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyExplorer.tsx @@ -19,108 +19,34 @@ import { Typography, } from '@openmetadata/ui-core-components'; import { SearchMd } from '@untitledui/icons'; -import { isAxiosError } from 'axios'; import classNames from 'classnames'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; -import { SearchIndex } from '../../enums/search.enum'; -import { Glossary } from '../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; -import { Metric } from '../../generated/entity/data/metric'; -import { EntityReference } from '../../generated/entity/type'; -import { TagSource } from '../../generated/type/tagLabel'; -import { TermRelation } from '../../generated/type/termRelation'; -import { - getGlossariesList, - getGlossaryTerms, - getGlossaryTermsAssetCounts, - getGlossaryTermsById, -} from '../../rest/glossaryAPI'; -import { getMetrics } from '../../rest/metricsAPI'; -import { - checkRdfEnabled, - downloadGlossaryOntology, - getGlossaryTermGraph, - GraphData, -} from '../../rest/rdfAPI'; -import { searchQuery } from '../../rest/searchAPI'; -import { - getGlossaryTermRelationSettings, - GlossaryTermRelationType, -} from '../../rest/settingConfigAPI'; -import { - getEntityDetailsPath, - getGlossaryTermDetailsPath, -} from '../../utils/RouterUtils'; -import { getTermQuery } from '../../utils/SearchUtils'; -import { showErrorToast } from '../../utils/ToastUtils'; import { useGenericContext } from '../Customization/GenericProvider/GenericProvider'; import EntitySummaryPanel from '../Explore/EntitySummaryPanel/EntitySummaryPanel.component'; import { buildOntologySlideoutEntityDetails } from './buildOntologySlideoutEntityDetails'; import ExportGraphPanel from './ExportGraphPanel'; import FilterToolbar from './FilterToolbar'; import GraphSettingsPanel from './GraphSettingsPanel'; +import { + DEFAULT_FILTERS, + useOntologyExplorer, +} from './hooks/useOntologyExplorer'; import NodeContextMenu from './NodeContextMenu'; import OntologyControlButtons from './OntologyControlButtons'; -import { - DATA_MODE_ASSET_LOAD_PAGE_SIZE, - DATA_MODE_MAX_RENDER_COUNT, - GLOSSARY_TERM_ASSET_COUNT_FETCH_CONCURRENCY, - LayoutEngine, - LayoutType, - ONTOLOGY_TERMS_PAGE_SIZE, - RELATION_COLORS, - toLayoutEngineType, - withoutOntologyAutocompleteAll, -} from './OntologyExplorer.constants'; +import { withoutOntologyAutocompleteAll } from './OntologyExplorer.constants'; import { ExplorationMode, - GraphFilters, - GraphSettings, - GraphViewMode, - OntologyEdge, OntologyExplorerProps, - OntologyGraphData, - OntologyGraphHandle, - OntologyNode, } from './OntologyExplorer.interface'; import OntologyGraph from './OntologyGraphG6'; import { OntologyNodeRelationsContent } from './OntologyNodeRelationsContent'; -import { computeGraphSearchHighlight } from './utils/graphSearchHighlight'; -import { buildHierarchyGraphs } from './utils/hierarchyGraphBuilder'; -import { computeGlossaryGroupPositions } from './utils/layoutCalculations'; - -const isValidUUID = (str: string): boolean => { - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - - return uuidRegex.test(str); -}; - -const GLOSSARY_COLORS = [ - '#3062d4', // Primary blue (lineage style) - '#7c3aed', // Purple - '#059669', // Emerald - '#dc2626', // Red - '#ea580c', // Orange - '#0891b2', // Cyan - '#4f46e5', // Indigo - '#ca8a04', // Yellow - '#be185d', // Pink - '#0d9488', // Teal -]; - -const METRIC_NODE_TYPE = 'metric'; -const METRIC_RELATION_TYPE = 'metricFor'; -const ASSET_NODE_TYPE = 'dataAsset'; -const ASSET_RELATION_TYPE = 'hasGlossaryTerm'; +import { + ASSET_NODE_TYPE, + isDataAssetLikeNode, + METRIC_NODE_TYPE, +} from './utils/graphBuilders'; const ONTOLOGY_GRAPH_BACKDROP_CLASS = 'tw:absolute tw:inset-0 tw:z-0 tw:bg-primary tw:[background-image:radial-gradient(circle,rgba(148,163,184,0.22)_1px,transparent_1px)] tw:[background-size:14px_14px]'; @@ -128,75 +54,23 @@ const ONTOLOGY_GRAPH_BACKDROP_CLASS = const ONTOLOGY_TOOLBAR_CARD_CLASS = 'tw:z-1 tw:border tw:border-utility-gray-blue-100 tw:ring-0 tw:shadow-md'; -const DEFAULT_SETTINGS: GraphSettings = { - layout: LayoutType.Hierarchical, - showEdgeLabels: true, -}; - -const DEFAULT_FILTERS: GraphFilters = { - viewMode: 'overview', - glossaryIds: [], - relationTypes: [], - showIsolatedNodes: true, - showCrossGlossaryOnly: false, - searchQuery: '', -}; - const ONTOLOGY_ENTITY_SUMMARY_SLIDEOUT_WIDTH = 576; -function isTermNode(node: OntologyNode): boolean { - return node.type === 'glossaryTerm' || node.type === 'glossaryTermIsolated'; +interface GraphEmptyStateProps { + message: string; + testId: string; } -function isDataAssetLikeNode(node: OntologyNode): boolean { - return node.type === ASSET_NODE_TYPE || node.type === METRIC_NODE_TYPE; -} - -function getScopedTermNodes( - nodes: OntologyNode[], - glossaryIds: string[], - scope: OntologyExplorerProps['scope'], - entityId?: string -): OntologyNode[] { - let termNodes = nodes.filter(isTermNode); - - if (glossaryIds.length > 0) { - termNodes = termNodes.filter( - (node) => node.glossaryId && glossaryIds.includes(node.glossaryId) - ); - } - - if (scope === 'term' && entityId) { - termNodes = termNodes.filter((node) => node.id === entityId); - } - - return termNodes; -} - -function searchHitSourceToEntityRef(source: unknown): EntityReference | null { - if (!source || typeof source !== 'object') { - return null; - } - const s = source as Record; - const id = s.id; - const typeField = s.entityType ?? s.type; - const fqn = s.fullyQualifiedName; - if ( - typeof id !== 'string' || - typeof typeField !== 'string' || - typeof fqn !== 'string' - ) { - return null; - } - - return { - id, - type: typeField, - name: typeof s.name === 'string' ? s.name : undefined, - displayName: typeof s.displayName === 'string' ? s.displayName : undefined, - fullyQualifiedName: fqn, - description: typeof s.description === 'string' ? s.description : undefined, - }; +function GraphEmptyState({ message, testId }: GraphEmptyStateProps) { + return ( +
+ + {message} + +
+ ); } const OntologyExplorer: React.FC = ({ @@ -209,1937 +83,89 @@ const OntologyExplorer: React.FC = ({ onLoadingChange, }) => { const { t } = useTranslation(); - const graphRef = useRef(null); - const contextData = useGenericContext(); const entityId = propEntityId ?? (scope === 'term' ? contextData?.data?.id : undefined); const termGlossaryId = scope === 'term' ? contextData?.data?.glossary?.id : undefined; - const [loading, setLoading] = useState(true); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [graphData, setGraphData] = useState(null); - const [assetGraphData, setAssetGraphData] = - useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [expandedTermIds, setExpandedTermIds] = useState>( - new Set() - ); - const [loadingTermIds, setLoadingTermIds] = useState>(new Set()); - const [rdfEnabled, setRdfEnabled] = useState(null); - const [dataSource, setDataSource] = useState<'rdf' | 'database'>('database'); - const [relationTypes, setRelationTypes] = useState< - GlossaryTermRelationType[] - >([]); - const [glossaries, setGlossaries] = useState([]); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [filters, setFilters] = useState(DEFAULT_FILTERS); - const [explorationMode, setExplorationMode] = - useState('model'); - const [contextMenu, setContextMenu] = useState<{ - node: OntologyNode; - position: { x: number; y: number }; - } | null>(null); - const [termAssetCounts, setTermAssetCounts] = useState< - Record - >({}); - const [hasMoreTerms, setHasMoreTerms] = useState(false); - const [dataModeRefreshKey, setDataModeRefreshKey] = useState(0); - - const graphDataRef = useRef(null); - const explorationModeRef = useRef('model'); - const filterFetchedGlossariesRef = useRef>(new Set()); - - // Saves the model-mode graph when global data mode overwrites graphData so - // it can be restored when the user switches back to model mode. - const savedModelGraphRef = useRef(null); - const isInGlobalDataModeRef = useRef(false); - - const pendingGlossariesRef = useRef([]); - const partialGlossaryRef = useRef<{ - glossary: Glossary; - afterCursor: string; - } | null>(null); - const isLoadingMoreRef = useRef(false); - const lastLoadCompletedRef = useRef(0); - - const modelFiltersRef = useRef(DEFAULT_FILTERS); - const dataFiltersRef = useRef({ - ...DEFAULT_FILTERS, - }); - const dataModeInitialLoadUsesSpinnerRef = useRef(false); - const dataModeAbortGenRef = useRef(0); - const hasEnteredDataModeRef = useRef(false); - - useEffect(() => { - graphDataRef.current = graphData; - }, [graphData]); - - useEffect(() => { - explorationModeRef.current = explorationMode; - }, [explorationMode]); - - const glossariesRef = useRef(glossaries); - glossariesRef.current = glossaries; - - const glossaryColorMap = useMemo(() => { - const map: Record = {}; - glossaries.forEach((g, i) => { - map[g.id] = GLOSSARY_COLORS[i % GLOSSARY_COLORS.length]; - }); - - return map; - }, [glossaries]); - - const loadedAssetCountPerTerm = useMemo(() => { - const counts: Record = {}; - assetGraphData?.edges.forEach((e) => { - if (e.relationType === ASSET_RELATION_TYPE) { - counts[e.to] = (counts[e.to] ?? 0) + 1; - } - }); - - return counts; - }, [assetGraphData]); - - const combinedGraphData = useMemo(() => { - if (!graphData) { - return null; - } - if (explorationMode === 'data') { - const nodesWithAssetCounts = graphData.nodes.map((node) => { - if ( - node.type !== 'glossaryTerm' && - node.type !== 'glossaryTermIsolated' - ) { - return node; - } - - return { - ...node, - assetCount: termAssetCounts[node.id] ?? 0, - loadedAssetCount: loadedAssetCountPerTerm[node.id] ?? 0, - isLoadingAssets: loadingTermIds.has(node.id), - }; - }); - - if (!assetGraphData) { - return { nodes: nodesWithAssetCounts, edges: graphData.edges }; - } - - const mergedNodeIds = new Set(nodesWithAssetCounts.map((n) => n.id)); - const mergedNodes = [...nodesWithAssetCounts]; - assetGraphData.nodes.forEach((n) => { - if (!mergedNodeIds.has(n.id)) { - mergedNodeIds.add(n.id); - mergedNodes.push(n); - } - }); - - const edgeKey = (e: OntologyEdge) => - `${e.from}-${e.to}-${e.relationType}`; - const mergedEdgeKeys = new Set(graphData.edges.map(edgeKey)); - const mergedEdges = [...graphData.edges]; - assetGraphData.edges.forEach((e) => { - const k = edgeKey(e); - if (!mergedEdgeKeys.has(k)) { - mergedEdgeKeys.add(k); - mergedEdges.push(e); - } - }); - - return { nodes: mergedNodes, edges: mergedEdges }; - } - - return graphData; - }, [ - graphData, - assetGraphData, + const { + graphRef, + loading, + isLoadingMore, + glossaries, + relationTypes, + settings, + filters, explorationMode, - termAssetCounts, - loadedAssetCountPerTerm, - loadingTermIds, - ]); - - const filteredGraphData = useMemo(() => { - if (!combinedGraphData) { - return null; - } - - let filteredNodes = [...combinedGraphData.nodes]; - let filteredEdges = [...combinedGraphData.edges]; + selectedNode, + contextMenu, + expandedTermIds, + rdfEnabled, + graphDataToShow, + filteredGraphData, + hierarchyGraphData, + hierarchyBakedPositions, + graphSearchHighlight, + glossaryColorMap, + isHierarchyView, + exportableGlossaryId, + setFilters, + setSelectedNode, + handleZoomIn, + handleZoomOut, + handleFitToScreen, + handleExportPng, + handleExportSvg, + handleExportTurtle, + handleExportRdfXml, + handleModeChange, + handleViewModeChange, + handleRefresh, + handleScrollNearEdge, + handleSettingsChange, + handleFiltersChange, + handleContextMenuClose, + handleContextMenuFocus, + handleContextMenuViewDetails, + handleContextMenuOpenInNewTab, + handleGraphNodeClick, + handleGraphNodeDoubleClick, + handleGraphNodeContextMenu, + handleGraphPaneClick, + } = useOntologyExplorer({ + scope, + entityId, + glossaryId, + termGlossaryId, + onStatsChange, + onLoadingChange, + }); - const glossaryFilterIds = withoutOntologyAutocompleteAll( - filters.glossaryIds - ); + const renderGraphContent = () => { + const hasNoVisibleNodes = + !graphDataToShow || graphDataToShow.nodes.length === 0; const relationTypeFilterIds = withoutOntologyAutocompleteAll( filters.relationTypes ); + const hasRelationFilter = relationTypeFilterIds.length > 0; - // Filter by glossary - if (glossaryFilterIds.length > 0) { - const glossaryTermIds = new Set( - filteredNodes - .filter( - (n) => - n.type !== METRIC_NODE_TYPE && - n.type !== ASSET_NODE_TYPE && - n.glossaryId && - glossaryFilterIds.includes(n.glossaryId) - ) - .map((n) => n.id) - ); - - const edgeKey = (e: OntologyEdge) => - `${e.from}-${e.to}-${e.relationType}`; - - // Keep glossary terms plus their directly related nodes/edges - const glossaryNeighborIds = new Set(glossaryTermIds); - const glossaryEdgeKeys = new Set(); - - filteredEdges.forEach((edge) => { - const isIncidentToGlossary = - glossaryTermIds.has(edge.from) || glossaryTermIds.has(edge.to); - if (!isIncidentToGlossary) { - return; - } - - glossaryNeighborIds.add(edge.from); - glossaryNeighborIds.add(edge.to); - glossaryEdgeKeys.add(edgeKey(edge)); - }); - - filteredNodes = filteredNodes.filter((n) => { - if (n.type === 'glossary') { - return glossaryFilterIds.includes(n.id); - } - - return glossaryNeighborIds.has(n.id); - }); - - filteredEdges = filteredEdges.filter((e) => - glossaryEdgeKeys.has(edgeKey(e)) - ); - } - - // Filter by relation type - if (relationTypeFilterIds.length > 0) { - const nodeTypeMap = new Map(filteredNodes.map((n) => [n.id, n.type])); - filteredEdges = filteredEdges.filter((e) => { - const fromType = nodeTypeMap.get(e.from); - const toType = nodeTypeMap.get(e.to); - // In data mode, always show asset/metric edges regardless of filter - if ( - explorationMode === 'data' && - (fromType === ASSET_NODE_TYPE || - fromType === METRIC_NODE_TYPE || - toType === ASSET_NODE_TYPE || - toType === METRIC_NODE_TYPE) - ) { - return true; - } - - return relationTypeFilterIds.includes(e.relationType); - }); - } - - // Filter cross-glossary relations only - if (filters.showCrossGlossaryOnly) { - const nodeById = new Map(filteredNodes.map((node) => [node.id, node])); - filteredEdges = filteredEdges.filter((edge) => { - const fromGlossary = nodeById.get(edge.from)?.glossaryId; - const toGlossary = nodeById.get(edge.to)?.glossaryId; - - return fromGlossary && toGlossary && fromGlossary !== toGlossary; - }); - - const nodeIds = new Set(); - filteredEdges.forEach((edge) => { - nodeIds.add(edge.from); - nodeIds.add(edge.to); - }); - filteredNodes = filteredNodes.filter((node) => nodeIds.has(node.id)); - } - - // Filter isolated nodes - if (!filters.showIsolatedNodes) { - const connectedIds = new Set(); - filteredEdges.forEach((e) => { - connectedIds.add(e.from); - connectedIds.add(e.to); - }); - filteredNodes = filteredNodes.filter( - (n) => connectedIds.has(n.id) || n.type === 'glossary' - ); - } - - return { nodes: filteredNodes, edges: filteredEdges }; - }, [combinedGraphData, filters]); - - const isHierarchyView = filters.viewMode === 'hierarchy'; - - const hierarchyGraphData = useMemo(() => { - if (!isHierarchyView || !filteredGraphData) { - return null; - } - - const terms = filteredGraphData.nodes.filter( - (n) => - n.type !== 'dataAsset' && - n.type !== 'metric' && - n.type !== METRIC_NODE_TYPE - ); - const termIds = new Set(terms.map((t) => t.id)); - const relations = filteredGraphData.edges.filter( - (e) => termIds.has(e.from) && termIds.has(e.to) - ); - const glossaryNames: Record = {}; - glossaries.forEach((g) => { - if (g.id && g.name) { - glossaryNames[g.id] = g.name; - } - }); - - return buildHierarchyGraphs({ - terms, - relations, - relationSettings: { relationTypes }, - relationColors: RELATION_COLORS, - glossaryNames, - }); - }, [isHierarchyView, filteredGraphData, relationTypes, glossaries]); - - const graphDataToShow = useMemo(() => { - if (isHierarchyView && hierarchyGraphData) { - return { - nodes: hierarchyGraphData.nodes, - edges: hierarchyGraphData.edges.map((e) => ({ - from: e.from, - to: e.to, - relationType: e.relationType, - label: e.relationType, - })), - }; - } - - return filteredGraphData; - }, [isHierarchyView, hierarchyGraphData, filteredGraphData]); - - const hierarchyBakedPositions = useMemo(() => { - if (!isHierarchyView || !hierarchyGraphData) { - return undefined; - } - const engine = toLayoutEngineType(settings.layout); - if (engine !== LayoutEngine.Circular) { - return undefined; - } - - return computeGlossaryGroupPositions(hierarchyGraphData.nodes, engine); - }, [hierarchyGraphData, isHierarchyView, settings.layout]); - - const graphSearchHighlight = useMemo(() => { - if (!graphDataToShow) { - return null; - } - - return computeGraphSearchHighlight( - graphDataToShow.nodes, - graphDataToShow.edges, - filters.searchQuery, - glossaries, - relationTypes + const nodeTypeMap = new Map( + (graphDataToShow?.nodes ?? []).map((n) => [n.id, n.type]) ); - }, [graphDataToShow, filters.searchQuery, glossaries, relationTypes]); - - const mergeMetricsIntoGraph = useCallback( - (graph: OntologyGraphData | null, metricList: Metric[]) => { - if (!graph || metricList.length === 0) { - return graph; - } - - const nodes = [...graph.nodes]; - const edges = [...graph.edges]; - const nodeIds = new Set(nodes.map((n) => n.id)); - const edgeKeys = new Set( - edges.map((edge) => `${edge.from}-${edge.to}-${edge.relationType}`) - ); - const termByFqn = new Map(); - - nodes.forEach((node) => { - if (node.fullyQualifiedName) { - termByFqn.set(node.fullyQualifiedName, node); - } - }); - - metricList.forEach((metric) => { - const glossaryTags = - metric.tags?.filter((tag) => tag.source === TagSource.Glossary) ?? []; - - if (glossaryTags.length === 0 || !metric.id) { - return; - } - - const relatedTerms = glossaryTags - .map((tag) => termByFqn.get(tag.tagFQN)) - .filter((term): term is OntologyNode => Boolean(term)); - - if (relatedTerms.length === 0) { - return; - } - - if (!nodeIds.has(metric.id)) { - nodes.push({ - id: metric.id, - label: metric.displayName || metric.name, - originalLabel: metric.displayName || metric.name, - type: METRIC_NODE_TYPE, - fullyQualifiedName: metric.fullyQualifiedName, - description: metric.description, - group: t('label.metric-plural'), - entityRef: { - id: metric.id, - name: metric.name, - displayName: metric.displayName, - type: EntityType.METRIC, - fullyQualifiedName: metric.fullyQualifiedName, - description: metric.description, - }, - }); - nodeIds.add(metric.id); - } - - relatedTerms.forEach((term) => { - const edgeKey = `${metric.id}-${term.id}-${METRIC_RELATION_TYPE}`; - if (!edgeKeys.has(edgeKey)) { - edges.push({ - from: metric.id, - to: term.id, - label: 'Metric for', - relationType: METRIC_RELATION_TYPE, - }); - edgeKeys.add(edgeKey); - } - }); - }); - - return { nodes, edges }; - }, - [t] - ); - - const fetchTermAssetCounts = useCallback( - async ( - termNodes: OntologyNode[], - glossaryFilterIds: string[], - append = false - ) => { - if (termNodes.length === 0) { - if (!append) { - setTermAssetCounts({}); - } - - return; - } - - try { - const scopedGlossaryId = - scope === 'glossary' - ? glossaryId - : scope === 'term' - ? termGlossaryId - : undefined; - const termGlossaryIds = new Set( - termNodes - .map((termNode) => termNode.glossaryId) - .filter((id): id is string => Boolean(id)) - ); - const requestedGlossaryIds = scopedGlossaryId - ? [scopedGlossaryId] - : glossaryFilterIds.length > 0 - ? glossaryFilterIds.filter((id) => termGlossaryIds.has(id)) - : []; - const glossaryFqnsToFetch = requestedGlossaryIds - .map( - (id) => - glossaries.find((glossary) => glossary.id === id) - ?.fullyQualifiedName - ) - .filter((fqn): fqn is string => Boolean(fqn)); - - const mergedResponse: Record = {}; - if (glossaryFqnsToFetch.length > 0) { - const { length } = glossaryFqnsToFetch; - const batchSize = GLOSSARY_TERM_ASSET_COUNT_FETCH_CONCURRENCY; - for (let i = 0; i < length; i += batchSize) { - const batch = glossaryFqnsToFetch.slice(i, i + batchSize); - const responses = await Promise.all( - batch.map((fqn) => getGlossaryTermsAssetCounts(fqn)) - ); - responses.forEach((response) => { - Object.assign(mergedResponse, response); - }); - } - } else { - Object.assign(mergedResponse, await getGlossaryTermsAssetCounts()); - } - - const counts: Record = {}; - termNodes.forEach((termNode) => { - const lookupKeys = [ - termNode.fullyQualifiedName, - termNode.originalLabel, - termNode.label, - ].filter((key): key is string => Boolean(key)); - const matchedKey = lookupKeys.find((key) => key in mergedResponse); - - if (matchedKey) { - counts[termNode.id] = mergedResponse[matchedKey]; - } - }); - - if (append) { - setTermAssetCounts((prev) => ({ ...prev, ...counts })); - } else { - setTermAssetCounts(counts); - } - } catch { - if (!append) { - setTermAssetCounts({}); - } - } - }, - [scope, glossaryId, termGlossaryId, glossaries] - ); - - const appendTermAssetsForTerm = useCallback( - async (termNode: OntologyNode, pageSize: number, fromOffset = 0) => { - if (!isTermNode(termNode) || !termNode.fullyQualifiedName) { - return; - } - - setLoadingTermIds((prev) => new Set(prev).add(termNode.id)); - - const size = Math.max(1, pageSize); - const pageNumber = Math.floor(fromOffset / size) + 1; - - try { - const res = await searchQuery({ - query: '**', - pageNumber, - pageSize: size, - searchIndex: SearchIndex.ALL, - queryFilter: getTermQuery({ - 'tags.tagFQN': termNode.fullyQualifiedName, - }) as Record, - }); - - const hits = res.hits.hits ?? []; - const newAssetNodes: OntologyNode[] = []; - const newEdges: OntologyEdge[] = []; - - hits.forEach((hit) => { - const entityRef = searchHitSourceToEntityRef(hit._source); - if (!entityRef) { - return; - } - newAssetNodes.push({ - id: entityRef.id, - label: - entityRef.displayName || - entityRef.name || - entityRef.fullyQualifiedName || - entityRef.id, - originalLabel: - entityRef.displayName || - entityRef.name || - entityRef.fullyQualifiedName || - entityRef.id, - type: ASSET_NODE_TYPE, - fullyQualifiedName: entityRef.fullyQualifiedName, - description: entityRef.description, - entityRef, - }); - newEdges.push({ - from: entityRef.id, - to: termNode.id, - label: t('label.tagged-with'), - relationType: ASSET_RELATION_TYPE, - }); - }); - - setAssetGraphData((prev) => { - const prevNodes = prev?.nodes ?? []; - const prevEdges = prev?.edges ?? []; - const nodeIds = new Set(prevNodes.map((n) => n.id)); - const edgeKeys = new Set( - prevEdges.map((e) => `${e.from}-${e.to}-${e.relationType}`) - ); - const mergedNodes = [...prevNodes]; - const mergedEdges = [...prevEdges]; - - newAssetNodes.forEach((n) => { - if (!nodeIds.has(n.id)) { - mergedNodes.push(n); - nodeIds.add(n.id); - } - }); - newEdges.forEach((e) => { - const key = `${e.from}-${e.to}-${e.relationType}`; - if (!edgeKeys.has(key)) { - mergedEdges.push(e); - edgeKeys.add(key); - } - }); - - return { nodes: mergedNodes, edges: mergedEdges }; - }); - } catch (error) { - showErrorToast( - isAxiosError(error) ? error : String(error), - t('server.entity-fetch-error') - ); - } finally { - setLoadingTermIds((prev) => { - const next = new Set(prev); - next.delete(termNode.id); - - return next; - }); - } - }, - [t] - ); - - const fetchAllMetrics = useCallback(async (): Promise => { - const allMetrics: Metric[] = []; - let after: string | undefined; - let pages = 0; - const MAX_SAFE_PAGES = 500; - - do { - const response = await getMetrics({ - fields: 'tags', - limit: 100, - after, - }); - allMetrics.push(...response.data); - after = response.paging?.after; - pages += 1; - } while (after && pages < MAX_SAFE_PAGES); - - return allMetrics; - }, []); - - const convertRdfGraphToOntologyGraph = useCallback( - (rdfData: GraphData, glossaryList: Glossary[]): OntologyGraphData => { - // Create mapping from glossary name to ID for lookups - const glossaryNameToId = new Map(); - glossaryList.forEach((g) => { - glossaryNameToId.set(g.name.toLowerCase(), g.id); - if (g.fullyQualifiedName) { - glossaryNameToId.set(g.fullyQualifiedName.toLowerCase(), g.id); - } - }); - - const nodes: OntologyNode[] = rdfData.nodes.map((node) => { - // Extract glossary name from group or FQN - let glossaryId: string | undefined; - if (node.group) { - glossaryId = glossaryNameToId.get(node.group.toLowerCase()); - } - if (!glossaryId && node.fullyQualifiedName) { - const glossaryName = node.fullyQualifiedName.split('.')[0]; - glossaryId = glossaryNameToId.get(glossaryName.toLowerCase()); - } - - // Determine the best label - fallback to extracting from FQN if label looks like a UUID - let nodeLabel = node.label; - const isUuidLabel = - nodeLabel && - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - nodeLabel - ); - - if (!nodeLabel || isUuidLabel) { - // Try to extract label from fullyQualifiedName (last part after the last dot) - if (node.fullyQualifiedName) { - const parts = node.fullyQualifiedName.split('.'); - nodeLabel = parts[parts.length - 1]; - } else if (node.title) { - nodeLabel = node.title; - } else { - nodeLabel = node.id; - } - } - - return { - id: node.id, - label: nodeLabel, - type: node.type || 'glossaryTerm', - fullyQualifiedName: node.fullyQualifiedName, - description: node.description, - glossaryId, - group: node.group, - }; - }); - - // Deduplicate edges, preferring specific relation types over 'relatedTo' - const edgeMap = new Map(); - rdfData.edges.forEach((edge) => { - const relationType = edge.relationType || 'relatedTo'; - const nodePairKey = [edge.from, edge.to].sort().join('-'); - const existingEdge = edgeMap.get(nodePairKey); - - // Add if no existing edge, or replace if new type is more specific - if ( - !existingEdge || - (existingEdge.relationType === 'relatedTo' && - relationType !== 'relatedTo') - ) { - edgeMap.set(nodePairKey, { - from: edge.from, - to: edge.to, - label: edge.label || relationType, - relationType: relationType, - }); - } - }); - - return { nodes, edges: Array.from(edgeMap.values()) }; - }, - [] - ); - - const buildGraphFromAllTerms = useCallback( - (terms: GlossaryTerm[], glossaryList: Glossary[]): OntologyGraphData => { - const nodesMap = new Map(); - const edges: OntologyEdge[] = []; - const edgeSet = new Set(); - - terms.forEach((term) => { - if (!term.id || !isValidUUID(term.id)) { - return; - } - - const hasRelations = - (term.relatedTerms && term.relatedTerms.length > 0) || - (term.children && term.children.length > 0) || - term.parent; - - nodesMap.set(term.id, { - id: term.id, - label: term.displayName || term.name, - type: hasRelations ? 'glossaryTerm' : 'glossaryTermIsolated', - fullyQualifiedName: term.fullyQualifiedName, - description: term.description, - glossaryId: term.glossary?.id, - group: glossaryList.find((g) => g.id === term.glossary?.id)?.name, - owners: term.owners, - }); - - if (term.relatedTerms && term.relatedTerms.length > 0) { - term.relatedTerms.forEach((relation: TermRelation) => { - const relatedTermRef = relation.term; - const relationType = relation.relationType || 'relatedTo'; - if (relatedTermRef?.id && isValidUUID(relatedTermRef.id)) { - // Use node-pair key (without relationType) to avoid duplicate edges - const nodePairKey = [term.id, relatedTermRef.id].sort().join('-'); - - // Check if we already have an edge for this node pair - if (!edgeSet.has(nodePairKey)) { - edgeSet.add(nodePairKey); - edges.push({ - from: term.id, - to: relatedTermRef.id, - label: relationType, - relationType: relationType, - }); - } else if (relationType !== 'relatedTo') { - // If we have a more specific relationType, update the existing edge - const existingEdgeIndex = edges.findIndex( - (e) => - [e.from, e.to].sort().join('-') === nodePairKey && - e.relationType === 'relatedTo' - ); - if (existingEdgeIndex !== -1) { - edges[existingEdgeIndex] = { - from: term.id, - to: relatedTermRef.id, - label: relationType, - relationType: relationType, - }; - } - } - } - }); - } - - if (term.parent?.id && isValidUUID(term.parent.id)) { - const edgeKey = `parent-${term.parent.id}-${term.id}`; - if (!edgeSet.has(edgeKey)) { - edgeSet.add(edgeKey); - edges.push({ - from: term.parent.id, - to: term.id, - label: t('label.parent'), - relationType: 'parentOf', - }); - } - } - }); - - const nodeIds = new Set(nodesMap.keys()); - const validEdges = edges.filter( - (e) => nodeIds.has(e.from) && nodeIds.has(e.to) - ); - - return { nodes: Array.from(nodesMap.values()), edges: validEdges }; - }, - [t] - ); - - const buildGraphFromCounts = useCallback( - (counts: Record): OntologyGraphData => { - const fqnSet = new Set(Object.keys(counts)); - const nodes: OntologyNode[] = []; - const edges: OntologyEdge[] = []; - const edgeSet = new Set(); - - fqnSet.forEach((fqn) => { - const parts = fqn.split('.'); - const label = parts[parts.length - 1]; - const glossaryFqn = parts[0]; - const glossary = glossaries.find( - (g) => g.fullyQualifiedName === glossaryFqn || g.name === glossaryFqn - ); - - nodes.push({ - id: fqn, - label, - type: 'glossaryTerm', - fullyQualifiedName: fqn, - glossaryId: glossary?.id, - group: glossary?.name ?? glossaryFqn, - originalLabel: fqn, - }); - - if (parts.length > 2) { - const parentFqn = parts.slice(0, -1).join('.'); - if (fqnSet.has(parentFqn)) { - const edgeKey = `parent-${parentFqn}-${fqn}`; - if (!edgeSet.has(edgeKey)) { - edgeSet.add(edgeKey); - edges.push({ - from: parentFqn, - to: fqn, - label: t('label.parent'), - relationType: 'parentOf', - }); - } - } - } - }); - - return { nodes, edges }; - }, - [glossaries, t] - ); - - const fetchGraphDataFromRdf = useCallback( - async (glossaryIdParam?: string, glossaryList?: Glossary[]) => { - const PAGE_SIZE = 500; - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - - const MAX_SAFE_PAGES = 100; - try { - const allNodes: GraphData['nodes'] = []; - const allEdges: GraphData['edges'] = []; - let offset = 0; - let source: string | undefined; - let pages = 0; - - while (pages < MAX_SAFE_PAGES) { - const page = await getGlossaryTermGraph({ - glossaryId: glossaryIdParam, - limit: PAGE_SIZE, - offset, - includeIsolated: true, - }); - - if (!page.nodes || page.nodes.length === 0) { - break; - } - - allNodes.push(...page.nodes); - allEdges.push(...(page.edges ?? [])); - source = source ?? page.source; - pages += 1; - - if (page.nodes.length < PAGE_SIZE) { - break; - } - offset += PAGE_SIZE; - } - - if (allNodes.length === 0) { - return null; - } - - const nodesWithBadLabels = allNodes.filter( - (node) => !node.label || uuidRegex.test(node.label) - ); - - if (nodesWithBadLabels.length > allNodes.length / 2) { - return null; - } - - setDataSource(source === 'database' ? 'database' : 'rdf'); - - return convertRdfGraphToOntologyGraph( - { nodes: allNodes, edges: allEdges }, - glossaryList ?? [] - ); - } catch { - return null; - } - }, - [convertRdfGraphToOntologyGraph] - ); - - const MODEL_TERM_FIELDS = [ - TabSpecificField.RELATED_TERMS, - TabSpecificField.CHILDREN, - TabSpecificField.PARENT, - TabSpecificField.OWNERS, - ]; + const termToTermEdges = (graphDataToShow?.edges ?? []).filter((e) => { + const fromType = nodeTypeMap.get(e.from); + const toType = nodeTypeMap.get(e.to); - const DATA_MODE_TERM_FIELDS = [TabSpecificField.PARENT]; - - const fetchTermsForGlossary = useCallback( - async ( - glossary: Glossary, - afterCursor?: string, - fields: TabSpecificField[] = MODEL_TERM_FIELDS - ): Promise<{ terms: GlossaryTerm[]; nextCursor?: string }> => { - try { - const response = await getGlossaryTerms({ - glossary: glossary.id, - fields, - limit: ONTOLOGY_TERMS_PAGE_SIZE, - after: afterCursor, - }); - - return { - terms: response.data, - nextCursor: response.paging?.after, - }; - } catch { - return { terms: [] }; - } - }, - [] - ); - - const loadNextTermPage = useCallback( - async (glossaryList?: Glossary[]): Promise => { - if (glossaryList) { - pendingGlossariesRef.current = [...glossaryList]; - partialGlossaryRef.current = null; - } - - const isDataMode = explorationModeRef.current === 'data'; - const fieldsToFetch = isDataMode - ? DATA_MODE_TERM_FIELDS - : MODEL_TERM_FIELDS; - - const accumulated: GlossaryTerm[] = []; - - if (partialGlossaryRef.current) { - const { glossary, afterCursor } = partialGlossaryRef.current; - const { terms, nextCursor } = await fetchTermsForGlossary( - glossary, - afterCursor, - fieldsToFetch - ); - accumulated.push(...terms); - - if (nextCursor) { - partialGlossaryRef.current = { glossary, afterCursor: nextCursor }; - } else { - partialGlossaryRef.current = null; - } - } - - while ( - accumulated.length < ONTOLOGY_TERMS_PAGE_SIZE && - pendingGlossariesRef.current.length > 0 - ) { - const glossary = pendingGlossariesRef.current.shift()!; - const { terms, nextCursor } = await fetchTermsForGlossary( - glossary, - undefined, - fieldsToFetch - ); - accumulated.push(...terms); - - if (nextCursor) { - partialGlossaryRef.current = { glossary, afterCursor: nextCursor }; - - break; - } - } - - const hasMore = - pendingGlossariesRef.current.length > 0 || - partialGlossaryRef.current !== null; - setHasMoreTerms(hasMore); - - if (!isDataMode) { - // Fetch cross-glossary referenced terms that are not in the current page - // so their edges can be rendered. These are fetched individually by id, - // not by walking their glossary (which may not be loaded yet). - const loadedIds = new Set(accumulated.map((t) => t.id)); - const missingIds = new Set(); - accumulated.forEach((term) => { - term.relatedTerms?.forEach((relation) => { - const id = relation.term?.id; - if (id && !loadedIds.has(id)) { - missingIds.add(id); - } - }); - }); - - if (missingIds.size > 0) { - const CONCURRENCY = 8; - const missingIdList = Array.from(missingIds); - for (let i = 0; i < missingIdList.length; i += CONCURRENCY) { - const batch = missingIdList.slice(i, i + CONCURRENCY); - const fetched = await Promise.allSettled( - batch.map((id) => - getGlossaryTermsById(id, { - fields: [ - TabSpecificField.RELATED_TERMS, - TabSpecificField.CHILDREN, - TabSpecificField.PARENT, - TabSpecificField.OWNERS, - ], - }) - ) - ); - fetched.forEach((r) => { - if (r.status === 'fulfilled') { - accumulated.push(r.value); - } - }); - } - } - } - - return accumulated; - }, - [fetchTermsForGlossary] - ); - - const loadDataModeTerms = useCallback( - async ( - glossaryFilterIds: string[] - ): Promise<{ - graphData: OntologyGraphData; - termCounts: Record; - }> => { - let counts: Record; - - if (glossaryFilterIds.length > 0) { - const filteredFqns = glossaries - .filter((g) => glossaryFilterIds.includes(g.id)) - .map((g) => g.fullyQualifiedName) - .filter((fqn): fqn is string => Boolean(fqn)); - - const results = await Promise.all( - filteredFqns.map((fqn) => getGlossaryTermsAssetCounts(fqn)) - ); - const merged: Record = {}; - results.forEach((r) => Object.assign(merged, r)); - - counts = - Object.keys(merged).length > 0 - ? merged - : await getGlossaryTermsAssetCounts(); - } else { - counts = await getGlossaryTermsAssetCounts(); - } - - const termCounts = Object.fromEntries( - Object.entries(counts).slice(0, DATA_MODE_MAX_RENDER_COUNT) - ); - - const baseGraph = buildGraphFromCounts(termCounts); - - const savedGraph = savedModelGraphRef.current; - if (savedGraph && savedGraph.edges.length > 0) { - const fqnSet = new Set( - baseGraph.nodes - .map((n) => n.fullyQualifiedName) - .filter((fqn): fqn is string => Boolean(fqn)) - ); - const uuidToFqn = new Map(); - savedGraph.nodes.forEach((n) => { - if (n.id && n.fullyQualifiedName) { - uuidToFqn.set(n.id, n.fullyQualifiedName); - } - }); - - const existingEdgeKeys = new Set( - baseGraph.edges.map((e) => `${e.from}-${e.to}`) - ); - const termTermEdges: OntologyEdge[] = []; - - savedGraph.edges.forEach((edge) => { - if (edge.relationType === 'parentOf') { - return; - } - const fromFqn = uuidToFqn.get(edge.from); - const toFqn = uuidToFqn.get(edge.to); - if ( - !fromFqn || - !toFqn || - !fqnSet.has(fromFqn) || - !fqnSet.has(toFqn) - ) { - return; - } - const key = `${fromFqn}-${toFqn}`; - if (!existingEdgeKeys.has(key)) { - existingEdgeKeys.add(key); - termTermEdges.push({ - from: fromFqn, - to: toFqn, - label: edge.label, - relationType: edge.relationType, - }); - } - }); - - return { - graphData: { - nodes: baseGraph.nodes, - edges: [...baseGraph.edges, ...termTermEdges], - }, - termCounts, - }; - } - - return { - graphData: baseGraph, - termCounts, - }; - }, - [buildGraphFromCounts, glossaries] - ); - - const fetchGraphDataFromDatabase = useCallback( - async (glossaryIdParam?: string, allGlossaries?: Glossary[]) => { - const glossariesToUse = allGlossaries ?? glossariesRef.current; - - const glossariesToFetch = glossaryIdParam - ? glossariesToUse.filter((g) => g.id === glossaryIdParam) - : glossariesToUse; - - const CONCURRENCY = 8; - const MAX_SAFE_PAGES = 50; - const fetchAllTermsForGlossary = async ( - glossary: Glossary - ): Promise => { - const terms: GlossaryTerm[] = []; - let after: string | undefined; - let pages = 0; - do { - try { - const termsResponse = await getGlossaryTerms({ - glossary: glossary.id, - fields: [ - TabSpecificField.RELATED_TERMS, - TabSpecificField.CHILDREN, - TabSpecificField.PARENT, - TabSpecificField.OWNERS, - ], - limit: 1000, - after, - }); - terms.push(...termsResponse.data); - after = termsResponse.paging?.after; - pages += 1; - } catch { - break; - } - } while (after && pages < MAX_SAFE_PAGES); - - return terms; - }; - - const allTerms: GlossaryTerm[] = []; - for (let i = 0; i < glossariesToFetch.length; i += CONCURRENCY) { - const batch = glossariesToFetch.slice(i, i + CONCURRENCY); - const results = await Promise.allSettled( - batch.map((g) => fetchAllTermsForGlossary(g)) - ); - results.forEach((r) => { - if (r.status === 'fulfilled') { - allTerms.push(...r.value); - } - }); - } - - // When fetching a single glossary (scoped view), related terms from - // other glossaries are not included. Fetch them so their edges render. - if (glossaryIdParam) { - const fetchedIds = new Set(allTerms.map((t) => t.id)); - const missingIds = new Set(); - allTerms.forEach((term) => { - term.relatedTerms?.forEach((relation) => { - const id = relation.term?.id; - if (id && !fetchedIds.has(id)) { - missingIds.add(id); - } - }); - }); - - if (missingIds.size > 0) { - const missingIdList = Array.from(missingIds); - for (let i = 0; i < missingIdList.length; i += CONCURRENCY) { - const batch = missingIdList.slice(i, i + CONCURRENCY); - const fetched = await Promise.allSettled( - batch.map((id) => - getGlossaryTermsById(id, { - fields: [ - TabSpecificField.RELATED_TERMS, - TabSpecificField.CHILDREN, - TabSpecificField.PARENT, - TabSpecificField.OWNERS, - ], - }) - ) - ); - fetched.forEach((r) => { - if (r.status === 'fulfilled') { - allTerms.push(r.value); - } - }); - } - } - } - - return buildGraphFromAllTerms(allTerms, glossariesToFetch); - }, - // Note: glossaries is intentionally excluded to prevent infinite loop - // allGlossaries parameter is always passed from fetchAllGlossaryData - - [buildGraphFromAllTerms] - ); - - const fetchAllGlossaryData = useCallback( - async (glossaryIdParam?: string) => { - setLoading(true); - try { - const [allGlossaries, metricsResponse] = await Promise.all([ - (async () => { - const collected = []; - let afterCursor: string | undefined; - let pages = 0; - const MAX_SAFE_PAGES = 500; - do { - const glossariesResponse = await getGlossariesList({ - fields: 'owners,tags', - limit: 100, - after: afterCursor, - }); - collected.push(...glossariesResponse.data); - afterCursor = glossariesResponse.paging?.after; - pages += 1; - } while (afterCursor && pages < MAX_SAFE_PAGES); - - return collected; - })(), - fetchAllMetrics().catch(() => []), - ]); - setGlossaries(allGlossaries); - - let data: OntologyGraphData | null = null; - - if (glossaryIdParam) { - // Scoped view: try RDF first, fall back to database - if (rdfEnabled) { - data = await fetchGraphDataFromRdf(glossaryIdParam, allGlossaries); - } - - if (!data || data.nodes.length === 0) { - setDataSource('database'); - data = await fetchGraphDataFromDatabase( - glossaryIdParam, - allGlossaries - ); - } - } else { - // Global view: walk glossaries sequentially, stop at PAGE_SIZE terms - setDataSource('database'); - const terms = await loadNextTermPage(allGlossaries); - data = buildGraphFromAllTerms(terms, allGlossaries); - } - - const mergedData = mergeMetricsIntoGraph(data, metricsResponse); - filterFetchedGlossariesRef.current = new Set(); - setAssetGraphData(null); - setTermAssetCounts({}); - setGraphData(mergedData); - lastLoadCompletedRef.current = Date.now(); - } catch (error) { - showErrorToast( - isAxiosError(error) ? error : String(error), - t('server.entity-fetch-error') - ); - setGraphData(null); - } finally { - setLoading(false); - } - }, - [ - rdfEnabled, - fetchGraphDataFromRdf, - fetchGraphDataFromDatabase, - fetchAllMetrics, - mergeMetricsIntoGraph, - loadNextTermPage, - buildGraphFromAllTerms, - t, - ] - ); - - const loadAssetsForDataMode = useCallback(async () => { - const data = graphDataRef.current; - if (!data) { - return; - } - - const gen = dataModeAbortGenRef.current; - const useSpinner = dataModeInitialLoadUsesSpinnerRef.current; - if (useSpinner) { - dataModeInitialLoadUsesSpinnerRef.current = false; - setLoading(true); - } - - try { - const glossaryFilterIds = withoutOntologyAutocompleteAll( - filters.glossaryIds - ); - const termNodes = getScopedTermNodes( - data.nodes, - glossaryFilterIds, - scope, - entityId - ); - - await fetchTermAssetCounts(termNodes, glossaryFilterIds); - if (dataModeAbortGenRef.current !== gen) { - return; - } - setAssetGraphData(null); - } finally { - if (useSpinner && dataModeAbortGenRef.current === gen) { - setLoading(false); - } - } - }, [filters.glossaryIds, scope, entityId, fetchTermAssetCounts]); - - // Initialize settings - useEffect(() => { - const initializeSettings = async () => { - const [enabled, relSettings] = await Promise.all([ - checkRdfEnabled(), - getGlossaryTermRelationSettings().catch(() => ({ relationTypes: [] })), - ]); - setRdfEnabled(enabled); - setRelationTypes(relSettings.relationTypes); - }; - initializeSettings(); - }, []); - - // Fetch data when scope changes - useEffect(() => { - if (rdfEnabled === null) { - return; - } - - if (scope === 'global') { - fetchAllGlossaryData(); - } else if (scope === 'glossary' && glossaryId) { - fetchAllGlossaryData(glossaryId); - } else if (scope === 'term' && entityId) { - fetchAllGlossaryData(termGlossaryId); - } else { - setLoading(false); - } - }, [ - scope, - glossaryId, - entityId, - termGlossaryId, - rdfEnabled, - fetchAllGlossaryData, - ]); - - useEffect(() => { - if (explorationMode !== 'data') { - dataModeAbortGenRef.current++; - if (hasEnteredDataModeRef.current) { - setLoading(false); - hasEnteredDataModeRef.current = false; - } - setAssetGraphData(null); - dataModeInitialLoadUsesSpinnerRef.current = false; - if (isInGlobalDataModeRef.current && savedModelGraphRef.current) { - setGraphData(savedModelGraphRef.current); - savedModelGraphRef.current = null; - } - isInGlobalDataModeRef.current = false; - - return; - } - - hasEnteredDataModeRef.current = true; - - if (scope !== 'global') { - // Scoped data mode: fetch counts for existing model-mode graph - loadAssetsForDataMode(); - - return; - } - if (!isInGlobalDataModeRef.current) { - savedModelGraphRef.current = graphDataRef.current; - isInGlobalDataModeRef.current = true; - } - const glossaryFilterIds = withoutOntologyAutocompleteAll( - filters.glossaryIds - ); - const gen = ++dataModeAbortGenRef.current; - setLoading(true); - setGraphData(null); - setTermAssetCounts({}); - loadDataModeTerms(glossaryFilterIds) - .then( - (result: { - graphData: OntologyGraphData; - termCounts: Record; - }) => { - if (dataModeAbortGenRef.current !== gen) { - return; - } - setGraphData(result.graphData); - setTermAssetCounts(result.termCounts); - setAssetGraphData(null); - } - ) - .catch(() => {}) - .finally(() => { - if (dataModeAbortGenRef.current === gen) { - setLoading(false); - } - }); - }, [ - explorationMode, - scope, - filters.glossaryIds, - loadAssetsForDataMode, - loadDataModeTerms, - dataModeRefreshKey, - ]); - const mergeGraphResults = useCallback((results: OntologyGraphData[]) => { - setGraphData((prev) => { - const base = prev ?? { nodes: [], edges: [] }; - const existingNodeIds = new Set(base.nodes.map((n) => n.id)); - const existingEdgeKeys = new Set( - base.edges.map((e) => `${e.from}-${e.to}`) + return ( + fromType !== ASSET_NODE_TYPE && + fromType !== METRIC_NODE_TYPE && + toType !== ASSET_NODE_TYPE && + toType !== METRIC_NODE_TYPE ); - const newNodes = [...base.nodes]; - const newEdges = [...base.edges]; - - results.forEach((result) => { - result.nodes.forEach((n) => { - if (!existingNodeIds.has(n.id)) { - newNodes.push(n); - existingNodeIds.add(n.id); - } - }); - result.edges.forEach((e) => { - const key = `${e.from}-${e.to}`; - if (!existingEdgeKeys.has(key)) { - newEdges.push(e); - existingEdgeKeys.add(key); - } - }); - }); - - return { nodes: newNodes, edges: newEdges }; }); - }, []); - - const loadMissingFilteredGlossaries = useCallback( - async (filtered: string[]) => { - const loadedGlossaryIds = new Set( - (graphDataRef.current?.nodes ?? []) - .filter((n) => n.glossaryId) - .map((n) => n.glossaryId!) - ); - - const unloaded = filtered.filter( - (id) => - !loadedGlossaryIds.has(id) && - !filterFetchedGlossariesRef.current.has(id) - ); - - if (unloaded.length === 0) { - return; - } - - setLoading(true); - try { - const results = await Promise.all( - unloaded.map((id) => fetchGraphDataFromDatabase(id)) - ); - unloaded.forEach((id) => filterFetchedGlossariesRef.current.add(id)); - mergeGraphResults(results); - } catch { - // keep existing graph on error - } finally { - setLoading(false); - } - }, - [fetchGraphDataFromDatabase, mergeGraphResults] - ); - - useEffect(() => { - if (explorationMode !== 'model' || scope !== 'global') { - return; - } - const filtered = withoutOntologyAutocompleteAll(filters.glossaryIds); - if (filtered.length > 0) { - loadMissingFilteredGlossaries(filtered); - } - }, [ - explorationMode, - scope, - filters.glossaryIds, - loadMissingFilteredGlossaries, - ]); - - const handleZoomIn = useCallback(() => { - graphRef.current?.zoomIn(); - }, []); - - const handleZoomOut = useCallback(() => { - graphRef.current?.zoomOut(); - }, []); - - const handleFitToScreen = useCallback(() => { - graphRef.current?.fitView(); - }, []); - - const handleExportPng = useCallback(async () => { - try { - await graphRef.current?.exportAsPng(); - } catch { - showErrorToast(t('server.unexpected-error')); - } - }, [t]); - - const handleExportSvg = useCallback(async () => { - try { - await graphRef.current?.exportAsSvg(); - } catch { - showErrorToast(t('server.unexpected-error')); - } - }, [t]); - - // Resolve the single glossary ID applicable in the current scope so we know - // which glossary to export as an ontology file. - const exportableGlossaryId = - scope === 'glossary' - ? glossaryId - : scope === 'term' - ? termGlossaryId - : undefined; - - const exportableGlossaryName = exportableGlossaryId - ? glossaries.find((g) => g.id === exportableGlossaryId)?.name ?? - exportableGlossaryId - : undefined; - - const handleOntologyExportError = useCallback( - async (error: unknown) => { - if (isAxiosError(error)) { - // Export endpoint returns a blob — read the blob as text to extract - // the backend error message (e.g. "RDF service not enabled"). - const data = error.response?.data; - if (data instanceof Blob) { - try { - const text = await data.text(); - const parsed = JSON.parse(text); - showErrorToast( - parsed?.message ?? parsed?.error ?? t('message.export-failed') - ); - - return; - } catch { - // blob wasn't JSON — fall through to generic message - } - } - showErrorToast( - error.response?.data?.message ?? t('message.export-failed') - ); - } else { - showErrorToast(t('message.export-failed')); - } - }, - [t] - ); - - const handleExportTurtle = useCallback(async () => { - if (!exportableGlossaryId || !exportableGlossaryName) { - return; - } - try { - await downloadGlossaryOntology( - exportableGlossaryId, - exportableGlossaryName, - 'turtle' - ); - } catch (error) { - await handleOntologyExportError(error); - } - }, [exportableGlossaryId, exportableGlossaryName, handleOntologyExportError]); - - const handleExportRdfXml = useCallback(async () => { - if (!exportableGlossaryId || !exportableGlossaryName) { - return; - } - try { - await downloadGlossaryOntology( - exportableGlossaryId, - exportableGlossaryName, - 'rdfxml' - ); - } catch (error) { - await handleOntologyExportError(error); - } - }, [exportableGlossaryId, exportableGlossaryName, handleOntologyExportError]); - - const handleModeChange = useCallback( - (mode: ExplorationMode) => { - if (mode === 'data') { - modelFiltersRef.current = filters; - const nextFilters: GraphFilters = { - ...dataFiltersRef.current, - glossaryIds: filters.glossaryIds, - viewMode: 'overview' satisfies GraphViewMode, - }; - if (graphData) { - dataModeInitialLoadUsesSpinnerRef.current = true; - setLoading(true); - } - setExplorationMode(mode); - setFilters(nextFilters); - } else { - dataFiltersRef.current = filters; - setSelectedNode(null); - setExpandedTermIds(new Set()); - setExplorationMode(mode); - setFilters(modelFiltersRef.current); - setTermAssetCounts({}); - } - }, - [filters, graphData] - ); - - const handleContextMenuClose = useCallback(() => { - setContextMenu(null); - }, []); - - const handleContextMenuFocus = useCallback((node: OntologyNode) => { - setSelectedNode(node); - graphRef.current?.focusNode(node.id); - }, []); - - const handleContextMenuViewDetails = useCallback((node: OntologyNode) => { - setSelectedNode(node); - }, []); - - const getNodePath = useCallback((node: OntologyNode) => { - if (node.entityRef?.type && node.entityRef?.fullyQualifiedName) { - const entityType = Object.values(EntityType).find( - (v) => v === node.entityRef!.type - ); - if (entityType) { - return getEntityDetailsPath( - entityType, - node.entityRef.fullyQualifiedName - ); - } - } - if (node.type === METRIC_NODE_TYPE && node.fullyQualifiedName) { - return getEntityDetailsPath(EntityType.METRIC, node.fullyQualifiedName); - } - if (node.fullyQualifiedName) { - return getGlossaryTermDetailsPath(node.fullyQualifiedName); - } - - return ''; - }, []); - - const handleContextMenuOpenInNewTab = useCallback( - (node: OntologyNode) => { - const path = getNodePath(node); - if (!path) { - return; - } - window.open(path, '_blank'); - }, - [getNodePath] - ); - - const handleRefresh = useCallback(() => { - if (explorationMode === 'data') { - if (scope === 'global') { - setDataModeRefreshKey((k) => k + 1); - } else { - dataModeInitialLoadUsesSpinnerRef.current = true; - loadAssetsForDataMode(); - } - - return; - } - if (scope === 'global') { - fetchAllGlossaryData(); - } else if (scope === 'glossary' && glossaryId) { - fetchAllGlossaryData(glossaryId); - } - }, [ - explorationMode, - scope, - glossaryId, - fetchAllGlossaryData, - loadAssetsForDataMode, - ]); - - const handleScrollNearEdge = useCallback(() => { - const activeGlossaryFilter = - withoutOntologyAutocompleteAll(filters.glossaryIds).length > 0; - - if ( - explorationMode === 'data' || - filters.viewMode !== 'overview' || - activeGlossaryFilter || - !hasMoreTerms || - isLoadingMoreRef.current || - scope !== 'global' || - Date.now() - lastLoadCompletedRef.current < 2000 - ) { - return; - } - - isLoadingMoreRef.current = true; - setIsLoadingMore(true); - loadNextTermPage() - .then((terms) => { - const newPageData = buildGraphFromAllTerms(terms, glossaries); - setGraphData((prev) => { - if (!prev) { - return newPageData; - } - const existingNodeIds = new Set(prev.nodes.map((n) => n.id)); - const existingEdgeKeys = new Set( - prev.edges.map((e) => `${e.from}-${e.to}`) - ); - - return { - ...prev, - nodes: [ - ...prev.nodes, - ...newPageData.nodes.filter((n) => !existingNodeIds.has(n.id)), - ], - edges: [ - ...prev.edges, - ...newPageData.edges.filter( - (e) => !existingEdgeKeys.has(`${e.from}-${e.to}`) - ), - ], - }; - }); - }) - .catch(() => { - // keep existing graph on error - }) - .finally(() => { - lastLoadCompletedRef.current = Date.now(); - isLoadingMoreRef.current = false; - setIsLoadingMore(false); - }); - }, [ - explorationMode, - filters.glossaryIds, - filters.viewMode, - hasMoreTerms, - scope, - loadNextTermPage, - buildGraphFromAllTerms, - glossaries, - ]); - - const handleSettingsChange = useCallback((nextSettings: GraphSettings) => { - setSettings(nextSettings); - }, []); - - const handleFiltersChange = useCallback((newFilters: GraphFilters) => { - setFilters(newFilters); - }, []); - - const handleViewModeChange = useCallback((viewMode: GraphViewMode) => { - setFilters((prev) => ({ - ...prev, - viewMode, - showCrossGlossaryOnly: viewMode === 'crossGlossary', - })); - }, []); - - const handleGraphNodeClick = useCallback( - ( - node: OntologyNode, - _position?: { x: number; y: number }, - meta?: { - dataModeAssetBadgeClick?: boolean; - dataModeLoadMoreBadgeClick?: boolean; - } - ) => { - setContextMenu(null); - if (explorationMode === 'data' && isTermNode(node)) { - if (meta?.dataModeLoadMoreBadgeClick) { - const loaded = node.loadedAssetCount ?? 0; - void appendTermAssetsForTerm( - node, - DATA_MODE_ASSET_LOAD_PAGE_SIZE, - loaded - ); - setSelectedNode(null); - - return; - } - if (meta?.dataModeAssetBadgeClick) { - setExpandedTermIds((prev) => { - const wasExpanded = prev.has(node.id); - const next = new Set(prev); - if (wasExpanded) { - next.delete(node.id); - } else { - next.add(node.id); - const count = termAssetCounts[node.id] ?? node.assetCount ?? 0; - if (count > 0) { - void appendTermAssetsForTerm( - node, - DATA_MODE_ASSET_LOAD_PAGE_SIZE, - 0 - ); - } - } - - return next; - }); - setSelectedNode(null); - - return; - } - setSelectedNode(node); - - return; - } - setSelectedNode(node); - }, - [explorationMode, appendTermAssetsForTerm, termAssetCounts] - ); - - const handleGraphNodeDoubleClick = useCallback( - (node: OntologyNode) => { - const path = getNodePath(node); - if (!path) { - return; - } - window.open(path, '_blank'); - }, - [getNodePath] - ); - - const handleGraphNodeContextMenu = useCallback( - (node: OntologyNode, position: { x: number; y: number }) => { - setContextMenu({ node, position }); - }, - [] - ); - - const handleGraphPaneClick = useCallback(() => { - setContextMenu(null); - setSelectedNode(null); - }, []); - - const statsItems = useMemo(() => { - if (!graphDataToShow) { - return []; - } - const termCount = graphDataToShow.nodes.filter( - (n) => n.type === 'glossaryTerm' || n.type === 'glossaryTermIsolated' - ).length; - const metricCount = graphDataToShow.nodes.filter( - (n) => n.type === METRIC_NODE_TYPE - ).length; - const assetCount = - explorationMode === 'data' - ? graphDataToShow.nodes - .filter( - (n) => - n.type === 'glossaryTerm' || n.type === 'glossaryTermIsolated' - ) - .reduce((sum, n) => sum + (n.assetCount ?? 0), 0) - : graphDataToShow.nodes.filter((n) => n.type === ASSET_NODE_TYPE) - .length; - const relationCount = graphDataToShow.edges.length; - const isolatedCount = graphDataToShow.nodes.filter( - (n) => n.type === 'glossaryTermIsolated' - ).length; - const sourceLabel = dataSource === 'rdf' ? ' (RDF)' : ''; - const items: string[] = [ - `${termCount} ${t('label.term-plural')}`, - ...(metricCount > 0 - ? [`${metricCount} ${t('label.metric-plural')}`] - : []), - ...(explorationMode === 'data' && assetCount > 0 - ? [`${assetCount} ${t('label.data-asset-plural')}`] - : []), - `${relationCount} ${t('label.relation-plural')}`, - `${isolatedCount} ${t('label.isolated')}${sourceLabel}`, - ]; - - return items; - }, [graphDataToShow, dataSource, explorationMode, t]); - - useEffect(() => { - onLoadingChange?.(loading); - }, [loading, onLoadingChange]); - - useEffect(() => { - onStatsChange?.(statsItems); - }, [statsItems, onStatsChange]); - - const renderGraphContent = () => { - const hasNoVisibleNodes = - !graphDataToShow || graphDataToShow.nodes.length === 0; + const hasNoMatchingRelationEdges = + hasRelationFilter && termToTermEdges.length === 0; if (loading && hasNoVisibleNodes) { return ( @@ -2164,31 +190,29 @@ const OntologyExplorer: React.FC = ({ hierarchyGraphData.edges.length === 0 ) { return ( -
- - {t('message.no-hierarchical-relations-found')} - -
+ ); } if (hasNoVisibleNodes && !loading && graphDataToShow !== null) { const hasActiveFilter = withoutOntologyAutocompleteAll(filters.glossaryIds).length > 0 || - withoutOntologyAutocompleteAll(filters.relationTypes).length > 0; + hasRelationFilter; return ( -
- - {hasActiveFilter + -
+ : t('message.no-glossary-terms-found') + } + testId="ontology-graph-empty" + /> ); } @@ -2196,6 +220,15 @@ const OntologyExplorer: React.FC = ({ return null; } + if (hasNoMatchingRelationEdges && !loading) { + return ( + + ); + } + return ( <> {filters.searchQuery.trim() ? ( @@ -2222,9 +255,9 @@ const OntologyExplorer: React.FC = ({ hierarchyCombos={ isHierarchyView && hierarchyGraphData ? hierarchyGraphData.combos.map((c) => ({ + glossaryId: c.glossaryId, id: c.id, label: c.label, - glossaryId: c.glossaryId, })) : undefined } @@ -2299,7 +332,6 @@ const OntologyExplorer: React.FC = ({ ? 'tw:rounded-b-lg tw:rounded-t-none tw:border-t-0' : 'tw:rounded-lg' )}> - {/* Bottom center: Mode tabs + Search in Graph + Settings */} = ({ } /> - {/* Bottom right: Zoom / view controls */} visibleIds.has(n.id)); - edgesForGraph = mergedEdgesList.filter( - (e) => visibleIds.has(e.from) && visibleIds.has(e.to) - ); + edgesForGraph = mergedEdgesList.filter((e) => { + if (!visibleIds.has(e.from) || !visibleIds.has(e.to)) { + return false; + } + const fromIsAsset = allAssetIds.has(e.from); + const toIsAsset = allAssetIds.has(e.to); + if (fromIsAsset || toIsAsset) { + const termId = fromIsAsset ? e.to : e.from; + + return idsToExpand.has(termId); + } + + return true; + }); } else if (explorationMode === 'hierarchy') { nodesForGraph = inputNodes; edgesForGraph = inputEdges.map((e) => ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts new file mode 100644 index 000000000000..376a924f6550 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts @@ -0,0 +1,1489 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { isAxiosError } from 'axios'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; +import { SearchIndex } from '../../../enums/search.enum'; +import { Glossary } from '../../../generated/entity/data/glossary'; +import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; +import { Metric } from '../../../generated/entity/data/metric'; +import { + getGlossariesList, + getGlossaryTerms, + getGlossaryTermsAssetCounts, + getGlossaryTermsById, +} from '../../../rest/glossaryAPI'; +import { getMetrics } from '../../../rest/metricsAPI'; +import { + checkRdfEnabled, + downloadGlossaryOntology, + getGlossaryTermGraph, +} from '../../../rest/rdfAPI'; +import { searchQuery } from '../../../rest/searchAPI'; +import { + getGlossaryTermRelationSettings, + GlossaryTermRelationType, +} from '../../../rest/settingConfigAPI'; +import { + getEntityDetailsPath, + getGlossaryTermDetailsPath, +} from '../../../utils/RouterUtils'; +import { getTermQuery } from '../../../utils/SearchUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { + DATA_MODE_ASSET_LOAD_PAGE_SIZE, + DATA_MODE_MAX_RENDER_COUNT, + GLOSSARY_TERM_ASSET_COUNT_FETCH_CONCURRENCY, + LayoutType, + ONTOLOGY_TERMS_PAGE_SIZE, + withoutOntologyAutocompleteAll, +} from '../OntologyExplorer.constants'; +import { + ExplorationMode, + GraphFilters, + GraphSettings, + GraphViewMode, + OntologyEdge, + OntologyExplorerProps, + OntologyGraphData, + OntologyGraphHandle, + OntologyNode, +} from '../OntologyExplorer.interface'; +import { + ASSET_NODE_TYPE, + ASSET_RELATION_TYPE, + buildGraphFromAllTerms, + buildGraphFromCounts, + convertRdfGraphToOntologyGraph, + getScopedTermNodes, + isTermNode, + mergeMetricsIntoGraph, + METRIC_NODE_TYPE, + searchHitSourceToEntityRef, +} from '../utils/graphBuilders'; +import { useOntologyGraphDerived } from './useOntologyGraphDerived'; + +const MODEL_TERM_FIELDS = [ + TabSpecificField.RELATED_TERMS, + TabSpecificField.CHILDREN, + TabSpecificField.PARENT, + TabSpecificField.OWNERS, +]; + +const DATA_MODE_TERM_FIELDS = [TabSpecificField.PARENT]; + +export const DEFAULT_SETTINGS: GraphSettings = { + layout: LayoutType.Hierarchical, + showEdgeLabels: true, +}; + +export const DEFAULT_FILTERS: GraphFilters = { + viewMode: 'overview', + glossaryIds: [], + relationTypes: [], + showIsolatedNodes: true, + showCrossGlossaryOnly: false, + searchQuery: '', +}; + +export interface UseOntologyExplorerOptions { + scope: OntologyExplorerProps['scope']; + entityId?: string; + glossaryId?: string; + termGlossaryId?: string; + onStatsChange?: (items: string[]) => void; + onLoadingChange?: (loading: boolean) => void; +} + +async function fetchAllTermsForGlossary( + glossary: Glossary +): Promise { + const terms: GlossaryTerm[] = []; + let after: string | undefined; + let pages = 0; + const MAX_SAFE_PAGES = 50; + do { + try { + const response = await getGlossaryTerms({ + glossary: glossary.id, + fields: [ + TabSpecificField.RELATED_TERMS, + TabSpecificField.CHILDREN, + TabSpecificField.PARENT, + TabSpecificField.OWNERS, + ], + limit: 1000, + after, + }); + terms.push(...response.data); + after = response.paging?.after; + pages += 1; + } catch { + break; + } + } while (after && pages < MAX_SAFE_PAGES); + + return terms; +} + +async function fetchAllGlossariesPaginated(): Promise { + const collected: Glossary[] = []; + let afterCursor: string | undefined; + let pages = 0; + const MAX_SAFE_PAGES = 500; + do { + const response = await getGlossariesList({ + fields: 'owners,tags', + limit: 100, + after: afterCursor, + }); + collected.push(...response.data); + afterCursor = response.paging?.after; + pages += 1; + } while (afterCursor && pages < MAX_SAFE_PAGES); + + return collected; +} + +async function fetchRdfGraphData( + glossaryId: string, + allGlossaries: Glossary[] +): Promise<{ graph: OntologyGraphData | null; source: 'rdf' | 'database' }> { + const PAGE_SIZE = 500; + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const MAX_SAFE_PAGES = 100; + try { + const allNodes: Parameters< + typeof convertRdfGraphToOntologyGraph + >[0]['nodes'] = []; + const allEdges: Parameters< + typeof convertRdfGraphToOntologyGraph + >[0]['edges'] = []; + let offset = 0; + let source: string | undefined; + let pages = 0; + + while (pages < MAX_SAFE_PAGES) { + const page = await getGlossaryTermGraph({ + glossaryId, + limit: PAGE_SIZE, + offset, + includeIsolated: true, + }); + if (!page.nodes || page.nodes.length === 0) { + break; + } + allNodes.push(...page.nodes); + allEdges.push(...(page.edges ?? [])); + source = source ?? page.source; + pages += 1; + if (page.nodes.length < PAGE_SIZE) { + break; + } + offset += PAGE_SIZE; + } + + if (allNodes.length === 0) { + return { graph: null, source: 'database' }; + } + const nodesWithBadLabels = allNodes.filter( + (node) => !node.label || uuidRegex.test(node.label) + ); + if (nodesWithBadLabels.length > allNodes.length / 2) { + return { graph: null, source: 'database' }; + } + + return { + graph: convertRdfGraphToOntologyGraph( + { nodes: allNodes, edges: allEdges }, + allGlossaries + ), + source: source === 'database' ? 'database' : 'rdf', + }; + } catch { + return { graph: null, source: 'database' }; + } +} + +function collectMissingRelatedTermIds( + accumulated: GlossaryTerm[], + loadedIds: Set +): Set { + const missingIds = new Set(); + for (const term of accumulated) { + for (const relation of term.relatedTerms ?? []) { + const id = relation.term?.id; + if (id && !loadedIds.has(id)) { + missingIds.add(id); + } + } + } + + return missingIds; +} + +export function useOntologyExplorer({ + scope, + entityId, + glossaryId, + termGlossaryId, + onStatsChange, + onLoadingChange, +}: UseOntologyExplorerOptions) { + const { t } = useTranslation(); + const graphRef = useRef(null); + + // --- State --- + + const [loading, setLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [graphData, setGraphData] = useState(null); + const [assetGraphData, setAssetGraphData] = + useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const [expandedTermIds, setExpandedTermIds] = useState>( + new Set() + ); + const [loadingTermIds, setLoadingTermIds] = useState>(new Set()); + const [rdfEnabled, setRdfEnabled] = useState(null); + const [dataSource, setDataSource] = useState<'rdf' | 'database'>('database'); + const [relationTypes, setRelationTypes] = useState< + GlossaryTermRelationType[] + >([]); + const [glossaries, setGlossaries] = useState([]); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [explorationMode, setExplorationMode] = + useState('model'); + const [contextMenu, setContextMenu] = useState<{ + node: OntologyNode; + position: { x: number; y: number }; + } | null>(null); + const [termAssetCounts, setTermAssetCounts] = useState< + Record + >({}); + const [hasMoreTerms, setHasMoreTerms] = useState(false); + const [dataModeRefreshKey, setDataModeRefreshKey] = useState(0); + + // --- Refs --- + + const graphDataRef = useRef(null); + const explorationModeRef = useRef('model'); + const filterFetchedGlossariesRef = useRef>(new Set()); + + // Saves the model-mode graph when global data mode overwrites graphData so + // it can be restored when the user switches back to model mode. + const savedModelGraphRef = useRef(null); + const isInGlobalDataModeRef = useRef(false); + + const pendingGlossariesRef = useRef([]); + const partialGlossaryRef = useRef<{ + glossary: Glossary; + afterCursor: string; + } | null>(null); + const isLoadingMoreRef = useRef(false); + const lastLoadCompletedRef = useRef(0); + + const modelFiltersRef = useRef(DEFAULT_FILTERS); + const dataFiltersRef = useRef({ ...DEFAULT_FILTERS }); + const dataModeInitialLoadUsesSpinnerRef = useRef(false); + const dataModeAbortGenRef = useRef(0); + const hasEnteredDataModeRef = useRef(false); + + useEffect(() => { + graphDataRef.current = graphData; + }, [graphData]); + + useEffect(() => { + explorationModeRef.current = explorationMode; + }, [explorationMode]); + + const glossariesRef = useRef(glossaries); + glossariesRef.current = glossaries; + + const { + filteredGraphData, + hierarchyGraphData, + graphDataToShow, + hierarchyBakedPositions, + graphSearchHighlight, + glossaryColorMap, + statsItems, + isHierarchyView, + exportableGlossaryId, + exportableGlossaryName, + } = useOntologyGraphDerived({ + graphData, + assetGraphData, + loadingTermIds, + termAssetCounts, + filters, + explorationMode, + glossaries, + relationTypes, + settings, + scope, + glossaryId, + termGlossaryId, + dataSource, + }); + + // --- Data fetching callbacks --- + + const fetchTermAssetCounts = useCallback( + async ( + termNodes: OntologyNode[], + glossaryFilterIds: string[], + append = false + ) => { + if (termNodes.length === 0) { + if (!append) { + setTermAssetCounts({}); + } + + return; + } + + try { + let scopedGlossaryId: string | undefined; + if (scope === 'glossary') { + scopedGlossaryId = glossaryId; + } else if (scope === 'term') { + scopedGlossaryId = termGlossaryId; + } + const termGlossaryIds = new Set( + termNodes + .map((termNode) => termNode.glossaryId) + .filter((id): id is string => Boolean(id)) + ); + const requestedGlossaryIds = scopedGlossaryId + ? [scopedGlossaryId] + : glossaryFilterIds.length > 0 + ? glossaryFilterIds.filter((id) => termGlossaryIds.has(id)) + : []; + const glossaryFqnsToFetch = requestedGlossaryIds + .map( + (id) => + glossaries.find((glossary) => glossary.id === id) + ?.fullyQualifiedName + ) + .filter((fqn): fqn is string => Boolean(fqn)); + + const mergedResponse: Record = {}; + if (glossaryFqnsToFetch.length > 0) { + const { length } = glossaryFqnsToFetch; + const batchSize = GLOSSARY_TERM_ASSET_COUNT_FETCH_CONCURRENCY; + for (let i = 0; i < length; i += batchSize) { + const batch = glossaryFqnsToFetch.slice(i, i + batchSize); + const responses = await Promise.all( + batch.map((fqn) => getGlossaryTermsAssetCounts(fqn)) + ); + responses.forEach((response) => { + Object.assign(mergedResponse, response); + }); + } + } else { + Object.assign(mergedResponse, await getGlossaryTermsAssetCounts()); + } + + const counts: Record = {}; + termNodes.forEach((termNode) => { + const lookupKeys = [ + termNode.fullyQualifiedName, + termNode.originalLabel, + termNode.label, + ].filter((key): key is string => Boolean(key)); + const matchedKey = lookupKeys.find((key) => key in mergedResponse); + if (matchedKey) { + counts[termNode.id] = mergedResponse[matchedKey]; + } + }); + + if (append) { + setTermAssetCounts((prev) => ({ ...prev, ...counts })); + } else { + setTermAssetCounts(counts); + } + } catch { + if (!append) { + setTermAssetCounts({}); + } + } + }, + [scope, glossaryId, termGlossaryId, glossaries] + ); + + const appendTermAssetsForTerm = useCallback( + async (termNode: OntologyNode, pageSize: number, fromOffset = 0) => { + if (!isTermNode(termNode) || !termNode.fullyQualifiedName) { + return; + } + + setLoadingTermIds((prev) => new Set(prev).add(termNode.id)); + + const size = Math.max(1, pageSize); + const pageNumber = Math.floor(fromOffset / size) + 1; + + try { + const res = await searchQuery({ + query: '**', + pageNumber, + pageSize: size, + searchIndex: SearchIndex.ALL, + queryFilter: getTermQuery({ + 'tags.tagFQN': termNode.fullyQualifiedName, + }) as Record, + }); + + const hits = res.hits.hits ?? []; + const newAssetNodes: OntologyNode[] = []; + const newEdges: OntologyEdge[] = []; + + hits.forEach((hit) => { + const entityRef = searchHitSourceToEntityRef(hit._source); + if (!entityRef) { + return; + } + newAssetNodes.push({ + id: entityRef.id, + label: + entityRef.displayName || + entityRef.name || + entityRef.fullyQualifiedName || + entityRef.id, + originalLabel: + entityRef.displayName || + entityRef.name || + entityRef.fullyQualifiedName || + entityRef.id, + type: ASSET_NODE_TYPE, + fullyQualifiedName: entityRef.fullyQualifiedName, + description: entityRef.description, + entityRef, + }); + newEdges.push({ + from: entityRef.id, + to: termNode.id, + label: t('label.tagged-with'), + relationType: ASSET_RELATION_TYPE, + }); + }); + + setAssetGraphData((prev) => { + const prevNodes = prev?.nodes ?? []; + const prevEdges = prev?.edges ?? []; + const nodeIds = new Set(prevNodes.map((n) => n.id)); + const edgeKeys = new Set( + prevEdges.map((e) => `${e.from}-${e.to}-${e.relationType}`) + ); + const mergedNodes = [...prevNodes]; + const mergedEdges = [...prevEdges]; + + newAssetNodes.forEach((n) => { + if (!nodeIds.has(n.id)) { + mergedNodes.push(n); + nodeIds.add(n.id); + } + }); + newEdges.forEach((e) => { + const key = `${e.from}-${e.to}-${e.relationType}`; + if (!edgeKeys.has(key)) { + mergedEdges.push(e); + edgeKeys.add(key); + } + }); + + return { nodes: mergedNodes, edges: mergedEdges }; + }); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : String(error), + t('server.entity-fetch-error') + ); + } finally { + setLoadingTermIds((prev) => { + const next = new Set(prev); + next.delete(termNode.id); + + return next; + }); + } + }, + [t] + ); + + const fetchAllMetrics = useCallback(async (): Promise => { + const allMetrics: Metric[] = []; + let after: string | undefined; + let pages = 0; + const MAX_SAFE_PAGES = 500; + + do { + const response = await getMetrics({ fields: 'tags', limit: 100, after }); + allMetrics.push(...response.data); + after = response.paging?.after; + pages += 1; + } while (after && pages < MAX_SAFE_PAGES); + + return allMetrics; + }, []); + + const fetchTermsForGlossary = useCallback( + async ( + glossary: Glossary, + afterCursor?: string, + fields: TabSpecificField[] = MODEL_TERM_FIELDS + ): Promise<{ terms: GlossaryTerm[]; nextCursor?: string }> => { + try { + const response = await getGlossaryTerms({ + glossary: glossary.id, + fields, + limit: ONTOLOGY_TERMS_PAGE_SIZE, + after: afterCursor, + }); + + return { terms: response.data, nextCursor: response.paging?.after }; + } catch { + return { terms: [] }; + } + }, + [] + ); + + const loadNextTermPage = useCallback( + async (glossaryList?: Glossary[]): Promise => { + if (glossaryList) { + pendingGlossariesRef.current = [...glossaryList]; + partialGlossaryRef.current = null; + } + + const isDataMode = explorationModeRef.current === 'data'; + const fieldsToFetch = isDataMode + ? DATA_MODE_TERM_FIELDS + : MODEL_TERM_FIELDS; + const accumulated: GlossaryTerm[] = []; + + if (partialGlossaryRef.current) { + const { glossary, afterCursor } = partialGlossaryRef.current; + const { terms, nextCursor } = await fetchTermsForGlossary( + glossary, + afterCursor, + fieldsToFetch + ); + accumulated.push(...terms); + partialGlossaryRef.current = nextCursor + ? { glossary, afterCursor: nextCursor } + : null; + } + + while ( + accumulated.length < ONTOLOGY_TERMS_PAGE_SIZE && + pendingGlossariesRef.current.length > 0 + ) { + const glossary = pendingGlossariesRef.current.shift()!; + const { terms, nextCursor } = await fetchTermsForGlossary( + glossary, + undefined, + fieldsToFetch + ); + accumulated.push(...terms); + if (nextCursor) { + partialGlossaryRef.current = { glossary, afterCursor: nextCursor }; + + break; + } + } + + setHasMoreTerms( + pendingGlossariesRef.current.length > 0 || + partialGlossaryRef.current !== null + ); + + if (!isDataMode) { + const loadedIds = new Set(accumulated.map((term) => term.id)); + const missingIds = collectMissingRelatedTermIds(accumulated, loadedIds); + + if (missingIds.size > 0) { + const CONCURRENCY = 8; + const missingIdList = Array.from(missingIds); + for (let i = 0; i < missingIdList.length; i += CONCURRENCY) { + const batch = missingIdList.slice(i, i + CONCURRENCY); + const fetched = await Promise.allSettled( + batch.map((id) => + getGlossaryTermsById(id, { + fields: [ + TabSpecificField.RELATED_TERMS, + TabSpecificField.CHILDREN, + TabSpecificField.PARENT, + TabSpecificField.OWNERS, + ], + }) + ) + ); + fetched.forEach((r) => { + if (r.status === 'fulfilled') { + accumulated.push(r.value); + } + }); + } + } + } + + return accumulated; + }, + [fetchTermsForGlossary] + ); + + const buildGraphFromAllTermsCb = useCallback( + (terms: GlossaryTerm[], glossaryList: Glossary[]) => + buildGraphFromAllTerms(terms, glossaryList, t), + [t] + ); + + const buildGraphFromCountsCb = useCallback( + (counts: Record) => + buildGraphFromCounts(counts, glossaries, t), + [glossaries, t] + ); + + const loadDataModeTerms = useCallback( + async ( + glossaryFilterIds: string[] + ): Promise<{ + graphData: OntologyGraphData; + termCounts: Record; + }> => { + let counts: Record; + + if (glossaryFilterIds.length > 0) { + const filteredFqns = glossaries + .filter((g) => glossaryFilterIds.includes(g.id)) + .map((g) => g.fullyQualifiedName) + .filter((fqn): fqn is string => Boolean(fqn)); + + const results = await Promise.all( + filteredFqns.map((fqn) => getGlossaryTermsAssetCounts(fqn)) + ); + const merged: Record = {}; + results.forEach((r) => Object.assign(merged, r)); + counts = + Object.keys(merged).length > 0 + ? merged + : await getGlossaryTermsAssetCounts(); + } else { + counts = await getGlossaryTermsAssetCounts(); + } + + const termCounts = Object.fromEntries( + Object.entries(counts).slice(0, DATA_MODE_MAX_RENDER_COUNT) + ); + const baseGraph = buildGraphFromCountsCb(termCounts); + const savedGraph = savedModelGraphRef.current; + + if (savedGraph && savedGraph.edges.length > 0) { + const fqnSet = new Set( + baseGraph.nodes + .map((n) => n.fullyQualifiedName) + .filter((fqn): fqn is string => Boolean(fqn)) + ); + const uuidToFqn = new Map(); + savedGraph.nodes.forEach((n) => { + if (n.id && n.fullyQualifiedName) { + uuidToFqn.set(n.id, n.fullyQualifiedName); + } + }); + + const existingEdgeKeys = new Set( + baseGraph.edges.map((e) => `${e.from}-${e.to}`) + ); + const termTermEdges: OntologyEdge[] = []; + + savedGraph.edges.forEach((edge) => { + if (edge.relationType === 'parentOf') { + return; + } + const fromFqn = uuidToFqn.get(edge.from); + const toFqn = uuidToFqn.get(edge.to); + if ( + !fromFqn || + !toFqn || + !fqnSet.has(fromFqn) || + !fqnSet.has(toFqn) + ) { + return; + } + const key = `${fromFqn}-${toFqn}`; + if (!existingEdgeKeys.has(key)) { + existingEdgeKeys.add(key); + termTermEdges.push({ + from: fromFqn, + to: toFqn, + label: edge.label, + relationType: edge.relationType, + }); + } + }); + + return { + graphData: { + nodes: baseGraph.nodes, + edges: [...baseGraph.edges, ...termTermEdges], + }, + termCounts, + }; + } + + return { graphData: baseGraph, termCounts }; + }, + [buildGraphFromCountsCb, glossaries] + ); + + const fetchGraphDataFromDatabase = useCallback( + async (glossaryIdParam?: string, allGlossaries?: Glossary[]) => { + const glossariesToUse = allGlossaries ?? glossariesRef.current; + const glossariesToFetch = glossaryIdParam + ? glossariesToUse.filter((g) => g.id === glossaryIdParam) + : glossariesToUse; + + const CONCURRENCY = 8; + const allTerms: GlossaryTerm[] = []; + for (let i = 0; i < glossariesToFetch.length; i += CONCURRENCY) { + const batch = glossariesToFetch.slice(i, i + CONCURRENCY); + const results = await Promise.allSettled( + batch.map((g) => fetchAllTermsForGlossary(g)) + ); + results.forEach((r) => { + if (r.status === 'fulfilled') { + allTerms.push(...r.value); + } + }); + } + + if (glossaryIdParam) { + const fetchedIds = new Set(allTerms.map((term) => term.id)); + const missingIds = new Set(); + allTerms.forEach((term) => { + term.relatedTerms?.forEach((relation) => { + const id = relation.term?.id; + if (id && !fetchedIds.has(id)) { + missingIds.add(id); + } + }); + }); + + if (missingIds.size > 0) { + const missingIdList = Array.from(missingIds); + for (let i = 0; i < missingIdList.length; i += CONCURRENCY) { + const batch = missingIdList.slice(i, i + CONCURRENCY); + const fetched = await Promise.allSettled( + batch.map((id) => + getGlossaryTermsById(id, { + fields: [ + TabSpecificField.RELATED_TERMS, + TabSpecificField.CHILDREN, + TabSpecificField.PARENT, + TabSpecificField.OWNERS, + ], + }) + ) + ); + fetched.forEach((r) => { + if (r.status === 'fulfilled') { + allTerms.push(r.value); + } + }); + } + } + } + + return buildGraphFromAllTermsCb(allTerms, glossariesToFetch); + }, + // Note: glossaries intentionally excluded — allGlossaries param is always passed + [buildGraphFromAllTermsCb] + ); + + const fetchAllGlossaryData = useCallback( + async (glossaryIdParam?: string) => { + setLoading(true); + try { + const [allGlossaries, metricsResponse] = await Promise.all([ + fetchAllGlossariesPaginated(), + fetchAllMetrics().catch(() => [] as Metric[]), + ]); + + setGlossaries(allGlossaries); + + let data: OntologyGraphData | null = null; + + if (glossaryIdParam) { + if (rdfEnabled) { + const { graph: rdfGraph, source } = await fetchRdfGraphData( + glossaryIdParam, + allGlossaries + ); + if (rdfGraph && rdfGraph.nodes.length > 0) { + data = rdfGraph; + setDataSource(source); + } + } + + if (!data || data.nodes.length === 0) { + setDataSource('database'); + data = await fetchGraphDataFromDatabase( + glossaryIdParam, + allGlossaries + ); + } + } else { + setDataSource('database'); + const terms = await loadNextTermPage(allGlossaries); + data = buildGraphFromAllTermsCb(terms, allGlossaries); + } + + const mergedData = mergeMetricsIntoGraph(data, metricsResponse, t); + filterFetchedGlossariesRef.current = new Set(); + setAssetGraphData(null); + setTermAssetCounts({}); + setGraphData(mergedData); + lastLoadCompletedRef.current = Date.now(); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : String(error), + t('server.entity-fetch-error') + ); + setGraphData(null); + } finally { + setLoading(false); + } + }, + [ + rdfEnabled, + fetchGraphDataFromDatabase, + fetchAllMetrics, + loadNextTermPage, + buildGraphFromAllTermsCb, + t, + ] + ); + + const loadAssetsForDataMode = useCallback(async () => { + const data = graphDataRef.current; + if (!data) { + return; + } + + const gen = dataModeAbortGenRef.current; + const useSpinner = dataModeInitialLoadUsesSpinnerRef.current; + if (useSpinner) { + dataModeInitialLoadUsesSpinnerRef.current = false; + setLoading(true); + } + + try { + const glossaryFilterIds = withoutOntologyAutocompleteAll( + filters.glossaryIds + ); + const termNodes = getScopedTermNodes( + data.nodes, + glossaryFilterIds, + scope, + entityId + ); + await fetchTermAssetCounts(termNodes, glossaryFilterIds); + if (dataModeAbortGenRef.current !== gen) { + return; + } + setAssetGraphData(null); + } finally { + if (useSpinner && dataModeAbortGenRef.current === gen) { + setLoading(false); + } + } + }, [filters.glossaryIds, scope, entityId, fetchTermAssetCounts]); + + const mergeGraphResults = useCallback((results: OntologyGraphData[]) => { + setGraphData((prev) => { + const base = prev ?? { nodes: [], edges: [] }; + const existingNodeIds = new Set(base.nodes.map((n) => n.id)); + const existingEdgeKeys = new Set( + base.edges.map((e) => `${e.from}-${e.to}`) + ); + const newNodes = [...base.nodes]; + const newEdges = [...base.edges]; + + results.forEach((result) => { + result.nodes.forEach((n) => { + if (!existingNodeIds.has(n.id)) { + newNodes.push(n); + existingNodeIds.add(n.id); + } + }); + result.edges.forEach((e) => { + const key = `${e.from}-${e.to}`; + if (!existingEdgeKeys.has(key)) { + newEdges.push(e); + existingEdgeKeys.add(key); + } + }); + }); + + return { nodes: newNodes, edges: newEdges }; + }); + }, []); + + const loadMissingFilteredGlossaries = useCallback( + async (filtered: string[]) => { + const loadedGlossaryIds = new Set( + (graphDataRef.current?.nodes ?? []) + .filter((n) => n.glossaryId) + .map((n) => n.glossaryId!) + ); + const unloaded = filtered.filter( + (id) => + !loadedGlossaryIds.has(id) && + !filterFetchedGlossariesRef.current.has(id) + ); + if (unloaded.length === 0) { + return; + } + + setLoading(true); + try { + const results = await Promise.all( + unloaded.map((id) => fetchGraphDataFromDatabase(id)) + ); + unloaded.forEach((id) => filterFetchedGlossariesRef.current.add(id)); + mergeGraphResults(results); + } catch { + // keep existing graph on error + } finally { + setLoading(false); + } + }, + [fetchGraphDataFromDatabase, mergeGraphResults] + ); + + // --- Effects --- + + useEffect(() => { + const initializeSettings = async () => { + const [enabled, relSettings] = await Promise.all([ + checkRdfEnabled(), + getGlossaryTermRelationSettings().catch(() => ({ relationTypes: [] })), + ]); + setRdfEnabled(enabled); + setRelationTypes(relSettings.relationTypes); + }; + initializeSettings(); + }, []); + + useEffect(() => { + if (rdfEnabled === null) { + return; + } + if (scope === 'global') { + fetchAllGlossaryData(); + } else if (scope === 'glossary' && glossaryId) { + fetchAllGlossaryData(glossaryId); + } else if (scope === 'term' && entityId) { + fetchAllGlossaryData(termGlossaryId); + } else { + setLoading(false); + } + }, [ + scope, + glossaryId, + entityId, + termGlossaryId, + rdfEnabled, + fetchAllGlossaryData, + ]); + + useEffect(() => { + if (explorationMode !== 'data') { + dataModeAbortGenRef.current++; + if (hasEnteredDataModeRef.current) { + setLoading(false); + hasEnteredDataModeRef.current = false; + } + setAssetGraphData(null); + dataModeInitialLoadUsesSpinnerRef.current = false; + if (isInGlobalDataModeRef.current && savedModelGraphRef.current) { + setGraphData(savedModelGraphRef.current); + savedModelGraphRef.current = null; + } + isInGlobalDataModeRef.current = false; + + return; + } + + hasEnteredDataModeRef.current = true; + + if (scope !== 'global') { + loadAssetsForDataMode(); + + return; + } + + if (!isInGlobalDataModeRef.current) { + savedModelGraphRef.current = graphDataRef.current; + isInGlobalDataModeRef.current = true; + } + + const glossaryFilterIds = withoutOntologyAutocompleteAll( + filters.glossaryIds + ); + const gen = ++dataModeAbortGenRef.current; + setLoading(true); + setGraphData(null); + setTermAssetCounts({}); + loadDataModeTerms(glossaryFilterIds) + .then( + (result: { + graphData: OntologyGraphData; + termCounts: Record; + }) => { + if (dataModeAbortGenRef.current !== gen) { + return; + } + setGraphData(result.graphData); + setTermAssetCounts(result.termCounts); + setAssetGraphData(null); + } + ) + .catch(() => {}) + .finally(() => { + if (dataModeAbortGenRef.current === gen) { + setLoading(false); + } + }); + }, [ + explorationMode, + scope, + filters.glossaryIds, + loadAssetsForDataMode, + loadDataModeTerms, + dataModeRefreshKey, + ]); + + useEffect(() => { + if (explorationMode !== 'model' || scope !== 'global') { + return; + } + const filtered = withoutOntologyAutocompleteAll(filters.glossaryIds); + if (filtered.length > 0) { + loadMissingFilteredGlossaries(filtered); + } + }, [ + explorationMode, + scope, + filters.glossaryIds, + loadMissingFilteredGlossaries, + ]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + useEffect(() => { + onStatsChange?.(statsItems); + }, [statsItems, onStatsChange]); + + // --- Event handlers --- + + const handleZoomIn = useCallback(() => { + graphRef.current?.zoomIn(); + }, []); + + const handleZoomOut = useCallback(() => { + graphRef.current?.zoomOut(); + }, []); + + const handleFitToScreen = useCallback(() => { + graphRef.current?.fitView(); + }, []); + + const handleExportPng = useCallback(async () => { + try { + await graphRef.current?.exportAsPng(); + } catch { + showErrorToast(t('server.unexpected-error')); + } + }, [t]); + + const handleExportSvg = useCallback(async () => { + try { + await graphRef.current?.exportAsSvg(); + } catch { + showErrorToast(t('server.unexpected-error')); + } + }, [t]); + + const handleOntologyExportError = useCallback( + async (error: unknown) => { + if (isAxiosError(error)) { + const data = error.response?.data; + if (data instanceof Blob) { + try { + const text = await data.text(); + const parsed = JSON.parse(text); + showErrorToast( + parsed?.message ?? parsed?.error ?? t('message.export-failed') + ); + + return; + } catch { + // blob wasn't JSON — fall through to generic message + } + } + showErrorToast( + error.response?.data?.message ?? t('message.export-failed') + ); + } else { + showErrorToast(t('message.export-failed')); + } + }, + [t] + ); + + const handleExportTurtle = useCallback(async () => { + if (!exportableGlossaryId || !exportableGlossaryName) { + return; + } + try { + await downloadGlossaryOntology( + exportableGlossaryId, + exportableGlossaryName, + 'turtle' + ); + } catch (error) { + await handleOntologyExportError(error); + } + }, [exportableGlossaryId, exportableGlossaryName, handleOntologyExportError]); + + const handleExportRdfXml = useCallback(async () => { + if (!exportableGlossaryId || !exportableGlossaryName) { + return; + } + try { + await downloadGlossaryOntology( + exportableGlossaryId, + exportableGlossaryName, + 'rdfxml' + ); + } catch (error) { + await handleOntologyExportError(error); + } + }, [exportableGlossaryId, exportableGlossaryName, handleOntologyExportError]); + + const handleModeChange = useCallback( + (mode: ExplorationMode) => { + if (mode === 'data') { + modelFiltersRef.current = filters; + const nextFilters: GraphFilters = { + ...dataFiltersRef.current, + glossaryIds: filters.glossaryIds, + viewMode: 'overview' satisfies GraphViewMode, + }; + if (graphData) { + dataModeInitialLoadUsesSpinnerRef.current = true; + setLoading(true); + } + setExplorationMode(mode); + setFilters(nextFilters); + } else { + dataFiltersRef.current = filters; + setSelectedNode(null); + setExpandedTermIds(new Set()); + setExplorationMode(mode); + setFilters(modelFiltersRef.current); + setTermAssetCounts({}); + } + }, + [filters, graphData] + ); + + const handleContextMenuClose = useCallback(() => { + setContextMenu(null); + }, []); + + const handleContextMenuFocus = useCallback((node: OntologyNode) => { + setSelectedNode(node); + graphRef.current?.focusNode(node.id); + }, []); + + const handleContextMenuViewDetails = useCallback((node: OntologyNode) => { + setSelectedNode(node); + }, []); + + const getNodePath = useCallback((node: OntologyNode) => { + if (node.entityRef?.type && node.entityRef?.fullyQualifiedName) { + const entityType = Object.values(EntityType).find( + (v) => v === node.entityRef!.type + ); + if (entityType) { + return getEntityDetailsPath( + entityType, + node.entityRef.fullyQualifiedName + ); + } + } + if (node.type === METRIC_NODE_TYPE && node.fullyQualifiedName) { + return getEntityDetailsPath(EntityType.METRIC, node.fullyQualifiedName); + } + if (node.fullyQualifiedName) { + return getGlossaryTermDetailsPath(node.fullyQualifiedName); + } + + return ''; + }, []); + + const handleContextMenuOpenInNewTab = useCallback( + (node: OntologyNode) => { + const path = getNodePath(node); + if (!path) { + return; + } + window.open(path, '_blank'); + }, + [getNodePath] + ); + + const handleRefresh = useCallback(() => { + if (explorationMode === 'data') { + if (scope === 'global') { + setDataModeRefreshKey((k) => k + 1); + } else { + dataModeInitialLoadUsesSpinnerRef.current = true; + loadAssetsForDataMode(); + } + + return; + } + if (scope === 'global') { + fetchAllGlossaryData(); + } else if (scope === 'glossary' && glossaryId) { + fetchAllGlossaryData(glossaryId); + } + }, [ + explorationMode, + scope, + glossaryId, + fetchAllGlossaryData, + loadAssetsForDataMode, + ]); + + const handleScrollNearEdge = useCallback(() => { + const activeGlossaryFilter = + withoutOntologyAutocompleteAll(filters.glossaryIds).length > 0; + + if ( + explorationMode === 'data' || + filters.viewMode !== 'overview' || + activeGlossaryFilter || + !hasMoreTerms || + isLoadingMoreRef.current || + scope !== 'global' || + Date.now() - lastLoadCompletedRef.current < 2000 + ) { + return; + } + + isLoadingMoreRef.current = true; + setIsLoadingMore(true); + loadNextTermPage() + .then((terms) => { + const newPageData = buildGraphFromAllTermsCb(terms, glossaries); + setGraphData((prev) => { + if (!prev) { + return newPageData; + } + const existingNodeIds = new Set(prev.nodes.map((n) => n.id)); + const existingEdgeKeys = new Set( + prev.edges.map((e) => `${e.from}-${e.to}`) + ); + + return { + ...prev, + nodes: [ + ...prev.nodes, + ...newPageData.nodes.filter((n) => !existingNodeIds.has(n.id)), + ], + edges: [ + ...prev.edges, + ...newPageData.edges.filter( + (e) => !existingEdgeKeys.has(`${e.from}-${e.to}`) + ), + ], + }; + }); + }) + .catch(() => { + // keep existing graph on error + }) + .finally(() => { + lastLoadCompletedRef.current = Date.now(); + isLoadingMoreRef.current = false; + setIsLoadingMore(false); + }); + }, [ + explorationMode, + filters.glossaryIds, + filters.viewMode, + hasMoreTerms, + scope, + loadNextTermPage, + buildGraphFromAllTermsCb, + glossaries, + ]); + + const handleSettingsChange = useCallback((nextSettings: GraphSettings) => { + setSettings(nextSettings); + }, []); + + const handleFiltersChange = useCallback((newFilters: GraphFilters) => { + setFilters(newFilters); + }, []); + + const handleViewModeChange = useCallback((viewMode: GraphViewMode) => { + setFilters((prev) => ({ + ...prev, + viewMode, + showCrossGlossaryOnly: viewMode === 'crossGlossary', + })); + }, []); + + const handleGraphNodeClick = useCallback( + ( + node: OntologyNode, + _position?: { x: number; y: number }, + meta?: { + dataModeAssetBadgeClick?: boolean; + dataModeLoadMoreBadgeClick?: boolean; + } + ) => { + setContextMenu(null); + if (explorationMode === 'data' && isTermNode(node)) { + if (meta?.dataModeLoadMoreBadgeClick) { + const loaded = node.loadedAssetCount ?? 0; + void appendTermAssetsForTerm( + node, + DATA_MODE_ASSET_LOAD_PAGE_SIZE, + loaded + ); + setSelectedNode(null); + + return; + } + if (meta?.dataModeAssetBadgeClick) { + setExpandedTermIds((prev) => { + const wasExpanded = prev.has(node.id); + const next = new Set(prev); + if (wasExpanded) { + next.delete(node.id); + } else { + next.add(node.id); + const count = termAssetCounts[node.id] ?? node.assetCount ?? 0; + if (count > 0) { + void appendTermAssetsForTerm( + node, + DATA_MODE_ASSET_LOAD_PAGE_SIZE, + 0 + ); + } + } + + return next; + }); + setSelectedNode(null); + + return; + } + } + setSelectedNode(node); + }, + [explorationMode, appendTermAssetsForTerm, termAssetCounts] + ); + + const handleGraphNodeDoubleClick = useCallback( + (node: OntologyNode) => { + const path = getNodePath(node); + if (!path) { + return; + } + window.open(path, '_blank'); + }, + [getNodePath] + ); + + const handleGraphNodeContextMenu = useCallback( + (node: OntologyNode, position: { x: number; y: number }) => { + setContextMenu({ node, position }); + }, + [] + ); + + const handleGraphPaneClick = useCallback(() => { + setContextMenu(null); + setSelectedNode(null); + }, []); + + return { + graphRef, + loading, + isLoadingMore, + glossaries, + relationTypes, + settings, + filters, + explorationMode, + selectedNode, + contextMenu, + expandedTermIds, + rdfEnabled, + graphDataToShow, + filteredGraphData, + hierarchyGraphData, + hierarchyBakedPositions, + graphSearchHighlight, + glossaryColorMap, + isHierarchyView, + exportableGlossaryId, + setFilters, + setSelectedNode, + handleZoomIn, + handleZoomOut, + handleFitToScreen, + handleExportPng, + handleExportSvg, + handleExportTurtle, + handleExportRdfXml, + handleModeChange, + handleViewModeChange, + handleRefresh, + handleScrollNearEdge, + handleSettingsChange, + handleFiltersChange, + handleContextMenuClose, + handleContextMenuFocus, + handleContextMenuViewDetails, + handleContextMenuOpenInNewTab, + handleGraphNodeClick, + handleGraphNodeDoubleClick, + handleGraphNodeContextMenu, + handleGraphPaneClick, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraph.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraph.ts index a2ea7bf9d5ab..c840d8d80d8b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraph.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraph.ts @@ -173,6 +173,20 @@ function isDataModeLoadMoreBadgeShape(originalTarget: unknown): boolean { return idx === 1; } +function stripNodePositionsForDataMode( + nodes: T[] +): T[] { + return nodes.map((node) => { + const style = node.style as Record | undefined; + if (!style || (!('x' in style) && !('y' in style))) { + return node; + } + const { x: _x, y: _y, ...restStyle } = style; + + return { ...node, style: restStyle }; + }); +} + interface GraphNodeMeta { color?: string; assetColor?: string; @@ -1436,7 +1450,11 @@ export function useOntologyGraph({ if (canPatchInPlace) { try { - graph.updateNodeData(graphData.nodes ?? []); + let nodesToUpdate = graphData.nodes ?? []; + if (isDataMode) { + nodesToUpdate = stripNodePositionsForDataMode(nodesToUpdate); + } + graph.updateNodeData(nodesToUpdate); graph.updateEdgeData(graphData.edges ?? []); graph.draw(); @@ -1612,7 +1630,11 @@ export function useOntologyGraph({ if (assetFingerprintChanged && !termFingerprintChanged && topologySynced) { assetFingerprintRef.current = newAssetFingerprint; try { - graph.updateNodeData(graphData.nodes ?? []); + let nodesToUpdate = graphData.nodes ?? []; + if (isDataMode) { + nodesToUpdate = stripNodePositionsForDataMode(nodesToUpdate); + } + graph.updateNodeData(nodesToUpdate); graph.updateEdgeData(graphData.edges ?? []); graph.draw(); } catch { @@ -1657,6 +1679,20 @@ export function useOntologyGraph({ setClickedEdgeIdRef.current(null); + const preUpdatePositions: Record = {}; + if (isDataMode && !termFingerprintChanged) { + inputNodesRef.current.forEach((n) => { + try { + const pos = graph.getElementPosition(n.id); + if (pos) { + preUpdatePositions[n.id] = [pos[0], pos[1]]; + } + } catch { + // not yet positioned + } + }); + } + graph.setData(graphData); if ( @@ -1667,7 +1703,34 @@ export function useOntologyGraph({ } else if (isModelViewLocal && layoutType === LayoutEngine.Circular) { positionCircularNodes(graph); } else if (hasBakedPositions) { - applyBakedPositions(graph, graphData.nodes ?? []); + if ( + isDataMode && + !termFingerprintChanged && + Object.keys(preUpdatePositions).length > 0 + ) { + const updates = (graphData.nodes ?? []) + .map((node) => { + const snapshotPos = preUpdatePositions[String(node.id)]; + if (!snapshotPos) { + return null; + } + + return { + id: node.id, + style: { + ...((node.style as Record) ?? {}), + x: snapshotPos[0], + y: snapshotPos[1], + }, + }; + }) + .filter((u): u is NonNullable => u !== null); + if (updates.length > 0) { + graph.updateNodeData(updates); + } + } else { + applyBakedPositions(graph, graphData.nodes ?? []); + } } else { graph.setLayout(layoutOptions); try { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraphDerived.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraphDerived.ts new file mode 100644 index 000000000000..b8e50915e40d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyGraphDerived.ts @@ -0,0 +1,397 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Glossary } from '../../../generated/entity/data/glossary'; +import { GlossaryTermRelationType } from '../../../rest/settingConfigAPI'; +import { + LayoutEngine, + RELATION_COLORS, + toLayoutEngineType, + withoutOntologyAutocompleteAll, +} from '../OntologyExplorer.constants'; +import { + ExplorationMode, + GraphFilters, + GraphSettings, + OntologyEdge, + OntologyExplorerProps, + OntologyGraphData, + OntologyNode, +} from '../OntologyExplorer.interface'; +import { + ASSET_NODE_TYPE, + ASSET_RELATION_TYPE, + GLOSSARY_COLORS, + METRIC_NODE_TYPE, +} from '../utils/graphBuilders'; +import { computeGraphSearchHighlight } from '../utils/graphSearchHighlight'; +import { buildHierarchyGraphs } from '../utils/hierarchyGraphBuilder'; +import { computeGlossaryGroupPositions } from '../utils/layoutCalculations'; + +export interface UseOntologyGraphDerivedOptions { + graphData: OntologyGraphData | null; + assetGraphData: OntologyGraphData | null; + loadingTermIds: Set; + termAssetCounts: Record; + filters: GraphFilters; + explorationMode: ExplorationMode; + glossaries: Glossary[]; + relationTypes: GlossaryTermRelationType[]; + settings: GraphSettings; + scope: OntologyExplorerProps['scope']; + glossaryId?: string; + termGlossaryId?: string; + dataSource: 'rdf' | 'database'; +} + +export function useOntologyGraphDerived({ + graphData, + assetGraphData, + loadingTermIds, + termAssetCounts, + filters, + explorationMode, + glossaries, + relationTypes, + settings, + scope, + glossaryId, + termGlossaryId, + dataSource, +}: UseOntologyGraphDerivedOptions) { + const { t } = useTranslation(); + + const glossaryColorMap = useMemo(() => { + const map: Record = {}; + glossaries.forEach((g, i) => { + map[g.id] = GLOSSARY_COLORS[i % GLOSSARY_COLORS.length]; + }); + + return map; + }, [glossaries]); + + const loadedAssetCountPerTerm = useMemo(() => { + const counts: Record = {}; + assetGraphData?.edges.forEach((e) => { + if (e.relationType === ASSET_RELATION_TYPE) { + counts[e.to] = (counts[e.to] ?? 0) + 1; + } + }); + + return counts; + }, [assetGraphData]); + + const combinedGraphData = useMemo(() => { + if (!graphData) { + return null; + } + + if (explorationMode === 'data') { + const nodesWithAssetCounts = graphData.nodes.map((node) => { + if ( + node.type !== 'glossaryTerm' && + node.type !== 'glossaryTermIsolated' + ) { + return node; + } + + return { + ...node, + assetCount: termAssetCounts[node.id] ?? 0, + loadedAssetCount: loadedAssetCountPerTerm[node.id] ?? 0, + isLoadingAssets: loadingTermIds.has(node.id), + }; + }); + + if (!assetGraphData) { + return { nodes: nodesWithAssetCounts, edges: graphData.edges }; + } + + const mergedNodeIds = new Set(nodesWithAssetCounts.map((n) => n.id)); + const mergedNodes = [...nodesWithAssetCounts]; + assetGraphData.nodes.forEach((n) => { + if (!mergedNodeIds.has(n.id)) { + mergedNodeIds.add(n.id); + mergedNodes.push(n); + } + }); + + const edgeKey = (e: OntologyEdge) => + `${e.from}-${e.to}-${e.relationType}`; + const mergedEdgeKeys = new Set(graphData.edges.map(edgeKey)); + const mergedEdges = [...graphData.edges]; + assetGraphData.edges.forEach((e) => { + const k = edgeKey(e); + if (!mergedEdgeKeys.has(k)) { + mergedEdgeKeys.add(k); + mergedEdges.push(e); + } + }); + + return { nodes: mergedNodes, edges: mergedEdges }; + } + + return graphData; + }, [ + graphData, + assetGraphData, + explorationMode, + termAssetCounts, + loadedAssetCountPerTerm, + loadingTermIds, + ]); + + const filteredGraphData = useMemo(() => { + if (!combinedGraphData) { + return null; + } + + let filteredNodes = [...combinedGraphData.nodes]; + let filteredEdges = [...combinedGraphData.edges]; + + const glossaryFilterIds = withoutOntologyAutocompleteAll( + filters.glossaryIds + ); + const relationTypeFilterIds = withoutOntologyAutocompleteAll( + filters.relationTypes + ); + + if (glossaryFilterIds.length > 0) { + const glossaryTermIds = new Set( + filteredNodes + .filter( + (n) => + n.type !== METRIC_NODE_TYPE && + n.type !== ASSET_NODE_TYPE && + n.glossaryId && + glossaryFilterIds.includes(n.glossaryId) + ) + .map((n) => n.id) + ); + + const edgeKey = (e: OntologyEdge) => + `${e.from}-${e.to}-${e.relationType}`; + const glossaryNeighborIds = new Set(glossaryTermIds); + const glossaryEdgeKeys = new Set(); + + filteredEdges.forEach((edge) => { + const isIncidentToGlossary = + glossaryTermIds.has(edge.from) || glossaryTermIds.has(edge.to); + if (!isIncidentToGlossary) { + return; + } + glossaryNeighborIds.add(edge.from); + glossaryNeighborIds.add(edge.to); + glossaryEdgeKeys.add(edgeKey(edge)); + }); + + filteredNodes = filteredNodes.filter((n) => { + if (n.type === 'glossary') { + return glossaryFilterIds.includes(n.id); + } + + return glossaryNeighborIds.has(n.id); + }); + filteredEdges = filteredEdges.filter((e) => + glossaryEdgeKeys.has(edgeKey(e)) + ); + } + + if (relationTypeFilterIds.length > 0) { + const nodeTypeMap = new Map(filteredNodes.map((n) => [n.id, n.type])); + filteredEdges = filteredEdges.filter((e) => { + const fromType = nodeTypeMap.get(e.from); + const toType = nodeTypeMap.get(e.to); + if ( + explorationMode === 'data' && + (fromType === ASSET_NODE_TYPE || + fromType === METRIC_NODE_TYPE || + toType === ASSET_NODE_TYPE || + toType === METRIC_NODE_TYPE) + ) { + return true; + } + + return relationTypeFilterIds.includes(e.relationType); + }); + } + + if (filters.showCrossGlossaryOnly) { + const nodeById = new Map(filteredNodes.map((node) => [node.id, node])); + filteredEdges = filteredEdges.filter((edge) => { + const fromGlossary = nodeById.get(edge.from)?.glossaryId; + const toGlossary = nodeById.get(edge.to)?.glossaryId; + + return fromGlossary && toGlossary && fromGlossary !== toGlossary; + }); + + const nodeIds = new Set(); + filteredEdges.forEach((edge) => { + nodeIds.add(edge.from); + nodeIds.add(edge.to); + }); + filteredNodes = filteredNodes.filter((node) => nodeIds.has(node.id)); + } + + if (!filters.showIsolatedNodes) { + const connectedIds = new Set(); + filteredEdges.forEach((e) => { + connectedIds.add(e.from); + connectedIds.add(e.to); + }); + filteredNodes = filteredNodes.filter( + (n) => connectedIds.has(n.id) || n.type === 'glossary' + ); + } + + return { nodes: filteredNodes, edges: filteredEdges }; + }, [combinedGraphData, filters, explorationMode]); + + const isHierarchyView = filters.viewMode === 'hierarchy'; + + const hierarchyGraphData = useMemo(() => { + if (!isHierarchyView || !filteredGraphData) { + return null; + } + + const terms = filteredGraphData.nodes.filter( + (n) => n.type !== ASSET_NODE_TYPE && n.type !== METRIC_NODE_TYPE + ); + const termIds = new Set(terms.map((term) => term.id)); + const relations = filteredGraphData.edges.filter( + (e) => termIds.has(e.from) && termIds.has(e.to) + ); + const glossaryNames: Record = {}; + glossaries.forEach((g) => { + if (g.id && g.name) { + glossaryNames[g.id] = g.name; + } + }); + + return buildHierarchyGraphs({ + terms, + relations, + relationSettings: { relationTypes }, + relationColors: RELATION_COLORS, + glossaryNames, + }); + }, [isHierarchyView, filteredGraphData, relationTypes, glossaries]); + + const graphDataToShow = useMemo(() => { + if (isHierarchyView && hierarchyGraphData) { + return { + nodes: hierarchyGraphData.nodes, + edges: hierarchyGraphData.edges.map((e) => ({ + from: e.from, + to: e.to, + relationType: e.relationType, + label: e.relationType, + })), + }; + } + + return filteredGraphData; + }, [isHierarchyView, hierarchyGraphData, filteredGraphData]); + + const hierarchyBakedPositions = useMemo(() => { + if (!isHierarchyView || !hierarchyGraphData) { + return undefined; + } + const engine = toLayoutEngineType(settings.layout); + if (engine !== LayoutEngine.Circular) { + return undefined; + } + + return computeGlossaryGroupPositions(hierarchyGraphData.nodes, engine); + }, [hierarchyGraphData, isHierarchyView, settings.layout]); + + const graphSearchHighlight = useMemo(() => { + if (!graphDataToShow) { + return null; + } + + return computeGraphSearchHighlight( + graphDataToShow.nodes, + graphDataToShow.edges, + filters.searchQuery, + glossaries, + relationTypes + ); + }, [graphDataToShow, filters.searchQuery, glossaries, relationTypes]); + + const exportableGlossaryId = + scope === 'glossary' + ? glossaryId + : scope === 'term' + ? termGlossaryId + : undefined; + + const exportableGlossaryName = exportableGlossaryId + ? glossaries.find((g) => g.id === exportableGlossaryId)?.name ?? + exportableGlossaryId + : undefined; + + const statsItems = useMemo(() => { + if (!graphDataToShow) { + return []; + } + const termCount = graphDataToShow.nodes.filter( + (n) => n.type === 'glossaryTerm' || n.type === 'glossaryTermIsolated' + ).length; + const metricCount = graphDataToShow.nodes.filter( + (n) => n.type === METRIC_NODE_TYPE + ).length; + const assetCount = + explorationMode === 'data' + ? graphDataToShow.nodes + .filter( + (n) => + n.type === 'glossaryTerm' || n.type === 'glossaryTermIsolated' + ) + .reduce((sum, n) => sum + ((n as OntologyNode).assetCount ?? 0), 0) + : graphDataToShow.nodes.filter((n) => n.type === ASSET_NODE_TYPE) + .length; + const relationCount = graphDataToShow.edges.length; + const isolatedCount = graphDataToShow.nodes.filter( + (n) => n.type === 'glossaryTermIsolated' + ).length; + const sourceLabel = dataSource === 'rdf' ? ' (RDF)' : ''; + + return [ + `${termCount} ${t('label.term-plural')}`, + ...(metricCount > 0 + ? [`${metricCount} ${t('label.metric-plural')}`] + : []), + ...(explorationMode === 'data' && assetCount > 0 + ? [`${assetCount} ${t('label.data-asset-plural')}`] + : []), + `${relationCount} ${t('label.relation-plural')}`, + `${isolatedCount} ${t('label.isolated')}${sourceLabel}`, + ]; + }, [graphDataToShow, dataSource, explorationMode, t]); + + return { + filteredGraphData, + hierarchyGraphData, + graphDataToShow, + hierarchyBakedPositions, + graphSearchHighlight, + glossaryColorMap, + statsItems, + isHierarchyView, + exportableGlossaryId, + exportableGlossaryName, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts new file mode 100644 index 000000000000..f4551bcf6a0d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts @@ -0,0 +1,392 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { TFunction } from 'i18next'; +import { EntityType } from '../../../enums/entity.enum'; +import { Glossary } from '../../../generated/entity/data/glossary'; +import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; +import { Metric } from '../../../generated/entity/data/metric'; +import { EntityReference } from '../../../generated/entity/type'; +import { TagSource } from '../../../generated/type/tagLabel'; +import { TermRelation } from '../../../generated/type/termRelation'; +import { GraphData } from '../../../rest/rdfAPI'; +import { + OntologyEdge, + OntologyExplorerProps, + OntologyGraphData, + OntologyNode, +} from '../OntologyExplorer.interface'; + +export const GLOSSARY_COLORS = [ + '#3062d4', + '#7c3aed', + '#059669', + '#dc2626', + '#ea580c', + '#0891b2', + '#4f46e5', + '#ca8a04', + '#be185d', + '#0d9488', +]; + +export const METRIC_NODE_TYPE = 'metric'; +export const METRIC_RELATION_TYPE = 'metricFor'; +export const ASSET_NODE_TYPE = 'dataAsset'; +export const ASSET_RELATION_TYPE = 'hasGlossaryTerm'; + +export function isValidUUID(str: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + str + ); +} + +export function isTermNode(node: OntologyNode): boolean { + return node.type === 'glossaryTerm' || node.type === 'glossaryTermIsolated'; +} + +export function isDataAssetLikeNode(node: OntologyNode): boolean { + return node.type === ASSET_NODE_TYPE || node.type === METRIC_NODE_TYPE; +} + +export function getScopedTermNodes( + nodes: OntologyNode[], + glossaryIds: string[], + scope: OntologyExplorerProps['scope'], + entityId?: string +): OntologyNode[] { + let termNodes = nodes.filter(isTermNode); + + if (glossaryIds.length > 0) { + termNodes = termNodes.filter( + (node) => node.glossaryId && glossaryIds.includes(node.glossaryId) + ); + } + + if (scope === 'term' && entityId) { + termNodes = termNodes.filter((node) => node.id === entityId); + } + + return termNodes; +} + +export function searchHitSourceToEntityRef( + source: unknown +): EntityReference | null { + if (!source || typeof source !== 'object') { + return null; + } + const s = source as Record; + const id = s.id; + const typeField = s.entityType ?? s.type; + const fqn = s.fullyQualifiedName; + if ( + typeof id !== 'string' || + typeof typeField !== 'string' || + typeof fqn !== 'string' + ) { + return null; + } + + return { + id, + type: typeField, + name: typeof s.name === 'string' ? s.name : undefined, + displayName: typeof s.displayName === 'string' ? s.displayName : undefined, + fullyQualifiedName: fqn, + description: typeof s.description === 'string' ? s.description : undefined, + }; +} + +export function convertRdfGraphToOntologyGraph( + rdfData: GraphData, + glossaryList: Glossary[] +): OntologyGraphData { + const glossaryNameToId = new Map(); + glossaryList.forEach((g) => { + glossaryNameToId.set(g.name.toLowerCase(), g.id); + if (g.fullyQualifiedName) { + glossaryNameToId.set(g.fullyQualifiedName.toLowerCase(), g.id); + } + }); + + const nodes: OntologyNode[] = rdfData.nodes.map((node) => { + let glossaryId: string | undefined; + if (node.group) { + glossaryId = glossaryNameToId.get(node.group.toLowerCase()); + } + if (!glossaryId && node.fullyQualifiedName) { + const glossaryName = node.fullyQualifiedName.split('.')[0]; + glossaryId = glossaryNameToId.get(glossaryName.toLowerCase()); + } + + let nodeLabel = node.label; + const isUuidLabel = + nodeLabel && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + nodeLabel + ); + + if (!nodeLabel || isUuidLabel) { + if (node.fullyQualifiedName) { + const parts = node.fullyQualifiedName.split('.'); + nodeLabel = parts[parts.length - 1]; + } else if (node.title) { + nodeLabel = node.title; + } else { + nodeLabel = node.id; + } + } + + return { + id: node.id, + label: nodeLabel, + type: node.type || 'glossaryTerm', + fullyQualifiedName: node.fullyQualifiedName, + description: node.description, + glossaryId, + group: node.group, + }; + }); + + const edgeMap = new Map(); + rdfData.edges.forEach((edge) => { + const relationType = edge.relationType || 'relatedTo'; + const nodePairKey = [edge.from, edge.to].sort().join('-'); + const existingEdge = edgeMap.get(nodePairKey); + if ( + !existingEdge || + (existingEdge.relationType === 'relatedTo' && + relationType !== 'relatedTo') + ) { + edgeMap.set(nodePairKey, { + from: edge.from, + to: edge.to, + label: edge.label || relationType, + relationType, + }); + } + }); + + return { nodes, edges: Array.from(edgeMap.values()) }; +} + +export function buildGraphFromAllTerms( + terms: GlossaryTerm[], + glossaryList: Glossary[], + t: TFunction +): OntologyGraphData { + const nodesMap = new Map(); + const edges: OntologyEdge[] = []; + const edgeSet = new Set(); + + terms.forEach((term) => { + if (!term.id || !isValidUUID(term.id)) { + return; + } + + const hasRelations = + (term.relatedTerms && term.relatedTerms.length > 0) || + (term.children && term.children.length > 0) || + term.parent; + + nodesMap.set(term.id, { + id: term.id, + label: term.displayName || term.name, + type: hasRelations ? 'glossaryTerm' : 'glossaryTermIsolated', + fullyQualifiedName: term.fullyQualifiedName, + description: term.description, + glossaryId: term.glossary?.id, + group: glossaryList.find((g) => g.id === term.glossary?.id)?.name, + owners: term.owners, + }); + + if (term.relatedTerms && term.relatedTerms.length > 0) { + term.relatedTerms.forEach((relation: TermRelation) => { + const relatedTermRef = relation.term; + const relationType = relation.relationType || 'relatedTo'; + if (relatedTermRef?.id && isValidUUID(relatedTermRef.id)) { + const nodePairKey = [term.id, relatedTermRef.id].sort().join('-'); + if (!edgeSet.has(nodePairKey)) { + edgeSet.add(nodePairKey); + edges.push({ + from: term.id, + to: relatedTermRef.id, + label: relationType, + relationType, + }); + } else if (relationType !== 'relatedTo') { + const existingEdgeIndex = edges.findIndex( + (e) => + [e.from, e.to].sort().join('-') === nodePairKey && + e.relationType === 'relatedTo' + ); + if (existingEdgeIndex !== -1) { + edges[existingEdgeIndex] = { + from: term.id, + to: relatedTermRef.id, + label: relationType, + relationType, + }; + } + } + } + }); + } + + if (term.parent?.id && isValidUUID(term.parent.id)) { + const edgeKey = `parent-${term.parent.id}-${term.id}`; + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ + from: term.parent.id, + to: term.id, + label: t('label.parent'), + relationType: 'parentOf', + }); + } + } + }); + + const nodeIds = new Set(nodesMap.keys()); + const validEdges = edges.filter( + (e) => nodeIds.has(e.from) && nodeIds.has(e.to) + ); + + return { nodes: Array.from(nodesMap.values()), edges: validEdges }; +} + +export function buildGraphFromCounts( + counts: Record, + glossaries: Glossary[], + t: TFunction +): OntologyGraphData { + const fqnSet = new Set(Object.keys(counts)); + const nodes: OntologyNode[] = []; + const edges: OntologyEdge[] = []; + const edgeSet = new Set(); + + fqnSet.forEach((fqn) => { + const parts = fqn.split('.'); + const label = parts[parts.length - 1]; + const glossaryFqn = parts[0]; + const glossary = glossaries.find( + (g) => g.fullyQualifiedName === glossaryFqn || g.name === glossaryFqn + ); + + nodes.push({ + id: fqn, + label, + type: 'glossaryTerm', + fullyQualifiedName: fqn, + glossaryId: glossary?.id, + group: glossary?.name ?? glossaryFqn, + originalLabel: fqn, + }); + + if (parts.length > 2) { + const parentFqn = parts.slice(0, -1).join('.'); + if (fqnSet.has(parentFqn)) { + const edgeKey = `parent-${parentFqn}-${fqn}`; + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ + from: parentFqn, + to: fqn, + label: t('label.parent'), + relationType: 'parentOf', + }); + } + } + } + }); + + return { nodes, edges }; +} + +export function mergeMetricsIntoGraph( + graph: OntologyGraphData | null, + metricList: Metric[], + t: TFunction +): OntologyGraphData | null { + if (!graph || metricList.length === 0) { + return graph; + } + + const nodes = [...graph.nodes]; + const edges = [...graph.edges]; + const nodeIds = new Set(nodes.map((n) => n.id)); + const edgeKeys = new Set( + edges.map((edge) => `${edge.from}-${edge.to}-${edge.relationType}`) + ); + const termByFqn = new Map(); + + nodes.forEach((node) => { + if (node.fullyQualifiedName) { + termByFqn.set(node.fullyQualifiedName, node); + } + }); + + metricList.forEach((metric) => { + const glossaryTags = + metric.tags?.filter((tag) => tag.source === TagSource.Glossary) ?? []; + + if (glossaryTags.length === 0 || !metric.id) { + return; + } + + const relatedTerms = glossaryTags + .map((tag) => termByFqn.get(tag.tagFQN)) + .filter((term): term is OntologyNode => Boolean(term)); + + if (relatedTerms.length === 0) { + return; + } + + if (!nodeIds.has(metric.id)) { + nodes.push({ + id: metric.id, + label: metric.displayName || metric.name, + originalLabel: metric.displayName || metric.name, + type: METRIC_NODE_TYPE, + fullyQualifiedName: metric.fullyQualifiedName, + description: metric.description, + group: t('label.metric-plural'), + entityRef: { + id: metric.id, + name: metric.name, + displayName: metric.displayName, + type: EntityType.METRIC, + fullyQualifiedName: metric.fullyQualifiedName, + description: metric.description, + }, + }); + nodeIds.add(metric.id); + } + + relatedTerms.forEach((term) => { + const edgeKey = `${metric.id}-${term.id}-${METRIC_RELATION_TYPE}`; + if (!edgeKeys.has(edgeKey)) { + edges.push({ + from: metric.id, + to: term.id, + label: 'Metric for', + relationType: METRIC_RELATION_TYPE, + }); + edgeKeys.add(edgeKey); + } + }); + }); + + return { nodes, edges }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts index cb6c5ba8824a..2312c8e4bffd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts @@ -56,6 +56,7 @@ const MarketplaceIcon = createIconWithStroke( export const SIDEBAR_NESTED_KEYS = { [ROUTES.OBSERVABILITY_ALERTS]: ROUTES.OBSERVABILITY_ALERTS, + [ROUTES.ONTOLOGY_EXPLORER]: ROUTES.ONTOLOGY_EXPLORER, }; export const SIDEBAR_LIST: Array = [ diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 1de43916991d..75e7e07acc8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "لم تقم بعرض أي أصول بيانات مؤخرًا. استكشف للعثور على شيء مثير للاهتمام!", "no-reference-available": "لا توجد مراجع متاحة.", "no-related-terms-available": "لا توجد مصطلحات ذات صلة متاحة.", + "no-relations-for-selected-filter": "لم يتم العثور على علاقات لأنواع العلاقة المحددة. حاول تحديد أنواع مختلفة.", "no-relations-found": "لم يتم العثور على علاقات لهذا المصطلح", "no-retry-queue-records": "لم يتم العثور على سجلات إعادة محاولة الفهرسة المباشرة. تمت فهرسة جميع الكيانات بنجاح.", "no-roles-assigned": "لم يتم تعيين أدوار", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 277045152dda..179d6d0ab29e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Keine kürzlich angesehenen Daten.", "no-reference-available": "Keine Verweise verfügbar.", "no-related-terms-available": "Keine verwandten Begriffe verfügbar.", + "no-relations-for-selected-filter": "Keine Beziehungen für die ausgewählten Beziehungstypen gefunden. Versuchen Sie, andere Typen auszuwählen.", "no-relations-found": "Keine Beziehungen für diesen Begriff gefunden", "no-retry-queue-records": "Keine Live-Indexierungs-Wiederholungsdatensätze gefunden. Alle Entitäten wurden erfolgreich indexiert.", "no-roles-assigned": "Keine Rollen zugewiesen", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 6693e37c0726..0dfa8dcd37d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "You haven't viewed any data assets recently. Explore to find something interesting!", "no-reference-available": "No references available.", "no-related-terms-available": "No related terms available.", + "no-relations-for-selected-filter": "No relations found for the selected relation types. Try selecting different types.", "no-relations-found": "No relations found for this term", "no-retry-queue-records": "No live indexing retry records found. All entities are indexed successfully.", "no-roles-assigned": "No roles assigned", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index ca3115e3622b..44ca51f550e0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "No hay datos vistos recientemente.", "no-reference-available": "No hay referencias disponibles.", "no-related-terms-available": "No hay términos relacionados disponibles.", + "no-relations-for-selected-filter": "No se encontraron relaciones para los tipos de relación seleccionados. Intente seleccionar tipos diferentes.", "no-relations-found": "No se encontraron relaciones para este término", "no-retry-queue-records": "No se encontraron registros de reintentos de indexación en vivo. Todas las entidades se indexaron correctamente.", "no-roles-assigned": "No hay roles asignados", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 5af5333bd19b..d2e8afc35ff4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Aucune donnée récemment consultée.", "no-reference-available": "Aucune référence disponible.", "no-related-terms-available": "Aucun terme associé disponible.", + "no-relations-for-selected-filter": "Aucune relation trouvée pour les types de relations sélectionnés. Essayez de sélectionner d'autres types.", "no-relations-found": "Aucune relation trouvée pour ce terme", "no-retry-queue-records": "Aucun enregistrement de nouvelle tentative d'indexation en direct trouvé. Toutes les entités sont indexées avec succès.", "no-roles-assigned": "Aucun rôle attribué", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index dbefb35a6946..7e4992d09b86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Aínda non visualizaches ningún activo de datos recentemente. Explora para atopar algo interesante!", "no-reference-available": "Non hai referencias dispoñibles.", "no-related-terms-available": "Non hai termos relacionados dispoñibles.", + "no-relations-for-selected-filter": "Non se atoparon relacións para os tipos de relación seleccionados. Tenta seleccionar tipos diferentes.", "no-relations-found": "Non se atoparon relacións para este termo", "no-retry-queue-records": "Non se atoparon rexistros de reintentos de indexación en vivo. Todas as entidades foron indexadas correctamente.", "no-roles-assigned": "Non se asignaron roles", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 4b8611409c1f..f23674f6c46a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "אין נתונים שנצפו לאחרונה.", "no-reference-available": "אין הפניות זמינות.", "no-related-terms-available": "אין מונחים קשורים זמינים.", + "no-relations-for-selected-filter": "לא נמצאו קשרים עבור סוגי הקשר שנבחרו. נסה לבחור סוגים שונים.", "no-relations-found": "לא נמצאו קשרים למונח זה", "no-retry-queue-records": "לא נמצאו רשומות ניסיון חוזר לאינדוקס בזמן אמת. כל הישויות אונדקסו בהצלחה.", "no-roles-assigned": "לא הוקצו תפקידים", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index ee674389ee65..0407d0dafe07 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "最近表示したデータはありません。", "no-reference-available": "参照はありません。", "no-related-terms-available": "関連用語はありません。", + "no-relations-for-selected-filter": "選択したリレーションタイプに一致するリレーションが見つかりませんでした。別のタイプを選択してみてください。", "no-relations-found": "この用語の関係は見つかりませんでした", "no-retry-queue-records": "ライブインデックスのリトライレコードが見つかりません。すべてのエンティティが正常にインデックスされています。", "no-roles-assigned": "ロールが割り当てられていません", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index 805f71883243..a5fa6016e977 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "최근에 본 데이터 자산이 없습니다. 흥미로운 것을 찾으려면 탐색해 보세요!", "no-reference-available": "사용 가능한 참조가 없습니다.", "no-related-terms-available": "사용 가능한 관련 용어가 없습니다.", + "no-relations-for-selected-filter": "선택한 관계 유형에 대한 관계를 찾을 수 없습니다. 다른 유형을 선택해 보세요.", "no-relations-found": "이 용어에 대한 관계를 찾을 수 없습니다", "no-retry-queue-records": "라이브 인덱싱 재시도 레코드가 없습니다. 모든 엔터티가 성공적으로 인덱싱되었습니다.", "no-roles-assigned": "할당된 역할 없음", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 1e0bf3c6c86d..918bdeded34b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "तुम्ही अलीकडे कोणतेही डेटा ॲसेट पाहिलेले नाहीत. काहीतरी मनोरंजक शोधण्यासाठी एक्सप्लोर करा!", "no-reference-available": "कोणतेही संदर्भ उपलब्ध नाहीत.", "no-related-terms-available": "संबंधित संज्ञा उपलब्ध नाहीत.", + "no-relations-for-selected-filter": "निवडलेल्या संबंध प्रकारांसाठी कोणतेही संबंध आढळले नाहीत. वेगळे प्रकार निवडण्याचा प्रयत्न करा.", "no-relations-found": "या संज्ञेसाठी संबंध आढळले नाहीत", "no-retry-queue-records": "लाइव्ह इंडेक्सिंग पुन्हा प्रयत्न रेकॉर्ड सापडले नाहीत. सर्व एंटिटी यशस्वीरित्या इंडेक्स केल्या आहेत.", "no-roles-assigned": "कोणत्याही भूमिका नियुक्त केलेल्या नाहीत", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index cbc20838c9be..baf87ab345c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Geen recent bekeken data.", "no-reference-available": "Geen referenties beschikbaar.", "no-related-terms-available": "Geen gerelateerde termen beschikbaar.", + "no-relations-for-selected-filter": "Geen relaties gevonden voor de geselecteerde relatietypes. Probeer andere types te selecteren.", "no-relations-found": "Geen relaties gevonden voor deze term", "no-retry-queue-records": "Geen live indexering herhalingspogingen gevonden. Alle entiteiten zijn succesvol geïndexeerd.", "no-roles-assigned": "Geen rollen toegewezen", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 59542b33cba2..603481f93694 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "شما اخیراً هیچ دارایی داده‌ای را مشاهده نکرده‌اید. کاوش کنید تا چیزی جالب پیدا کنید!", "no-reference-available": "هیچ مرجعی در دسترس نیست.", "no-related-terms-available": "هیچ واژه مرتبطی موجود نیست.", + "no-relations-for-selected-filter": "No relations found for the selected relation types. Try selecting different types.", "no-relations-found": "رابطه‌ای برای این اصطلاح یافت نشد", "no-retry-queue-records": "Nenhum registro de tentativa de indexação ao vivo encontrado. Todas as entidades foram indexadas com sucesso.", "no-roles-assigned": "هیچ نقشی اختصاص داده نشده است.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 825956dcfabe..3303258d4dcf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Nenhum dado visualizado recentemente.", "no-reference-available": "Nenhuma referência disponível.", "no-related-terms-available": "Nenhum termo relacionado disponível.", + "no-relations-for-selected-filter": "Nenhuma relação encontrada para os tipos de relação selecionados. Tente selecionar tipos diferentes.", "no-relations-found": "Nenhuma relação encontrada para este termo", "no-retry-queue-records": "Nenhum registro de tentativa de indexação ao vivo encontrado. Todas as entidades foram indexadas com sucesso.", "no-roles-assigned": "Nenhum papel atribuído", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index b056822ec6d7..6e7931d08052 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Ainda não visualizou nenhum ativo de dados recentemente. Explore para encontrar algo interessante!", "no-reference-available": "Nenhuma referência disponível.", "no-related-terms-available": "Nenhum termo relacionado disponível.", + "no-relations-for-selected-filter": "Não foram encontradas relações para os tipos de relação selecionados. Tente selecionar tipos diferentes.", "no-relations-found": "Não foram encontradas relações para este termo", "no-retry-queue-records": "Nenhum registo de tentativa de indexação em direto encontrado. Todas as entidades foram indexadas com sucesso.", "no-roles-assigned": "Nenhum papel atribuído", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 66c041dd7bf4..d7c049881dec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Нет недавно просмотренных данных.", "no-reference-available": "Нет доступных ссылок.", "no-related-terms-available": "Нет доступных связанных терминов.", + "no-relations-for-selected-filter": "Отношения для выбранных типов не найдены. Попробуйте выбрать другие типы.", "no-relations-found": "Связи для данного термина не найдены", "no-retry-queue-records": "Записей повторной индексации не найдено. Все сущности успешно проиндексированы.", "no-roles-assigned": "Роли не назначены", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index ce983d325990..2929a9cd37c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "คุณยังไม่ได้ดูสินทรัพย์ข้อมูลใด ๆ เมื่อเร็ว ๆ นี้ สำรวจเพื่อค้นหาสิ่งที่น่าสนใจ!", "no-reference-available": "ไม่มีการอ้างอิงที่ใช้งานได้", "no-related-terms-available": "ไม่มีคำที่เกี่ยวข้องที่สามารถใช้งานได้", + "no-relations-for-selected-filter": "ไม่พบความสัมพันธ์สำหรับประเภทความสัมพันธ์ที่เลือก ลองเลือกประเภทอื่น", "no-relations-found": "ไม่พบความสัมพันธ์สำหรับคำศัพท์นี้", "no-retry-queue-records": "ไม่พบบันทึกการลองจัดทำดัชนีใหม่ เอนทิตีทั้งหมดได้รับการจัดทำดัชนีสำเร็จแล้ว", "no-roles-assigned": "ไม่มีบทบาทที่มอบหมาย", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 0d320bc6436b..ebae9c3470f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "Son zamanlarda herhangi bir veri varlığı görüntülemediniz. İlginç bir şey bulmak için keşfedin!", "no-reference-available": "Kullanılabilir referans yok.", "no-related-terms-available": "Kullanılabilir ilgili terim yok.", + "no-relations-for-selected-filter": "Seçilen ilişki türleri için ilişki bulunamadı. Farklı türler seçmeyi deneyin.", "no-relations-found": "Bu terim için ilişki bulunamadı", "no-retry-queue-records": "Canlı endeksleme yeniden deneme kaydı bulunamadı. Tüm varlıklar başarıyla endekslendi.", "no-roles-assigned": "Atanmış rol yok", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 49d6ff3a7887..5059be40bf82 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "无最近查看过的数据", "no-reference-available": "无可用参考", "no-related-terms-available": "无相关术语可用", + "no-relations-for-selected-filter": "未找到所选关系类型的关系。请尝试选择其他类型。", "no-relations-found": "未找到该术语的关系", "no-retry-queue-records": "未发现实时索引重试记录。所有实体均已成功索引。", "no-roles-assigned": "未分配角色", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index 90cb968d4c61..09564c29a77c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -2880,6 +2880,7 @@ "no-recently-viewed-date": "您最近沒有檢視任何資料資產。探索以尋找有趣的東西!", "no-reference-available": "沒有可用的參考。", "no-related-terms-available": "沒有可用的相關術語。", + "no-relations-for-selected-filter": "未找到所選關係類型的關係。請嘗試選擇其他類型。", "no-relations-found": "找不到此術語的關係", "no-retry-queue-records": "未發現即時索引重試記錄。所有實體均已成功索引。", "no-roles-assigned": "未指派角色",