diff --git a/.github/workflows/docker-build-frontend.yml b/.github/workflows/docker-build-frontend.yml index b896fc00..d7833a42 100644 --- a/.github/workflows/docker-build-frontend.yml +++ b/.github/workflows/docker-build-frontend.yml @@ -87,6 +87,10 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 # https://github.com/peter-evans/dockerhub-description - name: Update Docker Hub description diff --git a/ichub-frontend/Dockerfile b/ichub-frontend/Dockerfile index 1f5dc909..6427d687 100644 --- a/ichub-frontend/Dockerfile +++ b/ichub-frontend/Dockerfile @@ -23,17 +23,26 @@ # Stage 1: Build the React app FROM node:23-alpine AS builder +# Set working directory first +WORKDIR /app + # Copy package files and install dependencies COPY package.json package-lock.json ./ -RUN npm ci --ignore-scripts -# Copy the rest of the source code -COPY . /app +# Install dependencies with optimizations for Docker +RUN npm ci --include=dev --ignore-scripts --prefer-offline -# Set the working directory -WORKDIR /app +# Copy only necessary source files for better caching +COPY src/ ./src/ +COPY public/ ./public/ +COPY index.html ./ +COPY vite.config.ts ./ +COPY tsconfig*.json ./ + +# Set production environment for optimized build +ENV NODE_ENV=production -# Build the application +# Build the application with optimized settings RUN npm run build diff --git a/ichub-frontend/package-lock.json b/ichub-frontend/package-lock.json index 49f07f46..eba841bc 100644 --- a/ichub-frontend/package-lock.json +++ b/ichub-frontend/package-lock.json @@ -6389,4 +6389,4 @@ } } } -} +} \ No newline at end of file diff --git a/ichub-frontend/package.json b/ichub-frontend/package.json index 169c6407..290324c9 100644 --- a/ichub-frontend/package.json +++ b/ichub-frontend/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "vite build", + "build:full": "tsc -b && vite build", + "typecheck": "tsc --noEmit", "lint": "eslint .", "preview": "vite preview", "build:docker": "IMAGE=ichub-frontend && docker build -t $IMAGE -f Dockerfile .", @@ -41,4 +43,4 @@ "vite": "^6.1.0", "vitest": "^3.2.4" } -} +} \ No newline at end of file diff --git a/ichub-frontend/src/components/general/AdditionalSidebar.tsx b/ichub-frontend/src/components/general/AdditionalSidebar.tsx new file mode 100644 index 00000000..99a9450d --- /dev/null +++ b/ichub-frontend/src/components/general/AdditionalSidebar.tsx @@ -0,0 +1,53 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box } from '@mui/material'; +import { useAdditionalSidebar } from '../../hooks/useAdditionalSidebar'; + +const AdditionalSidebar: React.FC = () => { + const { isVisible, content } = useAdditionalSidebar(); + + return ( + + + {content} + + + ); +}; + +export default AdditionalSidebar; diff --git a/ichub-frontend/src/components/layout/AdditionalSidebar.tsx b/ichub-frontend/src/components/layout/AdditionalSidebar.tsx new file mode 100644 index 00000000..bc74b5c0 --- /dev/null +++ b/ichub-frontend/src/components/layout/AdditionalSidebar.tsx @@ -0,0 +1,51 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box } from '@mui/material'; +import { useAdditionalSidebar } from '../../hooks/useAdditionalSidebar'; + +const AdditionalSidebar: React.FC = () => { + const { isVisible, content } = useAdditionalSidebar(); + + return ( + + {content} + + ); +}; + +export default AdditionalSidebar; diff --git a/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx b/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx new file mode 100644 index 00000000..5e101381 --- /dev/null +++ b/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx @@ -0,0 +1,70 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React, { createContext, useState, ReactNode, useCallback } from 'react'; + +export interface AdditionalSidebarContextType { + isVisible: boolean; + content: ReactNode; + showSidebar: (content: ReactNode) => void; + hideSidebar: () => void; + toggleSidebar: () => void; +} + +export const AdditionalSidebarContext = createContext(undefined); + +interface AdditionalSidebarProviderProps { + children: ReactNode; +} + +export const AdditionalSidebarProvider: React.FC = ({ children }) => { + const [isVisible, setIsVisible] = useState(false); + const [content, setContent] = useState(null); + + const showSidebar = useCallback((newContent: ReactNode) => { + setContent(newContent); + setIsVisible(true); + }, []); + + const hideSidebar = useCallback(() => { + setIsVisible(false); + setContent(null); + }, []); + + const toggleSidebar = useCallback(() => { + setIsVisible(!isVisible); + }, [isVisible]); + + return ( + + {children} + + ); +}; diff --git a/ichub-frontend/src/contexts/SidebarContext.tsx b/ichub-frontend/src/contexts/SidebarContext.tsx new file mode 100644 index 00000000..372cdc15 --- /dev/null +++ b/ichub-frontend/src/contexts/SidebarContext.tsx @@ -0,0 +1,76 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface SidebarContextType { + isVisible: boolean; + content: ReactNode | null; + showSidebar: (content: ReactNode) => void; + hideSidebar: () => void; + toggleSidebar: () => void; +} + +const SidebarContext = createContext(undefined); + +export const useSidebar = () => { + const context = useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider'); + } + return context; +}; + +interface SidebarProviderProps { + children: ReactNode; +} + +export const SidebarProvider: React.FC = ({ children }) => { + const [isVisible, setIsVisible] = useState(false); + const [content, setContent] = useState(null); + + const showSidebar = (content: ReactNode) => { + setContent(content); + setIsVisible(true); + }; + + const hideSidebar = () => { + setIsVisible(false); + setContent(null); + }; + + const toggleSidebar = () => { + setIsVisible(!isVisible); + }; + + return ( + + {children} + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/api.ts b/ichub-frontend/src/features/part-discovery/api.ts new file mode 100644 index 00000000..3f770666 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/api.ts @@ -0,0 +1,955 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import axios from 'axios'; +import { + getIchubBackendUrl, + getGovernanceConfig, + getDtrPoliciesConfig, + GovernanceConfig, + GovernanceConstraint, + GovernanceRule, + GovernancePolicy +} from '../../services/EnvironmentService'; +import { ApiPartData } from '../../types/product'; +import { CatalogPartTwinCreateType, TwinReadType } from '../../types/twin'; +import { ShellDiscoveryResponse, getNextPageCursor, getPreviousPageCursor, hasNextPage, hasPreviousPage } from './utils'; + +const CATALOG_PART_MANAGEMENT_BASE_PATH = '/part-management/catalog-part'; +const SHARE_CATALOG_PART_BASE_PATH = '/share/catalog-part'; +const TWIN_MANAGEMENT_BASE_PATH = '/twin-management/catalog-part-twin'; +const SHELL_DISCOVERY_BASE_PATH = '/discover/shells'; +const backendUrl = getIchubBackendUrl(); + +// Cache system with configuration change detection +interface PolicyCache { + configHash: string; + policies: OdrlPolicy[]; +} + +let dtrGovernancePoliciesCache: PolicyCache | null = null; +const governancePoliciesCache: Map = new Map(); +let defaultGovernancePolicyCache: PolicyCache | null = null; + +/** + * Generate a SHA-256 hash from an object for cache invalidation + */ +const generateConfigHash = async (config: unknown): Promise => { + const configString = JSON.stringify(config, null, 0); + const encoder = new TextEncoder(); + const data = encoder.encode(configString); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +}; + +/** + * Generate DTR policies with all constraint permutations + */ +const generateDtrPoliciesWithPermutations = (dtrPolicies: GovernancePolicy[]): OdrlPolicy[] => { + const allPolicyPermutations: OdrlPolicy[] = []; + + for (const policy of dtrPolicies) { + // Generate permutations for each rule type + const permissionPermutations = generateRulesPermutations(policy.permission); + const prohibitionPermutations = generateRulesPermutations(policy.prohibition); + const obligationPermutations = generateRulesPermutations(policy.obligation); + + // Create cartesian product of all rule permutations + for (const permission of permissionPermutations) { + for (const prohibition of prohibitionPermutations) { + for (const obligation of obligationPermutations) { + allPolicyPermutations.push({ + "odrl:permission": convertRulesToOdrl(permission), + "odrl:prohibition": convertRulesToOdrl(prohibition), + "odrl:obligation": convertRulesToOdrl(obligation) + }); + } + } + } + } + + return allPolicyPermutations; +}; + +/** + * Generate governance policies with permutations for a specific semantic ID + */ +const generateGovernancePoliciesWithPermutations = async (semanticId: string, config: GovernanceConfig[]): Promise => { + // First, try to find a specific configuration for the semantic ID + const relevantConfig = config.find(cfg => cfg.semanticid === semanticId); + + if (relevantConfig) { + // Found specific configuration, use it + return generateDtrPoliciesWithPermutations(relevantConfig.policies); + } + + // No specific configuration found, use default policies as fallback + console.log(`No specific governance configuration found for semantic ID: ${semanticId}, using default policies`); + return await getCachedDefaultGovernancePolicies(); +}; + +/** + * Generate default governance policy permutations + */ +const generateDefaultGovernancePolicyPermutations = (): OdrlPolicy[] => { + // Default policies with constraints that need permutations + const defaultPolicy: GovernancePolicy = { + strict: false, + permission: { + action: 'odrl:use', + LogicalConstraint: 'odrl:and', + constraints: [ + { + leftOperand: 'cx-policy:FrameworkAgreement', + operator: 'odrl:eq', + rightOperand: 'DataExchangeGovernance:1.0' + }, + { + leftOperand: 'cx-policy:Membership', + operator: 'odrl:eq', + rightOperand: 'active' + }, + { + leftOperand: 'cx-policy:UsagePurpose', + operator: 'odrl:eq', + rightOperand: 'cx.core.industrycore:1' + } + ] + }, + prohibition: [], + obligation: [] + }; + + return generateDtrPoliciesWithPermutations([defaultPolicy]); +}; + +/** + * Get cached DTR governance policies with configuration change detection + */ +const getCachedDtrGovernancePolicies = async (): Promise => { + const currentConfig = getDtrPoliciesConfig(); + const currentHash = await generateConfigHash(currentConfig); + + // Check if cache is valid + if (dtrGovernancePoliciesCache && dtrGovernancePoliciesCache.configHash === currentHash) { + return dtrGovernancePoliciesCache.policies; + } + + // Cache is invalid or doesn't exist, regenerate + console.log('DTR governance policies cache invalidated, regenerating...'); + const newPolicies = generateDtrPoliciesWithPermutations(currentConfig); + + dtrGovernancePoliciesCache = { + configHash: currentHash, + policies: newPolicies + }; + + return newPolicies; +}; + +/** + * Get cached governance policies for a specific semantic ID + */ +const getCachedGovernancePolicies = async (semanticId: string): Promise => { + const currentConfig = getGovernanceConfig(); + const currentHash = await generateConfigHash(currentConfig); + + // Check if cache is valid for this semantic ID + const cached = governancePoliciesCache.get(semanticId); + if (cached && cached.configHash === currentHash) { + return cached.policies; + } + + // Cache is invalid or doesn't exist, regenerate + console.log(`Governance policies cache invalidated for semantic ID: ${semanticId}, regenerating...`); + const newPolicies = await generateGovernancePoliciesWithPermutations(semanticId, currentConfig); + + governancePoliciesCache.set(semanticId, { + configHash: currentHash, + policies: newPolicies + }); + + return newPolicies; +}; + +/** + * Get cached default governance policies + */ +const getCachedDefaultGovernancePolicies = async (): Promise => { + // Default policies don't depend on configuration, but we still cache them + // Use a static hash since default policies don't change based on config + const staticHash = 'default_v1'; + + if (defaultGovernancePolicyCache && defaultGovernancePolicyCache.configHash === staticHash) { + return defaultGovernancePolicyCache.policies; + } + + console.log('Default governance policies cache invalidated, regenerating...'); + const newPolicies = generateDefaultGovernancePolicyPermutations(); + + defaultGovernancePolicyCache = { + configHash: staticHash, + policies: newPolicies + }; + + return newPolicies; +}; + +// Types for ODRL policies with flexible structure +interface OdrlConstraint { + "odrl:leftOperand": { "@id": string }; + "odrl:operator": { "@id": string }; + "odrl:rightOperand": string; +} + +// Support for logical operators: "and", "or", or single constraint +interface OdrlRule { + "odrl:action": { "@id": string }; + "odrl:constraint"?: + | { "odrl:and": OdrlConstraint[] } // Multiple constraints with AND logic + | { "odrl:or": OdrlConstraint[] } // Multiple constraints with OR logic + | OdrlConstraint; // Single constraint without logical operator +} + +interface OdrlPolicy { + "odrl:permission": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple + "odrl:prohibition": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple + "odrl:obligation": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple +} + +// Types for Shell Discovery API requests +export interface QuerySpecItem { + name: string; + value: string; +} + +export interface ShellDiscoveryRequest { + counterPartyId: string; + querySpec: QuerySpecItem[]; + limit?: number; + cursor?: string; + dtrGovernance?: OdrlPolicy[]; +} + +export const fetchCatalogParts = async (): Promise => { + const response = await axios.get(`${backendUrl}${CATALOG_PART_MANAGEMENT_BASE_PATH}`); + return response.data; +}; + +export const fetchCatalogPart = async ( + manufacturerId: string , + manufacturerPartId: string +): Promise => { + const response = await axios.get( + `${backendUrl}${CATALOG_PART_MANAGEMENT_BASE_PATH}/${manufacturerId}/${manufacturerPartId}` + ); + return response.data; +}; + +export const shareCatalogPart = async ( + manufacturerId: string, + manufacturerPartId: string, + businessPartnerNumber: string, + customerPartId?: string +): Promise => { + const requestBody: { + manufacturerId: string; + manufacturerPartId: string; + businessPartnerNumber: string; + customerPartId?: string; + } = { + manufacturerId, + manufacturerPartId, + businessPartnerNumber, + customerPartId: customerPartId && customerPartId.trim() ? customerPartId.trim() : undefined, + }; + + const response = await axios.post( + `${backendUrl}${SHARE_CATALOG_PART_BASE_PATH}`, + requestBody + ); + return response.data; +}; + +export const registerCatalogPartTwin = async ( + twinData: CatalogPartTwinCreateType +): Promise => { + const response = await axios.post( + `${backendUrl}${TWIN_MANAGEMENT_BASE_PATH}`, + twinData + ); + return response.data; +}; + +// Shell Discovery API Functions + +/** + * Discover shells based on query specifications + */ +export const discoverShells = async ( + request: ShellDiscoveryRequest +): Promise => { + const response = await axios.post( + `${backendUrl}${SHELL_DISCOVERY_BASE_PATH}`, + request + ); + return response.data; +}; + +/** + * Discover shells for a specific counter party and digital twin type + */ +export const discoverShellsByType = async ( + counterPartyId: string, + digitalTwinType: string, + limit?: number, + cursor?: string +): Promise => { + const dtrPolicies = await convertDtrPoliciesToOdrl(); + + const request: ShellDiscoveryRequest = { + counterPartyId, + querySpec: [ + { + name: 'digitalTwinType', + value: digitalTwinType + } + ], + dtrGovernance: dtrPolicies, + ...(limit && { limit }), + ...(cursor && { cursor }) + }; + + return discoverShells(request); +}; + +export const discoverShellsByCustomerPartId = async ( + counterPartyId: string, + customerPartId: string, + limit?: number, + cursor?: string +): Promise => { + const dtrPolicies = await convertDtrPoliciesToOdrl(); + + const request: ShellDiscoveryRequest = { + counterPartyId, + querySpec: [ + { + name: 'customerPartId', + value: customerPartId + } + ], + dtrGovernance: dtrPolicies, + ...(limit && { limit }), + ...(cursor && { cursor }) + }; + + return discoverShells(request); +}; + +/** + * Discover PartType shells for a specific counter party + */ +export const discoverPartTypeShells = async ( + counterPartyId: string, + limit?: number, + cursor?: string +): Promise => { + return discoverShellsByType(counterPartyId, 'PartType', limit, cursor); +}; + +export const discoverPartInstanceShells = async ( + counterPartyId: string, + limit?: number, + cursor?: string +): Promise => { + return discoverShellsByType(counterPartyId, 'PartInstance', limit, cursor); +}; + +/** + * Get next page of shell discovery results + */ +export const getNextShellsPage = async ( + counterPartyId: string, + digitalTwinType: string, + nextCursor: string, + limit?: number +): Promise => { + return discoverShellsByType(counterPartyId, digitalTwinType, limit, nextCursor); +}; + +/** + * Get previous page of shell discovery results + */ +export const getPreviousShellsPage = async ( + counterPartyId: string, + digitalTwinType: string, + previousCursor: string, + limit?: number +): Promise => { + return discoverShellsByType(counterPartyId, digitalTwinType, limit, previousCursor); +}; + +/** + * Discover shells with custom query specifications + */ +export const discoverShellsWithCustomQuery = async ( + counterPartyId: string, + querySpec: QuerySpecItem[], + limit?: number, + cursor?: string +): Promise => { + const dtrPolicies = await convertDtrPoliciesToOdrl(); + + const request: ShellDiscoveryRequest = { + counterPartyId, + querySpec, + dtrGovernance: dtrPolicies, + ...(limit && { limit }), + ...(cursor && { cursor }) + }; + + return discoverShells(request); +}; + +// Types for Single Shell Discovery +export interface SingleShellDiscoveryRequest { + counterPartyId: string; + id: string; + dtrGovernance?: OdrlPolicy[]; +} + +export interface SingleShellDiscoveryResponse { + shell_descriptor: { + description: unknown[]; + displayName: unknown[]; + globalAssetId: string; + id: string; + idShort: string; + specificAssetIds: Array<{ + supplementalSemanticIds: unknown[]; + name: string; + value: string; + externalSubjectId: { + type: string; + keys: Array<{ + type: string; + value: string; + }>; + }; + }>; + submodelDescriptors: Array<{ + endpoints: Array<{ + interface: string; + protocolInformation: { + href: string; + endpointProtocol: string; + endpointProtocolVersion: string[]; + subprotocol: string; + subprotocolBody: string; + subprotocolBodyEncoding: string; + securityAttributes: Array<{ + type: string; + key: string; + value: string; + }>; + }; + }>; + idShort: string; + id: string; + semanticId: { + type: string; + keys: Array<{ + type: string; + value: string; + }>; + }; + supplementalSemanticId: unknown[]; + description: unknown[]; + displayName: unknown[]; + }>; + }; + dtr: { + connectorUrl: string; + assetId: string; + }; +} + +/** + * Discover a single shell by AAS ID + */ +export const discoverSingleShell = async ( + counterPartyId: string, + aasId: string +): Promise => { + const dtrPolicies = await convertDtrPoliciesToOdrl(); + + const request: SingleShellDiscoveryRequest = { + counterPartyId, + id: aasId, + dtrGovernance: dtrPolicies + }; + + const response = await axios.post( + `${backendUrl}/discover/shell`, + request + ); + return response.data; +}; + +// Enhanced pagination functions that automatically extract cursors + +/** + * Get the next page of results using the current response + */ +export const getNextPageFromResponse = async ( + currentResponse: ShellDiscoveryResponse, + counterPartyId: string, + digitalTwinType: string, + limit?: number +): Promise => { + const nextCursor = getNextPageCursor(currentResponse); + if (!nextCursor) { + return null; // No more pages + } + + return discoverShellsByType(counterPartyId, digitalTwinType, limit, nextCursor); +}; + +/** + * Get the previous page of results using the current response + */ +export const getPreviousPageFromResponse = async ( + currentResponse: ShellDiscoveryResponse, + counterPartyId: string, + digitalTwinType: string, + limit?: number +): Promise => { + const previousCursor = getPreviousPageCursor(currentResponse); + if (!previousCursor) { + return null; // No previous pages + } + + return discoverShellsByType(counterPartyId, digitalTwinType, limit, previousCursor); +}; + +/** + * Get next page for custom query results + */ +export const getNextPageFromCustomQuery = async ( + currentResponse: ShellDiscoveryResponse, + counterPartyId: string, + querySpec: QuerySpecItem[], + limit?: number +): Promise => { + const nextCursor = getNextPageCursor(currentResponse); + if (!nextCursor) { + return null; + } + + return discoverShellsWithCustomQuery(counterPartyId, querySpec, limit, nextCursor); +}; + +/** + * Get previous page for custom query results + */ +export const getPreviousPageFromCustomQuery = async ( + currentResponse: ShellDiscoveryResponse, + counterPartyId: string, + querySpec: QuerySpecItem[], + limit?: number +): Promise => { + const previousCursor = getPreviousPageCursor(currentResponse); + if (!previousCursor) { + return null; + } + + return discoverShellsWithCustomQuery(counterPartyId, querySpec, limit, previousCursor); +}; + +/** + * Pagination helper that provides easy navigation methods + */ +export class ShellDiscoveryPaginator { + private currentResponse: ShellDiscoveryResponse; + private counterPartyId: string; + private digitalTwinType?: string; + private querySpec?: QuerySpecItem[]; + private limit?: number; + + constructor( + currentResponse: ShellDiscoveryResponse, + counterPartyId: string, + digitalTwinTypeOrQuerySpec?: string | QuerySpecItem[], + limit?: number + ) { + this.currentResponse = currentResponse; + this.counterPartyId = counterPartyId; + this.limit = limit; + + if (typeof digitalTwinTypeOrQuerySpec === 'string') { + this.digitalTwinType = digitalTwinTypeOrQuerySpec; + } else if (Array.isArray(digitalTwinTypeOrQuerySpec)) { + this.querySpec = digitalTwinTypeOrQuerySpec; + } + } + + /** + * Check if next page is available + */ + hasNext(): boolean { + return hasNextPage(this.currentResponse); + } + + /** + * Check if previous page is available + */ + hasPrevious(): boolean { + return hasPreviousPage(this.currentResponse); + } + + /** + * Get next page and update current response + */ + async next(): Promise { + let nextResponse: ShellDiscoveryResponse | null = null; + + if (this.digitalTwinType) { + nextResponse = await getNextPageFromResponse( + this.currentResponse, + this.counterPartyId, + this.digitalTwinType, + this.limit + ); + } else if (this.querySpec) { + nextResponse = await getNextPageFromCustomQuery( + this.currentResponse, + this.counterPartyId, + this.querySpec, + this.limit + ); + } + + if (nextResponse) { + this.currentResponse = nextResponse; + } + + return nextResponse; + } + + /** + * Get previous page and update current response + */ + async previous(): Promise { + let previousResponse: ShellDiscoveryResponse | null = null; + + if (this.digitalTwinType) { + previousResponse = await getPreviousPageFromResponse( + this.currentResponse, + this.counterPartyId, + this.digitalTwinType, + this.limit + ); + } else if (this.querySpec) { + previousResponse = await getPreviousPageFromCustomQuery( + this.currentResponse, + this.counterPartyId, + this.querySpec, + this.limit + ); + } + + if (previousResponse) { + this.currentResponse = previousResponse; + } + + return previousResponse; + } + + /** + * Get current response + */ + getCurrentResponse(): ShellDiscoveryResponse { + return this.currentResponse; + } + + /** + * Get pagination info + */ + getPaginationInfo() { + return { + currentPage: this.currentResponse.pagination.page, + shellsFound: this.currentResponse.shellsFound, + hasNext: this.hasNext(), + hasPrevious: this.hasPrevious() + }; + } +} + +// Submodel Discovery API Functions + +export interface SubmodelDiscoveryRequest { + counterPartyId: string; + id: string; + submodelId: string; + dtrGovernance?: OdrlPolicy[]; + governance: OdrlPolicy[]; +} + +export interface SubmodelDiscoveryResponse { + submodelDescriptor: { + submodelId: string; + semanticId: string; + semanticIdKeys: string; + assetId: string; + connectorUrl: string; + href: string; + status: string; + error?: string; + }; + submodel: Record; + dtr: { + connectorUrl: string; + assetId: string; + } | null; + status?: string; + error?: string; +} + +/** + * Convert constraint to ODRL format + */ +const convertConstraintToOdrl = (constraint: GovernanceConstraint): OdrlConstraint => ({ + "odrl:leftOperand": { + "@id": constraint.leftOperand + }, + "odrl:operator": { + "@id": constraint.operator + }, + "odrl:rightOperand": constraint.rightOperand +}); + +/** + * Generate all permutations of an array + */ +const generatePermutations = (arr: T[]): T[][] => { + if (arr.length <= 1) return [arr]; + + const result: T[][] = []; + for (let i = 0; i < arr.length; i++) { + const current = arr[i]; + const remaining = [...arr.slice(0, i), ...arr.slice(i + 1)]; + const perms = generatePermutations(remaining); + + for (const perm of perms) { + result.push([current, ...perm]); + } + } + + return result; +}; + +/** + * Generate all permutations of constraints within a rule, creating separate policies for each ordering + */ +const generateRulePermutations = (rule: GovernanceRule): GovernanceRule[] => { + if (!rule.constraints || rule.constraints.length <= 1) { + return [rule]; + } + + const constraintPermutations = generatePermutations(rule.constraints); + + return constraintPermutations.map(permutedConstraints => ({ + ...rule, + constraints: permutedConstraints + })); +}; + +/** + * Generate all permutations of rules (permission, prohibition, obligation) + */ +const generateRulesPermutations = (rules: GovernanceRule | GovernanceRule[]): (GovernanceRule | GovernanceRule[])[] => { + if (Array.isArray(rules)) { + if (rules.length === 0) return [rules]; + + // For array of rules, generate permutations for each rule + const rulePermutations = rules.map(generateRulePermutations); + + // Generate cartesian product of all rule permutations + const result: GovernanceRule[][] = [[]]; + for (const permutations of rulePermutations) { + const newResult: GovernanceRule[][] = []; + for (const existing of result) { + for (const perm of permutations) { + newResult.push([...existing, perm]); + } + } + result.length = 0; + result.push(...newResult); + } + + return result; + } else { + // Single rule - generate permutations for its constraints + return generateRulePermutations(rules); + } +}; + +/** + * Create constraint structure based on logical operator and constraints + */ +const createConstraintStructure = ( + constraints: GovernanceConstraint[], + logicalOperator?: string +): OdrlConstraint | { "odrl:and": OdrlConstraint[] } | { "odrl:or": OdrlConstraint[] } => { + const odrlConstraints = constraints.map(convertConstraintToOdrl); + + // Single constraint without logical operator + if (odrlConstraints.length === 1 && !logicalOperator) { + return odrlConstraints[0]; + } + + // Multiple constraints with logical operators + if (logicalOperator === "odrl:or" || logicalOperator === "or") { + return { "odrl:or": odrlConstraints }; + } + + // Default to "and" for multiple constraints + return { "odrl:and": odrlConstraints }; +}; + +/** + * Convert rule (permission, prohibition, obligation) to ODRL format + */ +const convertRuleToOdrl = ( + rule: { action: string; constraints: GovernanceConstraint[]; LogicalConstraint?: string } +): OdrlRule => { + const odrlRule: OdrlRule = { + "odrl:action": { + "@id": rule.action + } + }; + + if (rule.constraints && rule.constraints.length > 0) { + odrlRule["odrl:constraint"] = createConstraintStructure( + rule.constraints, + rule.LogicalConstraint + ); + } + + return odrlRule; +}; + +/** + * Convert governance rule(s) to ODRL format + * - Single rule -> single OdrlRule object + * - Multiple rules -> array of OdrlRule objects + */ +const convertRulesToOdrl = (rules: GovernanceRule | GovernanceRule[]): OdrlRule | OdrlRule[] => { + if (Array.isArray(rules)) { + // If it's an array, check the length + if (rules.length === 1) { + // Single item in array -> return as single object + return convertRuleToOdrl(rules[0]); + } else { + // Multiple items -> return as array + return rules.map(convertRuleToOdrl); + } + } else { + // Single object -> return as single object + return convertRuleToOdrl(rules); + } +}; + +/** + * Convert governance configuration to ODRL format + * + * @param config - The governance configuration from environment variables + * @returns Array of ODRL policies + */ +/** + * Convert governance rule(s) to ODRL format + * - Single rule -> single OdrlRule object + * - Multiple rules -> array of OdrlRule objects + */ + +/** + * Convert DTR policies from environment config to ODRL format with all possible constraint orderings. + * + * For each policy, this generates separate complete policies for each possible ordering of constraints. + * This ensures DTR matching succeeds regardless of constraint order by providing all permutations + * as separate policy entries in the array. + * + * Uses caching to avoid recalculating permutations on every request. + */ +const convertDtrPoliciesToOdrl = async (): Promise => { + return await getCachedDtrGovernancePolicies(); +}; + +/** + * Get governance policy based on semantic ID from environment configuration + */ +const getGovernancePolicyForSemanticId = async (semanticId: string): Promise => { + return await getCachedGovernancePolicies(semanticId); +}; + +/** + * Fetch a specific submodel data with request cancellation and concurrency control + */ +export const fetchSubmodel = async ( + counterPartyId: string, + shellId: string, + submodelId: string, + semanticId?: string +): Promise => { + // Get governance policies: + // - If semanticId provided: try to find specific config, fallback to default if not found + // - If no semanticId: use default policies + const governance = semanticId ? + await getGovernancePolicyForSemanticId(semanticId) : + await getCachedDefaultGovernancePolicies(); + + const dtrPolicies = await convertDtrPoliciesToOdrl(); + + // Debug logging to check governance policies + console.log('Submodel request governance policies:', { + semanticId, + governanceCount: governance?.length || 0, + governance: governance + }); + + const request: SubmodelDiscoveryRequest = { + counterPartyId, + id: shellId, + submodelId, + dtrGovernance: dtrPolicies, + governance + }; + + const response = await axios.post( + `${backendUrl}/discover/shell/submodel`, + request + ); + + return response.data; +}; diff --git a/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx b/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx new file mode 100644 index 00000000..7414147d --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx @@ -0,0 +1,122 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box, Chip, Typography } from '@mui/material'; +import { SearchFilters, PartType } from '../types'; + +interface FilterChipsProps { + filters: SearchFilters; + partType: PartType; +} + +export const FilterChips: React.FC = ({ filters, partType }) => { + const getActiveFilterChips = () => { + const filterList = [ + { + value: filters.customerPartId, + label: 'Customer Part ID', + tooltip: 'Customer Part ID' + }, + { + value: filters.manufacturerPartId, + label: 'Manufacturer Part ID', + tooltip: 'Manufacturer Part ID' + }, + { + value: filters.globalAssetId, + label: 'Global Asset ID', + tooltip: 'Global Asset ID' + }, + // Only show Part Instance ID filter when Part Instance is selected + ...(partType === 'Serialized' ? [{ + value: filters.partInstanceId, + label: 'Part Instance ID', + tooltip: 'Part Instance Identifier' + }] : []) + ]; + + return filterList + .filter(filter => filter.value && filter.value.trim()) + .map((filter, index) => ( + + + {filter.label}: + + + {filter.value} + + + } + size="medium" + color="primary" + variant="filled" + title={`${filter.tooltip}: ${filter.value}`} + sx={{ + backgroundColor: 'rgba(25, 118, 210, 0.1)', + color: '#1976d2', + border: '1px solid rgba(25, 118, 210, 0.3)', + borderRadius: '20px', + fontSize: '0.85rem', + fontWeight: '500', + px: 2, + py: 0.5, + height: 'auto', + minHeight: '32px', + maxWidth: '100%', + '& .MuiChip-label': { + px: 1, + py: 0.5, + whiteSpace: 'nowrap', + overflow: 'visible', + textOverflow: 'unset' + }, + '&:hover': { + backgroundColor: 'rgba(25, 118, 210, 0.15)', + borderColor: 'rgba(25, 118, 210, 0.5)', + transform: 'translateY(-1px)', + boxShadow: '0 4px 12px rgba(25, 118, 210, 0.2)' + }, + transition: 'all 0.2s ease-in-out' + }} + /> + )); + }; + + const chips = getActiveFilterChips(); + + if (chips.length === 0) { + return null; + } + + return ( + + + {chips} + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx b/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx new file mode 100644 index 00000000..a4c6fda5 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx @@ -0,0 +1,159 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box, Button, Typography, CircularProgress } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { ShellDiscoveryPaginator } from '../api'; + +interface PaginationControlsProps { + paginator: ShellDiscoveryPaginator | null; + currentPage: number; + totalPages: number; + pageLimit: number; + isLoading: boolean; + isLoadingNext: boolean; + isLoadingPrevious: boolean; + onPageChange: (page: number) => void; +} + +export const PaginationControls: React.FC = ({ + paginator, + currentPage, + totalPages, + pageLimit, + isLoading, + isLoadingNext, + isLoadingPrevious, + onPageChange +}) => { + if (!paginator || isLoading || pageLimit === 0) { + return null; + } + + return ( + + {paginator.hasPrevious() && ( + + )} + + + + Page {currentPage} + + {totalPages > 1 && ( + + of {totalPages} + + )} + + + {paginator.hasNext() && ( + + )} + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx b/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx new file mode 100644 index 00000000..4db5f275 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx @@ -0,0 +1,143 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { TextField, Button, Autocomplete, CircularProgress } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { PartnerInstance } from '../../../types/partner'; + +interface PartnerSearchProps { + bpnl: string; + onBpnlChange: (value: string) => void; + selectedPartner: PartnerInstance | null; + onSelectedPartnerChange: (partner: PartnerInstance | null) => void; + availablePartners: PartnerInstance[]; + isLoadingPartners: boolean; + onSearch: () => void; + isLoading: boolean; +} + +export const PartnerSearch: React.FC = ({ + bpnl, + onBpnlChange, + selectedPartner, + onSelectedPartnerChange, + availablePartners, + isLoadingPartners, + onSearch, + isLoading +}) => { + return ( + <> + { + onSelectedPartnerChange(newValue); + if (newValue) { + onBpnlChange(newValue.bpnl); + } + }} + inputValue={bpnl} + onInputChange={(_, newInputValue) => { + onBpnlChange(newInputValue); + if (!newInputValue) { + onSelectedPartnerChange(null); + } + }} + options={availablePartners} + getOptionLabel={(option) => option.bpnl} + loading={isLoadingPartners} + renderOption={(props, option) => ( +
  • +
    +
    {option.name}
    +
    {option.bpnl}
    +
    +
  • + )} + renderInput={(params) => ( + + {isLoadingPartners ? : null} + {params.InputProps.endAdornment} + + ), + }} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderRadius: 3, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 1)', + }, + '&.Mui-focused': { + backgroundColor: 'rgba(255, 255, 255, 1)', + boxShadow: '0 0 0 3px rgba(25, 118, 210, 0.1)', + } + } + }} + /> + )} + sx={{ mb: 3 }} + /> + + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx b/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx new file mode 100644 index 00000000..807a5791 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx @@ -0,0 +1,311 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { + Box, + Typography, + Radio, + RadioGroup, + FormControlLabel, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + Chip +} from '@mui/material'; + +interface PartsDiscoverySidebarProps { + partType: string; + onPartTypeChange: (event: React.ChangeEvent) => void; + pageLimit: number; + onPageLimitChange: (limit: number) => void; + customLimit: string; + onCustomLimitChange: (limit: string) => void; + isCustomLimit: boolean; + onIsCustomLimitChange: (isCustom: boolean) => void; + customerPartId: string; + onCustomerPartIdChange: (id: string) => void; + manufacturerPartId: string; + onManufacturerPartIdChange: (id: string) => void; + globalAssetId: string; + onGlobalAssetIdChange: (id: string) => void; + partInstanceId: string; + onPartInstanceIdChange: (id: string) => void; +} + +const PartsDiscoverySidebar: React.FC = ({ + partType, + onPartTypeChange, + pageLimit, + onPageLimitChange, + customLimit, + onCustomLimitChange, + isCustomLimit, + onIsCustomLimitChange, + customerPartId, + onCustomerPartIdChange, + manufacturerPartId, + onManufacturerPartIdChange, + globalAssetId, + onGlobalAssetIdChange, + partInstanceId, + onPartInstanceIdChange +}) => { + const sidebarStyles = { + textField: { + '& .MuiOutlinedInput-root': { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + borderRadius: 2, + border: '1px solid rgba(255, 255, 255, 0.2)', + color: 'white', + '& input::placeholder': { + fontSize: '0.75rem', + color: 'rgba(255, 255, 255, 0.5)' + }, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.12)', + borderColor: 'rgba(255, 255, 255, 0.3)' + }, + '&.Mui-focused': { + backgroundColor: 'rgba(255, 255, 255, 0.15)', + borderColor: '#60a5fa' + } + }, + '& .MuiInputLabel-root': { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: '0.875rem', + '&.Mui-focused': { + color: '#bfdbfe' + } + }, + '& .MuiFormHelperText-root': { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: '0.75rem' + } + }, + sectionTitle: { + fontWeight: '600', + color: 'white', + mb: 2, + fontSize: '0.85rem', + textTransform: 'uppercase' as const, + letterSpacing: '0.3px', + borderBottom: '1px solid rgba(255, 255, 255, 0.15)', + pb: 1 + } + }; + + return ( + <> + {/* Digital Twin Type Section */} + + + Digital Twin Type + + + } label="Part Type (Catalog)" /> + } label="Part Instance (Serialized)" /> + + + + {/* Results per Page Section */} + + + Results per Page + + + Results per Page + + + + {isCustomLimit && ( + + onCustomLimitChange(e.target.value)} + inputProps={{ min: 1, max: 1000 }} + helperText={ + customLimit && (isNaN(parseInt(customLimit)) || parseInt(customLimit) < 1 || parseInt(customLimit) > 1000) + ? "Please enter a valid number between 1 and 1000" + : "Enter a number between 1 and 1000" + } + error={customLimit !== '' && (isNaN(parseInt(customLimit)) || parseInt(customLimit) < 1 || parseInt(customLimit) > 1000)} + sx={sidebarStyles.textField} + /> + + + + Quick select: + + {[25, 75, 150, 200, 500].map((value) => ( + { + onCustomLimitChange(value.toString()); + onPageLimitChange(value); + }} + sx={{ + cursor: 'pointer', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + color: 'white', + border: '1px solid rgba(255, 255, 255, 0.3)', + fontSize: '0.7rem', + height: '24px', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderColor: '#60a5fa' + } + }} + /> + ))} + + + )} + + + {/* Advanced Options Section */} + + + Advanced Options + + + onCustomerPartIdChange(e.target.value)} + sx={{ mb: 2, ...sidebarStyles.textField }} + helperText="Search by specific Customer Part identifier" + /> + + onManufacturerPartIdChange(e.target.value)} + sx={{ mb: 2, ...sidebarStyles.textField }} + helperText="Search by specific Manufacturer Part identifier" + /> + + onGlobalAssetIdChange(e.target.value)} + sx={{ mb: 2, ...sidebarStyles.textField }} + helperText="Global Asset ID of the Digital Twin" + /> + + {/* Part Instance ID - Only shown when Part Instance is selected */} + {partType === 'Serialized' && ( + onPartInstanceIdChange(e.target.value)} + sx={{ mb: 2, ...sidebarStyles.textField }} + helperText="Search by specific Part Instance identifier" + /> + )} + + + ); +}; + +export default PartsDiscoverySidebar; diff --git a/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx b/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx new file mode 100644 index 00000000..070a4d74 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx @@ -0,0 +1,113 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box, Grid2, Button, Typography } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { FilterChips } from './FilterChips'; +import { SearchFilters, PartType } from '../types'; + +interface SearchHeaderProps { + bpnl: string; + companyName: string; + filters: SearchFilters; + partType: PartType; + onGoBack: () => void; +} + +export const SearchHeader: React.FC = ({ + bpnl, + companyName, + filters, + partType, + onGoBack +}) => { + return ( + + + + + + + + Dataspace Discovery + + + + + + + + {companyName} + + + {bpnl} + + + + + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx b/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx new file mode 100644 index 00000000..5f694948 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx @@ -0,0 +1,159 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Box, Button, Chip } from '@mui/material'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { SearchMode } from '../types'; + +interface SearchModeToggleProps { + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; + isVisible: boolean; + onDisplayFilters: () => void; + onHideFilters: () => void; +} + +export const SearchModeToggle: React.FC = ({ + searchMode, + onSearchModeChange, + isVisible, + onDisplayFilters, + onHideFilters +}) => { + return ( + + {/* Display Filters Button - Only show in Discovery mode when sidebar should be available but is hidden */} + {searchMode === 'discovery' && !isVisible && ( + + )} + + {/* Hide Filters Button - Only show in Discovery mode when sidebar is visible */} + {searchMode === 'discovery' && isVisible && ( + + )} + + {/* Search Mode Toggle */} + + onSearchModeChange('discovery')} + color={searchMode === 'discovery' ? 'primary' : 'default'} + size="small" + sx={{ + fontSize: '0.75rem', + fontWeight: 500, + transition: 'all 0.2s ease-in-out', + '&:hover': { + transform: 'translateY(-1px)', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)' + } + }} + /> + onSearchModeChange('single')} + color={searchMode === 'single' ? 'primary' : 'default'} + size="small" + sx={{ + fontSize: '0.75rem', + fontWeight: 500, + transition: 'all 0.2s ease-in-out', + '&:hover': { + transform: 'translateY(-1px)', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)' + } + }} + /> + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx b/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx new file mode 100644 index 00000000..12073935 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx @@ -0,0 +1,390 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { useState } from 'react'; +import { + Box, + Typography, + Chip, + Tooltip +} from '@mui/material'; +import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid'; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import Download from '@mui/icons-material/Download'; +import CheckCircle from '@mui/icons-material/CheckCircle'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { SerializedPartData } from '../types'; + +interface SerializedPartsTableProps { + parts: SerializedPartData[]; + onView?: (part: SerializedPartData) => void; +} + +const SerializedPartsTable = ({ parts, onView }: SerializedPartsTableProps) => { + const [copySuccess, setCopySuccess] = useState(null); + + const handleCopyAasId = async (aasId: string, partId: string) => { + try { + await navigator.clipboard.writeText(aasId); + console.log('AAS ID copied to clipboard:', aasId); + setCopySuccess(partId); + + // Reset after 2 seconds + setTimeout(() => { + setCopySuccess(null); + }, 2000); + } catch (err) { + console.error('Failed to copy AAS ID:', err); + } + }; + + const handleDownloadTwinData = (part: SerializedPartData) => { + if (part.rawTwinData) { + try { + const jsonString = JSON.stringify(part.rawTwinData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `twin-${part.manufacturerPartId || part.aasId || 'data'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + console.log('Twin data downloaded successfully'); + } catch (err) { + console.error('Failed to download twin data:', err); + } + } + }; + + // Transform data for DataGrid + const rows = parts.map((part, index) => ({ + id: part.id || `part-${index}`, + dtrIndex: part.dtrIndex, + globalAssetId: part.globalAssetId, + aasId: part.aasId, + idShort: part.idShort, + manufacturerId: part.manufacturerId, + manufacturerPartId: part.manufacturerPartId, + customerPartId: part.customerPartId || '', + partInstanceId: part.partInstanceId || '', + digitalTwinType: part.digitalTwinType, + submodelCount: part.submodelCount, + rawTwinData: part.rawTwinData + })); + + // Define columns for DataGrid + const columns: GridColDef[] = [ + { + field: 'dtrIndex', + headerName: 'DTR Source', + width: 100, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + ), + }, + { + field: 'globalAssetId', + headerName: 'Global Asset ID', + width: 280, + renderCell: (params) => ( + + + {params.value} + + + ), + }, + { + field: 'aasId', + headerName: 'AAS ID', + width: 280, + renderCell: (params) => ( + + + + {params.value} + + + + ), + }, + { + field: 'idShort', + headerName: 'ID Short', + width: 180, + renderCell: (params) => ( + + + {params.value || '-'} + + + ), + }, + { + field: 'manufacturerId', + headerName: 'Manufacturer ID', + width: 150, + renderCell: (params) => ( + + + {params.value} + + + ), + }, + { + field: 'manufacturerPartId', + headerName: 'Manufacturer Part ID', + width: 180, + renderCell: (params) => ( + + + {params.value} + + + ), + }, + { + field: 'customerPartId', + headerName: 'Customer Part ID', + width: 150, + renderCell: (params) => ( + + + {params.value || '-'} + + + ), + }, + { + field: 'partInstanceId', + headerName: 'Part Instance ID', + width: 150, + renderCell: (params) => ( + + + {params.value || '-'} + + + ), + }, + { + field: 'digitalTwinType', + headerName: 'Digital Twin Type', + width: 150, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + ), + }, + { + field: 'submodelCount', + headerName: 'Submodels', + width: 100, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + {params.value} + + ), + }, + { + field: 'actions', + type: 'actions', + headerName: 'Actions', + width: 120, + getActions: (params) => [ + + {copySuccess === params.row.id ? ( + + ) : ( + + )} + + } + label="Copy AAS ID" + onClick={() => handleCopyAasId(params.row.aasId, params.row.id)} + />, + + + + } + label="Download" + onClick={() => handleDownloadTwinData(params.row)} + disabled={!params.row.rawTwinData} + />, + + + + } + label="View" + onClick={() => { + console.log('View details for:', params.row); + if (onView) { + onView(params.row); + } + }} + />, + ], + }, + ]; + + return ( + + ( + + + Showing {parts.length} serialized parts + + + Click on column headers to sort • Use column filters for search + + + ), + }} + /> + + ); +}; + +export default SerializedPartsTable; diff --git a/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx b/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx new file mode 100644 index 00000000..11ff7a7c --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx @@ -0,0 +1,1507 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Card, + Chip, + Button, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Tooltip, + Snackbar, + useMediaQuery, + useTheme +} from '@mui/material'; +import InfoIcon from '@mui/icons-material/Info'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import FingerprintIcon from '@mui/icons-material/Fingerprint'; +import PublicIcon from '@mui/icons-material/Public'; +import LabelIcon from '@mui/icons-material/Label'; +import CloseIcon from '@mui/icons-material/Close'; +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import SecurityIcon from '@mui/icons-material/Security'; +import LockIcon from '@mui/icons-material/Lock'; +import { SubmodelViewer } from './SubmodelViewer'; + +interface SingleTwinResultProps { + counterPartyId: string; + singleTwinResult: { + shell_descriptor: { + id: string; + idShort?: string; + globalAssetId: string; + submodelDescriptors: Array<{ + endpoints: Array<{ + interface: string; + protocolInformation: { + href: string; + endpointProtocol: string; + endpointProtocolVersion: string[]; + subprotocol: string; + subprotocolBody: string; + subprotocolBodyEncoding: string; + securityAttributes: Array<{ + type: string; + key: string; + value: string; + }>; + }; + }>; + idShort: string; + id: string; + semanticId: { + type: string; + keys: Array<{ + type: string; + value: string; + }>; + }; + supplementalSemanticId: unknown[]; + description: unknown[]; + displayName: unknown[]; + }>; + specificAssetIds: Array<{ + name: string; + value: string; + }>; + }; + dtr?: { + connectorUrl: string; + assetId: string; + }; + }; +} + +export const SingleTwinResult: React.FC = ({ counterPartyId, singleTwinResult }) => { + const [dtrInfoOpen, setDtrInfoOpen] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + const [carouselIndex, setCarouselIndex] = useState(0); + const [allSubmodelsOpen, setAllSubmodelsOpen] = useState(false); + const [submodelViewerOpen, setSubmodelViewerOpen] = useState(false); + const [selectedSubmodel, setSelectedSubmodel] = useState(null); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + // Calculate items per slide for carousel - show fewer items to enable navigation + const itemsPerSlide = isMobile ? 2 : 3; + + // Parse semantic ID to extract version and model name + const parseSemanticId = (semanticId: string) => { + try { + // Handle different URN formats: + // urn:bamm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt + // urn:samm:io.catenax.generic.digital_product_passport:5.0.0#DigitalProductPassport + + const parts = semanticId.split(':'); + if (parts.length >= 4) { + const lastPart = parts[parts.length - 1]; // "3.0.0#SingleLevelBomAsBuilt" + const [version, modelName] = lastPart.split('#'); + + // Extract model name from the namespace if no # separator + let displayName = modelName || ''; + if (!displayName && parts.length >= 3) { + const namespacePart = parts[parts.length - 2]; // "io.catenax.single_level_bom_as_built" + const nameParts = namespacePart.split('.'); + displayName = nameParts[nameParts.length - 1] + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + return { + version: version || 'Unknown', + name: displayName || 'Unknown Model', + namespace: parts.slice(2, -1).join(':') + }; + } + + return { + version: 'Unknown', + name: 'Unknown Model', + namespace: semanticId + }; + } catch (error) { + console.warn('Error parsing semantic ID:', error); + return { + version: 'Unknown', + name: 'Unknown Model', + namespace: semanticId + }; + } + }; + + // Detect and parse verifiable credentials from semantic IDs + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parseVerifiableCredential = (submodel: any) => { + if (!submodel.semanticId?.keys) return null; + + const keys = submodel.semanticId.keys; + + // Look for verifiable credential patterns + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w3cKey = keys.find((key: any) => key.value?.includes('w3c.github.io/vc-jws') || key.value?.includes('www.w3.org/ns/credentials')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submodelKey = keys.find((key: any) => key.type === 'Submodel'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataElementKey = keys.find((key: any) => key.type === 'DataElement'); + // Look for additional credential-related keys + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operationKeys = keys.filter((key: any) => key.type === 'Operation'); + + if (w3cKey || dataElementKey) { + // This is a verifiable credential + let modelName = 'Verifiable Credential'; + let version = 'Unknown'; + let credentialType = 'Unknown'; + let signatureType = null; + let w3cVersion = 'Unknown'; + + // Extract model name and version from submodel key + if (submodelKey?.value) { + const parsed = parseSemanticId(submodelKey.value); + modelName = parsed.name; + version = parsed.version; + } + + // Extract credential type from data element + if (dataElementKey?.value) { + const parts = dataElementKey.value.split('#'); + if (parts.length > 1) { + credentialType = parts[1]; + } else { + const urlParts = dataElementKey.value.split(':'); + credentialType = urlParts[urlParts.length - 1]; + } + } + + // Extract signature type from operation keys and other sources + if (operationKeys.length > 0) { + for (const opKey of operationKeys) { + if (opKey.value) { + const parts = opKey.value.split('#'); + if (parts.length > 1) { + const candidate = parts[1]; + // Look for signature-related terms + if (candidate.toLowerCase().includes('signature') || candidate.includes('JsonWeb') || candidate.includes('Jws')) { + signatureType = candidate; + break; + } + } + } + } + } + + // If not found in operation keys, look in all keys for signature types + if (!signatureType) { + for (const key of keys) { + if (key.value) { + // Check for JsonWebSignature patterns + if (key.value.includes('JsonWebSignature2020') || key.value.includes('JsonWebSignature')) { + if (key.value.includes('JsonWebSignature2020')) { + signatureType = 'JWS 2020'; + } else { + signatureType = 'JsonWebSignature'; + } + break; + } + // Check for other signature patterns + const parts = key.value.split('#'); + if (parts.length > 1) { + const candidate = parts[1]; + if (candidate.toLowerCase().includes('signature') || candidate.includes('JsonWeb') || candidate.includes('Jws')) { + // Format common signature types nicely + if (candidate.includes('JsonWebSignature2020')) { + signatureType = 'JWS 2020'; + } else if (candidate.includes('JsonWebSignature')) { + signatureType = 'JsonWebSignature'; + } else { + signatureType = candidate; + } + break; + } + } + // Also check the URL path for signature type + if (key.value.includes('/vc-jws-')) { + signatureType = 'JWS 2020'; + break; + } + } + } + } + + // Extract W3C credential version from URL and fallback signature type + if (w3cKey?.value) { + const w3cUrl = w3cKey.value; + // Handle patterns like: https://www.w3.org/ns/credentials/v2 or https://www.w3.org/ns/credentials/v1.1 + if (w3cUrl.includes('www.w3.org/ns/credentials/')) { + const versionMatch = w3cUrl.match(/\/v(\d+(?:\.\d+)?)/); + if (versionMatch) { + w3cVersion = versionMatch[1]; // Extract just the version number (e.g., "2" or "1.1") + } + } else if (w3cUrl.includes('w3c.github.io/vc-jws')) { + // Handle older GitHub format - try to extract version from path + const versionMatch = w3cUrl.match(/-(\d{4})/); // Look for year-based versions + if (versionMatch) { + w3cVersion = versionMatch[1]; + } else { + w3cVersion = '2020'; // Default for vc-jws format + } + + // If signature type not found elsewhere, infer from vc-jws URL + if (!signatureType) { + signatureType = 'JWS 2020'; + } + } + } + + return { + isVerifiable: true, + modelName, + version, + credentialType, + signatureType, + w3cVersion, + w3cUrl: w3cKey?.value, + submodelUrl: submodelKey?.value, + dataElementUrl: dataElementKey?.value + }; + } + + return null; + }; + + // Reset carousel when submodels change + useEffect(() => { + setCarouselIndex(0); + }, [singleTwinResult.shell_descriptor.submodelDescriptors.length]); + + // Handle carousel navigation + const handlePrevious = () => { + setCarouselIndex(prev => Math.max(0, prev - itemsPerSlide)); + }; + + const handleNext = () => { + const maxIndex = Math.max(0, singleTwinResult.shell_descriptor.submodelDescriptors.length - itemsPerSlide); + setCarouselIndex(prev => Math.min(maxIndex, prev + itemsPerSlide)); + }; + + const handleCopyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }); + }; + + // Extract digitalTwinType from specificAssetIds + const digitalTwinType = singleTwinResult.shell_descriptor.specificAssetIds.find( + assetId => assetId.name === 'digitalTwinType' + )?.value || 'Asset Administration Shell'; + + const handleViewSubmodels = () => { + setAllSubmodelsOpen(true); + }; + + const handleRetrieveSubmodel = (submodel: SingleTwinResultProps['singleTwinResult']['shell_descriptor']['submodelDescriptors'][0]) => { + setSelectedSubmodel(submodel); + setSubmodelViewerOpen(true); + }; + + return ( + + {/* Single Twin Results Header */} + + {/* Display idShort if available, otherwise display nothing */} + {singleTwinResult.shell_descriptor.idShort && ( + + {singleTwinResult.shell_descriptor.idShort} + + )} + + {/* DTR Information Button */} + + + DTR Details + + setDtrInfoOpen(true)} + sx={{ + color: 'primary.main', + backgroundColor: 'rgba(25, 118, 210, 0.08)', + '&:hover': { + backgroundColor: 'rgba(25, 118, 210, 0.16)' + } + }} + > + + + + + + {/* Cards Row - Digital Twin Info and IDs */} + + {/* Digital Twin Information Card */} + + + + + Digital Twin Information + + + + + {/* Description if available */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const descriptor = singleTwinResult.shell_descriptor as any; + if (descriptor.description && Array.isArray(descriptor.description) && descriptor.description.length > 0) { + return ( + + + Description: + + + {descriptor.description[0].text} + {descriptor.description[0].language && ( + + ({descriptor.description[0].language}) + + )} + + + ); + } + return null; + })()} + + {/* Asset Identifiers Section */} + {singleTwinResult.shell_descriptor.specificAssetIds.length > 0 && ( + + + + Asset Identifiers + + + {singleTwinResult.shell_descriptor.specificAssetIds.map((assetId, index) => ( + + } + label={`${assetId.name}: ${assetId.value}`} + variant="outlined" + size="small" + sx={{ + maxWidth: '400px', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'success.main' + } + }} + /> + + handleCopyToClipboard(assetId.value)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'success.main', + backgroundColor: 'rgba(76, 175, 80, 0.1)' + } + }} + > + + + + + ))} + + + )} + + + + {/* IDs Card */} + + + + Identifiers + + + + {/* AAS ID */} + + + AAS ID: + + + } + label={singleTwinResult.shell_descriptor.id} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'primary.main' + } + }} + /> + + handleCopyToClipboard(singleTwinResult.shell_descriptor.id)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'primary.main', + backgroundColor: 'rgba(25, 118, 210, 0.1)' + } + }} + > + + + + + + + {/* Global Asset ID */} + + + Global Asset ID: + + + } + label={singleTwinResult.shell_descriptor.globalAssetId} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'success.main' + } + }} + /> + + handleCopyToClipboard(singleTwinResult.shell_descriptor.globalAssetId)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'primary.main', + backgroundColor: 'rgba(25, 118, 210, 0.1)' + } + }} + > + + + + + + + + + + + {/* Submodels Section */} + + + + + Available Submodels + + + {singleTwinResult.shell_descriptor.submodelDescriptors.length} submodel(s) available + + + {/* View All Submodels button moved to top right */} + {singleTwinResult.shell_descriptor.submodelDescriptors.length > 0 && ( + + )} + + + {singleTwinResult.shell_descriptor.submodelDescriptors.length > 0 && (() => { + const totalSubmodels = singleTwinResult.shell_descriptor.submodelDescriptors.length; + const endIndex = Math.min(carouselIndex + itemsPerSlide, totalSubmodels); + const currentSubmodels = singleTwinResult.shell_descriptor.submodelDescriptors.slice(carouselIndex, endIndex); + const canGoPrev = carouselIndex > 0; + const canGoNext = endIndex < totalSubmodels; + + return ( + + {/* Carousel Container */} + itemsPerSlide ? 1.5 : 0 + }}> + {currentSubmodels.map((submodel, index) => { + const actualIndex = carouselIndex + index; + return ( + + + {/* Header with title and ID in top right */} + + + {submodel.idShort || `Submodel ${actualIndex + 1}`} + + + + {/* Green lock for verifiable credentials with signature type */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + if (verifiableInfo) { + return ( + + + + + {verifiableInfo.signatureType && ( + + + + )} + + ); + } + return null; + })()} + + {/* ID in top right with copy button */} + {submodel.id && ( + <> + + + {submodel.id.split(':').pop()?.substring(0, 8)}... + + + + handleCopyToClipboard(submodel.id)} + sx={{ + p: 0.25, + color: 'text.secondary', + '&:hover': { + color: 'primary.main', + backgroundColor: 'rgba(25, 118, 210, 0.1)' + } + }} + > + + + + + )} + + + + {/* Description if available */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submodelAny = submodel as any; + if (submodelAny.description && Array.isArray(submodelAny.description) && submodelAny.description.length > 0) { + return ( + + + Description: + + + {submodelAny.description[0].text} + {submodelAny.description[0].language && ( + + ({submodelAny.description[0].language}) + + )} + + + ); + } + return null; + })()} + + {submodel.semanticId && submodel.semanticId.keys && submodel.semanticId.keys.length > 0 && ( + + {/* Check if this is a verifiable credential */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + + if (verifiableInfo) { + // Display verifiable credential information + return ( + + + + + Verifiable Credential + + + + + + + + Credential Type: + + + + + + + ); + } else { + // Display regular semantic ID info + const firstKey = submodel.semanticId.keys[0]; + if (firstKey?.value) { + const parsedSemanticId = parseSemanticId(firstKey.value); + return ( + + + + Model: + + + + + + ); + } + return null; + } + })()} + + {/* Show additional semantic IDs only if we don't know the model (no chips displayed) */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + const firstKey = submodel.semanticId.keys[0]; + const hasKnownModel = verifiableInfo || (firstKey?.value && parseSemanticId(firstKey.value).name !== 'Unknown Model'); + + return !hasKnownModel && submodel.semanticId.keys.length > 0 && ( + + + Additional Semantic IDs: + + + {submodel.semanticId.keys.map((key, keyIndex) => ( + + + {key.value} + {key.type && ( + + ({key.type}) + + )} + + + ))} + + + ); + })()} + + )} + + + {/* View button at the bottom of each card */} + + + + + ); + })} + + + {/* Carousel Indicators and Navigation */} + {totalSubmodels > itemsPerSlide && ( + + {/* Left Arrow */} + + + + + {/* Indicators */} + + {Array.from({ length: Math.ceil(totalSubmodels / itemsPerSlide) }).map((_, pageIndex) => { + const isActive = Math.floor(carouselIndex / itemsPerSlide) === pageIndex; + return ( + setCarouselIndex(pageIndex * itemsPerSlide)} + /> + ); + })} + + {Math.floor(carouselIndex / itemsPerSlide) + 1} of {Math.ceil(totalSubmodels / itemsPerSlide)} • {totalSubmodels} total + + + + {/* Right Arrow */} + + + + + )} + + ); + })()} + + + {/* Full-Screen All Submodels Dialog */} + setAllSubmodelsOpen(false)} + maxWidth={false} + fullScreen + PaperProps={{ + sx: { + background: 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)', + borderRadius: 0 + } + }} + > + + + All Submodels ({singleTwinResult.shell_descriptor.submodelDescriptors.length}) + + setAllSubmodelsOpen(false)} + sx={{ + color: 'text.secondary', + '&:hover': { + backgroundColor: 'rgba(0,0,0,0.04)' + } + }} + > + + + + + + {singleTwinResult.shell_descriptor.submodelDescriptors.map((submodel, index) => ( + + + {/* Header with title and ID in top right */} + + + {submodel.idShort || `Submodel ${index + 1}`} + + + {/* ID in top right with copy button */} + {submodel.id && ( + + {/* Green lock for verifiable credentials with signature type */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + if (verifiableInfo) { + return ( + + + + + {verifiableInfo.signatureType && ( + + + + )} + + ); + } + return null; + })()} + + + + {submodel.id.split(':').pop()?.substring(0, 12)}... + + + + handleCopyToClipboard(submodel.id)} + sx={{ + p: 0.5, + color: 'text.secondary', + '&:hover': { + color: 'primary.main', + backgroundColor: 'rgba(25, 118, 210, 0.1)' + } + }} + > + + + + + )} + + + {/* Description if available */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submodelAny = submodel as any; + if (submodelAny.description && Array.isArray(submodelAny.description) && submodelAny.description.length > 0) { + return ( + + + Description: + + + {submodelAny.description[0].text} + {submodelAny.description[0].language && ( + + ({submodelAny.description[0].language}) + + )} + + + ); + } + return null; + })()} + + {submodel.semanticId && submodel.semanticId.keys && submodel.semanticId.keys.length > 0 && ( + + {/* Check if this is a verifiable credential */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + + if (verifiableInfo) { + // Display verifiable credential information + return ( + + + + + Verifiable Credential + + + + + + + + Credential Type: + + + + + + {verifiableInfo.w3cUrl && ( + + + W3C Credential: + + + {verifiableInfo.w3cUrl} + + + )} + + ); + } else { + // Display regular semantic ID info + const firstKey = submodel.semanticId.keys[0]; + if (firstKey?.value) { + const parsedSemanticId = parseSemanticId(firstKey.value); + return ( + + + Model Information: + + + + + + + {parsedSemanticId.namespace} + + + ); + } + return null; + } + })()} + + {/* Show additional semantic IDs only if we don't know the model (no chips displayed) */} + {(() => { + const verifiableInfo = parseVerifiableCredential(submodel); + const firstKey = submodel.semanticId.keys[0]; + const hasKnownModel = verifiableInfo || (firstKey?.value && parseSemanticId(firstKey.value).name !== 'Unknown Model'); + + return !hasKnownModel && submodel.semanticId.keys.length > 0 && ( + + + Additional Semantic IDs: + + + {submodel.semanticId.keys.map((key, keyIndex) => ( + + + {key.value} + {key.type && ( + + ({key.type}) + + )} + + + ))} + + + ); + })()} + + )} + + + {/* View button at the bottom of each card */} + + + + + ))} + + + + + {/* DTR Information Dialog */} + setDtrInfoOpen(false)} + maxWidth="md" + fullWidth + PaperProps={{ + sx: { + borderRadius: 3, + boxShadow: '0 20px 60px rgba(0,0,0,0.15)' + } + }} + > + + Digital Twin Registry Information + + + + {/* DTR Connector URL */} + + + DTR Connector URL: + + + {singleTwinResult?.dtr?.connectorUrl} + + + + {/* DTR Asset ID */} + + + DTR Asset ID: + + + {singleTwinResult?.dtr?.assetId} + + + + + + + + + + {/* Copy Success Snackbar */} + setCopySuccess(false)} + message="ID copied to clipboard!" + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + sx={{ + '& .MuiSnackbarContent-root': { + backgroundColor: 'success.main', + fontSize: '0.875rem' + } + }} + /> + + {/* Submodel Viewer Dialog */} + {selectedSubmodel && ( + setSubmodelViewerOpen(false)} + counterPartyId={counterPartyId} + shellId={singleTwinResult.shell_descriptor.id} + dtrConnectorUrl={singleTwinResult.dtr?.connectorUrl} + submodel={selectedSubmodel} + /> + )} + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx b/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx new file mode 100644 index 00000000..f268a8df --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx @@ -0,0 +1,99 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import React from 'react'; +import { Grid2, TextField, Button, CircularProgress } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; + +interface SingleTwinSearchProps { + singleTwinAasId: string; + onSingleTwinAasIdChange: (value: string) => void; + onSearch: () => void; + isLoading: boolean; +} + +export const SingleTwinSearch: React.FC = ({ + singleTwinAasId, + onSingleTwinAasIdChange, + onSearch, + isLoading +}) => { + return ( + + + onSingleTwinAasIdChange(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderRadius: 3, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 1)', + }, + '&.Mui-focused': { + backgroundColor: 'rgba(255, 255, 255, 1)', + boxShadow: '0 0 0 3px rgba(25, 118, 210, 0.1)', + } + } + }} + /> + + + + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx b/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx new file mode 100644 index 00000000..58f4bc74 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx @@ -0,0 +1,990 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import axios from 'axios'; +import { + Box, + Typography, + Card, + CardContent, + Chip, + Button, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + CircularProgress, + Tooltip, + Tabs, + Tab, + useTheme, + Paper +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import InfoIcon from '@mui/icons-material/Info'; +import SecurityIcon from '@mui/icons-material/Security'; +import DataObjectIcon from '@mui/icons-material/DataObject'; +import DescriptionIcon from '@mui/icons-material/Description'; +import DownloadIcon from '@mui/icons-material/Download'; +import EmailIcon from '@mui/icons-material/Email'; +import CheckIcon from '@mui/icons-material/Check'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { fetchSubmodel, SubmodelDiscoveryResponse } from '../api'; +import { submodelAddonRegistry } from './submodel-addons/shared/registry'; +import { usTariffInformationAddon } from './submodel-addons/us-tariff-information/addon'; + +interface SubmodelViewerProps { + open: boolean; + onClose: () => void; + counterPartyId: string; + shellId: string; + dtrConnectorUrl?: string; + submodel: { + id: string; + idShort: string; + semanticId: { + type: string; + keys: Array<{ + type: string; + value: string; + }>; + }; + }; +} + +const JsonViewer: React.FC<{ data: Record; filename?: string }> = ({ data, filename = 'submodel.json' }) => { + const [copySuccess, setCopySuccess] = useState(false); + + const handleCopyJson = () => { + navigator.clipboard.writeText(JSON.stringify(data, null, 5)).then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }); + }; + + // Format JSON with line numbers and higher indentation + const jsonString = JSON.stringify(data, null, 4); // Increased indentation to 4 spaces + const lines = jsonString.split('\n'); + + const formatJsonWithLineNumbers = () => { + // Calculate the width needed for line numbers based on total lines + const totalLines = lines.length; + const lineNumberWidth = Math.max(50, (totalLines.toString().length * 8) + 16); // 8px per digit + 16px padding + + return lines.map((line, index) => { + const lineNumber = index + 1; + return ( + + + {lineNumber} + + + + + + ); + }); + }; + + const highlightJson = (line: string): string => { + let highlightedLine = line; + + // Escape HTML first + highlightedLine = highlightedLine + .replace(/&/g, '&') + .replace(//g, '>'); + + // Property names (keys) - strings followed by colon + highlightedLine = highlightedLine.replace( + /("(?:[^"\\]|\\.)*")\s*:/g, + '$1:' + ); + + // String values after colon (object values) + highlightedLine = highlightedLine.replace( + /:\s*("(?:[^"\\]|\\.)*")/g, + ': $1' + ); + + // String values in arrays (after [ or , but not after :) + highlightedLine = highlightedLine.replace( + /(\[\s*|,\s*)("(?:[^"\\]|\\.)*")/g, + '$1$2' + ); + + // Numbers after colon (object values) + highlightedLine = highlightedLine.replace( + /:\s*(-?\d+\.?\d*)/g, + ': $1' + ); + + // Numbers in arrays (after [ or , but not after :) + highlightedLine = highlightedLine.replace( + /(\[\s*|,\s*)(-?\d+\.?\d*)/g, + '$1$2' + ); + + // Booleans after colon + highlightedLine = highlightedLine.replace( + /:\s*(true|false)/g, + ': $1' + ); + + // Booleans in arrays + highlightedLine = highlightedLine.replace( + /(\[\s*|,\s*)(true|false)/g, + '$1$2' + ); + + // null after colon + highlightedLine = highlightedLine.replace( + /:\s*(null)/g, + ': $1' + ); + + // null in arrays + highlightedLine = highlightedLine.replace( + /(\[\s*|,\s*)(null)/g, + '$1$2' + ); + + // Brackets and braces + highlightedLine = highlightedLine.replace( + /([{}[\]])/g, + '$1' + ); + + // Commas + highlightedLine = highlightedLine.replace( + /(,)/g, + '$1' + ); + + return highlightedLine; + }; + + return ( + + {/* VS Code-like tab header */} + + + {filename} + + + + {copySuccess ? : } + + + + + + + + + + {formatJsonWithLineNumbers()} + + + + ); +}; + +export const SubmodelViewer: React.FC = ({ + open, + onClose, + counterPartyId, + shellId, + dtrConnectorUrl, + submodel +}) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [submodelData, setSubmodelData] = useState(null); + const [rightPanelTab, setRightPanelTab] = useState(0); // Separate state for right panel tabs + const [lastLoadedSubmodelId, setLastLoadedSubmodelId] = useState(null); + const isFetching = useRef(false); + const theme = useTheme(); + + const semanticIdValue = submodel.semanticId?.keys?.[0]?.value || ''; + + // Register addons on component mount + useEffect(() => { + // Register US Tariff Information addon if not already registered + if (!submodelAddonRegistry.getAddon('us-tariff-information')) { + submodelAddonRegistry.register(usTariffInformationAddon as unknown as import('./submodel-addons/shared/types').VersionedSubmodelAddon); + console.log('Registered US Tariff Information addon'); + } + }, []); + + // Check if there's a specialized addon for this submodel + const getSpecializedAddon = useCallback(() => { + if (!submodelData?.submodel || !semanticIdValue) { + return null; + } + + try { + const resolution = submodelAddonRegistry.resolve(semanticIdValue, submodelData.submodel); + return resolution; + } catch (error) { + console.warn('Error resolving addon for semantic ID:', semanticIdValue, error); + return null; + } + }, [submodelData?.submodel, semanticIdValue]); + + const fetchSubmodelData = useCallback(async (forceRefresh = false) => { + // Prevent multiple calls for the same submodel or if already fetching, unless it's a forced refresh + if (!forceRefresh && (lastLoadedSubmodelId === submodel.id || isFetching.current)) { + console.log('SubmodelViewer: Preventing duplicate API call for submodel:', submodel.id); + return; + } + + console.log('SubmodelViewer: Fetching submodel data for:', submodel.id, forceRefresh ? '(forced refresh)' : ''); + isFetching.current = true; + setLoading(true); + setError(null); + + try { + const response = await fetchSubmodel( + counterPartyId, + shellId, + submodel.id, + semanticIdValue + ); + setSubmodelData(response); + setLastLoadedSubmodelId(submodel.id); + console.log('SubmodelViewer: Successfully fetched submodel data'); + } catch (err) { + // Don't show error for cancelled requests + if (axios.isCancel(err)) { + console.log('SubmodelViewer: Request was cancelled'); + return; + } + console.error('Error fetching submodel:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch submodel data'); + } finally { + setLoading(false); + isFetching.current = false; + } + }, [counterPartyId, shellId, submodel.id, semanticIdValue, lastLoadedSubmodelId]); + + useEffect(() => { + if (open && submodel.id && counterPartyId && shellId) { + fetchSubmodelData(); + } + }, [open, submodel.id, counterPartyId, shellId, fetchSubmodelData]); + + // Reset data when submodel changes + useEffect(() => { + if (submodel.id !== lastLoadedSubmodelId) { + setSubmodelData(null); + setError(null); + } + }, [submodel.id, lastLoadedSubmodelId]); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setRightPanelTab(0); + setError(null); + isFetching.current = false; + } + }, [open]); + + // Auto-select specialized view when data loads (if available) + useEffect(() => { + if (submodelData?.submodel && semanticIdValue) { + const hasSpecializedAddon = getSpecializedAddon(); + if (hasSpecializedAddon) { + setRightPanelTab(0); // Specialized view first + } else { + setRightPanelTab(0); // JSON view (will be the only tab) + } + } + }, [submodelData, semanticIdValue, getSpecializedAddon]); + + const handleRightPanelTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setRightPanelTab(newValue); + }; + + const getRightPanelTabs = () => { + const tabs = []; + const hasSpecializedAddon = getSpecializedAddon(); + + if (hasSpecializedAddon) { + tabs.push( + } + iconPosition="start" + sx={{ minHeight: 'auto', padding: 0, py: 1, textTransform: 'none', fontWeight: 600 }} + /> + ); + } + + tabs.push( + } + iconPosition="start" + sx={{ minHeight: 'auto', py: 1, textTransform: 'none', fontWeight: 600 }} + /> + ); + + return tabs; + }; + + const getRightPanelContent = () => { + const hasSpecializedAddon = getSpecializedAddon(); + + if (hasSpecializedAddon && rightPanelTab === 0) { + return renderSpecializedView(); + } else if (hasSpecializedAddon && rightPanelTab === 1) { + return renderJsonData(); + } else if (!hasSpecializedAddon && rightPanelTab === 0) { + return renderJsonData(); + } + + return renderJsonData(); // fallback + }; + + const handleRefresh = () => { + fetchSubmodelData(true); + }; + + const handleDownloadJson = () => { + if (submodelData?.submodel) { + const jsonString = JSON.stringify(submodelData.submodel, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `submodel-${submodel.id}-${submodel.idShort || 'data'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + }; + + const handleShareEmail = () => { + if (submodelData?.submodel) { + const jsonString = JSON.stringify(submodelData.submodel, null, 2); + const subject = encodeURIComponent(`Submodel Data: ${submodel.idShort || 'Digital Twin Data'}`); + const body = encodeURIComponent(`Hello, + +I'm sharing submodel data with you: + +Digital Twin ID: ${shellId} +Business Partner Number (BPN): ${counterPartyId} +DTR Endpoint: ${dtrConnectorUrl || 'N/A'} +Submodel ID: ${submodelData.submodelDescriptor.submodelId} +Semantic ID: ${submodelData.submodelDescriptor.semanticId} +Status: ${submodelData.submodelDescriptor.status} + +JSON Data: +${jsonString} + +Best regards`); + + const mailtoLink = `mailto:?subject=${subject}&body=${body}`; + window.open(mailtoLink, '_blank'); + } + }; + + const renderSubmodelInfo = () => { + if (!submodelData) return null; + + return ( + + + + + + Submodel Information + + + + + + + Submodel ID: + + + } + label={submodelData.submodelDescriptor.submodelId} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'primary.main' + } + }} + /> + + navigator.clipboard.writeText(submodelData.submodelDescriptor.submodelId)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'success.main', + backgroundColor: 'rgba(76, 175, 80, 0.1)' + } + }} + > + + + + + + + + Semantic ID: + + + } + label={submodelData.submodelDescriptor.semanticId} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'secondary.main' + } + }} + /> + + navigator.clipboard.writeText(submodelData.submodelDescriptor.semanticId)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'success.main', + backgroundColor: 'rgba(76, 175, 80, 0.1)' + } + }} + > + + + + + + + + Asset ID: + + + } + label={submodelData.submodelDescriptor.assetId} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'warning.main' + } + }} + /> + + navigator.clipboard.writeText(submodelData.submodelDescriptor.assetId)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'success.main', + backgroundColor: 'rgba(76, 175, 80, 0.1)' + } + }} + > + + + + + + + + Connector URL: + + + {submodelData.submodelDescriptor.connectorUrl} + + + {submodelData.submodelDescriptor.error && ( + + + + Error Details: + + + {submodelData.submodelDescriptor.error} + + + + )} + + + + + {submodelData.dtr && ( + + + + + DTR Information + + + + + Connector URL: + + + {submodelData.dtr.connectorUrl} + + + + + Asset ID: + + + } + label={submodelData.dtr.assetId} + variant="outlined" + size="small" + sx={{ + maxWidth: '100%', + '& .MuiChip-label': { + fontFamily: 'monospace', + fontSize: '0.75rem' + }, + '& .MuiChip-icon': { + color: 'warning.main' + } + }} + /> + + navigator.clipboard.writeText(submodelData.dtr!.assetId)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'success.main', + backgroundColor: 'rgba(76, 175, 80, 0.1)' + } + }} + > + + + + + + + + + )} + + ); + }; + + const renderJsonData = () => { + if (!submodelData?.submodel || Object.keys(submodelData.submodel).length === 0) { + return ( + + No submodel data available or data could not be retrieved. + + ); + } + + // Generate the same filename as the download function + const filename = `submodel-${submodel.id}-${submodel.idShort || 'data'}.json`; + + return ; + }; + + const renderSpecializedView = () => { + if (!submodelData?.submodel) { + return ( + + No submodel data available. + + ); + } + + const addonResolution = getSpecializedAddon(); + if (!addonResolution) { + return ( + + No specialized viewer found for this semantic ID. + + ); + } + + const { addon } = addonResolution; + const AddonComponent = addon.component; + + try { + return ( + + ); + } catch (error) { + console.error('Error rendering specialized addon:', error); + return ( + + Error rendering specialized view: {error instanceof Error ? error.message : 'Unknown error'} + + ); + } + }; + + return ( + + + + + Submodel Viewer + + + {submodel.idShort} + + + + + + + + + + + + + + + + + {/* Right side - Tabs for Specialized/JSON Views - Full Height */} + + + + + {getRightPanelTabs()} + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : ( + + {getRightPanelContent()} + + )} + + + + {/* Left Panel - Submodel Information - Positioned lower */} + + + + + Submodel Information + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : ( + renderSubmodelInfo() + )} + + + + + + + + + + + + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx b/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx new file mode 100644 index 00000000..79e1752a --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx @@ -0,0 +1,334 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import MoreVert from "@mui/icons-material/MoreVert"; +import Launch from "@mui/icons-material/Launch"; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import Download from '@mui/icons-material/Download'; +import CheckCircle from '@mui/icons-material/CheckCircle'; +import { Box, Typography, IconButton, Button, Tooltip, Menu } from "@mui/material"; +import { useState } from "react"; +import ReportProblemIcon from '@mui/icons-material/ReportProblem'; +import { DiscoveryCardChip } from "./DiscoveryCardChip"; +import { StatusVariants } from "../../../../types/statusVariants"; +import { ErrorNotFound } from "../../../../components/general/ErrorNotFound"; +import LoadingSpinner from "../../../../components/general/LoadingSpinner"; +import { AASData } from "../../../part-discovery/utils"; + +export interface AppContent { + id?: string; + manufacturerId: string; + manufacturerPartId: string; + name?: string; + category?: string; + status?: StatusVariants; + dtrIndex?: number; // DTR index for display + shellId?: string; // Shell ID (AAS ID) for display + idShort?: string; // idShort for display + rawTwinData?: AASData; // Raw AAS/shell data for download +} + +export interface CardDecisionProps { + items: AppContent[]; + onClick: (e: string) => void; + onRegisterClick?: (manufacturerId: string, manufacturerPartId: string) => void; + isLoading: boolean; +} + +export const CatalogPartsDiscovery = ({ + items, + onClick, + isLoading +}: CardDecisionProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const open = Boolean(anchorEl); + + const handleMenuClick = (event: React.MouseEvent, item: AppContent) => { + event.stopPropagation(); + setAnchorEl(event.currentTarget); + setSelectedItem(item); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedItem(null); + }; + + const handleCopyShellId = async () => { + if (selectedItem?.shellId) { + try { + await navigator.clipboard.writeText(selectedItem.shellId); + console.log('Shell ID copied to clipboard:', selectedItem.shellId); + setCopySuccess(true); + + // Close menu after showing feedback for 1.5 seconds + setTimeout(() => { + handleMenuClose(); + // Reset success state after menu closes + setTimeout(() => { + setCopySuccess(false); + }, 300); + }, 1500); + } catch (err) { + console.error('Failed to copy Shell ID:', err); + handleMenuClose(); + } + } else { + handleMenuClose(); + } + }; + + const handleDownloadTwinData = () => { + if (selectedItem?.rawTwinData) { + try { + const jsonString = JSON.stringify(selectedItem.rawTwinData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `twin-${selectedItem.manufacturerPartId || selectedItem.shellId || 'data'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + console.log('Twin data downloaded successfully'); + } catch (err) { + console.error('Failed to download twin data:', err); + } + } + handleMenuClose(); + }; + + return ( + <> + + {isLoading && ( + + )} + {!isLoading && items.length === 0 && ( + + )} + {items.map((item) => { + const name = item.name ?? ""; + const productId = item.manufacturerId + "/" + item.manufacturerPartId; + return ( + + + + + + + + + {item.rawTwinData && ( + + { + e.stopPropagation(); + if (item.rawTwinData) { + try { + const jsonString = JSON.stringify(item.rawTwinData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `twin-${item.manufacturerPartId || item.shellId || 'data'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + console.log('Twin data downloaded successfully'); + } catch (err) { + console.error('Failed to download twin data:', err); + } + } + }} + > + + + + )} + + handleMenuClick(e, item)} + > + + + + + + + + {name} + + {(item.idShort || item.shellId) && ( + + {item.idShort || item.shellId} + + )} +

    + + {item.category} + +
    + + + +
    +
    + ); + })} + + {/* More options menu */} + + {selectedItem?.shellId && ( + + + {copySuccess ? 'Copied!' : 'Copy Shell ID'} + + {copySuccess ? ( + + ) : ( + + )} + + )} + {selectedItem?.rawTwinData && ( + + + Download Twin Data + + + + )} + +
    + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx b/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx new file mode 100644 index 00000000..c4264ce4 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx @@ -0,0 +1,135 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import PersonIcon from '@mui/icons-material/Person' +import { type Palette, useTheme } from '@mui/material' +import MuiChip from '@mui/material/Chip' +import { StatusVariants } from '../../../../types/statusVariants' + +export interface CardChipProps { + status?: StatusVariants + statusText?: string + dtrIndex?: number + useDtrDisplay?: boolean +} + +interface ChipStyle { + color: keyof Palette['chip'] + backgroundColor: keyof Palette['chip'] + border: keyof Palette['chip'] +} + +const statusStyles: Record = { + [StatusVariants.registered]: { + color: 'registered', + backgroundColor: 'black', + border: 'bgRegistered', + }, + [StatusVariants.shared]: { + color: 'black', + backgroundColor: 'warning', + border: 'none', + }, + [StatusVariants.draft]: { + color: 'bgDefault', + backgroundColor: 'none', + border: 'borderDraft', + }, + [StatusVariants.pending]: { + color: 'inReview', + backgroundColor: 'bgInReview', + border: 'inReview', + }, + default: { + color: 'default', + backgroundColor: 'bgDefault', + border: 'none', + } +} + +// Helper function to get consistent colors for DTR identifiers +const getDtrColor = (dtrIndex: number) => { + const baseColors = [ + { bg: 'rgba(76, 175, 80, 0.9)', color: 'white' }, // Green + { bg: 'rgba(33, 150, 243, 0.9)', color: 'white' }, // Blue + { bg: 'rgba(255, 152, 0, 0.9)', color: 'white' }, // Orange + { bg: 'rgba(156, 39, 176, 0.9)', color: 'white' }, // Purple + { bg: 'rgba(244, 67, 54, 0.9)', color: 'white' }, // Red + { bg: 'rgba(0, 188, 212, 0.9)', color: 'white' }, // Cyan + { bg: 'rgba(139, 195, 74, 0.9)', color: 'white' }, // Light Green + { bg: 'rgba(121, 85, 72, 0.9)', color: 'white' }, // Brown + ]; + + const colorIndex = dtrIndex % baseColors.length; + const variation = Math.floor(dtrIndex / baseColors.length); + + // For DTRs beyond 8, add opacity variations to distinguish them + const baseColor = baseColors[colorIndex]; + const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity + + return { + bg: baseColor.bg.replace('0.9)', `${opacity})`), + color: baseColor.color + }; +}; + +export const DiscoveryCardChip = ({ status, statusText, dtrIndex, useDtrDisplay }: CardChipProps) => { + const theme = useTheme() + + // If DTR display is requested and dtrIndex is provided, use DTR styling + if (useDtrDisplay && dtrIndex !== undefined) { + const dtrColors = getDtrColor(dtrIndex); + return ( + + ); + } + + // Otherwise, use original status styling + const statusKey = status && statusStyles[status] ? status : 'default' + const { color, backgroundColor, border } = statusStyles[statusKey] + + return ( + :undefined} + /> + ) +} diff --git a/ichub-frontend/src/features/part-discovery/components/index.ts b/ichub-frontend/src/features/part-discovery/components/index.ts new file mode 100644 index 00000000..7c5fc17c --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/index.ts @@ -0,0 +1,32 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +export { PaginationControls } from './PaginationControls'; +export { SearchModeToggle } from './SearchModeToggle'; +export { FilterChips } from './FilterChips'; +export { PartnerSearch } from './PartnerSearch'; +export { SingleTwinSearch } from './SingleTwinSearch'; +export { SearchHeader } from './SearchHeader'; +export { SingleTwinResult } from './SingleTwinResult'; +export { default as SerializedPartsTable } from './SerializedPartsTable'; +export { default as PartsDiscoverySidebar } from './PartsDiscoverySidebar'; +export { CatalogPartsDiscovery } from './catalog-parts/CatalogPartsDiscovery'; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx b/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx new file mode 100644 index 00000000..294f4a64 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx @@ -0,0 +1,126 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import React from 'react'; +import { Box, Typography, Card, CardContent, Chip } from '@mui/material'; +import InfoIcon from '@mui/icons-material/Info'; +import { SubmodelAddonProps } from './types'; + +/** + * Base wrapper component for submodel add-ons + */ +export const SubmodelAddonWrapper: React.FC<{ + title: string; + subtitle?: string; + children: React.ReactNode; + actions?: React.ReactNode; +}> = ({ title, subtitle, children, actions }) => { + return ( + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {actions && ( + + {actions} + + )} + + + {children} + + + ); +}; + +/** + * Default fallback component when no specific add-on is available + */ +export const DefaultSubmodelAddon: React.FC = ({ + data, + semanticId +}) => { + return ( + + + + + + + Submodel Information + + + + + + Semantic ID: + + + + + + Data Structure: + + + This submodel contains {Object.keys(data).length} top-level properties. + No specialized visualization is available for this semantic ID. + + + + + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts new file mode 100644 index 00000000..7b80f88d --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts @@ -0,0 +1,146 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { SubmodelAddonConfig, SubmodelAddonRegistry, ParsedSemanticId } from './types'; + +/** + * Utility function to parse semantic ID from string + */ +function parseSemanticId(semanticId: string): ParsedSemanticId | null { + try { + // Parse semantic ID format: urn:samm:org.eclipse.esmf.samm:example:1.0.0#Property + const match = semanticId.match(/^urn:(samm|bamm):([^:]+):([^:]+):(\d+)\.(\d+)\.(\d+)(?:#(.+))?$/); + + if (!match) { + return null; + } + + const [, prefix, namespace, name, major, minor, patch, fragment] = match; + + return { + prefix: prefix as 'samm' | 'bamm', + namespace, + name, + version: { + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10) + }, + fragment, + originalId: semanticId + }; + } catch (error) { + console.warn('Failed to parse semantic ID:', semanticId, error); + return null; + } +} + +/** + * Check if a parsed semantic ID matches the add-on's criteria + */ +function addonCanHandle(config: SubmodelAddonConfig, parsedSemanticId: ParsedSemanticId): boolean { + // Check namespace and name match + if (config.semanticNamespace !== parsedSemanticId.namespace || + config.semanticName !== parsedSemanticId.name) { + return false; + } + + // Check version compatibility + const version = parsedSemanticId.version; + const { supportedVersions } = config; + + if (supportedVersions.exact) { + return version.major === supportedVersions.exact.major && + version.minor === supportedVersions.exact.minor && + version.patch === supportedVersions.exact.patch; + } + + if (supportedVersions.min) { + const minVersion = supportedVersions.min; + if (version.major < minVersion.major || + (version.major === minVersion.major && version.minor < minVersion.minor) || + (version.major === minVersion.major && version.minor === minVersion.minor && version.patch < minVersion.patch)) { + return false; + } + } + + if (supportedVersions.max) { + const maxVersion = supportedVersions.max; + if (version.major > maxVersion.major || + (version.major === maxVersion.major && version.minor > maxVersion.minor) || + (version.major === maxVersion.major && version.minor === maxVersion.minor && version.patch > maxVersion.patch)) { + return false; + } + } + + return true; +} + +/** + * Create a new submodel add-on registry + */ +export function createSubmodelAddonRegistry(): SubmodelAddonRegistry { + const addons = new Map(); + + return { + addons, + + register(config: SubmodelAddonConfig) { + // If no custom canHandle function provided, create a default one + if (!config.canHandle) { + config.canHandle = (parsedSemanticId: ParsedSemanticId) => + addonCanHandle(config, parsedSemanticId); + } + + addons.set(config.id, config); + console.log(`Registered submodel add-on: ${config.name} (${config.id})`); + }, + + getAddon(semanticId: string): SubmodelAddonConfig | null { + const parsedSemanticId = parseSemanticId(semanticId); + if (!parsedSemanticId) { + return null; + } + + const candidates = Array.from(addons.values()) + .filter(addon => addon.canHandle(parsedSemanticId)) + .sort((a, b) => b.priority - a.priority); // Sort by priority descending + + return candidates.length > 0 ? candidates[0] : null; + }, + + getCompatibleAddons(parsedSemanticId: ParsedSemanticId): SubmodelAddonConfig[] { + return Array.from(addons.values()) + .filter(addon => addon.canHandle(parsedSemanticId)) + .sort((a, b) => b.priority - a.priority); // Sort by priority descending + }, + + getAllAddons(): SubmodelAddonConfig[] { + return Array.from(addons.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + }; +} + +/** + * Global registry instance + */ +export const submodelAddonRegistry = createSubmodelAddonRegistry(); diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts new file mode 100644 index 00000000..ac74c8b5 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts @@ -0,0 +1,395 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { + VersionedSubmodelAddon, + SubmodelAddonRegistryEntry, + AddonResolutionResult, + SubmodelAddonError, + SubmodelAddonErrorType, + SubmodelAddonSystemConfig, +} from './types'; +import { isSemanticIdForModel, parseSemanticId } from './semantic-id-utils'; + +/** + * Default configuration for the submodel addon system + */ +const DEFAULT_CONFIG: SubmodelAddonSystemConfig = { + strictMode: false, + maxCacheSize: 50, + enablePerformanceMonitoring: false, +}; + +/** + * Registry for managing submodel addons + * + * This registry provides a centralized way to register, discover, and resolve + * submodel addons based on semantic IDs. It supports versioning, prioritization, + * and fallback mechanisms. + */ +export class SubmodelAddonRegistry { + private readonly addons = new Map(); + private readonly semanticIdCache = new Map(); + private readonly config: SubmodelAddonSystemConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Registers a submodel addon + * + * @param addon - The addon to register + * @param options - Registration options + * @throws {SubmodelAddonError} If registration fails + */ + register( + addon: VersionedSubmodelAddon, + options: { + enabled?: boolean; + loadPriority?: number; + replaceExisting?: boolean; + } = {} + ): void { + const { enabled = true, loadPriority = 0, replaceExisting = false } = options; + + // Validate addon configuration + this.validateAddon(addon); + + // Check if addon already exists + if (this.addons.has(addon.id) && !replaceExisting) { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + `Addon with ID '${addon.id}' is already registered. Use replaceExisting: true to override.`, + undefined, + addon.id + ); + } + + // Register the addon + this.addons.set(addon.id, { + addon, + enabled, + loadPriority, + }); + + // Clear relevant cache entries + this.clearCacheForAddon(addon); + + console.debug(`Registered submodel addon: ${addon.id} (${addon.name})`); + } + + /** + * Unregisters a submodel addon + * + * @param addonId - The ID of the addon to unregister + * @returns True if the addon was found and removed + */ + unregister(addonId: string): boolean { + const entry = this.addons.get(addonId); + if (!entry) { + return false; + } + + this.addons.delete(addonId); + this.clearCacheForAddon(entry.addon); + + console.debug(`Unregistered submodel addon: ${addonId}`); + return true; + } + + /** + * Resolves the best addon for a given semantic ID + * + * @param semanticId - The semantic ID to resolve + * @param data - The submodel data (optional, for validation) + * @returns The resolved addon or null if none found + */ + resolve( + semanticId: string, + data?: unknown + ): AddonResolutionResult | null { + // Check cache first + const cached = this.semanticIdCache.get(semanticId); + if (cached) { + // Validate data if provided + if (data !== undefined && !cached.addon.isValidData(semanticId, data)) { + return null; + } + return cached as AddonResolutionResult; + } + + // Find matching addons + const candidates = this.findMatchingAddons(semanticId, data); + if (candidates.length === 0) { + if (this.config.strictMode) { + throw new SubmodelAddonError( + SubmodelAddonErrorType.ADDON_NOT_FOUND, + `No addon found for semantic ID: ${semanticId}`, + semanticId + ); + } + return null; + } + + // Select the best candidate + const best = this.selectBestAddon(candidates, semanticId); + + // Cache the result + if (this.semanticIdCache.size >= this.config.maxCacheSize) { + // Simple LRU: remove oldest entry + const firstKey = this.semanticIdCache.keys().next().value; + if (firstKey) { + this.semanticIdCache.delete(firstKey); + } + } + this.semanticIdCache.set(semanticId, best); + + return best as AddonResolutionResult; + } + + /** + * Gets all registered addons + * + * @param includeDisabled - Whether to include disabled addons + * @returns Array of addon entries + */ + getAllAddons(includeDisabled = false): SubmodelAddonRegistryEntry[] { + return Array.from(this.addons.values()).filter( + entry => includeDisabled || entry.enabled + ); + } + + /** + * Gets addon by ID + * + * @param addonId - The addon ID + * @returns The addon entry or undefined + */ + getAddon(addonId: string): SubmodelAddonRegistryEntry | undefined { + return this.addons.get(addonId); + } + + /** + * Enables or disables an addon + * + * @param addonId - The addon ID + * @param enabled - Whether to enable the addon + * @returns True if the addon was found and updated + */ + setAddonEnabled(addonId: string, enabled: boolean): boolean { + const entry = this.addons.get(addonId); + if (!entry) { + return false; + } + + entry.enabled = enabled; + this.clearCacheForAddon(entry.addon); + return true; + } + + /** + * Clears the resolution cache + */ + clearCache(): void { + this.semanticIdCache.clear(); + } + + /** + * Gets statistics about the registry + */ + getStats(): { + totalAddons: number; + enabledAddons: number; + cacheSize: number; + namespaces: string[]; + } { + const entries = Array.from(this.addons.values()); + const enabledEntries = entries.filter(e => e.enabled); + const namespaces = [...new Set(entries.map(e => e.addon.namespace))]; + + return { + totalAddons: entries.length, + enabledAddons: enabledEntries.length, + cacheSize: this.semanticIdCache.size, + namespaces, + }; + } + + /** + * Validates an addon configuration + */ + private validateAddon(addon: VersionedSubmodelAddon): void { + if (!addon.id || typeof addon.id !== 'string') { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must have a valid string ID', + undefined, + addon.id + ); + } + + if (!addon.name || typeof addon.name !== 'string') { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must have a valid name', + undefined, + addon.id + ); + } + + if (!addon.namespace || typeof addon.namespace !== 'string') { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must have a valid namespace', + undefined, + addon.id + ); + } + + if (!addon.modelName || typeof addon.modelName !== 'string') { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must have a valid modelName', + undefined, + addon.id + ); + } + + if (!Array.isArray(addon.supportedSemanticIds) || addon.supportedSemanticIds.length === 0) { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must support at least one semantic ID', + undefined, + addon.id + ); + } + + if (typeof addon.isValidData !== 'function') { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must provide a valid isValidData function', + undefined, + addon.id + ); + } + + if (!addon.component) { + throw new SubmodelAddonError( + SubmodelAddonErrorType.REGISTRATION_ERROR, + 'Addon must provide a React component', + undefined, + addon.id + ); + } + } + + /** + * Finds addons that match the given semantic ID + */ + private findMatchingAddons(semanticId: string, data?: unknown): SubmodelAddonRegistryEntry[] { + const parsed = parseSemanticId(semanticId); + if (!parsed) { + return []; + } + + return Array.from(this.addons.values()).filter(entry => { + if (!entry.enabled) { + return false; + } + + const { addon } = entry; + + // Check if addon supports this namespace and model + if (!isSemanticIdForModel(semanticId, addon.namespace, addon.modelName)) { + return false; + } + + // Check if addon explicitly supports this semantic ID + if (!addon.supportedSemanticIds.includes(semanticId)) { + return false; + } + + // Validate data if provided + if (data !== undefined && !addon.isValidData(semanticId, data)) { + return false; + } + + return true; + }); + } + + /** + * Selects the best addon from candidates + */ + private selectBestAddon( + candidates: SubmodelAddonRegistryEntry[], + semanticId: string + ): AddonResolutionResult { + // Sort by priority (descending) and load priority (ascending) + const sorted = candidates.sort((a, b) => { + if (a.addon.priority !== b.addon.priority) { + return b.addon.priority - a.addon.priority; // Higher priority first + } + return a.loadPriority - b.loadPriority; // Lower load priority first + }); + + const best = sorted[0]; + const isPreferred = sorted.length === 1 || best.addon.priority > sorted[1].addon.priority; + const confidence = this.calculateConfidence(best.addon, semanticId); + + return { + addon: best.addon, + isPreferred, + confidence, + }; + } + + /** + * Calculates confidence score for an addon match + */ + private calculateConfidence(addon: VersionedSubmodelAddon, semanticId: string): number { + // Base confidence for exact semantic ID match + let confidence = addon.supportedSemanticIds.includes(semanticId) ? 1.0 : 0.8; + + // Adjust based on addon priority + confidence *= Math.min(1.0, (addon.priority + 10) / 20); + + return Math.max(0, Math.min(1, confidence)); + } + + /** + * Clears cache entries related to an addon + */ + private clearCacheForAddon(addon: VersionedSubmodelAddon): void { + for (const [semanticId, result] of this.semanticIdCache.entries()) { + if (result.addon.id === addon.id) { + this.semanticIdCache.delete(semanticId); + } + } + } +} + +/** + * Global singleton instance of the submodel addon registry + */ +export const submodelAddonRegistry = new SubmodelAddonRegistry(); diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts new file mode 100644 index 00000000..f7e4de39 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts @@ -0,0 +1,269 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +/** + * Generic utilities for parsing and working with Catena-X semantic model IDs + * + * Semantic ID format: urn:samm:io.catenax.{namespace}:{version}#{modelName} + * Examples: + * - urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation + * - urn:samm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt + * - urn:samm:io.catenax.shared.shopfloor_information_types:2.0.0#ShopfloorInformationTypes + */ + +/** + * Parsed semantic ID components + */ +export interface ParsedSemanticId { + /** The namespace part (e.g., 'us_tariff_information', 'single_level_bom_as_built') */ + namespace: string; + /** The version (e.g., '1.0.0', '3.0.0') */ + version: string; + /** The model name (e.g., 'UsTariffInformation', 'SingleLevelBomAsBuilt') */ + modelName: string; + /** The complete original URN */ + fullUrn: string; +} + +/** + * Regular expression for parsing Catena-X semantic IDs + */ +const SEMANTIC_ID_REGEX = /^urn:samm:io\.catenax\.([\w.]+):(\d+\.\d+\.\d+)#(\w+)$/; + +/** + * Parses a Catena-X semantic ID into its components + * + * @param semanticId - The semantic ID to parse + * @returns Parsed components or null if invalid format + * + * @example + * ```typescript + * const parsed = parseSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'); + * // Returns: { + * // namespace: 'us_tariff_information', + * // version: '1.0.0', + * // modelName: 'UsTariffInformation', + * // fullUrn: 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation' + * // } + * ``` + */ +export function parseSemanticId(semanticId: string): ParsedSemanticId | null { + const match = semanticId.match(SEMANTIC_ID_REGEX); + if (!match) { + return null; + } + + return { + namespace: match[1], + version: match[2], + modelName: match[3], + fullUrn: semanticId + }; +} + +/** + * Extracts the version from a semantic ID + * + * @param semanticId - The semantic ID + * @returns The version string or null if invalid + * + * @example + * ```typescript + * extractVersionFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'); + * // Returns: '1.0.0' + * ``` + */ +export function extractVersionFromSemanticId(semanticId: string): string | null { + const parsed = parseSemanticId(semanticId); + return parsed?.version ?? null; +} + +/** + * Extracts the model name from a semantic ID + * + * @param semanticId - The semantic ID + * @returns The model name or null if invalid + * + * @example + * ```typescript + * extractModelNameFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'); + * // Returns: 'UsTariffInformation' + * ``` + */ +export function extractModelNameFromSemanticId(semanticId: string): string | null { + const parsed = parseSemanticId(semanticId); + return parsed?.modelName ?? null; +} + +/** + * Extracts the namespace from a semantic ID + * + * @param semanticId - The semantic ID + * @returns The namespace or null if invalid + * + * @example + * ```typescript + * extractNamespaceFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'); + * // Returns: 'us_tariff_information' + * ``` + */ +export function extractNamespaceFromSemanticId(semanticId: string): string | null { + const parsed = parseSemanticId(semanticId); + return parsed?.namespace ?? null; +} + +/** + * Checks if a semantic ID matches a specific model type + * + * @param semanticId - The semantic ID to check + * @param namespace - The expected namespace + * @param modelName - The expected model name + * @returns True if the semantic ID matches the specified model + * + * @example + * ```typescript + * isSemanticIdForModel( + * 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation', + * 'us_tariff_information', + * 'UsTariffInformation' + * ); + * // Returns: true + * ``` + */ +export function isSemanticIdForModel( + semanticId: string, + namespace: string, + modelName: string +): boolean { + const parsed = parseSemanticId(semanticId); + return parsed?.namespace === namespace && parsed?.modelName === modelName; +} + +/** + * Checks if a semantic ID is valid Catena-X format + * + * @param semanticId - The semantic ID to validate + * @returns True if the semantic ID is valid + * + * @example + * ```typescript + * isValidSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'); + * // Returns: true + * + * isValidSemanticId('invalid-id'); + * // Returns: false + * ``` + */ +export function isValidSemanticId(semanticId: string): boolean { + return parseSemanticId(semanticId) !== null; +} + +/** + * Creates a semantic ID from components + * + * @param namespace - The namespace + * @param version - The version + * @param modelName - The model name + * @returns The constructed semantic ID + * + * @example + * ```typescript + * createSemanticId('us_tariff_information', '1.0.0', 'UsTariffInformation'); + * // Returns: 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation' + * ``` + */ +export function createSemanticId(namespace: string, version: string, modelName: string): string { + return `urn:samm:io.catenax.${namespace}:${version}#${modelName}`; +} + +/** + * Compares two version strings using semantic versioning rules + * + * @param version1 - First version to compare + * @param version2 - Second version to compare + * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + * + * @example + * ```typescript + * compareVersions('1.0.0', '1.1.0'); // Returns: -1 + * compareVersions('2.0.0', '1.9.9'); // Returns: 1 + * compareVersions('1.0.0', '1.0.0'); // Returns: 0 + * ``` + */ +export function compareVersions(version1: string, version2: string): number { + const parts1 = version1.split('.').map(Number); + const parts2 = version2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0; + const part2 = parts2[i] || 0; + + if (part1 < part2) return -1; + if (part1 > part2) return 1; + } + + return 0; +} + +/** + * Gets the latest version from a list of semantic IDs for the same model + * + * @param semanticIds - Array of semantic IDs + * @param namespace - The namespace to filter by + * @param modelName - The model name to filter by + * @returns The semantic ID with the latest version, or null if none found + * + * @example + * ```typescript + * const ids = [ + * 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation', + * 'urn:samm:io.catenax.us_tariff_information:1.1.0#UsTariffInformation', + * 'urn:samm:io.catenax.us_tariff_information:2.0.0#UsTariffInformation' + * ]; + * getLatestVersionSemanticId(ids, 'us_tariff_information', 'UsTariffInformation'); + * // Returns: 'urn:samm:io.catenax.us_tariff_information:2.0.0#UsTariffInformation' + * ``` + */ +export function getLatestVersionSemanticId( + semanticIds: string[], + namespace: string, + modelName: string +): string | null { + const matchingIds = semanticIds.filter(id => + isSemanticIdForModel(id, namespace, modelName) + ); + + if (matchingIds.length === 0) { + return null; + } + + return matchingIds.reduce((latest, current) => { + const latestVersion = extractVersionFromSemanticId(latest); + const currentVersion = extractVersionFromSemanticId(current); + + if (!latestVersion || !currentVersion) { + return latest; + } + + return compareVersions(currentVersion, latestVersion) > 0 ? current : latest; + }); +} diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts new file mode 100644 index 00000000..90b0a52e --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts @@ -0,0 +1,147 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { ComponentType } from 'react'; + +/** + * Base interface for all submodel addon configurations + */ +export interface SubmodelAddonBase { + /** Unique identifier for the addon */ + id: string; + /** Display name for the addon */ + name: string; + /** Brief description of what this addon visualizes */ + description: string; + /** Semantic model namespace (e.g., 'us_tariff_information') */ + namespace: string; + /** Semantic model name (e.g., 'UsTariffInformation') */ + modelName: string; + /** Array of supported semantic IDs */ + supportedSemanticIds: readonly string[]; + /** Priority for addon selection (higher = preferred) */ + priority: number; +} + +/** + * Versioned submodel addon configuration + */ +export interface VersionedSubmodelAddon extends SubmodelAddonBase { + /** Type guard function to validate data structure */ + isValidData: (semanticId: string, data: unknown) => data is TData; + /** React component for rendering the submodel data */ + component: ComponentType>; +} + +/** + * Props passed to submodel addon components + */ +export interface SubmodelAddonProps { + /** The semantic ID of the submodel */ + semanticId: string; + /** The validated submodel data */ + data: TData; + /** Optional metadata about the submodel */ + metadata?: SubmodelMetadata; + /** Callback for error handling */ + onError?: (error: Error) => void; + /** Additional props that can be passed down */ + [key: string]: unknown; +} + +/** + * Metadata about a submodel + */ +export interface SubmodelMetadata { + /** Source of the submodel data */ + source?: string; + /** Timestamp when the data was last updated */ + lastUpdated?: Date; + /** Version information */ + version?: string; + /** Additional metadata */ + [key: string]: unknown; +} + +/** + * Registry entry for a submodel addon + */ +export interface SubmodelAddonRegistryEntry { + /** The addon configuration */ + addon: VersionedSubmodelAddon; + /** Whether the addon is enabled */ + enabled: boolean; + /** Load priority (for lazy loading) */ + loadPriority: number; +} + +/** + * Error types that can occur in submodel addon system + */ +export enum SubmodelAddonErrorType { + ADDON_NOT_FOUND = 'ADDON_NOT_FOUND', + INVALID_DATA = 'INVALID_DATA', + COMPONENT_ERROR = 'COMPONENT_ERROR', + REGISTRATION_ERROR = 'REGISTRATION_ERROR', +} + +/** + * Error class for submodel addon related errors + */ +export class SubmodelAddonError extends Error { + constructor( + public type: SubmodelAddonErrorType, + message: string, + public semanticId?: string, + public addonId?: string, + public cause?: Error + ) { + super(message); + this.name = 'SubmodelAddonError'; + } +} + +/** + * Result of addon resolution + */ +export interface AddonResolutionResult { + /** The resolved addon */ + addon: VersionedSubmodelAddon; + /** Whether this is the preferred addon for the semantic ID */ + isPreferred: boolean; + /** Confidence score (0-1) for the match */ + confidence: number; +} + +/** + * Configuration for the submodel addon system + */ +export interface SubmodelAddonSystemConfig { + /** Whether to enable strict mode (throws on missing addons) */ + strictMode: boolean; + /** Default fallback component for unknown submodels */ + fallbackComponent?: ComponentType; + /** Maximum number of addons to cache */ + maxCacheSize: number; + /** Whether to enable performance monitoring */ + enablePerformanceMonitoring: boolean; +} diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts new file mode 100644 index 00000000..aa5a4224 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts @@ -0,0 +1,181 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +/** + * Generic utilities for working with Catena-X semantic models + */ + +import { ParsedSemanticId } from '../types'; +import { compareVersions } from '../utils/version-utils'; + +/** + * Semantic model constants + */ +export const SEMANTIC_MODEL_CONSTANTS = { + SAMM_PREFIX: 'urn:samm:', + BAMM_PREFIX: 'urn:bamm:', + NAMESPACE_PREFIX: 'io.catenax.', + SEPARATOR: ':', + FRAGMENT_SEPARATOR: '#', +} as const; + +/** + * Creates a semantic ID string from its components + * + * @param namespace - The namespace (e.g., 'us_tariff_information') + * @param version - The version (e.g., '1.0.0') + * @param modelName - The model name (e.g., 'UsTariffInformation') + * @param useBAMM - Whether to use BAMM prefix instead of SAMM (default: false) + * @returns The complete semantic ID + */ +export function createSemanticId(namespace: string, version: string, modelName: string, useBAMM = false): string { + const prefix = useBAMM ? SEMANTIC_MODEL_CONSTANTS.BAMM_PREFIX : SEMANTIC_MODEL_CONSTANTS.SAMM_PREFIX; + return `${prefix}${SEMANTIC_MODEL_CONSTANTS.NAMESPACE_PREFIX}${namespace}${SEMANTIC_MODEL_CONSTANTS.SEPARATOR}${version}${SEMANTIC_MODEL_CONSTANTS.FRAGMENT_SEPARATOR}${modelName}`; +} + +/** + * Parses a semantic ID string into its components + * + * @param semanticId - The semantic ID to parse + * @returns Parsed components or null if invalid + */ +export function parseSemanticId(semanticId: string): ParsedSemanticId | null { + try { + // Expected format: urn:(samm|bamm):io.catenax.namespace:version#ModelName + const regex = /^urn:(samm|bamm):io\.catenax\.([^:]+):(\d+)\.(\d+)\.(\d+)#(.+)$/; + const match = semanticId.match(regex); + + if (!match) { + return null; + } + + const [, prefix, namespace, major, minor, patch, modelName] = match; + + // Extract the actual name from namespace (last part after dots) + const namespaceParts = namespace.split('.'); + const name = namespaceParts[namespaceParts.length - 1]; + + return { + prefix: prefix as 'samm' | 'bamm', + namespace, + name, + version: { + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10) + }, + fragment: modelName, + originalId: semanticId, + }; + } catch { + return null; + } +} + +/** + * Checks if a semantic ID belongs to a specific model (any version) + * + * @param semanticId - The semantic ID to check + * @param namespace - The expected namespace + * @param modelName - The expected model name + * @returns True if the semantic ID matches the namespace and model name + */ +export function isSemanticIdForModel(semanticId: string, namespace: string, modelName: string): boolean { + const parsed = parseSemanticId(semanticId); + return parsed !== null && parsed.namespace === namespace && parsed.fragment === modelName; +} + +/** + * Checks if a semantic ID matches a specific version of a model + * + * @param semanticId - The semantic ID to check + * @param namespace - The expected namespace + * @param version - The expected version as SemanticVersion object + * @param modelName - The expected model name + * @returns True if the semantic ID matches all components + */ +export function isSemanticIdForModelVersion( + semanticId: string, + namespace: string, + version: { major: number; minor: number; patch: number }, + modelName: string +): boolean { + const parsed = parseSemanticId(semanticId); + return ( + parsed !== null && + parsed.namespace === namespace && + parsed.version.major === version.major && + parsed.version.minor === version.minor && + parsed.version.patch === version.patch && + parsed.fragment === modelName + ); +} + +/** + * Extracts the version from a semantic ID + * + * @param semanticId - The semantic ID to extract version from + * @returns The version string or null if invalid + */ +export function getSemanticIdVersion(semanticId: string): string | null { + const parsed = parseSemanticId(semanticId); + return parsed ? `${parsed.version.major}.${parsed.version.minor}.${parsed.version.patch}` : null; +} + +/** + * Validates if a semantic ID follows the Catena-X SAMM format + * + * @param semanticId - The semantic ID to validate + * @returns True if the semantic ID is valid + */ +export function isValidSemanticId(semanticId: string): boolean { + return parseSemanticId(semanticId) !== null; +} + +/** + * Gets all supported versions for a specific model from a list of semantic IDs + * + * @param semanticIds - Array of semantic IDs to filter + * @param namespace - The namespace to filter by + * @param modelName - The model name to filter by + * @returns Array of versions sorted in ascending order + */ +export function getSupportedVersionsForModel( + semanticIds: string[], + namespace: string, + modelName: string +): string[] { + return semanticIds + .map(parseSemanticId) + .filter((parsed): parsed is ParsedSemanticId => + parsed !== null && + parsed.namespace === namespace && + parsed.fragment === modelName + ) + .map(parsed => `${parsed.version.major}.${parsed.version.minor}.${parsed.version.patch}`) + .sort((a, b) => { + // Convert string versions to SemanticVersion objects for comparison + const versionA = { major: parseInt(a.split('.')[0]), minor: parseInt(a.split('.')[1]), patch: parseInt(a.split('.')[2]) }; + const versionB = { major: parseInt(b.split('.')[0]), minor: parseInt(b.split('.')[1]), patch: parseInt(b.split('.')[2]) }; + return compareVersions(versionA, versionB); + }); +} diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts new file mode 100644 index 00000000..217f6f6c --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts @@ -0,0 +1,120 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import React from 'react'; + +/** + * Semantic version representation + */ +export interface SemanticVersion { + major: number; + minor: number; + patch: number; +} + +/** + * Version range for compatibility checking + */ +export interface VersionRange { + min?: SemanticVersion; + max?: SemanticVersion; + exact?: SemanticVersion; +} + +/** + * Parsed semantic ID information + */ +export interface ParsedSemanticId { + prefix: 'samm' | 'bamm'; + namespace: string; + name: string; + version: SemanticVersion; + fragment?: string; + originalId: string; +} + +/** + * Base interface for submodel data + */ +export interface SubmodelData { + [key: string]: unknown; +} + +/** + * Props passed to submodel add-on components + */ +export interface SubmodelAddonProps { + data: SubmodelData; + semanticId: string; + parsedSemanticId: ParsedSemanticId; + submodelId: string; + onExport?: (data: SubmodelData, filename: string) => void; + onShare?: (data: SubmodelData, title: string) => void; +} + +/** + * Configuration for a submodel add-on + */ +export interface SubmodelAddonConfig { + /** Unique identifier for the add-on */ + id: string; + /** Display name for the add-on */ + name: string; + /** Description of what this add-on visualizes */ + description: string; + /** Add-on version */ + version: SemanticVersion; + /** Semantic ID namespace this add-on handles */ + semanticNamespace: string; + /** Semantic ID name this add-on handles */ + semanticName: string; + /** Supported semantic ID versions */ + supportedVersions: VersionRange; + /** Priority for add-on selection (higher = more preferred) */ + priority: number; + /** Icon component to display */ + icon: React.ComponentType<{ fontSize?: string; color?: string }>; + /** The main visualization component */ + component: React.ComponentType; + /** Whether this add-on can handle the given parsed semantic ID */ + canHandle: (parsedSemanticId: ParsedSemanticId) => boolean; +} + +/** + * Registry for all submodel add-ons + */ +export interface SubmodelAddonRegistry { + addons: Map; + register: (config: SubmodelAddonConfig) => void; + getAddon: (semanticId: string) => SubmodelAddonConfig | null; + getAllAddons: () => SubmodelAddonConfig[]; + getCompatibleAddons: (parsedSemanticId: ParsedSemanticId) => SubmodelAddonConfig[]; +} + +/** + * Context for the submodel add-on system + */ +export interface SubmodelAddonContext { + registry: SubmodelAddonRegistry; + currentAddon: SubmodelAddonConfig | null; + setCurrentAddon: (addon: SubmodelAddonConfig | null) => void; +} diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx new file mode 100644 index 00000000..28d8eeda --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx @@ -0,0 +1,554 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import React from 'react'; +import { + Box, + Typography, + Card, + CardContent, + Chip, + Grid, + Alert, + List, + ListItem, + ListItemText +} from '@mui/material'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { PieChart } from '@mui/x-charts/PieChart'; +import InfoIcon from '@mui/icons-material/Info'; +import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import FactoryIcon from '@mui/icons-material/Factory'; +import VerifiedIcon from '@mui/icons-material/Verified'; +import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'; +import PieChartIcon from '@mui/icons-material/PieChart'; +import CategoryIcon from '@mui/icons-material/Category'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import GppGoodIcon from '@mui/icons-material/GppGood'; +import BusinessIcon from '@mui/icons-material/Business'; +import { SubmodelAddonProps } from '../shared/types'; +import { SubmodelAddonWrapper } from '../BaseAddon'; +import { UsTariffInformation } from './types'; +import { getCountryFlag } from '../utils/country-flags'; + +/** + * Specialized viewer component for US Tariff Information submodels + */ +export const UsTariffInformationViewer: React.FC> = ({ + data, + semanticId +}) => { + const formatCurrency = (value: number, currency: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency + }).format(value); + }; + + const formatWeight = (weight: { value: number; unit: string }) => { + return `${weight.value} ${weight.unit}`; + }; + + // Distinct color palette for better visibility and differentiation + const distinctColorPalette = [ + '#2196F3', // Blue + '#4CAF50', // Green + '#FF9800', // Orange + '#9C27B0', // Purple + '#F44336', // Red + '#00BCD4', // Cyan + '#795548', // Brown + '#607D8B', // Blue Grey + '#E91E63', // Pink + '#8BC34A', // Light Green + '#FF5722', // Deep Orange + '#3F51B5', // Indigo + ]; + + // Prepare pie chart data from material composition + const pieChartData = data.materialList.map((material, index) => ({ + id: index, + value: material.value, + label: material.material.materialName, + color: distinctColorPalette[index % distinctColorPalette.length] + })); + + // Prepare country origin share data + const countryOriginData = (() => { + const countryMap = new Map(); + + data.materialList.forEach(material => { + material.origin.forEach(origin => { + const country = origin.originCountry; + const percentage = origin.valuePercentage; + countryMap.set(country, (countryMap.get(country) || 0) + percentage); + }); + }); + + return Array.from(countryMap.entries()).map(([country, percentage], index) => ({ + id: index, + value: percentage, + label: `${getCountryFlag(country)} ${country}`, + color: distinctColorPalette[(index + 6) % distinctColorPalette.length] // Offset to use different colors + })); + })(); + + // Prepare data for DataGrid + const materialRows = data.materialList.map((material, index) => ({ + id: index, + materialName: material.material.materialName, + classificationType: material.material.classificationType, + classificationId: material.material.classificationId, + referenceNumber: material.referenceNumber, + value: formatCurrency(material.value, material.currency), + originCountries: material.origin.map(origin => `${origin.originCountry} (${origin.valuePercentage}%)`).join(', '), + processingSteps: material.processing.length, + material: material // Keep full material object for complex rendering + })); + + const materialColumns: GridColDef[] = [ + { + field: 'materialName', + headerName: 'Material', + flex: 1, + minWidth: 200, + renderCell: (params) => ( + + + {params.row.materialName} + + + {params.row.classificationType}: {params.row.classificationId} + + + ) + }, + { + field: 'referenceNumber', + headerName: 'Reference', + width: 140 + }, + { + field: 'value', + headerName: 'Value', + width: 120 + }, + { + field: 'originCountries', + headerName: 'Origin Countries', + flex: 1, + minWidth: 250, + renderCell: (params) => ( + + {params.row.material.origin.map((origin: { originCountry: string; valuePercentage: number }, idx: number) => ( + + ))} + + ) + }, + { + field: 'processingSteps', + headerName: 'Processing Steps', + width: 130, + renderCell: (params) => ( + + {params.value} step(s) + + ) + } + ]; + + return ( + + + {/* Part Information */} + + + + + Part Information + + + + Part ID + {data.partId} + + + Part Name + {data.partName} + + + Description + {data.partDescription} + + + Weight + {formatWeight(data.partWeight)} + + + Vehicle System + {data.partUsage.vehicleSystem} + + + Vehicle Subassembly + {data.partUsage.vehicleSubassembly} + + + OEM Part References + + {data.partUsage.oemPartRef.map((ref, index) => ( + + ))} + + + + + + + {/* Tariff Information */} + + + + + Tariff Information + + + + + + HTS Code + + + {data.tariff.htsCode} + + + + Coding System + {data.tariff.htsCodingSystem} + + + HTS Description + {data.tariff.htsDescription} + + + Country of Import + {data.tariff.countryOfImport} + + + Country of Export + {data.tariff.countryOfExport} + + + Incoterms + {data.tariff.incoterms} + + + Declared Customs Value + + {formatCurrency(data.tariff.declaredCustomsValue.value, data.tariff.declaredCustomsValue.currency)} + + + {data.tariff.dutyRateNote && ( + + Duty Rate Note + + {data.tariff.dutyRateNote} + + + )} + + + + + {/* Material Value Distribution Chart */} + + + + + Material Analysis + + + {/* Material Value Distribution */} + + + + + Value Distribution by Material + + + + + + + + + {/* Country Origin Share */} + + + + + + Country Origin Share (%) + + + + + + + + + + + + {/* Material Composition Details Table */} + + + + + Material Composition Details + + + 'auto'} + sx={{ + '& .MuiDataGrid-cell': { + py: 1 + } + }} + /> + + + + + {/* Compliance Information */} + + + + + Compliance Information + + + + + + RoHS Compliance + + + + {data.compliance.rohs.exemptions.length > 0 && ( + + {data.compliance.rohs.exemptions.length} exemption(s) + + )} + + + + REACH SVHC Content + + {data.compliance.reach.svhcContentWppm} wppm + + + + ISO Certificates + + {data.compliance.isoCertificates.map((cert, index) => ( + + ))} + + + + + + + {/* Supply Chain */} + + + + + Supply Chain Information + + + + + + Manufacturer + + + {data.supplyChain.manufacturer} + + + + + + Final Assembly + + + {getCountryFlag(data.supplyChain.finalAssembly)} {data.supplyChain.finalAssembly} + + + + Batch Number + + {data.supplyChain.batchNumber} + + + + Lot Code Marking + + {data.supplyChain.traceability.lotCodeMarking} + + + + Date Code Format + + {data.supplyChain.traceability.dateCodeFormat} + + + + + + + {/* Totals Check */} + + + + Totals Verification + + + + Sum of Material Weights + + {data.totalsCheck.sumOfMaterialWeights_g} g + + + + Sum of Origin Value Percentages + + {data.totalsCheck.sumOfOriginValuePercentages}% + + + + + + + {/* Notes */} + {data.notes && data.notes.length > 0 && ( + + + + Additional Notes + + + {data.notes.map((note, index) => ( + + + + ))} + + + + )} + + + ); +}; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts new file mode 100644 index 00000000..35462bad --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts @@ -0,0 +1,49 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { VersionedSubmodelAddon } from '../shared/types'; +import { + UsTariffInformation, + US_TARIFF_INFORMATION_NAMESPACE, + US_TARIFF_INFORMATION_MODEL_NAME, + US_TARIFF_INFORMATION_SEMANTIC_IDS, + isUsTariffInformation, +} from './types'; +import { UsTariffInformationViewer } from '.'; + +/** + * US Tariff Information submodel addon configuration + * + * This addon provides specialized visualization for US Tariff Information submodels, + * displaying detailed information about parts, materials, tariffs, and compliance data. + */ +export const usTariffInformationAddon: VersionedSubmodelAddon = { + id: 'us-tariff-information', + name: 'US Tariff Information', + description: 'Specialized visualization for US Tariff Information submodels including part details, material composition, tariff codes, and compliance information.', + namespace: US_TARIFF_INFORMATION_NAMESPACE, + modelName: US_TARIFF_INFORMATION_MODEL_NAME, + supportedSemanticIds: Object.values(US_TARIFF_INFORMATION_SEMANTIC_IDS), + priority: 10, // High priority for exact matches + isValidData: isUsTariffInformation, + component: UsTariffInformationViewer, +}; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts new file mode 100644 index 00000000..87eda4fa --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +export { UsTariffInformationViewer } from './UsTariffInformationViewer'; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts new file mode 100644 index 00000000..df8b7a68 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts @@ -0,0 +1,281 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +/** + * Type definitions for US Tariff Information submodel + * Semantic Model: urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation + */ + +import { isSemanticIdForModel, createSemanticId } from '../shared/utils'; + +// Constants for US Tariff Information semantic model +export const US_TARIFF_INFORMATION_NAMESPACE = 'us_tariff_information'; +export const US_TARIFF_INFORMATION_MODEL_NAME = 'UsTariffInformation'; + +/** + * Supported semantic IDs for US Tariff Information submodel + */ +export const US_TARIFF_INFORMATION_SEMANTIC_IDS = { + V1_0_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '1.0.0', US_TARIFF_INFORMATION_MODEL_NAME), + // Future versions will be added here as they become available: + // V1_1_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '1.1.0', US_TARIFF_INFORMATION_MODEL_NAME), + // V2_0_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '2.0.0', US_TARIFF_INFORMATION_MODEL_NAME), +} as const; + +export interface Weight { + value: number; + unit: string; +} + +export interface Currency { + value: number; + currency: string; +} + +export interface PartUsage { + vehicleSystem: string; + vehicleSubassembly: string; + oemPartRef: string[]; +} + +export interface TariffInfo { + htsCode: string; + htsCodingSystem: string; + htsDescription: string; + countryOfImport: string; + declaredCustomsValue: Currency; + incoterms: string; + countryOfExport: string; + dutyRateNote?: string; +} + +export interface MaterialClassification { + classificationType: string; + classificationId: string; + materialName: string; +} + +export interface Origin { + originCountry: string; + valuePercentage: number; + originWeight: Weight; +} + +export interface Processing { + processingCountry: string; + processingId: number; + processingType: string; + successor?: Array<{ successorId: number }>; + certificateId?: string; +} + +export interface SurfaceTreatment { + type: string; + standard: string; + hexavalentChromiumFree: boolean; +} + +export interface Material { + material: MaterialClassification; + referenceNumber: string; + origin: Origin[]; + processing: Processing[]; + surfaceTreatment?: SurfaceTreatment[]; + currency: string; + value: number; +} + +export interface Compliance { + rohs: { + compliant: boolean; + exemptions: string[]; + }; + reach: { + svhcContentWppm: number; + }; + isoCertificates: string[]; +} + +export interface SupplyChain { + manufacturer: string; + finalAssembly: string; + batchNumber: string; + traceability: { + lotCodeMarking: string; + dateCodeFormat: string; + }; +} + +export interface TotalsCheck { + sumOfMaterialWeights_g: number; + sumOfOriginValuePercentages: number; +} + +// Version 1.0.0 types for UsTariffInformation +export interface UsTariffInformationV1_0_0 { + partId: string; + partName: string; + partDescription: string; + partWeight: { + value: number; + unit: string; + }; + partUsage: { + vehicleSystem: string; + vehicleSubassembly: string; + oemPartRef: string[]; + }; + tariff: { + htsCode: string; + htsCodingSystem: string; + htsDescription: string; + countryOfImport: string; + declaredCustomsValue: { + value: number; + currency: string; + }; + incoterms: string; + countryOfExport: string; + dutyRateNote: string; + }; + materialList: Array<{ + material: { + classificationType: string; + classificationId: string; + materialName: string; + }; + referenceNumber: string; + origin: Array<{ + originCountry: string; + valuePercentage: number; + originWeight: { + value: number; + unit: string; + }; + }>; + processing: Array<{ + processingCountry: string; + processingId: number; + processingType: string; + successor?: Array<{ + successorId: number; + }>; + certificateId?: string; + }>; + surfaceTreatment?: Array<{ + type: string; + standard: string; + hexavalentChromiumFree: boolean; + }>; + currency: string; + value: number; + }>; + compliance: { + rohs: { + compliant: boolean; + exemptions: string[]; + }; + reach: { + svhcContentWppm: number; + }; + isoCertificates: string[]; + }; + supplyChain: { + manufacturer: string; + finalAssembly: string; + batchNumber: string; + traceability: { + lotCodeMarking: string; + dateCodeFormat: string; + }; + }; + totalsCheck: { + sumOfMaterialWeights_g: number; + sumOfOriginValuePercentages: number; + }; + notes: string[]; +} + +// Union type for all supported versions (currently only 1.0.0) +export type UsTariffInformation = UsTariffInformationV1_0_0; + +// When new versions are released, add them here: +// export type UsTariffInformation = +// | UsTariffInformationV1_0_0 +// | UsTariffInformationV1_1_0 +// | UsTariffInformationV2_0_0; + +/** + * Type guards for US Tariff Information submodel versions + */ + +/** + * Type guard for US Tariff Information v1.0.0 + * + * @param semanticId - The semantic ID to validate + * @param data - The data to validate + * @returns True if data matches UsTariffInformationV1_0_0 structure + */ +export function isUsTariffInformationV1_0_0(semanticId: string, data: unknown): data is UsTariffInformationV1_0_0 { + return ( + semanticId === US_TARIFF_INFORMATION_SEMANTIC_IDS.V1_0_0 && + typeof data === 'object' && + data !== null && + 'partId' in data && + typeof (data as Record).partId === 'string' && + 'partName' in data && + typeof (data as Record).partName === 'string' && + 'partDescription' in data && + typeof (data as Record).partDescription === 'string' && + 'partWeight' in data && + typeof (data as Record).partWeight === 'object' && + 'materialList' in data && + Array.isArray((data as Record).materialList) && + 'tariff' in data && + typeof (data as Record).tariff === 'object' + ); +} + +/** + * Generic type guard for any supported US Tariff Information version + * + * @param semanticId - The semantic ID to validate + * @param data - The data to validate + * @returns True if data matches any supported UsTariffInformation version + */ +export function isUsTariffInformation(semanticId: string, data: unknown): data is UsTariffInformation { + return isUsTariffInformationV1_0_0(semanticId, data); + // When new versions are added, extend this logic: + // return isUsTariffInformationV1_0_0(semanticId, data) || + // isUsTariffInformationV1_1_0(semanticId, data) || + // isUsTariffInformationV2_0_0(semanticId, data); +} + +/** + * Checks if a semantic ID is for US Tariff Information (any version) + * + * @param semanticId - The semantic ID to check + * @returns True if the semantic ID is for US Tariff Information + */ +export function isUsTariffInformationSemanticId(semanticId: string): boolean { + return isSemanticIdForModel(semanticId, US_TARIFF_INFORMATION_NAMESPACE, US_TARIFF_INFORMATION_MODEL_NAME); +} diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts new file mode 100644 index 00000000..0ff8ab6b --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts @@ -0,0 +1,171 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +/** + * Comprehensive mapping of country codes and names to their flag emojis + */ +const COUNTRY_FLAG_MAP: Record = { + // North America + 'US': '🇺🇸', 'USA': '🇺🇸', 'United States': '🇺🇸', 'United States of America': '🇺🇸', + 'CA': '🇨🇦', 'CAN': '🇨🇦', 'Canada': '🇨🇦', + 'MX': '🇲🇽', 'MEX': '🇲🇽', 'Mexico': '🇲🇽', + + // Europe - Western + 'DE': '🇩🇪', 'DEU': '🇩🇪', 'Germany': '🇩🇪', 'Deutschland': '🇩🇪', + 'FR': '🇫🇷', 'FRA': '🇫🇷', 'France': '🇫🇷', + 'IT': '🇮🇹', 'ITA': '🇮🇹', 'Italy': '🇮🇹', + 'GB': '🇬🇧', 'GBR': '🇬🇧', 'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'Britain': '🇬🇧', + 'ES': '🇪🇸', 'ESP': '🇪🇸', 'Spain': '🇪🇸', + 'NL': '🇳🇱', 'NLD': '🇳🇱', 'Netherlands': '🇳🇱', 'Holland': '🇳🇱', + 'BE': '🇧🇪', 'BEL': '🇧🇪', 'Belgium': '🇧🇪', + 'CH': '🇨🇭', 'CHE': '🇨🇭', 'Switzerland': '🇨🇭', + 'AT': '🇦🇹', 'AUT': '🇦🇹', 'Austria': '🇦🇹', + 'PT': '🇵🇹', 'PRT': '🇵🇹', 'Portugal': '🇵🇹', + 'IE': '🇮🇪', 'IRL': '🇮🇪', 'Ireland': '🇮🇪', + 'LU': '🇱🇺', 'LUX': '🇱🇺', 'Luxembourg': '🇱🇺', + + // Europe - Nordic + 'SE': '🇸🇪', 'SWE': '🇸🇪', 'Sweden': '🇸🇪', + 'NO': '🇳🇴', 'NOR': '🇳🇴', 'Norway': '🇳🇴', + 'DK': '🇩🇰', 'DNK': '🇩🇰', 'Denmark': '🇩🇰', + 'FI': '🇫🇮', 'FIN': '🇫🇮', 'Finland': '🇫🇮', + + // Europe - Eastern + 'PL': '🇵🇱', 'POL': '🇵🇱', 'Poland': '🇵🇱', + 'CZ': '🇨🇿', 'CZE': '🇨🇿', 'Czech Republic': '🇨🇿', 'Czechia': '🇨🇿', + 'HU': '🇭🇺', 'HUN': '🇭🇺', 'Hungary': '🇭🇺', + 'RO': '🇷🇴', 'ROU': '🇷🇴', 'Romania': '🇷🇴', + 'SK': '🇸🇰', 'SVK': '🇸🇰', 'Slovakia': '🇸🇰', + 'SI': '🇸🇮', 'SVN': '🇸🇮', 'Slovenia': '🇸🇮', + 'HR': '🇭🇷', 'HRV': '🇭🇷', 'Croatia': '🇭🇷', + 'BG': '🇧🇬', 'BGR': '🇧🇬', 'Bulgaria': '🇧🇬', + 'GR': '🇬🇷', 'GRC': '🇬🇷', 'Greece': '🇬🇷', + 'EE': '🇪🇪', 'EST': '🇪🇪', 'Estonia': '🇪🇪', + 'LV': '🇱🇻', 'LVA': '🇱🇻', 'Latvia': '🇱🇻', + 'LT': '🇱🇹', 'LTU': '🇱🇹', 'Lithuania': '🇱🇹', + + // Europe - Mediterranean + 'MT': '🇲🇹', 'MLT': '🇲🇹', 'Malta': '🇲🇹', + 'CY': '🇨🇾', 'CYP': '🇨🇾', 'Cyprus': '🇨🇾', + + // Asia - East + 'CN': '🇨🇳', 'CHN': '🇨🇳', 'China': '🇨🇳', 'People\'s Republic of China': '🇨🇳', + 'JP': '🇯🇵', 'JPN': '🇯🇵', 'Japan': '🇯🇵', + 'KR': '🇰🇷', 'KOR': '🇰🇷', 'South Korea': '🇰🇷', 'Korea': '🇰🇷', + 'TW': '🇹🇼', 'TWN': '🇹🇼', 'Taiwan': '🇹🇼', + + // Asia - Southeast + 'SG': '🇸🇬', 'SGP': '🇸🇬', 'Singapore': '🇸🇬', + 'MY': '🇲🇾', 'MYS': '🇲🇾', 'Malaysia': '🇲🇾', + 'TH': '🇹🇭', 'THA': '🇹🇭', 'Thailand': '🇹🇭', + 'VN': '🇻🇳', 'VNM': '🇻🇳', 'Vietnam': '🇻🇳', + 'PH': '🇵🇭', 'PHL': '🇵🇭', 'Philippines': '🇵🇭', + 'ID': '🇮🇩', 'IDN': '🇮🇩', 'Indonesia': '🇮🇩', + + // Asia - South + 'IN': '🇮🇳', 'IND': '🇮🇳', 'India': '🇮🇳', + + // Asia - West/Middle East + 'RU': '🇷🇺', 'RUS': '🇷🇺', 'Russia': '🇷🇺', 'Russian Federation': '🇷🇺', + 'TR': '🇹🇷', 'TUR': '🇹🇷', 'Turkey': '🇹🇷', 'Türkiye': '🇹🇷', + 'IL': '🇮🇱', 'ISR': '🇮🇱', 'Israel': '🇮🇱', + 'SA': '🇸🇦', 'SAU': '🇸🇦', 'Saudi Arabia': '🇸🇦', + 'AE': '🇦🇪', 'ARE': '🇦🇪', 'UAE': '🇦🇪', 'United Arab Emirates': '🇦🇪', + + // Africa + 'ZA': '🇿🇦', 'ZAF': '🇿🇦', 'South Africa': '🇿🇦', + 'EG': '🇪🇬', 'EGY': '🇪🇬', 'Egypt': '🇪🇬', + + // Oceania + 'AU': '🇦🇺', 'AUS': '🇦🇺', 'Australia': '🇦🇺', + 'NZ': '🇳🇿', 'NZL': '🇳🇿', 'New Zealand': '🇳🇿', + + // South America + 'BR': '🇧🇷', 'BRA': '🇧🇷', 'Brazil': '🇧🇷', + 'AR': '🇦🇷', 'ARG': '🇦🇷', 'Argentina': '🇦🇷', + 'CL': '🇨🇱', 'CHL': '🇨🇱', 'Chile': '🇨🇱', + 'CO': '🇨🇴', 'COL': '🇨🇴', 'Colombia': '🇨🇴', + 'PE': '🇵🇪', 'PER': '🇵🇪', 'Peru': '🇵🇪', + 'VE': '🇻🇪', 'VEN': '🇻🇪', 'Venezuela': '🇻🇪', +}; + +/** + * Gets the flag emoji for a given country code or name + * + * @param countryCode - Country code (ISO 2/3 letter) or full country name + * @returns Flag emoji string, or default flag if not found + * + * @example + * ```typescript + * getCountryFlag('US') // Returns '🇺🇸' + * getCountryFlag('Germany') // Returns '🇩🇪' + * getCountryFlag('unknown') // Returns '🏳️' + * ``` + */ +export const getCountryFlag = (countryCode: string): string => { + // Handle empty or invalid input + if (!countryCode || typeof countryCode !== 'string') { + return '🏳️'; + } + + // Try exact match first + if (COUNTRY_FLAG_MAP[countryCode]) { + return COUNTRY_FLAG_MAP[countryCode]; + } + + // Try case-insensitive search + const lowerCode = countryCode.toLowerCase(); + for (const [key, flag] of Object.entries(COUNTRY_FLAG_MAP)) { + if (key.toLowerCase() === lowerCode) { + return flag; + } + } + + // Try partial match for longer country names + for (const [key, flag] of Object.entries(COUNTRY_FLAG_MAP)) { + if (countryCode.toLowerCase().includes(key.toLowerCase()) || + key.toLowerCase().includes(countryCode.toLowerCase())) { + return flag; + } + } + + return '🏳️'; // Default flag for unknown countries +}; + +/** + * Gets all available country codes and names that have flag mappings + * + * @returns Array of country identifiers + */ +export const getAvailableCountries = (): string[] => { + return Object.keys(COUNTRY_FLAG_MAP); +}; + +/** + * Checks if a country has a flag mapping available + * + * @param countryCode - Country code or name to check + * @returns True if flag mapping exists + */ +export const hasCountryFlag = (countryCode: string): boolean => { + return getCountryFlag(countryCode) !== '🏳️'; +}; diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts new file mode 100644 index 00000000..c82f902c --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts @@ -0,0 +1,161 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { SemanticVersion, VersionRange, ParsedSemanticId } from '../types'; + +/** + * Parse a semantic ID string into its components + * Format: urn:(samm|bamm):namespace:version#fragment + * Examples: + * - urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation + * - urn:bamm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt + */ +export function parseSemanticId(semanticId: string): ParsedSemanticId | null { + try { + // Regular expression to parse URN SAMM/BAMM format + const pattern = /^urn:(samm|bamm):([^:]+):(\d+)\.(\d+)\.(\d+)(?:#(.+))?$/; + const match = semanticId.match(pattern); + + if (!match) { + console.warn(`Invalid semantic ID format: ${semanticId}`); + return null; + } + + const [, prefix, namespace, major, minor, patch, fragment] = match; + + // Extract the actual name from namespace (last part after dots) + const namespaceParts = namespace.split('.'); + const name = namespaceParts[namespaceParts.length - 1]; + + return { + prefix: prefix as 'samm' | 'bamm', + namespace, + name, + version: { + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10) + }, + fragment, + originalId: semanticId + }; + } catch (error) { + console.error(`Error parsing semantic ID: ${semanticId}`, error); + return null; + } +} + +/** + * Compare two semantic versions + * Returns: -1 if a < b, 0 if a === b, 1 if a > b + */ +export function compareVersions(a: SemanticVersion, b: SemanticVersion): number { + if (a.major !== b.major) { + return a.major - b.major; + } + if (a.minor !== b.minor) { + return a.minor - b.minor; + } + return a.patch - b.patch; +} + +/** + * Check if a version is within a given range + */ +export function isVersionInRange(version: SemanticVersion, range: VersionRange): boolean { + // Check exact version match first + if (range.exact) { + return compareVersions(version, range.exact) === 0; + } + + // Check minimum version + if (range.min && compareVersions(version, range.min) < 0) { + return false; + } + + // Check maximum version + if (range.max && compareVersions(version, range.max) > 0) { + return false; + } + + return true; +} + +/** + * Check if two versions are compatible (same major version) + */ +export function areVersionsCompatible(a: SemanticVersion, b: SemanticVersion): boolean { + return a.major === b.major; +} + +/** + * Format a semantic version as a string + */ +export function formatVersion(version: SemanticVersion): string { + return `${version.major}.${version.minor}.${version.patch}`; +} + +/** + * Format a version range as a string + */ +export function formatVersionRange(range: VersionRange): string { + if (range.exact) { + return formatVersion(range.exact); + } + + const parts: string[] = []; + if (range.min) { + parts.push(`>= ${formatVersion(range.min)}`); + } + if (range.max) { + parts.push(`<= ${formatVersion(range.max)}`); + } + + return parts.length > 0 ? parts.join(' && ') : '*'; +} + +/** + * Create a version range for backward compatibility + * Supports same major version with any minor/patch + */ +export function createCompatibleVersionRange(version: SemanticVersion): VersionRange { + return { + min: { major: version.major, minor: 0, patch: 0 }, + max: { major: version.major, minor: Number.MAX_SAFE_INTEGER, patch: Number.MAX_SAFE_INTEGER } + }; +} + +/** + * Create a version range for exact version match + */ +export function createExactVersionRange(version: SemanticVersion): VersionRange { + return { + exact: version + }; +} + +/** + * Create a version range for a specific version range + */ +export function createVersionRange(min?: SemanticVersion, max?: SemanticVersion): VersionRange { + return { min, max }; +} diff --git a/ichub-frontend/src/features/part-discovery/data-converters.ts b/ichub-frontend/src/features/part-discovery/data-converters.ts new file mode 100644 index 00000000..d89e11f2 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/data-converters.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { AASData, getAASDataSummary } from './utils'; +import { PartCardData, SerializedPartData } from './types'; + +/** + * Converts AAS data to Part Card format for catalog view + */ +export const convertToPartCards = ( + shells: AASData[], + shellToDtrMap?: Map +): PartCardData[] => { + return shells.map(shell => { + const summary = getAASDataSummary(shell); + const dtrIndex = shellToDtrMap?.get(shell.id); + return { + id: shell.id, + manufacturerId: summary.manufacturerId || 'Unknown', + manufacturerPartId: summary.manufacturerPartId || 'Unknown', + customerPartId: summary.customerPartId || undefined, + name: `${summary.manufacturerPartId}`, + category: summary.customerPartId || undefined, + digitalTwinType: summary.digitalTwinType || 'Unknown', + globalAssetId: shell.globalAssetId, + submodelCount: summary.submodelCount, + dtrIndex, + idShort: shell.idShort, // Include idShort from AAS data + rawTwinData: shell + }; + }); +}; + +/** + * Converts AAS data to Serialized Parts format for instance view + */ +export const convertToSerializedParts = ( + shells: AASData[], + shellToDtrMap?: Map +): SerializedPartData[] => { + return shells.map(shell => { + const summary = getAASDataSummary(shell); + const dtrIndex = shellToDtrMap?.get(shell.id); + return { + id: shell.id, + globalAssetId: shell.globalAssetId, + aasId: shell.id, // AAS Shell ID + manufacturerId: summary.manufacturerId || 'Unknown', + manufacturerPartId: summary.manufacturerPartId || 'Unknown', + customerPartId: summary.customerPartId || undefined, + partInstanceId: summary.partInstanceId || undefined, + digitalTwinType: summary.digitalTwinType || 'Unknown', + submodelCount: summary.submodelCount, + dtrIndex, + idShort: shell.idShort, // Include idShort from AAS data + rawTwinData: shell + }; + }); +}; diff --git a/ichub-frontend/src/features/part-discovery/dtr-utils.ts b/ichub-frontend/src/features/part-discovery/dtr-utils.ts new file mode 100644 index 00000000..e59af7de --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/dtr-utils.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +/** + * Creates a map from shell ID to DTR index for efficient lookup + */ +export const createShellToDtrMap = ( + dtrs: Array & { shells?: string[] }> +): Map => { + const shellToDtrMap = new Map(); + dtrs.forEach((dtr, dtrIndex) => { + if (dtr.shells && Array.isArray(dtr.shells)) { + dtr.shells.forEach((shellId: string) => { + shellToDtrMap.set(shellId, dtrIndex); + }); + } + }); + return shellToDtrMap; +}; + +interface DtrColor { + bg: string; + color: string; + light: string; + border: string; +} + +/** + * Gets consistent colors for DTR identifiers with support for many DTRs + */ +export const getDtrColor = (dtrIndex: number): DtrColor => { + const baseColors = [ + { bg: 'rgba(76, 175, 80, 0.9)', color: 'white', light: 'rgba(76, 175, 80, 0.1)', border: 'rgba(76, 175, 80, 0.3)' }, // Green + { bg: 'rgba(33, 150, 243, 0.9)', color: 'white', light: 'rgba(33, 150, 243, 0.1)', border: 'rgba(33, 150, 243, 0.3)' }, // Blue + { bg: 'rgba(255, 152, 0, 0.9)', color: 'white', light: 'rgba(255, 152, 0, 0.1)', border: 'rgba(255, 152, 0, 0.3)' }, // Orange + { bg: 'rgba(156, 39, 176, 0.9)', color: 'white', light: 'rgba(156, 39, 176, 0.1)', border: 'rgba(156, 39, 176, 0.3)' }, // Purple + { bg: 'rgba(244, 67, 54, 0.9)', color: 'white', light: 'rgba(244, 67, 54, 0.1)', border: 'rgba(244, 67, 54, 0.3)' }, // Red + { bg: 'rgba(0, 188, 212, 0.9)', color: 'white', light: 'rgba(0, 188, 212, 0.1)', border: 'rgba(0, 188, 212, 0.3)' }, // Cyan + { bg: 'rgba(139, 195, 74, 0.9)', color: 'white', light: 'rgba(139, 195, 74, 0.1)', border: 'rgba(139, 195, 74, 0.3)' }, // Light Green + { bg: 'rgba(121, 85, 72, 0.9)', color: 'white', light: 'rgba(121, 85, 72, 0.1)', border: 'rgba(121, 85, 72, 0.3)' }, // Brown + ]; + + const colorIndex = dtrIndex % baseColors.length; + const variation = Math.floor(dtrIndex / baseColors.length); + + // For DTRs beyond 8, add opacity variations to distinguish them + const baseColor = baseColors[colorIndex]; + const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity + + return { + bg: baseColor.bg.replace('0.9)', `${opacity})`), + color: baseColor.color, + light: baseColor.light, + border: baseColor.border + }; +}; diff --git a/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts b/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts new file mode 100644 index 00000000..131238cc --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts @@ -0,0 +1,389 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { useState } from 'react'; +import { + discoverShellsWithCustomQuery, + discoverSingleShell, + ShellDiscoveryPaginator, + SingleShellDiscoveryResponse +} from '../api'; +import { + ShellDiscoveryResponse, +} from '../utils'; +import { convertToPartCards, convertToSerializedParts } from '../data-converters'; +import { createShellToDtrMap } from '../dtr-utils'; +import { + PartCardData, + SerializedPartData, + SearchFilters, + PartType, + PaginationSettings +} from '../types'; + +export const usePartsDiscoverySearch = () => { + // Results and pagination + const [partTypeCards, setPartTypeCards] = useState([]); + const [serializedParts, setSerializedParts] = useState([]); + const [currentResponse, setCurrentResponse] = useState(null); + const [paginator, setPaginator] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + + // Single twin search + const [singleTwinResult, setSingleTwinResult] = useState(null); + + // Loading and error states + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + // Pagination loading states + const [isLoadingNext, setIsLoadingNext] = useState(false); + const [isLoadingPrevious, setIsLoadingPrevious] = useState(false); + + const resetSearch = () => { + setHasSearched(false); + setCurrentResponse(null); + setPaginator(null); + setPartTypeCards([]); + setSerializedParts([]); + setCurrentPage(1); + setTotalPages(0); + setError(null); + setSingleTwinResult(null); + setIsLoadingNext(false); + setIsLoadingPrevious(false); + }; + + const clearError = () => { + setError(null); + }; + + const performDiscoverySearch = async ( + bpnl: string, + partType: PartType, + filters: SearchFilters, + paginationSettings: PaginationSettings + ) => { + // Validate custom limit + if (paginationSettings.isCustomLimit) { + if (!paginationSettings.customLimit.trim()) { + setError('Please enter a custom limit or select a predefined option'); + return; + } + const customLimitNum = parseInt(paginationSettings.customLimit); + if (isNaN(customLimitNum) || customLimitNum < 1 || customLimitNum > 1000) { + setError('Custom limit must be a number between 1 and 1000'); + return; + } + } + + setIsLoading(true); + setError(null); + setIsLoadingNext(false); + setIsLoadingPrevious(false); + + try { + // Calculate the correct limit based on whether custom limit is being used + let limit: number | undefined; + if (paginationSettings.isCustomLimit) { + const customLimitNum = parseInt(paginationSettings.customLimit); + limit = customLimitNum; + } else { + limit = paginationSettings.pageLimit === 0 ? undefined : paginationSettings.pageLimit; + } + + // Build custom query with all provided parameters + const querySpec: Array<{ name: string; value: string }> = []; + + // Add digitalTwinType based on part type selection + querySpec.push({ + name: 'digitalTwinType', + value: partType === 'Catalog' ? 'PartType' : 'PartInstance' + }); + + // Add all provided search parameters + if (filters.customerPartId.trim()) { + querySpec.push({ + name: 'customerPartId', + value: filters.customerPartId.trim() + }); + } + + if (filters.manufacturerPartId.trim()) { + querySpec.push({ + name: 'manufacturerPartId', + value: filters.manufacturerPartId.trim() + }); + } + + if (filters.globalAssetId.trim()) { + querySpec.push({ + name: 'globalAssetId', + value: filters.globalAssetId.trim() + }); + } + + // Only add partInstanceId if part type is Serialized (PartInstance) + if (partType === 'Serialized' && filters.partInstanceId.trim()) { + querySpec.push({ + name: 'partInstanceId', + value: filters.partInstanceId.trim() + }); + } + + // Use custom query for comprehensive search + const response = await discoverShellsWithCustomQuery(bpnl, querySpec, limit); + + setCurrentResponse(response); + + // Check if the API returned an error in the response + if (response.error) { + if (response.error.toLowerCase().includes('no dtrs found')) { + setError(`No Digital Twin Registries found for partner "${bpnl}". Please verify the BPNL is correct and if the partner has a Connector (with a reachable DTR) connected in the same dataspace as you.`); + } else { + setError(`Search failed: ${response.error}`); + } + return; + } + + // Check if no shell descriptors were found + if (!response.shellDescriptors || response.shellDescriptors.length === 0) { + setError('No digital twins found for the specified criteria. Please try different search parameters.'); + return; + } + + // Create paginator + const digitalTwinType = partType === 'Catalog' ? 'PartType' : 'PartInstance'; + const newPaginator = new ShellDiscoveryPaginator( + response, + bpnl, + digitalTwinType, + limit + ); + setPaginator(newPaginator); + + // Create DTR mapping if DTRs are available + const shellToDtrMap = response.dtrs ? createShellToDtrMap(response.dtrs as unknown as Array & { shells?: string[] }>) : undefined; + + // Process results based on part type + if (partType === 'Catalog') { + const cards = convertToPartCards(response.shellDescriptors, shellToDtrMap); + setPartTypeCards(cards); + setSerializedParts([]); + } else { + const serialized = convertToSerializedParts(response.shellDescriptors, shellToDtrMap); + setSerializedParts(serialized); + setPartTypeCards([]); + } + + setCurrentPage(response.pagination?.page || 1); + // Calculate total pages + if (limit === undefined) { + setTotalPages(1); // No pagination when no limit is set + } else { + setTotalPages(Math.ceil(response.shellsFound / limit)); + } + + // Mark that search has been performed successfully + setHasSearched(true); + + } catch (err) { + console.error('Search error:', err); + + let errorMessage = 'Error searching for parts. Please try again.'; + + if (err instanceof Error) { + errorMessage = `Search failed: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `Search failed: ${err}`; + } else if (err && typeof err === 'object') { + if ('response' in err && err.response) { + const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } }; + if (axiosErr.response.data?.error) { + errorMessage = `API Error: ${axiosErr.response.data.error}`; + } else if (axiosErr.response.data?.message) { + errorMessage = `API Error: ${axiosErr.response.data.message}`; + } else if (axiosErr.response.statusText) { + errorMessage = `HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`; + } else { + errorMessage = `HTTP Error ${axiosErr.response.status}`; + } + } else if ('message' in err) { + const errWithMessage = err as { message: string }; + errorMessage = `Search failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const performSingleTwinSearch = async (bpnl: string, aasId: string) => { + setIsLoading(true); + setError(null); + setSingleTwinResult(null); + + try { + const response = await discoverSingleShell(bpnl, aasId.trim()); + setSingleTwinResult(response); + setHasSearched(true); + } catch (err) { + let errorMessage = 'Failed to discover digital twin'; + + if (err instanceof Error) { + errorMessage = `Single twin search failed: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `Single twin search failed: ${err}`; + } else if (err && typeof err === 'object') { + if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response) { + const responseData = err.response.data as Record; + if (typeof responseData.message === 'string') { + errorMessage = `Single twin search failed: ${responseData.message}`; + } + } else if ('message' in err) { + const errWithMessage = err as { message: string }; + errorMessage = `Single twin search failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handlePageChange = async (page: number, partType: PartType, bpnl: string) => { + if (!paginator || page === currentPage) return; + + // Determine direction and set appropriate loading state + const isNext = page === currentPage + 1; + const isPrevious = page === currentPage - 1; + + if (isNext) { + setIsLoadingNext(true); + } else if (isPrevious) { + setIsLoadingPrevious(true); + } + + setError(null); + + try { + let newResponse: ShellDiscoveryResponse | null = null; + + // Handle sequential navigation + if (page === currentPage + 1 && paginator.hasNext()) { + newResponse = await paginator.next(); + } else if (page === currentPage - 1 && paginator.hasPrevious()) { + newResponse = await paginator.previous(); + } else { + setError(`Direct navigation to page ${page} is not supported. Please use next/previous navigation.`); + return; + } + + if (newResponse) { + // Check if the pagination response contains an error + if (newResponse.error) { + if (newResponse.error.toLowerCase().includes('no dtrs found')) { + setError(`No Digital Twin Registries found for partner "${bpnl}" on page ${page}. Please verify the BPNL is correct and the partner has registered digital twins.`); + } else { + setError(`Pagination failed: ${newResponse.error}`); + } + return; + } + + setCurrentResponse(newResponse); + setCurrentPage(newResponse.pagination?.page || currentPage); + + // Create DTR mapping if DTRs are available + const shellToDtrMap = newResponse.dtrs ? createShellToDtrMap(newResponse.dtrs as unknown as Array & { shells?: string[] }>) : undefined; + + // Update results based on part type + if (partType === 'Catalog') { + const cards = convertToPartCards(newResponse.shellDescriptors, shellToDtrMap); + setPartTypeCards(cards); + } else { + const serialized = convertToSerializedParts(newResponse.shellDescriptors, shellToDtrMap); + setSerializedParts(serialized); + } + } else { + setError('No more pages available in that direction.'); + } + } catch (err) { + console.error('Pagination error:', err); + + let errorMessage = 'Error loading page. Please try again.'; + + if (err instanceof Error) { + errorMessage = `Pagination failed: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `Pagination failed: ${err}`; + } else if (err && typeof err === 'object') { + if ('response' in err && err.response) { + const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } }; + if (axiosErr.response.data?.error) { + errorMessage = `Pagination API Error: ${axiosErr.response.data.error}`; + } else if (axiosErr.response.data?.message) { + errorMessage = `Pagination API Error: ${axiosErr.response.data.message}`; + } else { + errorMessage = `Pagination HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`; + } + } else if ('message' in err) { + const errWithMessage = err as { message: string }; + errorMessage = `Pagination failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + // Clean up pagination loading states + setIsLoadingNext(false); + setIsLoadingPrevious(false); + } + }; + + return { + // State + partTypeCards, + serializedParts, + currentResponse, + paginator, + currentPage, + totalPages, + singleTwinResult, + isLoading, + error, + hasSearched, + isLoadingNext, + isLoadingPrevious, + + // Actions + resetSearch, + clearError, + performDiscoverySearch, + performSingleTwinSearch, + handlePageChange, + }; +}; diff --git a/ichub-frontend/src/features/part-discovery/index.ts b/ichub-frontend/src/features/part-discovery/index.ts new file mode 100644 index 00000000..94d14a4e --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/index.ts @@ -0,0 +1,44 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +// Export components +export { PaginationControls } from './components/PaginationControls'; +export { SearchModeToggle } from './components/SearchModeToggle'; +export { FilterChips } from './components/FilterChips'; +export { PartnerSearch } from './components/PartnerSearch'; +export { SingleTwinSearch } from './components/SingleTwinSearch'; +export { SearchHeader } from './components/SearchHeader'; +export { default as PartsDiscoverySidebar } from './components/PartsDiscoverySidebar'; + +// Export hooks +export { usePartsDiscoverySearch } from './hooks/usePartsDiscoverySearch'; + +// Export types +export * from './types'; + +// Export utilities +export * from './utils'; +export * from './dtr-utils'; +export * from './data-converters'; + +// Export API +export * from './api'; diff --git a/ichub-frontend/src/features/part-discovery/types.ts b/ichub-frontend/src/features/part-discovery/types.ts new file mode 100644 index 00000000..29aee4c7 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/types.ts @@ -0,0 +1,68 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { AASData } from './utils'; + +export interface PartCardData { + id: string; + manufacturerId: string; + manufacturerPartId: string; + customerPartId?: string; + name?: string; + category?: string; + digitalTwinType: string; + globalAssetId: string; + submodelCount: number; + dtrIndex?: number; + rawTwinData?: AASData; +} + +export interface SerializedPartData { + id: string; + globalAssetId: string; + aasId: string; + idShort?: string; + manufacturerId: string; + manufacturerPartId: string; + customerPartId?: string; + partInstanceId?: string; + digitalTwinType: string; + submodelCount: number; + dtrIndex?: number; + rawTwinData?: AASData; +} + +export interface SearchFilters { + customerPartId: string; + manufacturerPartId: string; + globalAssetId: string; + partInstanceId: string; +} + +export interface PaginationSettings { + pageLimit: number; + customLimit: string; + isCustomLimit: boolean; +} + +export type SearchMode = 'discovery' | 'single'; +export type PartType = 'Catalog' | 'Serialized'; diff --git a/ichub-frontend/src/features/part-discovery/utils.ts b/ichub-frontend/src/features/part-discovery/utils.ts new file mode 100644 index 00000000..c23f2165 --- /dev/null +++ b/ichub-frontend/src/features/part-discovery/utils.ts @@ -0,0 +1,473 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +import { StatusVariants } from '../../types/statusVariants'; +import { ApiPartData, PartType } from '../../types/product'; +import { SharedPartner } from '../../types/sharedPartners'; + +// Helper function to map numeric API status to StatusVariants +export const mapApiStatusToVariant = (apiStatus: number): StatusVariants => { + switch (apiStatus) { + case 0: + return StatusVariants.draft; + case 1: + return StatusVariants.pending; + case 2: + return StatusVariants.registered; + case 3: + return StatusVariants.shared; + default: + return StatusVariants.draft; + } +}; + +// Helper function to map StatusVariants to numeric API status +export const mapVariantToApiStatus = (variant: StatusVariants): number => { + switch (variant) { + case StatusVariants.draft: + return 0; + case StatusVariants.pending: + return 1; + case StatusVariants.registered: + return 2; + case StatusVariants.shared: + return 3; + default: + return 0; // Default to draft if unknown variant + } +}; + +// Maps ApiPartData to PartInstance +export const mapApiPartDataToPartType = (apiData: ApiPartData): PartType => { + const { status, ...rest } = apiData; + return { + ...rest, + status: mapApiStatusToVariant(status), + }; +}; + +// Maps PartInstance to ApiPartData +export const mapPartInstanceToApiPartData = (partInstance: PartType): ApiPartData => { + const { status, ...rest } = partInstance; + return { + ...rest, + status: mapVariantToApiStatus(status ?? StatusVariants.draft), + }; +}; + +export const mapSharePartCustomerPartIds = ( + customerPartIds: Record +): SharedPartner[] => { + return Object.entries(customerPartIds).map(([customerPartId, { name, bpnl }]) => ({ + name, + bpnl, + customerPartId, + })); +}; + +// Types for AAS data structure +export interface AASData { + description: string[]; + displayName: string[]; + globalAssetId: string; + id: string; + idShort?: string; // Optional idShort field + specificAssetIds: SpecificAssetId[]; + submodelDescriptors: SubmodelDescriptor[]; +} + +export interface SpecificAssetId { + supplementalSemanticIds: string[]; + name: string; + value: string; + externalSubjectId: ExternalReference; +} + +export interface ExternalReference { + type: string; + keys: ReferenceKey[]; +} + +export interface ReferenceKey { + type: string; + value: string; +} + +export interface SubmodelDescriptor { + endpoints: Endpoint[]; + idShort: string; + id: string; + semanticId: ExternalReference; + supplementalSemanticId: string[]; + description: string[]; + displayName: string[]; +} + +export interface Endpoint { + interface: string; + protocolInformation: ProtocolInformation; +} + +export interface ProtocolInformation { + href: string; + endpointProtocol: string; + endpointProtocolVersion: string[]; + subprotocol: string; + subprotocolBody: string; + subprotocolBodyEncoding: string; + securityAttributes: SecurityAttribute[]; +} + +export interface SecurityAttribute { + type: string; + key: string; + value: string; +} + +// Utility functions for parsing AAS data + +/** + * Extracts a specific asset ID value by name from the AAS data + */ +export const getSpecificAssetIdByName = (aasData: AASData, name: string): string | null => { + const specificAssetId = aasData.specificAssetIds.find(id => id.name === name); + return specificAssetId?.value || null; +}; + +/** + * Gets the manufacturer part ID from the AAS data + */ +export const getManufacturerPartId = (aasData: AASData): string | null => { + return getSpecificAssetIdByName(aasData, 'manufacturerPartId'); +}; + +/** + * Gets the manufacturer ID from the AAS data + */ +export const getManufacturerId = (aasData: AASData): string | null => { + return getSpecificAssetIdByName(aasData, 'manufacturerId'); +}; + +/** + * Gets the customer part ID from the AAS data + */ +export const getCustomerPartId = (aasData: AASData): string | null => { + return getSpecificAssetIdByName(aasData, 'customerPartId'); +}; + +/** + * Gets the digital twin type from the AAS data + */ +export const getDigitalTwinType = (aasData: AASData): string | null => { + return getSpecificAssetIdByName(aasData, 'digitalTwinType'); +}; + +/** + * Gets the part instance ID from the AAS data + */ +export const getPartInstanceId = (aasData: AASData): string | null => { + return getSpecificAssetIdByName(aasData, 'partInstanceId'); +}; + +/** + * Gets all specific asset IDs as a key-value map + */ +export const getAllSpecificAssetIds = (aasData: AASData): Record => { + return aasData.specificAssetIds.reduce((acc, asset) => { + acc[asset.name] = asset.value; + return acc; + }, {} as Record); +}; + +/** + * Extracts submodel descriptor by idShort + */ +export const getSubmodelDescriptorByIdShort = (aasData: AASData, idShort: string): SubmodelDescriptor | null => { + return aasData.submodelDescriptors.find(descriptor => descriptor.idShort === idShort) || null; +}; + +/** + * Extracts submodel descriptor by semantic ID value + */ +export const getSubmodelDescriptorBySemanticId = (aasData: AASData, semanticIdValue: string): SubmodelDescriptor | null => { + return aasData.submodelDescriptors.find(descriptor => + descriptor.semanticId?.keys?.some(key => key.value === semanticIdValue) + ) || null; +}; + +/** + * Gets all submodel endpoints for a specific submodel + */ +export const getSubmodelEndpoints = (submodelDescriptor: SubmodelDescriptor): string[] => { + return submodelDescriptor.endpoints.map(endpoint => endpoint.protocolInformation.href); +}; + +/** + * Extracts the DSP endpoint from subprotocolBody + */ +export const getDspEndpointFromSubmodel = (submodelDescriptor: SubmodelDescriptor): string | null => { + const endpoint = submodelDescriptor.endpoints[0]; + if (!endpoint?.protocolInformation?.subprotocolBody) return null; + + const match = endpoint.protocolInformation.subprotocolBody.match(/dspEndpoint=([^;]+)/); + return match ? match[1] : null; +}; + +/** + * Extracts the asset ID from subprotocolBody + */ +export const getAssetIdFromSubmodel = (submodelDescriptor: SubmodelDescriptor): string | null => { + const endpoint = submodelDescriptor.endpoints[0]; + if (!endpoint?.protocolInformation?.subprotocolBody) return null; + + const match = endpoint.protocolInformation.subprotocolBody.match(/id=([^;]+)/); + return match ? match[1] : null; +}; + +/** + * Gets the external subject ID (BPNL) from a specific asset ID + */ +export const getExternalSubjectId = (specificAssetId: SpecificAssetId): string | null => { + return specificAssetId.externalSubjectId?.keys?.[0]?.value || null; +}; + +/** + * Checks if the AAS data represents a specific digital twin type + */ +export const isDigitalTwinType = (aasData: AASData, expectedType: string): boolean => { + const digitalTwinType = getDigitalTwinType(aasData); + return digitalTwinType === expectedType; +}; + +/** + * Validates if the AAS data structure is complete and valid + */ +export const validateAASData = (aasData: AASData): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + if (!aasData.globalAssetId) { + errors.push('Missing globalAssetId'); + } + + if (!aasData.id) { + errors.push('Missing id'); + } + + if (!aasData.specificAssetIds || aasData.specificAssetIds.length === 0) { + errors.push('Missing or empty specificAssetIds'); + } + + if (!aasData.submodelDescriptors || aasData.submodelDescriptors.length === 0) { + errors.push('Missing or empty submodelDescriptors'); + } + + return { + isValid: errors.length === 0, + errors + }; +}; + +/** + * Extracts summary information from AAS data for display purposes + */ +export const getAASDataSummary = (aasData: AASData) => { + const manufacturerPartId = getManufacturerPartId(aasData); + const manufacturerId = getManufacturerId(aasData); + const customerPartId = getCustomerPartId(aasData); + const digitalTwinType = getDigitalTwinType(aasData); + const partInstanceId = getPartInstanceId(aasData); + + return { + globalAssetId: aasData.globalAssetId, + id: aasData.id, + manufacturerPartId, + manufacturerId, + customerPartId, + digitalTwinType, + partInstanceId, + submodelCount: aasData.submodelDescriptors.length + }; +}; + +// Shell Discovery Response Types and Utilities +export interface ShellDiscoveryResponse { + shellDescriptors: AASData[]; + dtrs: DTRInfo[]; + shellsFound: number; + pagination: PaginationInfo; + error?: string; // Optional error field for API error responses +} + +export interface DTRInfo { + connectorUrl: string; + assetId: string; + status: string; + shellsFound: number; + shells: string[]; + paging_metadata: { + cursor?: string; + }; +} + +export interface PaginationInfo { + page: number; + next?: string; + previous?: string; +} + +/** + * Check if there are more pages available (next page exists) + */ +export const hasNextPage = (response: ShellDiscoveryResponse): boolean => { + return !!response.pagination.next; +}; + +/** + * Check if there are previous pages available + */ +export const hasPreviousPage = (response: ShellDiscoveryResponse): boolean => { + return !!response.pagination.previous; +}; + +/** + * Get the next page cursor from the response + */ +export const getNextPageCursor = (response: ShellDiscoveryResponse): string | null => { + return response.pagination.next || null; +}; + +/** + * Get the previous page cursor from the response + */ +export const getPreviousPageCursor = (response: ShellDiscoveryResponse): string | null => { + return response.pagination.previous || null; +}; + +/** + * Extract summary information from shell discovery response for display + */ +export const getShellDiscoverySummary = (response: ShellDiscoveryResponse) => { + const shellSummaries = response.shellDescriptors.map(shell => getAASDataSummary(shell)); + + return { + totalShellsFound: response.shellsFound, + currentPageShells: response.shellDescriptors.length, + currentPage: response.pagination.page, + hasNext: hasNextPage(response), + hasPrevious: hasPreviousPage(response), + dtrsCount: response.dtrs.length, + successfulDtrs: response.dtrs.filter(dtr => dtr.status === 'success').length, + shells: shellSummaries + }; +}; + +/** + * Filter shells by manufacturer ID + */ +export const filterShellsByManufacturerId = ( + response: ShellDiscoveryResponse, + manufacturerId: string +): AASData[] => { + return response.shellDescriptors.filter(shell => + getManufacturerId(shell) === manufacturerId + ); +}; + +/** + * Filter shells by customer part ID + */ +export const filterShellsByCustomerPartId = ( + response: ShellDiscoveryResponse, + customerPartId: string +): AASData[] => { + return response.shellDescriptors.filter(shell => + getCustomerPartId(shell) === customerPartId + ); +}; + +/** + * Group shells by manufacturer ID + */ +export const groupShellsByManufacturerId = ( + response: ShellDiscoveryResponse +): Record => { + return response.shellDescriptors.reduce((acc, shell) => { + const manufacturerId = getManufacturerId(shell); + if (manufacturerId) { + if (!acc[manufacturerId]) { + acc[manufacturerId] = []; + } + acc[manufacturerId].push(shell); + } + return acc; + }, {} as Record); +}; + +/** + * Get all unique manufacturer part IDs from the response + */ +export const getUniqueManufacturerPartIds = (response: ShellDiscoveryResponse): string[] => { + const partIds = response.shellDescriptors + .map(shell => getManufacturerPartId(shell)) + .filter((id): id is string => id !== null); + + return [...new Set(partIds)]; +}; + +/** + * Get all unique customer part IDs from the response + */ +export const getUniqueCustomerPartIds = (response: ShellDiscoveryResponse): string[] => { + const partIds = response.shellDescriptors + .map(shell => getCustomerPartId(shell)) + .filter((id): id is string => id !== null); + + return [...new Set(partIds)]; +}; + +/** + * Find shells that have submodel descriptors + */ +export const getShellsWithSubmodels = (response: ShellDiscoveryResponse): AASData[] => { + return response.shellDescriptors.filter(shell => + shell.submodelDescriptors && shell.submodelDescriptors.length > 0 + ); +}; + +/** + * Convert shell discovery response to a format suitable for table display + */ +export const formatShellsForTable = (response: ShellDiscoveryResponse) => { + return response.shellDescriptors.map(shell => { + const summary = getAASDataSummary(shell); + return { + id: shell.id, + globalAssetId: shell.globalAssetId, + manufacturerPartId: summary.manufacturerPartId || 'N/A', + manufacturerId: summary.manufacturerId || 'N/A', + customerPartId: summary.customerPartId || 'N/A', + digitalTwinType: summary.digitalTwinType || 'N/A', + hasSubmodels: shell.submodelDescriptors.length > 0, + submodelCount: shell.submodelDescriptors.length + }; + }); +}; \ No newline at end of file diff --git a/ichub-frontend/src/hooks/useAdditionalSidebar.ts b/ichub-frontend/src/hooks/useAdditionalSidebar.ts new file mode 100644 index 00000000..bd93be44 --- /dev/null +++ b/ichub-frontend/src/hooks/useAdditionalSidebar.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { useContext } from 'react'; +import { AdditionalSidebarContext } from '../contexts/AdditionalSidebarContext'; + +export const useAdditionalSidebar = () => { + const context = useContext(AdditionalSidebarContext); + if (!context) { + throw new Error('useAdditionalSidebar must be used within an AdditionalSidebarProvider'); + } + return context; +}; + +export default useAdditionalSidebar; diff --git a/ichub-frontend/src/layouts/MainLayout.tsx b/ichub-frontend/src/layouts/MainLayout.tsx index a59ab62d..440c65e6 100644 --- a/ichub-frontend/src/layouts/MainLayout.tsx +++ b/ichub-frontend/src/layouts/MainLayout.tsx @@ -24,9 +24,12 @@ import { Outlet } from "react-router-dom"; import Grid2 from '@mui/material/Grid2'; import Header from '../components/general/Header'; import Sidebar from '../components/general/Sidebar'; +import AdditionalSidebar from '../components/general/AdditionalSidebar'; +import { SidebarProvider } from '../contexts/SidebarContext'; +import { AdditionalSidebarProvider } from '../contexts/AdditionalSidebarContext'; import { features } from '../features/main'; -function MainLayout() { +function MainLayoutContent() { return ( @@ -36,12 +39,25 @@ function MainLayout() { - + + + + ); +} + +function MainLayout() { + return ( + + + + + + ); }; export default MainLayout; \ No newline at end of file diff --git a/ichub-frontend/src/pages/PartsDiscovery.tsx b/ichub-frontend/src/pages/PartsDiscovery.tsx index 9c47ab6c..081e1a6e 100644 --- a/ichub-frontend/src/pages/PartsDiscovery.tsx +++ b/ichub-frontend/src/pages/PartsDiscovery.tsx @@ -20,186 +20,2103 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -import { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, Grid2, Typography, - Radio, - RadioGroup, - FormControlLabel, - Slider, TextField, Button, InputAdornment, - IconButton + useTheme, + useMediaQuery, + IconButton, + Alert, + CircularProgress, + Card, + Chip, + Autocomplete } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; -import type { ApiPartData, PartType } from '../types/product'; -import { ProductCard } from '../features/catalog-management/components/product-list/ProductCard'; -import { mapApiPartDataToPartType } from '../features/catalog-management/utils'; -import InstanceProductsTable from '../features/catalog-management/components/product-detail/InstanceProductsTable'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; +import { CatalogPartsDiscovery } from '../features/part-discovery/components/catalog-parts/CatalogPartsDiscovery'; +import PartsDiscoverySidebar from '../features/part-discovery/components/PartsDiscoverySidebar'; +import SerializedPartsTable from '../features/part-discovery/components/SerializedPartsTable'; +import { SingleTwinResult } from '../features/part-discovery/components/SingleTwinResult'; +import { useAdditionalSidebar } from '../hooks/useAdditionalSidebar'; +import { + discoverShellsWithCustomQuery, + discoverSingleShell, + ShellDiscoveryPaginator, + SingleShellDiscoveryResponse +} from '../features/part-discovery/api'; +import { + ShellDiscoveryResponse, + AASData, + getAASDataSummary +} from '../features/part-discovery/utils'; +import { fetchPartners } from '../features/partner-management/api'; +import { PartnerInstance } from '../types/partner'; + +interface PartCardData { + id: string; + manufacturerId: string; + manufacturerPartId: string; + customerPartId?: string; + name?: string; + category?: string; + digitalTwinType: string; + globalAssetId: string; + submodelCount: number; + dtrIndex?: number; // DTR index for display + idShort?: string; // Optional idShort from AAS data + rawTwinData?: AASData; // Raw AAS/shell data for download +} + +interface SerializedPartData { + id: string; + globalAssetId: string; + aasId: string; // AAS Shell ID + manufacturerId: string; + manufacturerPartId: string; + customerPartId?: string; + partInstanceId?: string; // Part Instance ID + digitalTwinType: string; + submodelCount: number; + dtrIndex?: number; // DTR index for display + idShort?: string; // Optional idShort from AAS data + rawTwinData?: AASData; // Raw AAS/shell data for download +} + +// Helper function to create a map from shell ID to DTR index +const createShellToDtrMap = (dtrs: Array & { shells?: string[] }>): Map => { + const shellToDtrMap = new Map(); + dtrs.forEach((dtr, dtrIndex) => { + if (dtr.shells && Array.isArray(dtr.shells)) { + dtr.shells.forEach((shellId: string) => { + shellToDtrMap.set(shellId, dtrIndex); + }); + } + }); + return shellToDtrMap; +}; + +// Helper function to get consistent colors for DTR identifiers +const getDtrColor = (dtrIndex: number) => { + const baseColors = [ + { bg: 'rgba(76, 175, 80, 0.9)', color: 'white', light: 'rgba(76, 175, 80, 0.1)', border: 'rgba(76, 175, 80, 0.3)' }, // Green + { bg: 'rgba(33, 150, 243, 0.9)', color: 'white', light: 'rgba(33, 150, 243, 0.1)', border: 'rgba(33, 150, 243, 0.3)' }, // Blue + { bg: 'rgba(255, 152, 0, 0.9)', color: 'white', light: 'rgba(255, 152, 0, 0.1)', border: 'rgba(255, 152, 0, 0.3)' }, // Orange + { bg: 'rgba(156, 39, 176, 0.9)', color: 'white', light: 'rgba(156, 39, 176, 0.1)', border: 'rgba(156, 39, 176, 0.3)' }, // Purple + { bg: 'rgba(244, 67, 54, 0.9)', color: 'white', light: 'rgba(244, 67, 54, 0.1)', border: 'rgba(244, 67, 54, 0.3)' }, // Red + { bg: 'rgba(0, 188, 212, 0.9)', color: 'white', light: 'rgba(0, 188, 212, 0.1)', border: 'rgba(0, 188, 212, 0.3)' }, // Cyan + { bg: 'rgba(139, 195, 74, 0.9)', color: 'white', light: 'rgba(139, 195, 74, 0.1)', border: 'rgba(139, 195, 74, 0.3)' }, // Light Green + { bg: 'rgba(121, 85, 72, 0.9)', color: 'white', light: 'rgba(121, 85, 72, 0.1)', border: 'rgba(121, 85, 72, 0.3)' }, // Brown + ]; + + const colorIndex = dtrIndex % baseColors.length; + const variation = Math.floor(dtrIndex / baseColors.length); + + // For DTRs beyond 8, add opacity variations to distinguish them + const baseColor = baseColors[colorIndex]; + const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity + + return { + bg: baseColor.bg.replace('0.9)', `${opacity})`), + color: baseColor.color, + light: baseColor.light, + border: baseColor.border + }; +}; const PartsDiscovery = () => { - const [partType, setPartType] = useState('Part'); - const [numParts, setNumParts] = useState(10); + const { showSidebar, hideSidebar, isVisible } = useAdditionalSidebar(); + + // Ref to prevent duplicate API calls in React StrictMode + const partnersLoadedRef = useRef(false); + + const [partType, setPartType] = useState('Catalog'); const [bpnl, setBpnl] = useState(''); - const [parts, setParts] = useState([]); + const [selectedPartner, setSelectedPartner] = useState(null); + const [availablePartners, setAvailablePartners] = useState([]); + const [isLoadingPartners, setIsLoadingPartners] = useState(false); + const [globalAssetId, setGlobalAssetId] = useState(''); + const [customerPartId, setCustomerPartId] = useState(''); + const [manufacturerPartId, setManufacturerPartId] = useState(''); + const [partInstanceId, setPartInstanceId] = useState(''); + const [pageLimit, setPageLimit] = useState(10); + const [customLimit, setCustomLimit] = useState(''); + const [isCustomLimit, setIsCustomLimit] = useState(false); + + // Single Twin Search Mode + const [searchMode, setSearchMode] = useState<'discovery' | 'single' | 'view'>('discovery'); + const [singleTwinAasId, setSingleTwinAasId] = useState(''); + const [singleTwinResult, setSingleTwinResult] = useState(null); + + // Twin View Mode (for viewing existing twin data) + const [viewingTwin, setViewingTwin] = useState(null); + + // DTR Section Visibility + const [dtrSectionVisible, setDtrSectionVisible] = useState(false); + + // DTR carousel state + const [dtrCarouselIndex, setDtrCarouselIndex] = useState(0); + + // DTR copy states + const [copiedAssetId, setCopiedAssetId] = useState(null); + const [copiedConnectorUrl, setCopiedConnectorUrl] = useState(null); + + // Results and pagination + const [partTypeCards, setPartTypeCards] = useState([]); + const [serializedParts, setSerializedParts] = useState([]); + const [currentResponse, setCurrentResponse] = useState(null); + const [paginator, setPaginator] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + + // Loading and error states + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [hasSearched, setHasSearched] = useState(false); - const handleSearchClick = async () => { - if (!bpnl) { - alert('Please enter a partner BPNL'); + // Pagination loading states + const [isLoadingNext, setIsLoadingNext] = useState(false); + const [isLoadingPrevious, setIsLoadingPrevious] = useState(false); + + // Responsive design hooks + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + // DTR carousel configuration + const dtrItemsPerSlide = isMobile ? 1 : 2; + const isSingleDtr = currentResponse?.dtrs.length === 1; + + // DTR carousel navigation functions + const handleDtrPrevious = () => { + setDtrCarouselIndex(prev => Math.max(0, prev - dtrItemsPerSlide)); + }; + + const handleDtrNext = () => { + if (currentResponse?.dtrs) { + const maxIndex = Math.max(0, currentResponse.dtrs.length - dtrItemsPerSlide); + setDtrCarouselIndex(prev => Math.min(maxIndex, prev + dtrItemsPerSlide)); + } + }; + + // Reset DTR carousel when DTRs change + useEffect(() => { + setDtrCarouselIndex(0); + }, [currentResponse?.dtrs]); + + // Copy functions for DTR data + const handleCopyAssetId = async (assetId: string, dtrIndex: number) => { + try { + await navigator.clipboard.writeText(assetId); + setCopiedAssetId(`${dtrIndex}-${assetId}`); + setTimeout(() => setCopiedAssetId(null), 2000); + } catch (err) { + console.error('Failed to copy asset ID:', err); + } + }; + + const handleCopyConnectorUrl = async (connectorUrl: string, dtrIndex: number) => { + try { + await navigator.clipboard.writeText(connectorUrl); + setCopiedConnectorUrl(`${dtrIndex}-${connectorUrl}`); + setTimeout(() => setCopiedConnectorUrl(null), 2000); + } catch (err) { + console.error('Failed to copy connector URL:', err); + } + }; + + // Show sidebar when in discovery mode and not searched + useEffect(() => { + if (searchMode === 'discovery' && !hasSearched) { + showSidebar( + + ); + } else { + hideSidebar(); + } + }, [searchMode, hasSearched, partType, pageLimit, customLimit, isCustomLimit, customerPartId, manufacturerPartId, globalAssetId, partInstanceId, showSidebar, hideSidebar]); + + // Cleanup: Hide sidebar when component unmounts (navigation away from PartsDiscovery) + useEffect(() => { + return () => { + hideSidebar(); + }; + }, [hideSidebar]); + + // Load available partners on component mount + useEffect(() => { + const loadPartners = async () => { + // Prevent duplicate calls in React StrictMode + if (partnersLoadedRef.current) { + return; + } + partnersLoadedRef.current = true; + + try { + setIsLoadingPartners(true); + const partners = await fetchPartners(); + setAvailablePartners(partners); + } catch (err) { + console.error('Error loading partners:', err); + // Don't show error for partners loading as it's not critical + // Reset the ref on error so it can be retried + partnersLoadedRef.current = false; + } finally { + setIsLoadingPartners(false); + } + }; + + loadPartners(); + }, []); + + // Helper function to get company name from BPNL + const getCompanyName = (bpnlValue: string): string => { + const partner = availablePartners.find(p => p.bpnl === bpnlValue); + return partner?.name || bpnlValue; + }; + + // Handle part type change and clear Part Instance ID when switching to Part + const handlePartTypeChange = (event: React.ChangeEvent) => { + const newPartType = event.target.value; + setPartType(newPartType); + + // Clear Part Instance ID when switching to Part Type + if (newPartType === 'Catalog') { + setPartInstanceId(''); + } + }; + + // Helper function to display filters sidebar + const handleDisplayFilters = () => { + showSidebar( + + ); + }; + + // Handle single twin search + const handleSingleTwinSearch = async () => { + if (!bpnl.trim()) { + setError('Please enter a partner BPNL'); + return; + } + + if (!singleTwinAasId.trim()) { + setError('Please enter an AAS ID'); return; } + setIsLoading(true); + setError(null); + setSingleTwinResult(null); + try { - console.log(`Searching for parts with BPNL: ${bpnl}`); - // Here we should call the API to fetch parts based on the BPNL and part type - const fakeParts: ApiPartData[] = Array.from({ length: numParts }).map((_, idx) => ({ - manufacturerId: `MFR-${idx + 1}`, - manufacturerPartId: `PART-${idx + 1}`, - name: `Part Name ${idx + 1}`, - status: 2, - description: `Description for part ${idx + 1}`, - category: 'Category A', - materials: [], - })); - - const mappedFakeParts: PartType[] = fakeParts.map((part) => - mapApiPartDataToPartType(part) + const response = await discoverSingleShell(bpnl, singleTwinAasId.trim()); + setSingleTwinResult(response); + setHasSearched(true); + } catch (err) { + let errorMessage = 'Failed to discover digital twin'; + + if (err instanceof Error) { + errorMessage = `Single twin search failed: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `Single twin search failed: ${err}`; + } else if (err && typeof err === 'object') { + if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response) { + const responseData = err.response.data as Record; + if (typeof responseData.message === 'string') { + errorMessage = `Single twin search failed: ${responseData.message}`; + } + } else if ('message' in err) { + const errWithMessage = err as { message: string }; + errorMessage = `Single twin search failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + // Helper function to generate active filter chips - scalable for future filters + const getActiveFilterChips = () => { + const filters = [ + { + value: customerPartId, + label: 'Customer Part ID', + tooltip: 'Customer Part ID' + }, + { + value: manufacturerPartId, + label: 'Manufacturer Part ID', + tooltip: 'Manufacturer Part ID' + }, + { + value: globalAssetId, + label: 'Global Asset ID', + tooltip: 'Global Asset ID' + }, + // Only show Part Instance ID filter when Part Instance is selected + ...(partType === 'Serialized' ? [{ + value: partInstanceId, + label: 'Part Instance ID', + tooltip: 'Part Instance Identifier' + }] : []) + // Future filters can be easily added here: + // { + // value: someNewFilter, + // label: 'New Filter Name', + // tooltip: 'New Filter Description' + // } + ]; + + return filters + .filter(filter => filter.value && filter.value.trim()) + .map((filter, index) => { + return ( + + + {filter.label}: + + + {filter.value} + +
    + } + size="medium" + color="primary" + variant="filled" + title={`${filter.tooltip}: ${filter.value}`} + sx={{ + backgroundColor: 'rgba(25, 118, 210, 0.1)', + color: '#1976d2', + border: '1px solid rgba(25, 118, 210, 0.3)', + borderRadius: '20px', + fontSize: '0.85rem', + fontWeight: '500', + px: 2, + py: 0.5, + height: 'auto', + minHeight: '32px', + maxWidth: '100%', + '& .MuiChip-label': { + px: 1, + py: 0.5, + whiteSpace: 'nowrap', + overflow: 'visible', + textOverflow: 'unset' + }, + '&:hover': { + backgroundColor: 'rgba(25, 118, 210, 0.15)', + borderColor: 'rgba(25, 118, 210, 0.5)', + transform: 'translateY(-1px)', + boxShadow: '0 4px 12px rgba(25, 118, 210, 0.2)' + }, + transition: 'all 0.2s ease-in-out' + }} + /> + ); + }); + }; + + // Convert AAS data to card format + const convertToPartCards = (shells: AASData[], shellToDtrMap?: Map): PartCardData[] => { + return shells.map(shell => { + const summary = getAASDataSummary(shell); + const dtrIndex = shellToDtrMap?.get(shell.id); + return { + id: shell.id, + manufacturerId: summary.manufacturerId || 'Unknown', + manufacturerPartId: summary.manufacturerPartId || 'Unknown', + customerPartId: summary.customerPartId || undefined, + name: `${summary.manufacturerPartId}`, + category: summary.customerPartId || undefined, + digitalTwinType: summary.digitalTwinType || 'Unknown', + globalAssetId: shell.globalAssetId, + submodelCount: summary.submodelCount, + dtrIndex, + idShort: shell.idShort, // Include idShort from AAS data + rawTwinData: shell + }; + }); + }; + + // Convert AAS data to serialized parts format + const convertToSerializedParts = (shells: AASData[], shellToDtrMap?: Map): SerializedPartData[] => { + return shells.map(shell => { + const summary = getAASDataSummary(shell); + const dtrIndex = shellToDtrMap?.get(shell.id); + return { + id: shell.id, + globalAssetId: shell.globalAssetId, + aasId: shell.id, // AAS Shell ID + manufacturerId: summary.manufacturerId || 'Unknown', + manufacturerPartId: summary.manufacturerPartId || 'Unknown', + customerPartId: summary.customerPartId || undefined, + partInstanceId: summary.partInstanceId || undefined, + digitalTwinType: summary.digitalTwinType || 'Unknown', + submodelCount: summary.submodelCount, + dtrIndex, + idShort: shell.idShort, // Include idShort from AAS data + rawTwinData: shell + }; + }); + }; + + const handleGoBack = () => { + setHasSearched(false); + setCurrentResponse(null); + setPaginator(null); + setPartTypeCards([]); + setSerializedParts([]); + setCurrentPage(1); + setTotalPages(0); + setError(null); + // Reset pagination loading states + setIsLoadingNext(false); + setIsLoadingPrevious(false); + // Reset search fields + setBpnl(''); + setSelectedPartner(null); + setCustomerPartId(''); + setManufacturerPartId(''); + }; + + const handleSearch = async () => { + if (!bpnl.trim()) { + setError('Please enter a partner BPNL'); + return; + } + + // Validate custom limit + if (isCustomLimit) { + if (!customLimit.trim()) { + setError('Please enter a custom limit or select a predefined option'); + return; + } + const customLimitNum = parseInt(customLimit); + if (isNaN(customLimitNum) || customLimitNum < 1 || customLimitNum > 1000) { + setError('Custom limit must be a number between 1 and 1000'); + return; + } + } + + setIsLoading(true); + setError(null); + // Reset pagination loading states for new search + setIsLoadingNext(false); + setIsLoadingPrevious(false); + + try { + // Calculate the correct limit based on whether custom limit is being used + let limit: number | undefined; + if (isCustomLimit) { + const customLimitNum = parseInt(customLimit); + limit = customLimitNum; + } else { + limit = pageLimit === 0 ? undefined : pageLimit; // No limit if pageLimit is 0 + } + + // Build custom query with all provided parameters + const querySpec: Array<{ name: string; value: string }> = []; + + // Add digitalTwinType based on part type selection + querySpec.push({ + name: 'digitalTwinType', + value: partType === 'Catalog' ? 'PartType' : 'PartInstance' + }); + + // Add all provided search parameters + if (customerPartId.trim()) { + querySpec.push({ + name: 'customerPartId', + value: customerPartId.trim() + }); + } + + if (manufacturerPartId.trim()) { + querySpec.push({ + name: 'manufacturerPartId', + value: manufacturerPartId.trim() + }); + } + + if (globalAssetId.trim()) { + querySpec.push({ + name: 'globalAssetId', + value: globalAssetId.trim() + }); + } + + // Only add partInstanceId if part type is Serialized (PartInstance) + if (partType === 'Serialized' && partInstanceId.trim()) { + querySpec.push({ + name: 'partInstanceId', + value: partInstanceId.trim() + }); + } + + // Use custom query for comprehensive search + const response = await discoverShellsWithCustomQuery(bpnl, querySpec, limit); + + setCurrentResponse(response); + + // Log the full response for debugging + console.log('API Response:', response); + + // Check for any error-like fields in the response object + const responseObj = response as unknown as Record; + const errorFields = Object.keys(responseObj).filter(key => + key.toLowerCase().includes('error') || + key.toLowerCase().includes('warning') || + key.toLowerCase().includes('message') + ); + + if (errorFields.length > 0) { + const errorValues = errorFields + .map(field => ({ field, value: responseObj[field] })) + .filter(({ value }) => value && typeof value === 'string' && value.trim() !== ''); + + if (errorValues.length > 0) { + console.warn('Additional error fields found in response:', errorValues); + // Log but don't automatically show these as errors unless they're critical + } + } + + // Check if the API returned an error in the response + if (response.error) { + // Handle specific error cases with user-friendly messages + if (response.error.toLowerCase().includes('no dtrs found')) { + setError(`No Digital Twin Registries found for partner "${bpnl}". Please verify the BPNL is correct and if the partner has a Connector (with a reachable DTR) connected in the same dataspace as you.`); + } else { + setError(`Search failed: ${response.error}`); + } + setIsLoading(false); + return; + } + + // Check if no shell descriptors were found + if (!response.shellDescriptors || response.shellDescriptors.length === 0) { + setError('No digital twins found for the specified criteria. Please try different search parameters.'); + setIsLoading(false); + return; + } + + // Check for errors in DTR statuses + if (response.dtrs && response.dtrs.length > 0) { + const errorDtrs = response.dtrs.filter(dtr => + dtr.status && ( + dtr.status.toLowerCase().includes('error') || + dtr.status.toLowerCase().includes('failed') || + dtr.status.toLowerCase().includes('timeout') || + dtr.status.toLowerCase().includes('unavailable') + ) + ); + if (errorDtrs.length > 0) { + console.warn('DTR errors found:', errorDtrs); + const errorMessages = errorDtrs.map(dtr => `DTR ${dtr.connectorUrl}: ${dtr.status}`); + setError(`DTR issues detected: ${errorMessages.join(', ')}`); + // Don't return here - continue processing in case there are still valid results + } + } + + // Create paginator + const digitalTwinType = partType === 'Catalog' ? 'PartType' : 'PartInstance'; + const newPaginator = new ShellDiscoveryPaginator( + response, + bpnl, + digitalTwinType, + limit ); + setPaginator(newPaginator); + + // Create DTR mapping if DTRs are available + const shellToDtrMap = response.dtrs ? createShellToDtrMap(response.dtrs as unknown as Array & { shells?: string[] }>) : undefined; + + // Process results based on part type + if (partType === 'Catalog') { + const cards = convertToPartCards(response.shellDescriptors, shellToDtrMap); + setPartTypeCards(cards); + setSerializedParts([]); + } else { + const serialized = convertToSerializedParts(response.shellDescriptors, shellToDtrMap); + setSerializedParts(serialized); + setPartTypeCards([]); + } + + setCurrentPage(response.pagination?.page || 1); + // Calculate total pages (this would ideally come from the API) + if (limit === undefined) { + setTotalPages(1); // No pagination when no limit is set + } else { + setTotalPages(Math.ceil(response.shellsFound / limit)); + } - mappedFakeParts.reverse(); + // Mark that search has been performed successfully + setHasSearched(true); + + } catch (err) { + console.error('Search error:', err); + + // Extract meaningful error message from different error types + let errorMessage = 'Error searching for parts. Please try again.'; + + if (err instanceof Error) { + // Handle standard Error objects + errorMessage = `Search failed: ${err.message}`; + } else if (typeof err === 'string') { + // Handle string errors + errorMessage = `Search failed: ${err}`; + } else if (err && typeof err === 'object') { + // Handle axios or other structured errors + if ('response' in err && err.response) { + // Axios error with response + const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } }; + if (axiosErr.response.data?.error) { + errorMessage = `API Error: ${axiosErr.response.data.error}`; + } else if (axiosErr.response.data?.message) { + errorMessage = `API Error: ${axiosErr.response.data.message}`; + } else if (axiosErr.response.statusText) { + errorMessage = `HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`; + } else { + errorMessage = `HTTP Error ${axiosErr.response.status}`; + } + } else if ('message' in err) { + // Object with message property + const errWithMessage = err as { message: string }; + errorMessage = `Search failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handlePageChange = async (_: React.ChangeEvent, page: number) => { + if (!paginator || page === currentPage) return; + + // Determine direction and set appropriate loading state + const isNext = page === currentPage + 1; + const isPrevious = page === currentPage - 1; + + if (isNext) { + setIsLoadingNext(true); + } else if (isPrevious) { + setIsLoadingPrevious(true); + } + + setError(null); + + try { + let newResponse: ShellDiscoveryResponse | null = null; + + // Handle sequential navigation (most common case) + if (page === currentPage + 1 && paginator.hasNext()) { + newResponse = await paginator.next(); + } else if (page === currentPage - 1 && paginator.hasPrevious()) { + newResponse = await paginator.previous(); + } else { + // For non-sequential navigation, show a helpful message + // Cursor-based pagination doesn't support random page access efficiently + setError(`Direct navigation to page ${page} is not supported. Please use next/previous navigation.`); + return; + } + + if (newResponse) { + // Check if the pagination response contains an error + if (newResponse.error) { + if (newResponse.error.toLowerCase().includes('no dtrs found')) { + setError(`No Digital Twin Registries found for partner "${bpnl}" on page ${page}. Please verify the BPNL is correct and the partner has registered digital twins.`); + } else { + setError(`Pagination failed: ${newResponse.error}`); + } + return; + } + + setCurrentResponse(newResponse); + setCurrentPage(newResponse.pagination?.page || currentPage); + + // Create DTR mapping if DTRs are available + const shellToDtrMap = newResponse.dtrs ? createShellToDtrMap(newResponse.dtrs as unknown as Array & { shells?: string[] }>) : undefined; + + // Update results based on part type + if (partType === 'Catalog') { + const cards = convertToPartCards(newResponse.shellDescriptors, shellToDtrMap); + setPartTypeCards(cards); + } else { + const serialized = convertToSerializedParts(newResponse.shellDescriptors, shellToDtrMap); + setSerializedParts(serialized); + } + } else { + setError('No more pages available in that direction.'); + } + } catch (err) { + console.error('Pagination error:', err); + + // Extract meaningful error message from pagination errors + let errorMessage = 'Error loading page. Please try again.'; + + if (err instanceof Error) { + errorMessage = `Pagination failed: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `Pagination failed: ${err}`; + } else if (err && typeof err === 'object') { + if ('response' in err && err.response) { + const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } }; + if (axiosErr.response.data?.error) { + errorMessage = `Pagination API Error: ${axiosErr.response.data.error}`; + } else if (axiosErr.response.data?.message) { + errorMessage = `Pagination API Error: ${axiosErr.response.data.message}`; + } else { + errorMessage = `Pagination HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`; + } + } else if ('message' in err) { + const errWithMessage = err as { message: string }; + errorMessage = `Pagination failed: ${errWithMessage.message}`; + } + } + + setError(errorMessage); + } finally { + // Clean up pagination loading states + setIsLoadingNext(false); + setIsLoadingPrevious(false); + } + }; + + const handleCardClick = (partId: string) => { + console.log('Card clicked:', partId); + + // Find the card data to get the raw twin data + const card = partTypeCards.find(c => c.id === partId || `${c.manufacturerId}/${c.manufacturerPartId}` === partId); + if (card && card.rawTwinData) { + // Get DTR information if available + let dtrInfo = undefined; + if (currentResponse?.dtrs && card.dtrIndex !== undefined && currentResponse.dtrs[card.dtrIndex]) { + const dtr = currentResponse.dtrs[card.dtrIndex]; + dtrInfo = { + connectorUrl: dtr.connectorUrl || 'Unknown', + assetId: dtr.assetId || card.id + }; + } + + // Convert the raw twin data to the format expected by SingleTwinResult + const twinResult: SingleShellDiscoveryResponse = { + shell_descriptor: { + ...card.rawTwinData, + idShort: card.rawTwinData.idShort || card.rawTwinData.id, // Use actual idShort if available, otherwise fallback to AAS ID + }, + dtr: dtrInfo || { + connectorUrl: 'Local Discovery', + assetId: card.id + } + }; + + setViewingTwin(twinResult); + setSearchMode('view'); + } + }; - setParts(mappedFakeParts); - } catch (error) { - console.error('Error al buscar:', error); - alert('Error'); + const handleSerializedPartView = (part: SerializedPartData) => { + console.log('View serialized part:', part); + + if (part.rawTwinData) { + // Get DTR information if available + let dtrInfo = undefined; + if (currentResponse?.dtrs && part.dtrIndex !== undefined && currentResponse.dtrs[part.dtrIndex]) { + const dtr = currentResponse.dtrs[part.dtrIndex]; + dtrInfo = { + connectorUrl: dtr.connectorUrl || 'Unknown', + assetId: dtr.assetId || part.aasId + }; + } + + // Convert the raw twin data to the format expected by SingleTwinResult + const twinResult: SingleShellDiscoveryResponse = { + shell_descriptor: { + ...part.rawTwinData, + idShort: part.rawTwinData.idShort || part.rawTwinData.id, // Use actual idShort if available, otherwise fallback to AAS ID + }, + dtr: dtrInfo || { + connectorUrl: 'Local Discovery', + assetId: part.aasId + } + }; + + setViewingTwin(twinResult); + setSearchMode('view'); } }; + const handleRegisterClick = (manufacturerId: string, manufacturerPartId: string) => { + console.log('Register part:', manufacturerId, manufacturerPartId); + // Implement registration functionality + }; + return ( - - {/* Sidebar */} - - Part Type - setPartType(e.target.value)}> - } label="Part Type" /> - } label="Serialized" /> - - - - - Nº Parts - { - if (typeof val === 'number') { - setNumParts(val); - } - }} - valueLabelDisplay="auto" - /> + + {/* Compact Header - shown when search results are displayed */} + {hasSearched && ( + + + + + {searchMode === 'view' ? ( + + ) : ( + + )} + + + + + Dataspace Discovery + + + + + + + {getCompanyName(bpnl)} + + + {bpnl} + + + + - - - {numParts} - - - - - - {/* Main Content */} - - - Parts Discovery - - - {/* Search fields */} - - - setBpnl(e.target.value)} - slotProps={{ - input: { - endAdornment: ( - - - - - - ), - }, + + )} + + {/* Main Content Container */} + + {/* Search Mode Toggle */} + {!hasSearched && ( + + {/* Display Filters Button - Only show in Discovery mode when sidebar should be available but is hidden */} + {searchMode === 'discovery' && !isVisible && ( + + )} + + {/* Hide Filters Button - Only show in Discovery mode when sidebar is visible */} + {searchMode === 'discovery' && isVisible && ( + + )} + + {/* Mode Toggle */} + + setSearchMode('discovery')} + > + Discovery Mode + + - - - - - - - - ), - }, + onClick={() => setSearchMode(searchMode === 'discovery' ? 'single' : 'discovery')} + > + + + - - - - - - - {/* Parts grid */} - - {partType === 'Serialized' ? ( - - - - ) : ( - {}} - onShare={() => {}} - onMore={() => {}} - onRegisterClick={() => {}} - items={parts.slice(0, numParts).map((part) => ({ - manufacturerId: part.manufacturerId, - manufacturerPartId: part.manufacturerPartId, - name: part.name, - category: part.category, - status: part.status, - }))} - isLoading={false} - isDiscovery={true} - /> + Single Twin + + + )} - - - + + {/* Main Content */} + + {/* Centered Welcome Screen - only shown when no search has been performed and in discovery mode */} + {!hasSearched && searchMode === 'discovery' && ( + + + Dataspace Discovery + + + Discover and explore digital twin parts in a Tractus-X network + + + {/* Centered Search Card */} + + + { + if (typeof option === 'string') return option; + return `${option.name} - ${option.bpnl}`; + }} + value={bpnl} + onChange={(_, newValue) => { + if (typeof newValue === 'string') { + // Custom BPNL entered + setBpnl(newValue); + setSelectedPartner(null); + } else if (newValue) { + // Partner selected from dropdown + setBpnl(newValue.bpnl); + setSelectedPartner(newValue); + } else { + // Cleared + setBpnl(''); + setSelectedPartner(null); + } + }} + onInputChange={(_, newInputValue) => { + setBpnl(newInputValue); + if (!availablePartners.find(p => p.bpnl === newInputValue)) { + setSelectedPartner(null); + } + }} + renderInput={(params) => ( + + {isLoadingPartners ? : null} + {params.InputProps.endAdornment} + + + + + + + ), + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + fontSize: '1.1rem', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.9)' + }, + '&.Mui-focused': { + backgroundColor: 'white' + } + }, + '& .MuiInputLabel-root': { + fontSize: '1.1rem' + } + }} + /> + )} + renderOption={(props, option) => ( + + + {option.name} + + + {option.bpnl} + + + )} + loading={isLoadingPartners} + loadingText="Loading partners..." + noOptionsText="No partners found. You can still enter a custom BPNL." + sx={{ width: '100%' }} + /> + + + + + + )} + + {/* Single Twin Search Screen - only shown when no search has been performed and in single mode */} + {!hasSearched && searchMode === 'single' && ( + + + Single Digital Twin + + + Search for a specific digital twin by providing its Asset Administration Shell (AAS) ID + + + {/* Centered Search Card */} + + + { + if (typeof option === 'string') return option; + return `${option.name} - ${option.bpnl}`; + }} + value={bpnl} + onChange={(_, newValue) => { + if (typeof newValue === 'string') { + // Custom BPNL entered + setBpnl(newValue); + setSelectedPartner(null); + } else if (newValue) { + // Partner selected from dropdown + setBpnl(newValue.bpnl); + setSelectedPartner(newValue); + } else { + // Cleared + setBpnl(''); + setSelectedPartner(null); + } + }} + onInputChange={(_, newInputValue) => { + setBpnl(newInputValue); + if (!availablePartners.find(p => p.bpnl === newInputValue)) { + setSelectedPartner(null); + } + }} + renderInput={(params) => ( + + {isLoadingPartners ? : null} + {params.InputProps.endAdornment} + + ), + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + fontSize: '1.1rem', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.9)' + }, + '&.Mui-focused': { + backgroundColor: 'white' + } + }, + '& .MuiInputLabel-root': { + fontSize: '1.1rem' + } + }} + /> + )} + renderOption={(props, option) => ( + + + {option.name} + + + {option.bpnl} + + + )} + loading={isLoadingPartners} + loadingText="Loading partners..." + noOptionsText="No partners found. You can still enter a custom BPNL." + sx={{ width: '100%' }} + /> + + {/* AAS ID Field */} + ) => setSingleTwinAasId(e.target.value)} + error={!!error && !singleTwinAasId.trim()} + helperText={ + !!error && !singleTwinAasId.trim() + ? 'AAS ID is required' + : 'Enter the unique identifier for the Asset Administration Shell' + } + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + fontSize: '1.1rem', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.9)' + }, + '&.Mui-focused': { + backgroundColor: 'white' + } + }, + '& .MuiInputLabel-root': { + fontSize: '1.1rem' + } + }} + /> + + + + + + + )} + + {/* Error Alert */} + {error && ( + + setError(null)} sx={{ maxWidth: '600px' }}> + {error} + + + )} + + {/* Results Section - shown when search has been performed */} + {hasSearched && ( + + {/* Single Twin Mode Results - Outside Results Display to avoid padding inheritance */} + {singleTwinResult && searchMode === 'single' && ( + + )} + + {/* View Twin Mode Results - For viewing twins from catalog */} + {viewingTwin && searchMode === 'view' && ( + + + + )} + {/* Discovery Mode Results */} + {currentResponse && searchMode === 'discovery' && ( + + {/* Left Side - Part Type Indicator + Active Filters */} + + {/* Part Type - Always shown */} + + + {partType === 'Catalog' ? 'Catalog Parts' : 'Serialized Parts'} + + + + {/* Active Filters - Only shown when there are filters */} + {getActiveFilterChips().length > 0 && ( + <> + + Active Filters: + + {getActiveFilterChips()} + + )} + + + {/* Results Count - Right Side */} + + {/* Results Count */} + + + {currentResponse.shellsFound} + + + + + )} + + {/* Remove the duplicate simple results count section since we now handle both cases above */} + + {/* DTR Information Section - Discovery Mode */} + {currentResponse && currentResponse.dtrs && searchMode === 'discovery' && ( + + setDtrSectionVisible(!dtrSectionVisible)} + > + + Digital Twin Registries ({currentResponse.dtrs.length}) + + + {dtrSectionVisible ? : } + + + + {dtrSectionVisible && ( + + {/* DTR Carousel */} + {currentResponse.dtrs.length > 0 && ( + + {/* Carousel Navigation Header */} + {currentResponse.dtrs.length > dtrItemsPerSlide && ( + + + {Math.floor(dtrCarouselIndex / dtrItemsPerSlide) + 1} of {Math.ceil(currentResponse.dtrs.length / dtrItemsPerSlide)} • {currentResponse.dtrs.length} total + + + + + + = currentResponse.dtrs.length - dtrItemsPerSlide} + sx={{ + color: dtrCarouselIndex >= currentResponse.dtrs.length - dtrItemsPerSlide ? 'text.disabled' : 'primary.main', + '&:hover': { backgroundColor: 'primary.light' } + }} + > + + + + + )} + + {/* DTR Cards Grid */} + + {currentResponse.dtrs + .slice(dtrCarouselIndex, dtrCarouselIndex + dtrItemsPerSlide) + .map((dtr, relativeIndex) => { + const actualIndex = dtrCarouselIndex + relativeIndex; + const dtrColor = getDtrColor(actualIndex); + return ( + + {/* DTR Header */} + + + + + + + + + {/* Shells Found - Top Right Corner */} + + + Shells Found + + + + + + {/* Connector URL and Asset ID Grid */} + + + + Connector URL: + + + + {dtr.connectorUrl} + + handleCopyConnectorUrl(dtr.connectorUrl, actualIndex)} + sx={{ + p: 0.5, + minWidth: 'auto', + '&:hover': { backgroundColor: 'rgba(0,0,0,0.1)' } + }} + > + {copiedConnectorUrl === `${actualIndex}-${dtr.connectorUrl}` ? + : + + } + + + + + + + Asset ID: + + + + {dtr.assetId} + + handleCopyAssetId(dtr.assetId, actualIndex)} + sx={{ + p: 0.5, + minWidth: 'auto', + '&:hover': { backgroundColor: 'rgba(0,0,0,0.1)' } + }} + > + {copiedAssetId === `${actualIndex}-${dtr.assetId}` ? + : + + } + + + + + + ); + })} + + + {/* Carousel Indicators */} + {currentResponse.dtrs.length > dtrItemsPerSlide && ( + + {Array.from({ length: Math.ceil(currentResponse.dtrs.length / dtrItemsPerSlide) }).map((_, pageIndex) => { + const isActive = Math.floor(dtrCarouselIndex / dtrItemsPerSlide) === pageIndex; + return ( + setDtrCarouselIndex(pageIndex * dtrItemsPerSlide)} + sx={{ + width: 8, + height: 8, + borderRadius: '50%', + backgroundColor: isActive ? 'primary.main' : 'grey.300', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isActive ? 'primary.dark' : 'grey.400' + } + }} + /> + ); + })} + + )} + + )} + + )} + + )} + + {/* Results Display */} + {searchMode === 'discovery' && ( + + {partType === 'Serialized' ? ( + <> + {serializedParts.length > 0 ? ( + + ) : !isLoading && currentResponse ? ( + + No serialized parts found + + ) : null} + + ) : ( + <> + {partTypeCards.length > 0 ? ( + ({ + id: card.id, + manufacturerId: card.manufacturerId, + manufacturerPartId: card.manufacturerPartId, + name: card.name, + category: card.category, + dtrIndex: card.dtrIndex, + shellId: card.id, // The shell ID is the same as the card ID (AAS ID) + rawTwinData: card.rawTwinData + }))} + isLoading={isLoading} + /> + ) : !isLoading && currentResponse ? ( + + No catalog parts found + + ) : null} + + )} + + )} + + + {/* Pagination */} + {currentResponse && !isLoading && pageLimit > 0 && searchMode === 'discovery' && ( + + {paginator?.hasPrevious() && ( + + )} + + + + Page {currentPage} + + {totalPages > 1 && ( + + of {totalPages} + + )} + + + {paginator?.hasNext() && ( + + )} + + )} + + + )} + + + ); }; diff --git a/ichub-frontend/src/routes.tsx b/ichub-frontend/src/routes.tsx index d17ef30b..309aaeed 100644 --- a/ichub-frontend/src/routes.tsx +++ b/ichub-frontend/src/routes.tsx @@ -20,29 +20,93 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ - +import { lazy, Suspense } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import MainLayout from "./layouts/MainLayout"; -import ProductsList from './pages/ProductsList'; -import PartnersList from './pages/PartnersList'; -import ProductsDetails from './pages/ProductsDetails'; -import PartsDiscovery from "./pages/PartsDiscovery"; -import SerializedParts from "./pages/SerializedParts" +import { CircularProgress, Box } from "@mui/material"; + +// Lazy load page components for automatic code splitting +const ProductsList = lazy(() => import('./pages/ProductsList')); +const PartnersList = lazy(() => import('./pages/PartnersList')); +const ProductsDetails = lazy(() => import('./pages/ProductsDetails')); +const PartsDiscovery = lazy(() => import("./pages/PartsDiscovery")); +const SerializedParts = lazy(() => import("./pages/SerializedParts")); + +// Loading component +const PageLoader = () => ( + + + +); export default function AppRoutes() { return ( } > - } /> - } /> + }> + + + } + /> + }> + + + } + /> {/* Here we must change the elements as we go along as we develop */} - } /> - } /> - } /> - } /> - } /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> diff --git a/ichub-frontend/src/services/EnvironmentService.ts b/ichub-frontend/src/services/EnvironmentService.ts index e54fb58a..308a89c7 100644 --- a/ichub-frontend/src/services/EnvironmentService.ts +++ b/ichub-frontend/src/services/EnvironmentService.ts @@ -20,18 +20,127 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -declare const ENV: Record +// Types for governance configuration +export interface GovernanceConstraint { + leftOperand: string; + operator: string; + rightOperand: string; +} + +export interface GovernanceRule { + action: string; + LogicalConstraint?: string; + constraints: GovernanceConstraint[]; +} + +export interface GovernancePolicy { + strict: boolean; + permission: GovernanceRule | GovernanceRule[]; + prohibition: GovernanceRule | GovernanceRule[]; + obligation: GovernanceRule | GovernanceRule[]; +} + +export interface GovernanceConfig { + semanticid: string; + policies: GovernancePolicy[]; +} export const isRequireHttpsUrlPattern = () => - ENV.REQUIRE_HTTPS_URL_PATTERN !== 'false'; + import.meta.env.VITE_REQUIRE_HTTPS_URL_PATTERN !== 'false'; + +export const getIchubBackendUrl = () => import.meta.env.VITE_ICHUB_BACKEND_URL ?? ''; +export const getParticipantId = () => import.meta.env.VITE_PARTICIPANT_ID ?? 'BPNL0000000093Q7'; -export const getIchubBackendUrl = () => ENV.ICHUB_BACKEND_URL ?? ''; -export const getParticipantId = () => ENV.PARTICIPANT_ID ?? 'BPNL000000000000'; +export const getGovernanceConfig = (): GovernanceConfig[] => { + try { + // First try to get from window.ENV (runtime injection), then fallback to import.meta.env + const configStr = window?.ENV?.GOVERNANCE_CONFIG || import.meta.env.VITE_GOVERNANCE_CONFIG; + if (!configStr) return []; + return JSON.parse(configStr) as GovernanceConfig[]; + } catch (error) { + console.warn('Failed to parse governance configuration:', error); + return []; + } +}; + +export const getDtrPoliciesConfig = (): GovernancePolicy[] => { + try { + // First try to get from window.ENV (runtime injection), then fallback to import.meta.env + const configStr = window?.ENV?.DTR_POLICIES_CONFIG || import.meta.env.VITE_DTR_POLICIES_CONFIG; + if (!configStr) { + // Return default DTR policies if no configuration is provided + return [{ + strict: false, + permission: { + action: 'odrl:use', + LogicalConstraint: 'odrl:and', + constraints: [ + { + leftOperand: 'cx-policy:FrameworkAgreement', + operator: 'odrl:eq', + rightOperand: 'DataExchangeGovernance:1.0' + }, + { + leftOperand: 'cx-policy:Membership', + operator: 'odrl:eq', + rightOperand: 'active' + }, + { + leftOperand: 'cx-policy:UsagePurpose', + operator: 'odrl:eq', + rightOperand: 'cx.core.digitalTwinRegistry:1' + } + ] + }, + prohibition: [], + obligation: [] + }]; + } + return JSON.parse(configStr) as GovernancePolicy[]; + } catch (error) { + console.warn('Failed to parse DTR policies configuration:', error); + // Return default DTR policies on error + return [{ + strict: false, + permission: { + action: 'odrl:use', + LogicalConstraint: 'odrl:and', + constraints: [ + { + leftOperand: 'cx-policy:FrameworkAgreement', + operator: 'odrl:eq', + rightOperand: 'DataExchangeGovernance:1.0' + }, + { + leftOperand: 'cx-policy:Membership', + operator: 'odrl:eq', + rightOperand: 'active' + }, + { + leftOperand: 'cx-policy:UsagePurpose', + operator: 'odrl:eq', + rightOperand: 'cx.core.digitalTwinRegistry:1' + } + ] + }, + prohibition: { + action: 'odrl:prohibit', + constraints: [] + }, + obligation: { + action: 'odrl:compensate', + constraints: [] + } + }]; + } +}; const EnvironmentService = { isRequireHttpsUrlPattern, getIchubBackendUrl, - getParticipantId + getParticipantId, + getGovernanceConfig, + getDtrPoliciesConfig }; export default EnvironmentService; diff --git a/ichub-frontend/src/types/product.ts b/ichub-frontend/src/types/product.ts index 16924b81..812cd473 100644 --- a/ichub-frontend/src/types/product.ts +++ b/ichub-frontend/src/types/product.ts @@ -56,6 +56,14 @@ export interface PartType { customerPartIds?: Record; // e.g., { "CUSTOMER_BPNL_XYZ": { name: "BMW", bpnl: "BPNL00000003CRHK" } } } + +export interface DiscoveryPartType { + manufacturerId: string; + manufacturerPartId: string; + customerPartId: string, + id: string, + globalAssetId: string, +} export type ApiPartData = Omit & { status: number; // Status from API is a number }; \ No newline at end of file diff --git a/ichub-frontend/src/utils/governancePolicyUtils.ts b/ichub-frontend/src/utils/governancePolicyUtils.ts new file mode 100644 index 00000000..cc53c1c9 --- /dev/null +++ b/ichub-frontend/src/utils/governancePolicyUtils.ts @@ -0,0 +1,262 @@ +/******************************************************************************** + * Eclipse Tractus-X - Industry Core Hub Frontend + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://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 govern in permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import type { GovernanceConfig, GovernanceConstraint, GovernanceRule } from '../services/EnvironmentService'; + +// ODRL constraint interface for API compatibility +export interface OdrlConstraint { + "odrl:leftOperand": { "@id": string }; + "odrl:operator": { "@id": string }; + "odrl:rightOperand": string; +} + +// ODRL rule interface for API compatibility +export interface OdrlRule { + "odrl:action": { "@id": string }; + "odrl:constraint"?: { + "odrl:and"?: OdrlConstraint[]; + "odrl:or"?: OdrlConstraint[]; + } | OdrlConstraint; // Single constraint or logical constraint group +} + +// ODRL policy interface for API compatibility +export interface OdrlPolicy { + "odrl:permission": OdrlRule; + "odrl:prohibition": OdrlRule[]; + "odrl:obligation": OdrlRule[]; +} + +/** + * Convert governance constraint to ODRL format + */ +function convertToOdrlConstraint(constraint: GovernanceConstraint): OdrlConstraint { + return { + "odrl:leftOperand": { "@id": constraint.leftOperand }, + "odrl:operator": { "@id": constraint.operator }, + "odrl:rightOperand": constraint.rightOperand + }; +} + +/** + * Generate all permutations of an array + */ +function generatePermutations(arr: T[]): T[][] { + if (arr.length <= 1) return [arr]; + + const result: T[][] = []; + for (let i = 0; i < arr.length; i++) { + const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; + const perms = generatePermutations(rest); + for (const perm of perms) { + result.push([arr[i], ...perm]); + } + } + return result; +} + +/** + * Generate all combinations (subsets) of constraints + */ +function generateCombinations(arr: T[]): T[][] { + const result: T[][] = []; + + // Generate all possible combinations (1 to n elements) + for (let size = 1; size <= arr.length; size++) { + const combinations = getCombinationsOfSize(arr, size); + result.push(...combinations); + } + + return result; +} + +/** + * Get all combinations of a specific size + */ +function getCombinationsOfSize(arr: T[], size: number): T[][] { + if (size === 1) return arr.map(item => [item]); + if (size === arr.length) return [arr]; + + const result: T[][] = []; + + for (let i = 0; i <= arr.length - size; i++) { + const smallerCombinations = getCombinationsOfSize(arr.slice(i + 1), size - 1); + for (const combination of smallerCombinations) { + result.push([arr[i], ...combination]); + } + } + + return result; +} + +/** + * Generate all permutations of all combinations + */ +function generateAllVariants(arr: T[]): T[][] { + const combinations = generateCombinations(arr); + const result: T[][] = []; + + for (const combination of combinations) { + const permutations = generatePermutations(combination); + result.push(...permutations); + } + + return result; +} + +/** + * Create ODRL constraint structure based on logical constraint type and constraints + */ +function createOdrlConstraintStructure( + odrlConstraints: OdrlConstraint[], + logicalConstraint?: string +): OdrlRule["odrl:constraint"] { + // Single constraint - no logical wrapper needed + if (odrlConstraints.length === 1) { + return odrlConstraints[0]; + } + + // Multiple constraints - use logical constraint (default to AND) + const logic = logicalConstraint?.toLowerCase() === 'odrl:or' ? 'odrl:or' : 'odrl:and'; + + if (logic === 'odrl:or') { + return { "odrl:or": odrlConstraints }; + } else { + return { "odrl:and": odrlConstraints }; + } +} + +/** + * Process rules (permission, prohibition, or obligation) and generate ODRL constraints + */ +function processRules( + rules: GovernanceRule[], + ruleType: 'permission' | 'prohibition' | 'obligation', + isStrict: boolean +): OdrlPolicy[] { + if (!rules || rules.length === 0) { + return []; + } + + const result: OdrlPolicy[] = []; + + for (const rule of rules) { + if (!rule.constraints || rule.constraints.length === 0) { + continue; + } + + // Convert constraints to ODRL format + const odrlConstraints = rule.constraints.map(convertToOdrlConstraint); + + if (isStrict) { + // Strict mode: use exact order as configured + const odrlRule: OdrlRule = { + "odrl:action": { "@id": rule.action }, + "odrl:constraint": createOdrlConstraintStructure(odrlConstraints, rule.LogicalConstraint) + }; + + const policy: OdrlPolicy = { + "odrl:permission": ruleType === 'permission' ? odrlRule : { "odrl:action": { "@id": "odrl:use" } }, + "odrl:prohibition": ruleType === 'prohibition' ? [odrlRule] : [], + "odrl:obligation": ruleType === 'obligation' ? [odrlRule] : [] + }; + + result.push(policy); + } else { + // Non-strict mode: generate all permutations of all combinations + const variants = generateAllVariants(odrlConstraints); + + for (const variant of variants) { + const odrlRule: OdrlRule = { + "odrl:action": { "@id": rule.action }, + "odrl:constraint": createOdrlConstraintStructure(variant, rule.LogicalConstraint) + }; + + const policy: OdrlPolicy = { + "odrl:permission": ruleType === 'permission' ? odrlRule : { "odrl:action": { "@id": "odrl:use" } }, + "odrl:prohibition": ruleType === 'prohibition' ? [odrlRule] : [], + "odrl:obligation": ruleType === 'obligation' ? [odrlRule] : [] + }; + + result.push(policy); + } + } + } + + return result; +} + +/** + * Convert governance policy to ODRL policies with permutations + */ +export function generateOdrlPolicies( + governanceConfig: GovernanceConfig[], + semanticId?: string +): OdrlPolicy[] { + // Find the matching governance config + const config = semanticId + ? governanceConfig.find(c => c.semanticid === semanticId) + : governanceConfig[0]; // Use first if no semantic ID specified + + if (!config || !config.policies || config.policies.length === 0) { + return []; + } + + const result: OdrlPolicy[] = []; + + for (const policy of config.policies) { + // Process permissions + const permissionRules = Array.isArray(policy.permission) ? policy.permission : [policy.permission]; + const permissionPolicies = processRules(permissionRules, 'permission', policy.strict); + result.push(...permissionPolicies); + + // Process prohibitions + const prohibitionRules = Array.isArray(policy.prohibition) ? policy.prohibition : [policy.prohibition]; + const prohibitionPolicies = processRules(prohibitionRules, 'prohibition', policy.strict); + result.push(...prohibitionPolicies); + + // Process obligations + const obligationRules = Array.isArray(policy.obligation) ? policy.obligation : [policy.obligation]; + const obligationPolicies = processRules(obligationRules, 'obligation', policy.strict); + result.push(...obligationPolicies); + } + + return result; +} + +/** + * Get governance policy for a specific semantic ID + */ +export function getGovernancePolicyForSemanticId( + governanceConfig: GovernanceConfig[], + semanticId: string +): OdrlPolicy[] { + return generateOdrlPolicies(governanceConfig, semanticId); +} + +/** + * Get default governance policy (first configuration) + */ +export function getDefaultGovernancePolicy( + governanceConfig: GovernanceConfig[] +): OdrlPolicy[] { + return generateOdrlPolicies(governanceConfig); +} diff --git a/ichub-frontend/src/vite-env.d.ts b/ichub-frontend/src/vite-env.d.ts index a48a8541..40a1887b 100644 --- a/ichub-frontend/src/vite-env.d.ts +++ b/ichub-frontend/src/vite-env.d.ts @@ -21,3 +21,14 @@ ********************************************************************************/ /// + +declare global { + interface Window { + ENV?: { + GOVERNANCE_CONFIG?: string; + DTR_POLICIES_CONFIG?: string; + } + } +} + +export {}; diff --git a/ichub-frontend/vite.config.ts b/ichub-frontend/vite.config.ts index 7fcc3298..d9fb21d6 100644 --- a/ichub-frontend/vite.config.ts +++ b/ichub-frontend/vite.config.ts @@ -27,9 +27,82 @@ import react from '@vitejs/plugin-react-swc' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, -}); + build: { + // Optimize build performance and chunk sizes + target: 'esnext', + minify: 'esbuild', + + // Increase chunk size warning limit to 1MB (from default 500kB) + chunkSizeWarningLimit: 1000, + + // Optimize for faster builds in Docker + reportCompressedSize: false, // Skip gzip size reporting for faster builds + + rollupOptions: { + output: { + // Automatic chunking based on file patterns - no manual maintenance needed + manualChunks: (id) => { + // Vendor chunks + if (id.includes('node_modules')) { + // Split large vendor libraries into separate chunks + if (id.includes('@mui')) return 'mui'; + if (id.includes('react-router')) return 'router'; + if (id.includes('react') || id.includes('react-dom')) return 'react'; + return 'vendor'; + } + + // Feature-based chunking - automatically handles new features + if (id.includes('/features/')) { + const featureName = id.split('/features/')[1]?.split('/')[0]; + if (featureName) return `feature-${featureName}`; + } + + // Page-based chunking - automatically handles new pages + if (id.includes('/pages/')) { + const pageName = id.split('/pages/')[1]?.split('.')[0]; + if (pageName) return `page-${pageName}`; + } + + // Component-based chunking for large component directories + if (id.includes('/components/') && id.includes('/part-discovery/')) { + return 'components-part-discovery'; + } + } + } + }, + + // Disable source maps in production for faster builds + sourcemap: false + }, + + // Optimize dev server + server: { + hmr: { + overlay: false + } + }, + + // Optimize dependency pre-bundling + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-router-dom', + '@mui/material', + '@mui/icons-material' + ] + }, + + // Additional optimizations for Docker builds + esbuild: { + target: 'esnext', + // Drop console logs in production + drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [] + } +})