diff --git a/index.html b/index.html index 9156cbe7..3bb5b224 100644 --- a/index.html +++ b/index.html @@ -3,9 +3,9 @@ - + - MCP + ManagedControlPlane UI diff --git a/public/co-logo-orchestrating-dark.png b/public/co-logo-orchestrating-dark.png new file mode 100644 index 00000000..6d4ddd0b Binary files /dev/null and b/public/co-logo-orchestrating-dark.png differ diff --git a/public/co-logo-orchestrating.png b/public/co-logo-orchestrating.png index 93addb7e..b98d4b26 100644 Binary files a/public/co-logo-orchestrating.png and b/public/co-logo-orchestrating.png differ diff --git a/public/eso_dark.png b/public/eso_dark.png new file mode 100644 index 00000000..7e9dc54d Binary files /dev/null and b/public/eso_dark.png differ diff --git a/public/eso_light.png b/public/eso_light.png new file mode 100644 index 00000000..7cb6bba7 Binary files /dev/null and b/public/eso_light.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..65492784 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/kyverno.svg b/public/kyverno.svg new file mode 100644 index 00000000..4f12a782 --- /dev/null +++ b/public/kyverno.svg @@ -0,0 +1,4515 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/public/locales/en.json b/public/locales/en.json index e24cb086..8e058860 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -15,6 +15,9 @@ "ComponentList": { "tableComponentHeader": "Name", "tableVersionHeader": "Version" + }, + "MemberList": { + }, "FluxList": { "tableNameHeader": "Name", @@ -23,6 +26,7 @@ "tableVersionHeader": "Revision", "noFluxError": "Please install flux to view this component", "gitOpsTitle": "GitOps", + "repositoriesTitle": "Repositories", "kustomizationsTitle": "Kustomizations", "undefinedError": "Something went wrong" }, @@ -346,6 +350,7 @@ "btp": "BTP", "error": "Error", "ready": "Ready", + "notReady": "Not Ready", "synced": "Synced", "healthy": "Healthy", "installed": "Installed", @@ -354,7 +359,14 @@ "unhealthy": "Unhealthy", "progress": "Managed", "remaining": "Remaining", - "active": "Active" + "active": "Active", + "inactive": "Inactive", + "comingSoon": "Coming soon...", + "providers": "Provider", + "health": "Health", + "serviceaccounts": "ServiceAccounts", + "loading": "Loading...", + "resources": "Status" }, "errors": { "installError": "Install error", @@ -390,44 +402,48 @@ "Hints": { "CrossplaneHint": { "title": "Crossplane", - "subtitle": "Managed Resources Readiness", + "subtitle": "Manage your cloud landscape in code", "activeStatus": "Active v", - "progressAvailable": "% Available", + "progressAvailable": "Available", "noResources": "No Resources", "inactive": "Inactive", "activate": "Activate", - "healthy": "Healthy", - "hoverContent": { - "totalResources": "Total Resources", - "healthy": "Healthy", - "creating": "Creating", - "failing": "Failing" - } + "healthy": "Healthy" }, "GitOpsHint": { "title": "Flux", - "subtitle": "GitOps Progress", + "subtitle": "Persist desired state in code repositories", "activeStatus": "Active v", - "progressAvailable": "% Available", + "progressAvailable": "Available", "noResources": "No Resources", "inactive": "Inactive", "activate": "Activate", - "managed": "Managed", - "hoverContent": { - "totalResources": "Total Resources", - "managed": "Managed", - "unmanaged": "Unmanaged" - } + "managed": "Persisted" }, - "VaultHint": { - "title": "Vault", - "subtitle": "Rotating Secrets Progress", + "MembersHint": { + "title": "Members", + "subtitle": "Manage access to your Managed Control Plane", + "users": "Users", + "serviceAccounts": "Service Accounts" + }, + "ESOHint": { + "title": "External Secrets", + "subtitle": "Secure secrets with rotation", "activeStatus": "Active v", - "progressAvailable": "% Available", + "progressAvailable": "Available", "noResources": "No Resources", "inactive": "Coming soon...", "activate": "Activate" }, + "KyvernoHint": { + "title": "Kyverno", + "subtitle": "Enforce policies", + "activeStatus": "Active v", + "progressAvailable": "Available", + "noResources": "No Resources", + "inactive": "Inactive", + "activate": "Activate" + }, "common": { "loading": "Loading...", "errorLoadingResources": "Error loading resources", diff --git a/public/logo.png b/public/logo.png index 28d703df..a2c2347a 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/public/members.svg b/public/members.svg new file mode 100644 index 00000000..63d1b4aa --- /dev/null +++ b/public/members.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/vault.png b/public/vault.png deleted file mode 100644 index 6a3bdae4..00000000 Binary files a/public/vault.png and /dev/null differ diff --git a/src/components/BentoGrid/BentoGrid.module.css b/src/components/BentoGrid/BentoGrid.module.css new file mode 100644 index 00000000..3ca0ada9 --- /dev/null +++ b/src/components/BentoGrid/BentoGrid.module.css @@ -0,0 +1,92 @@ +/* Bento Grid Layout - 12 columns x 6 rows */ +.bentoGrid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-template-rows: repeat(6, 1fr); + gap: 24px; + width: 100%; + height: 600px; /* Fixed height for the entire grid */ + position: relative; +} + +/* Base card styling */ +.bentoCard { + border-radius: 18px; /* Increased to match or slightly exceed ui5-card */ + overflow: hidden; + background: var(--sapTile_Background); + border: 1px solid var(--sapTile_BorderColor); + box-shadow: var(--sapContent_Shadow0); /* Reduced shadow intensity */ + transition: all 0.3s ease; + display: flex; + flex-direction: column; + position: relative; +} + +.bentoCard:hover { + box-shadow: var(--sapContent_Shadow1); /* Reduced hover shadow intensity */ + transform: translateY(-2px); +} + +/* Card sizes are explicitly positioned via gridColumn/gridRow props */ +/* Layout: + - Left: Extra large graph (8 cols x 4 rows) + Large crossplane (8 cols x 2 rows) + - Right: Two medium GitOps (4 cols x 2 rows each) + Two small underneath (2 cols x 2 rows each) +*/ + +/* Fallback positioning for cards in case the layout breaks */ +.card-small { + grid-column: span 2; + grid-row: span 2; +} + +.card-medium { + grid-column: span 3; + grid-row: span 2; +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .bentoCard { + background: var(--sapTile_Background); + border-color: var(--sapTile_BorderColor); + } +} + +/* Responsive behavior - simplified as requested */ +@media (max-width: 768px) { + .bentoGrid { + grid-template-columns: repeat(6, 1fr); + grid-template-rows: repeat(10, 1fr); + height: 1000px; + } + + .card-extra-large { + grid-column: 1 / 7; + grid-row: 1 / 5; + } + + .card-large { + grid-column: 1 / 7; + grid-row: 5 / 7; + } + + .card-medium:nth-of-type(3) { + grid-column: 1 / 7; + grid-row: 7 / 9; + } + + .card-medium:nth-of-type(4) { + grid-column: 1 / 7; + grid-row: 9 / 11; + } + + .card-small:nth-of-type(5) { + grid-column: 1 / 4; + grid-row: 11 / 13; + } + + .card-small:nth-of-type(6) { + grid-column: 4 / 7; + grid-row: 11 / 13; + } +} diff --git a/src/components/BentoGrid/BentoGrid.tsx b/src/components/BentoGrid/BentoGrid.tsx new file mode 100644 index 00000000..d8c9071a --- /dev/null +++ b/src/components/BentoGrid/BentoGrid.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styles from './BentoGrid.module.css'; + +export type CardSize = 'small' | 'medium' | 'large' | 'extra-large'; + +export interface BentoCardProps { + size: CardSize; + children: React.ReactNode; + className?: string; + gridColumn?: string; + gridRow?: string; +} + +export interface BentoGridProps { + children: React.ReactNode; + className?: string; +} + +export const BentoCard: React.FC = ({ size, children, className = '', gridColumn, gridRow }) => { + const cardClass = `${styles.bentoCard} ${styles[`card-${size}`]} ${className}`; + + const style: React.CSSProperties = {}; + if (gridColumn) style.gridColumn = gridColumn; + if (gridRow) style.gridRow = gridRow; + + return ( +
+ {children} +
+ ); +}; + +export const BentoGrid: React.FC = ({ children, className = '' }) => { + return
{children}
; +}; diff --git a/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.module.css b/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.module.css new file mode 100644 index 00000000..d1475648 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.module.css @@ -0,0 +1,60 @@ +.container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.card { + height: 100%; + display: flex; + flex-direction: column; +} + +.disabled { + opacity: 0.6; + user-select: none; + pointer-events: none; +} + +.clickable { + cursor: pointer; +} + +.disabledOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.1); + z-index: 1; + pointer-events: none; +} + +.avatar { + width: 50px; + height: 50px; + border-radius: 50%; + background: transparent; + object-fit: cover; +} + +.expandButton { + position: absolute; + bottom: 8px; + right: 8px; + min-width: 32px; + height: 32px; + z-index: 10; +} + +.expandButtonSmall { + position: absolute; + bottom: 8px; + right: 8px; + min-width: 24px; + height: 24px; + z-index: 10; +} \ No newline at end of file diff --git a/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.tsx b/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.tsx new file mode 100644 index 00000000..06d8ba7b --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/BaseCard/BaseCard.tsx @@ -0,0 +1,108 @@ +import { Card, CardHeader, Button } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import cx from 'clsx'; +import { ReactNode } from 'react'; +import styles from './BaseCard.module.css'; + +type CardState = 'active' | 'inactive' | 'coming-soon'; + +interface BaseCardProps { + title: string; + subtitle?: string; + iconSrc: string; + iconAlt: string; + iconStyle?: React.CSSProperties; + version?: string; + enabled: boolean; + cardState?: CardState; // New prop to handle card states + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; + children: ReactNode; +} + +export const BaseCard = ({ + title, + subtitle, + iconSrc, + iconAlt, + iconStyle, + version, + enabled, + cardState = 'active', + onClick, + expanded = false, + size = 'medium', + children, +}: BaseCardProps) => { + const { t } = useTranslation(); + + // Determine if card should be interactive + const isInteractive = enabled && cardState === 'active'; + + // Determine version display logic + const shouldShowVersion = enabled && cardState === 'active' && version; + const versionInSubtitle = size === 'small' && shouldShowVersion; + const versionInAdditionalText = !versionInSubtitle && shouldShowVersion; + + // Determine subtitle content + const getSubtitleContent = () => { + if (size === 'small') { + if (cardState === 'inactive') return t('common.inactive'); + if (cardState === 'coming-soon') return t('common.comingSoon'); + if (versionInSubtitle) return `v${version}`; + return undefined; + } + return subtitle; + }; + + return ( +
+ + } + titleText={title} + subtitleText={getSubtitleContent()} + interactive={isInteractive} + /> + } + className={cx(styles.card, { + [styles.disabled]: !enabled || cardState !== 'active', + [styles.clickable]: !!onClick && isInteractive, + })} + onClick={isInteractive ? onClick : undefined} + > + {(!enabled || cardState !== 'active') &&
} + + {onClick && isInteractive && ( +
+ ); +}; + +export type { CardState }; diff --git a/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.module.css b/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.module.css new file mode 100644 index 00000000..96e9c923 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.module.css @@ -0,0 +1,54 @@ +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.contentContainerMultiple { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBarContainerSmall { + display: flex; + gap: 6px; + width: 100%; + max-width: 400px; + padding: 0 0.5rem; +} + +.progressBarContainerMedium { + display: flex; + gap: 6px; + width: 100%; + max-width: 600px; + padding: 0 0.75rem; +} + +.progressBarContainerLarge { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: none; + padding: 0 1rem; +} + +.progressBar { + width: 100%; +} \ No newline at end of file diff --git a/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.tsx b/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.tsx new file mode 100644 index 00000000..0681b79a --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard.tsx @@ -0,0 +1,171 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BaseCard } from '../BaseCard/BaseCard'; +import { MultiPercentageBar } from '../../MultiPercentageBar/MultiPercentageBar'; +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; +import { calculateCrossplaneSegments, calculateProviderDistribution } from './crossplaneCalculations'; +import { useProvidersConfigResource } from '../../../../lib/api/useApiResource'; +import styles from './CrossplaneCard.module.css'; + +interface CrossplaneCardProps { + allItems: ManagedResourceItem[]; + enabled: boolean; + version?: string; + isLoading?: boolean; + error?: APIError; + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; +} + +export const CrossplaneCard = ({ + allItems = [], + enabled, + version, + isLoading = false, + error, + onClick, + expanded = false, + size = 'medium', +}: CrossplaneCardProps) => { + const { t } = useTranslation(); + const [isProviderChartHovered, setIsProviderChartHovered] = useState(false); + const [isHealthChartHovered, setIsHealthChartHovered] = useState(false); + + // Show labels continuously only when explicitly expanded (coupled to expansion button) + const shouldShowLabelsAlways = expanded; + + // Fetch provider configs for distribution calculation + const { data: providerConfigsList } = useProvidersConfigResource({ + refreshInterval: 60000, + }); + + const crossplaneState = useMemo( + () => calculateCrossplaneSegments(allItems, isLoading, error, enabled, t), + [allItems, isLoading, error, enabled, t], + ); + + // Calculate provider distribution for secondary bar + const providerDistribution = useMemo( + () => calculateProviderDistribution(allItems, providerConfigsList || []), + [allItems, providerConfigsList], + ); + + const secondarySegments = providerDistribution.segments; + + return ( + +
+ {/* Health chart container - now primary */} +
+
setIsHealthChartHovered(true) : undefined} + onMouseLeave={crossplaneState.hasData ? () => setIsHealthChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label}${segment.count !== undefined ? ` (${segment.count})` : ''}` : (segment.count || 0) > 0 ? `${segment.count}` : '', // Status (count) - percentage handled by component + segmentLabelColor: (segment.color === '#e9e9e9ff') ? 'black' : 'white' + }))} + className={styles.progressBar} + showOnlyNonZero={crossplaneState.showOnlyNonZero ?? true} + isHealthy={crossplaneState.isHealthy} + barWidth={size === 'medium' ? '80%' : '90%'} + barHeight={size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, // Never show automatic percentage since we're using primaryLabelValue + showCount: false, + primaryLabelText: isLoading ? t('Hints.common.loading') : 'Health', + primaryLabelValue: isLoading ? undefined : `${crossplaneState.healthyPercentage || 0}%`, + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} + showLabels={crossplaneState.hasData && (shouldShowLabelsAlways || isHealthChartHovered)} + minSegmentWidthForLabel={12} + /> +
+
+ + {/* Provider chart container - now secondary */} + {(size === 'medium' || size === 'large' || size === 'extra-large') && ( +
+
0 ? () => setIsProviderChartHovered(true) : undefined} + onMouseLeave={providerDistribution.segments.length > 0 ? () => setIsProviderChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label} (${segment.count})` : (segment.count || 0) > 0 ? `${segment.count}` : '', // Provider name (count) - percentage handled by component + segmentLabelColor: (segment.color === '#e9e9e9ff') ? 'black' : 'white' + })) + } + className={styles.progressBar} + showOnlyNonZero={true} + barWidth={size === 'medium' ? '80%' : '90%'} + barHeight={size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, // Don't show percentage in primary label, only in segments + primaryLabelText: isLoading ? t('Hints.common.loading') : t('common.providers'), + primaryLabelValue: isLoading ? undefined : providerDistribution.totalProviders, + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} // Show segment labels only on hover + showLabels={providerDistribution.segments.length > 0 && (shouldShowLabelsAlways || isProviderChartHovered)} // Show continuously when expanded/large or on hover + minSegmentWidthForLabel={12} + /> +
+
+ )} +
+
+ ); +}; diff --git a/src/components/BentoGrid/ComponentCard/CrossplaneCard/crossplaneCalculations.ts b/src/components/BentoGrid/ComponentCard/CrossplaneCard/crossplaneCalculations.ts new file mode 100644 index 00000000..5a60842e --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/CrossplaneCard/crossplaneCalculations.ts @@ -0,0 +1,231 @@ +import { ManagedResourceItem, Condition, ProviderConfigs } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; +import { resolveProviderType, generateColorMap } from '../../../Graphs/graphUtils'; +import { NodeData } from '../../../Graphs/types'; + +export const HINT_COLORS = { + healthy: '#28a745', + creating: '#0874f4', + unhealthy: '#d22020ff', + inactive: '#e9e9e9ff', +} as const; + +export interface CrossplaneSegment { + percentage: number; + color: string; + label: string; + count?: number; +} + +export interface CrossplaneState { + segments: CrossplaneSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; + hasData?: boolean; + healthyPercentage?: number; +} + +export const calculateCrossplaneSegments = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): CrossplaneState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + healthyPercentage: 0, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + healthyPercentage: 0, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.inactive') }], + label: t('Hints.CrossplaneHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + healthyPercentage: 0, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.noResources') }], + label: t('Hints.CrossplaneHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + healthyPercentage: 0, + }; + } + + const healthyCount = allItems.filter((item: ManagedResourceItem) => { + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + return !!ready && !!synced; + }).length; + + const creatingCount = allItems.filter((item: ManagedResourceItem) => { + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + return !!synced && !ready; + }).length; + + const unhealthyCount = totalCount - healthyCount - creatingCount; + const healthyPercentage = Math.round((healthyCount / totalCount) * 100); + const creatingPercentage = Math.round((creatingCount / totalCount) * 100); + const unhealthyPercentage = Math.round((unhealthyCount / totalCount) * 100); + + return { + segments: [ + { percentage: healthyPercentage, color: HINT_COLORS.healthy, label: t('common.healthy'), count: healthyCount }, + { + percentage: creatingPercentage, + color: HINT_COLORS.creating, + label: t('common.creating'), + count: creatingCount, + }, + { + percentage: unhealthyPercentage, + color: HINT_COLORS.unhealthy, + label: t('common.unhealthy'), + count: unhealthyCount, + }, + ], + label: t('Hints.CrossplaneHint.healthy'), + showPercentage: true, + isHealthy: healthyPercentage === 100 && totalCount > 0, + showOnlyNonZero: true, + hasData: true, + healthyPercentage, // Add the healthy percentage to the return value + }; +}; + +export const calculateCrossplaneHealthSegments = ( + allItems: ManagedResourceItem[], + t: (key: string) => string, + enabled: boolean, + isLoading: boolean = false, +) => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.loading') || 'Loading...' }], + healthyPercentage: 0, + isLoading: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.inactive') }], + healthyPercentage: 0, + isInactive: true, + }; + } + + if (!allItems || allItems.length === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.inactive') }], + healthyPercentage: 0, + isInactive: true, + }; + } + + // Count health states for all Crossplane managed resources + const healthyCounts = allItems.filter((item) => + item?.status?.conditions?.some((condition: any) => condition.type === 'Ready' && condition.status === 'True'), + ).length; + + const total = allItems.length; + const healthyPercentage = Math.round((healthyCounts / total) * 100); + const remainingPercentage = 100 - healthyPercentage; + + const segments = [ + { + percentage: healthyPercentage, + color: '#38d4bc', + label: t('common.healthy'), + count: healthyCounts, + }, + remainingPercentage > 0 && { + percentage: remainingPercentage, + color: HINT_COLORS.inactive, + label: t('common.remaining'), + count: total - healthyCounts, + }, + ].filter(Boolean) as { percentage: number; color: string; label: string; count: number }[]; + + return { + segments, + healthyPercentage, + isInactive: false, + }; +}; + +// Utility function to calculate provider distribution with graph colors +export const calculateProviderDistribution = (items: ManagedResourceItem[], providerConfigs: ProviderConfigs[]) => { + if (!items || items.length === 0) return { segments: [], totalProviders: 0 }; + + // Count resources by provider type (same method as graph) + const providerCounts: Record = {}; + + items.forEach((item) => { + const providerConfigName = item?.spec?.providerConfigRef?.name ?? 'unknown'; + const providerType = resolveProviderType(providerConfigName, providerConfigs); + providerCounts[providerType] = (providerCounts[providerType] || 0) + 1; + }); + + // Create NodeData-like objects for color generation (reuse graph's color logic) + const nodeDataForColors = Object.keys(providerCounts).map((providerType) => ({ + providerType, + providerConfigName: '', // Not needed for color generation + fluxName: undefined, + })); + + // Generate colors using the same logic as the graph + const colorMap = generateColorMap(nodeDataForColors as NodeData[], 'source'); + + // Convert to segments with percentages and counts + const total = items.length; + const segments = Object.entries(providerCounts) + .map(([provider, count]) => ({ + percentage: Math.round((count / total) * 100), + color: colorMap[provider] || '#BFBFBF', // fallback color + label: provider.replace('provider-', '').toUpperCase(), + count: count, + })) + .filter((segment) => segment.percentage > 0) + .sort((a, b) => b.percentage - a.percentage); + + return { + segments, + totalProviders: segments.length, + }; +}; diff --git a/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.module.css b/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.module.css new file mode 100644 index 00000000..f0612441 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.module.css @@ -0,0 +1,53 @@ +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.inactiveContent { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 60px; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBarContainerSmall { + display: flex; + gap: 6px; + width: 100%; + max-width: 400px; + padding: 0 0.5rem; +} + +.progressBarContainerMedium { + display: flex; + gap: 6px; + width: 100%; + max-width: 600px; + padding: 0 0.75rem; +} + +.progressBarContainerLarge { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: none; + padding: 0 1rem; +} + +.progressBar { + width: 100%; +} \ No newline at end of file diff --git a/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.tsx b/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.tsx new file mode 100644 index 00000000..2174094f --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/ESOCard/ESOCard.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BaseCard, CardState } from '../BaseCard/BaseCard'; +import { MultiPercentageBar } from '../../MultiPercentageBar/MultiPercentageBar'; +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; +import { calculateESOSegments } from './esoCalculations'; +import styles from './ESOCard.module.css'; + +interface ESOCardProps { + allItems: ManagedResourceItem[]; + enabled: boolean; + version?: string; + isLoading?: boolean; + error?: APIError; + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; + cardState?: CardState; +} + +export const ESOCard = ({ + allItems = [], + enabled, + version, + isLoading = false, + error, + onClick, + expanded = false, + size = 'medium', + cardState = 'coming-soon', // Default to coming-soon for ESO +}: ESOCardProps) => { + const { t } = useTranslation(); + + const esoState = useMemo( + () => calculateESOSegments(allItems, isLoading, error, enabled, t), + [allItems, isLoading, error, enabled, t], + ); + + // Determine title based on size + const getTitle = () => { + if (size === 'small') return 'ESO'; + return 'External Secrets Operator'; + }; + + return ( + +
+
+ +
+
+
+ ); +}; diff --git a/src/components/BentoGrid/ComponentCard/ESOCard/esoCalculations.ts b/src/components/BentoGrid/ComponentCard/ESOCard/esoCalculations.ts new file mode 100644 index 00000000..eb56123d --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/ESOCard/esoCalculations.ts @@ -0,0 +1,83 @@ +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; + +export const HINT_COLORS = { + healthy: '#28a745', + unhealthy: '#d22020ff', + inactive: '#e9e9e9ff', +} as const; + +export interface ESOSegment { + percentage: number; + color: string; + label: string; + count?: number; +} + +export interface ESOState { + segments: ESOSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; +} + +export const calculateESOSegments = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): ESOState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.ESOHint.inactive') }], + label: t('Hints.ESOHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.ESOHint.noResources') }], + label: t('Hints.ESOHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + // TODO: Implement ESO-specific logic + // For now, return a placeholder + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.notImplemented') }], + label: t('Hints.ESOHint.title'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; +}; diff --git a/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.module.css b/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.module.css new file mode 100644 index 00000000..96e9c923 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.module.css @@ -0,0 +1,54 @@ +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.contentContainerMultiple { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBarContainerSmall { + display: flex; + gap: 6px; + width: 100%; + max-width: 400px; + padding: 0 0.5rem; +} + +.progressBarContainerMedium { + display: flex; + gap: 6px; + width: 100%; + max-width: 600px; + padding: 0 0.75rem; +} + +.progressBarContainerLarge { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: none; + padding: 0 1rem; +} + +.progressBar { + width: 100%; +} \ No newline at end of file diff --git a/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.tsx b/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.tsx new file mode 100644 index 00000000..92196cb8 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/FluxCard/FluxCard.tsx @@ -0,0 +1,171 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BaseCard } from '../BaseCard/BaseCard'; +import { MultiPercentageBar } from '../../MultiPercentageBar/MultiPercentageBar'; +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; +import { calculateGitOpsSegments, calculateFluxResourceStatus } from './fluxCalculations'; +import { useApiResource } from '../../../../lib/api/useApiResource'; +import { FluxRequest } from '../../../../lib/api/types/flux/listGitRepo'; +import { FluxKustomization } from '../../../../lib/api/types/flux/listKustomization'; +import styles from './FluxCard.module.css'; + +interface FluxCardProps { + allItems: ManagedResourceItem[]; + enabled: boolean; + version?: string; + isLoading?: boolean; + error?: APIError; + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; +} + +export const FluxCard = ({ + allItems = [], + enabled, + version, + isLoading = false, + error, + onClick, + expanded = false, + size = 'medium', +}: FluxCardProps) => { + const { t } = useTranslation(); + const [isPrimaryChartHovered, setIsPrimaryChartHovered] = useState(false); + const [isResourceChartHovered, setIsResourceChartHovered] = useState(false); + + // Show labels continuously only when explicitly expanded (coupled to expansion button) + const shouldShowLabelsAlways = expanded; + + // Fetch Flux resources for distribution calculation + const { data: gitReposData } = useApiResource(FluxRequest, { refreshInterval: 60000 }); + const { data: kustomizationsData } = useApiResource(FluxKustomization, { refreshInterval: 60000 }); + + const fluxState = useMemo( + () => calculateGitOpsSegments(allItems, isLoading, error, enabled, t), + [allItems, isLoading, error, enabled, t], + ); + + // Calculate Flux resource status for secondary chart + const fluxResourceStatus = useMemo( + () => calculateFluxResourceStatus( + gitReposData?.items || [], + kustomizationsData?.items || [], + t + ), + [gitReposData, kustomizationsData, t], + ); + + return ( + +
+ {/* Primary chart container */} +
setIsPrimaryChartHovered(true) : undefined} + onMouseLeave={fluxState.hasData ? () => setIsPrimaryChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label}${segment.count !== undefined ? ` (${segment.count})` : ''}` : (segment.count || 0) > 0 ? `${segment.count}` : '', + segmentLabelColor: (segment.color === '#e9e9e9ff' || segment.label?.toLowerCase().includes('remaining')) ? 'black' : 'white' + }))} + className={styles.progressBar} + showOnlyNonZero={fluxState.showOnlyNonZero ?? true} + isHealthy={fluxState.isHealthy} + barWidth={size === 'small' ? '80%' : size === 'medium' ? '80%' : '90%'} + barHeight={size === 'small' ? '10px' : size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'small' ? '400px' : size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, + showCount: false, + primaryLabelText: isLoading ? t('Hints.common.loading') : fluxState.label, + primaryLabelValue: isLoading ? undefined : (fluxState.hasData && fluxState.segments.length > 0 ? `${fluxState.segments[0]?.percentage || 0}%` : undefined), + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} + showLabels={fluxState.hasData && (shouldShowLabelsAlways || isPrimaryChartHovered)} + minSegmentWidthForLabel={12} + /> +
+ + {/* Flux Resources chart container - rendered below the primary chart */} + {(size === 'medium' || size === 'large' || size === 'extra-large') && ( +
setIsResourceChartHovered(true) : undefined} + onMouseLeave={fluxResourceStatus.hasData ? () => setIsResourceChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label} (${segment.count || 0})` : (segment.count || 0) > 0 ? `${segment.count}` : '', + segmentLabelColor: 'white' + })) + } + className={styles.progressBar} + showOnlyNonZero={true} + barWidth={size === 'medium' ? '80%' : '90%'} + barHeight={size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, + showCount: false, + primaryLabelText: isLoading ? t('Hints.common.loading') : t('common.resources') || 'Resources', + primaryLabelValue: isLoading ? undefined : (fluxResourceStatus.hasData && fluxResourceStatus.segments.length > 0 ? `${fluxResourceStatus.segments[0]?.percentage || 0}%` : undefined), + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} + showLabels={fluxResourceStatus.hasData && (shouldShowLabelsAlways || isResourceChartHovered)} + minSegmentWidthForLabel={12} + /> +
+ )} +
+
+ ); +}; diff --git a/src/components/BentoGrid/ComponentCard/FluxCard/fluxCalculations.ts b/src/components/BentoGrid/ComponentCard/FluxCard/fluxCalculations.ts new file mode 100644 index 00000000..c972fb06 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/FluxCard/fluxCalculations.ts @@ -0,0 +1,257 @@ +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; + +export const HINT_COLORS = { + flux: '#386ce4', + inactive: '#e9e9e9ff', +} as const; + +export interface FluxSegment { + percentage: number; + color: string; + label: string; + count?: number; +} + +export interface FluxState { + segments: FluxSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; + hasData?: boolean; +} + +export const calculateGitOpsSegments = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): FluxState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: '#d22020ff', label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.inactive') }], + label: t('Hints.GitOpsHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.noResources') }], + label: t('Hints.GitOpsHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + const fluxLabelCount = allItems.filter( + (item: ManagedResourceItem) => + item?.metadata?.labels && + Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name'), + ).length; + + const progressValue = totalCount > 0 ? Math.round((fluxLabelCount / totalCount) * 100) : 0; + const restPercentage = 100 - progressValue; + + return { + segments: [ + { percentage: progressValue, color: HINT_COLORS.flux, label: t('common.progress'), count: fluxLabelCount }, + { + percentage: restPercentage, + color: HINT_COLORS.inactive, + label: t('common.remaining'), + count: totalCount - fluxLabelCount, + }, + ], + label: t('Hints.GitOpsHint.managed'), + showPercentage: true, + isHealthy: false, // Don't apply green styling to GitOps labels + showOnlyNonZero: true, + hasData: true, + }; +}; + +export const calculateFluxHealthSegments = ( + allItems: ManagedResourceItem[], + t: (key: string) => string, + enabled: boolean, + isLoading: boolean = false, +) => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.loading') || 'Loading...' }], + healthyPercentage: 0, + isLoading: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.inactive') }], + healthyPercentage: 0, + isInactive: true, + }; + } + + if (!allItems || allItems.length === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.inactive') }], + healthyPercentage: 0, + isInactive: true, + }; + } + + // Filter for Flux-managed items + const fluxManagedItems = allItems.filter( + (item: ManagedResourceItem) => + item?.metadata?.labels && + Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name'), + ); + + if (fluxManagedItems.length === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.inactive') }], + healthyPercentage: 0, + isInactive: true, + }; + } + + // Count health states + const healthyCounts = fluxManagedItems.filter((item) => + item?.status?.conditions?.some((condition: any) => condition.type === 'Ready' && condition.status === 'True'), + ).length; + + const total = fluxManagedItems.length; + const healthyPercentage = Math.round((healthyCounts / total) * 100); + const remainingPercentage = 100 - healthyPercentage; + + const segments = [ + { + percentage: healthyPercentage, + color: HINT_COLORS.flux, + label: t('common.healthy'), + count: healthyCounts, + }, + remainingPercentage > 0 && { + percentage: remainingPercentage, + color: HINT_COLORS.inactive, + label: t('common.remaining'), + count: total - healthyCounts, + }, + ].filter(Boolean) as { percentage: number; color: string; label: string; count: number }[]; + + return { + segments, + healthyPercentage, + isInactive: false, + }; +}; + +export interface FluxResourceStatus { + segments: FluxSegment[]; + totalResources: number; + hasData: boolean; +} + +export const calculateFluxResourceStatus = ( + gitRepos: any[] = [], + kustomizations: any[] = [], + t: (key: string) => string, +): FluxResourceStatus => { + const allResources = [ + ...(gitRepos.map(repo => ({ + ...repo, + type: 'repository' + })) || []), + ...(kustomizations.map(kust => ({ + ...kust, + type: 'kustomization' + })) || []) + ]; + + const totalResources = allResources.length; + + if (totalResources === 0) { + return { + segments: [{ + percentage: 100, + color: HINT_COLORS.inactive, + label: t('Hints.GitOpsHint.noResources') || 'No resources', + count: 0 + }], + totalResources: 0, + hasData: false, + }; + } + + // Count ready vs not ready resources + const readyResources = allResources.filter(resource => { + const readyCondition = resource.status?.conditions?.find((c: any) => c.type === 'Ready'); + return readyCondition?.status === 'True'; + }); + + const notReadyResources = allResources.filter(resource => { + const readyCondition = resource.status?.conditions?.find((c: any) => c.type === 'Ready'); + return readyCondition?.status !== 'True'; + }); + + const readyCount = readyResources.length; + const notReadyCount = notReadyResources.length; + + const segments: FluxSegment[] = []; + + if (readyCount > 0) { + segments.push({ + percentage: Math.round((readyCount / totalResources) * 100), + color: HINT_COLORS.flux, // Blue for Ready (matching primary chart) + label: t('common.ready') || 'Ready', + count: readyCount, + }); + } + + if (notReadyCount > 0) { + segments.push({ + percentage: Math.round((notReadyCount / totalResources) * 100), + color: HINT_COLORS.inactive, // Gray for Not Ready (matching primary chart) + label: t('common.notReady') || 'Not Ready', + count: notReadyCount, + }); + } + + return { + segments, + totalResources, + hasData: true, + }; +}; diff --git a/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.module.css b/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.module.css new file mode 100644 index 00000000..f0612441 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.module.css @@ -0,0 +1,53 @@ +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.inactiveContent { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 60px; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBarContainerSmall { + display: flex; + gap: 6px; + width: 100%; + max-width: 400px; + padding: 0 0.5rem; +} + +.progressBarContainerMedium { + display: flex; + gap: 6px; + width: 100%; + max-width: 600px; + padding: 0 0.75rem; +} + +.progressBarContainerLarge { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: none; + padding: 0 1rem; +} + +.progressBar { + width: 100%; +} \ No newline at end of file diff --git a/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.tsx b/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.tsx new file mode 100644 index 00000000..25d49298 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BaseCard, CardState } from '../BaseCard/BaseCard'; +import { MultiPercentageBar } from '../../MultiPercentageBar/MultiPercentageBar'; +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; +import { calculateKyvernoSegments } from './kyvernoCalculations'; +import styles from './KyvernoCard.module.css'; + +interface KyvernoCardProps { + allItems: ManagedResourceItem[]; + enabled: boolean; + version?: string; + isLoading?: boolean; + error?: APIError; + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; + cardState?: CardState; +} + +export const KyvernoCard = ({ + allItems = [], + enabled, + version, + isLoading = false, + error, + onClick, + expanded = false, + size = 'medium', + cardState = 'coming-soon', // Default to coming-soon for Kyverno +}: KyvernoCardProps) => { + const { t } = useTranslation(); + + const kyvernoState = useMemo( + () => calculateKyvernoSegments(allItems, isLoading, error, enabled, t), + [allItems, isLoading, error, enabled, t], + ); + + return ( + +
+
+ +
+
+
+ ); +}; diff --git a/src/components/BentoGrid/ComponentCard/KyvernoCard/kyvernoCalculations.ts b/src/components/BentoGrid/ComponentCard/KyvernoCard/kyvernoCalculations.ts new file mode 100644 index 00000000..37936038 --- /dev/null +++ b/src/components/BentoGrid/ComponentCard/KyvernoCard/kyvernoCalculations.ts @@ -0,0 +1,83 @@ +import { ManagedResourceItem } from '../../../../lib/shared/types'; +import { APIError } from '../../../../lib/api/error'; + +export const HINT_COLORS = { + healthy: '#28a745', + unhealthy: '#d22020ff', + inactive: '#e9e9e9ff', +} as const; + +export interface KyvernoSegment { + percentage: number; + color: string; + label: string; + count?: number; +} + +export interface KyvernoState { + segments: KyvernoSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; +} + +export const calculateKyvernoSegments = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): KyvernoState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.KyvernoHint.inactive') }], + label: t('Hints.KyvernoHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.KyvernoHint.noResources') }], + label: t('Hints.KyvernoHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + // TODO: Implement Kyverno-specific logic + // For now, return a placeholder + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('common.notImplemented') }], + label: t('Hints.KyvernoHint.title'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; +}; diff --git a/src/components/BentoGrid/GraphCard/GraphCard.module.css b/src/components/BentoGrid/GraphCard/GraphCard.module.css new file mode 100644 index 00000000..3d911f2d --- /dev/null +++ b/src/components/BentoGrid/GraphCard/GraphCard.module.css @@ -0,0 +1,28 @@ +.container { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + background: var(--sapTile_Background); + border: 1px solid var(--sapTile_BorderColor); + border-radius: 12px; + overflow: hidden; +} + +.simpleWrapper { + height: 100%; + width: 100%; + flex: 1; + position: relative; + z-index: 1; + pointer-events: auto !important; + overflow: hidden; +} + +/* Override the Graph component's fixed height globally within this wrapper */ +.simpleWrapper > div { + height: 100% !important; + border: none !important; + border-radius: 0 !important; + background: transparent !important; +} diff --git a/src/components/BentoGrid/GraphCard/GraphCard.tsx b/src/components/BentoGrid/GraphCard/GraphCard.tsx new file mode 100644 index 00000000..c031b76a --- /dev/null +++ b/src/components/BentoGrid/GraphCard/GraphCard.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Graph from '../../Graphs/Graph'; +import styles from './GraphCard.module.css'; +import { ColorBy } from '../../Graphs/types'; + +export interface GraphCardProps { + title?: string; + className?: string; + colorBy?: ColorBy; +} + +export const GraphCard: React.FC = ({ className = '', colorBy = 'source' }) => { + return ( +
+
+ +
+
+ ); +}; diff --git a/src/components/BentoGrid/GraphCard/index.ts b/src/components/BentoGrid/GraphCard/index.ts new file mode 100644 index 00000000..4ee686ce --- /dev/null +++ b/src/components/BentoGrid/GraphCard/index.ts @@ -0,0 +1,2 @@ +export { GraphCard } from './GraphCard'; +export type { GraphCardProps } from './GraphCard'; diff --git a/src/components/BentoGrid/MembersCard/MembersCard.module.css b/src/components/BentoGrid/MembersCard/MembersCard.module.css new file mode 100644 index 00000000..96e9c923 --- /dev/null +++ b/src/components/BentoGrid/MembersCard/MembersCard.module.css @@ -0,0 +1,54 @@ +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.contentContainerMultiple { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 0.125rem 0; + flex: 1; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBarContainerSmall { + display: flex; + gap: 6px; + width: 100%; + max-width: 400px; + padding: 0 0.5rem; +} + +.progressBarContainerMedium { + display: flex; + gap: 6px; + width: 100%; + max-width: 600px; + padding: 0 0.75rem; +} + +.progressBarContainerLarge { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: none; + padding: 0 1rem; +} + +.progressBar { + width: 100%; +} \ No newline at end of file diff --git a/src/components/BentoGrid/MembersCard/MembersCard.tsx b/src/components/BentoGrid/MembersCard/MembersCard.tsx new file mode 100644 index 00000000..3e0d6d58 --- /dev/null +++ b/src/components/BentoGrid/MembersCard/MembersCard.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BaseCard } from '../ComponentCard/BaseCard/BaseCard'; +import { MultiPercentageBar } from '../MultiPercentageBar/MultiPercentageBar'; +import { APIError } from '../../../lib/api/error'; +import { calculateMembersSegments, MemberItem } from './membersCalculations'; +import styles from './MembersCard.module.css'; + +interface MembersCardProps { + allItems: MemberItem[]; + enabled: boolean; + version?: string; + isLoading?: boolean; + error?: APIError; + onClick?: () => void; + expanded?: boolean; + size?: 'small' | 'medium' | 'large' | 'extra-large'; +} + +export const MembersCard = ({ + allItems = [], + enabled, + version, + isLoading = false, + error, + onClick, + expanded = false, + size = 'medium', +}: MembersCardProps) => { + const { t } = useTranslation(); + const [isUsersChartHovered, setIsUsersChartHovered] = useState(false); + const [isServiceAccountsChartHovered, setIsServiceAccountsChartHovered] = useState(false); + + // Show labels continuously only when explicitly expanded (coupled to expansion button) + const shouldShowLabelsAlways = expanded; + + const usersState = useMemo( + () => calculateMembersSegments(allItems, isLoading, error, enabled, t, 'user'), + [allItems, isLoading, error, enabled, t], + ); + + const serviceAccountsState = useMemo( + () => calculateMembersSegments(allItems, isLoading, error, enabled, t, 'serviceaccount'), + [allItems, isLoading, error, enabled, t], + ); + + return ( + +
+ {/* Users chart container */} +
setIsUsersChartHovered(true) : undefined} + onMouseLeave={usersState.hasData ? () => setIsUsersChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label}${segment.count !== undefined ? ` (${segment.count})` : ''}` : (segment.count || 0) > 0 ? `${segment.count}` : '', + segmentLabelColor: (segment.color === '#e9e9e9ff') ? 'black' : 'white' + }))} + className={styles.progressBar} + showOnlyNonZero={usersState.showOnlyNonZero ?? true} + isHealthy={usersState.isHealthy} + barWidth={size === 'small' ? '80%' : size === 'medium' ? '80%' : '90%'} + barHeight={size === 'small' ? '10px' : size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'small' ? '400px' : size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, + showCount: false, + primaryLabelText: isLoading ? t('Hints.common.loading') : usersState.label, + primaryLabelValue: isLoading ? undefined : (usersState.hasData ? usersState.totalCount : undefined), + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} + showLabels={usersState.hasData && (shouldShowLabelsAlways || isUsersChartHovered)} + minSegmentWidthForLabel={12} + /> +
+ + {/* Service Accounts chart container - rendered below the users chart */} + {(size === 'medium' || size === 'large' || size === 'extra-large') && ( +
setIsServiceAccountsChartHovered(true) : undefined} + onMouseLeave={serviceAccountsState.hasData ? () => setIsServiceAccountsChartHovered(false) : undefined} + > + ({ + ...segment, + segmentLabel: segment.percentage > 15 ? `${segment.label}${segment.count !== undefined ? ` (${segment.count})` : ''}` : (segment.count || 0) > 0 ? `${segment.count}` : '', + segmentLabelColor: (segment.color === '#e9e9e9ff') ? 'black' : 'white' + })) + } + className={styles.progressBar} + showOnlyNonZero={serviceAccountsState.showOnlyNonZero ?? true} + isHealthy={serviceAccountsState.isHealthy} + barWidth={size === 'medium' ? '80%' : '90%'} + barHeight={size === 'medium' ? '16px' : '18px'} + barMaxWidth={size === 'medium' ? '500px' : 'none'} + labelConfig={{ + position: 'above', + displayMode: 'primary', + showPercentage: false, + showCount: false, + primaryLabelText: isLoading ? t('Hints.common.loading') : serviceAccountsState.label, + primaryLabelValue: isLoading ? undefined : (serviceAccountsState.hasData ? serviceAccountsState.totalCount : undefined), + hideWhenSingleFull: false, + fontWeight: isLoading ? 'normal' : 'bold', + }} + animationConfig={{ + enableWave: size !== 'medium', + enableTransitions: size !== 'medium', + duration: size === 'medium' ? 0 : 400, + staggerDelay: size === 'medium' ? 0 : 100, + }} + showSegmentLabels={false} + showSegmentLabelsOnHover={true} + showLabels={serviceAccountsState.hasData && (shouldShowLabelsAlways || isServiceAccountsChartHovered)} + minSegmentWidthForLabel={12} + /> +
+ )} +
+
+ ); +}; diff --git a/src/components/BentoGrid/MembersCard/membersCalculations.ts b/src/components/BentoGrid/MembersCard/membersCalculations.ts new file mode 100644 index 00000000..a8342677 --- /dev/null +++ b/src/components/BentoGrid/MembersCard/membersCalculations.ts @@ -0,0 +1,116 @@ +import { APIError } from '../../../lib/api/error'; + +export const HINT_COLORS = { + roles: '#08848c', + inactive: '#e9e9e9ff', + unhealthy: '#d22020ff', +} as const; + +export interface MemberItem { + role?: string; + type?: 'user' | 'serviceaccount'; +} + +export interface MemberSegment { + percentage: number; + color: string; + label: string; + count?: number; +} + +export interface MemberState { + segments: MemberSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; + totalCount?: number; + hasData?: boolean; +} + +export const calculateMembersSegments = ( + allItems: MemberItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, + memberType: 'user' | 'serviceaccount' = 'user', +): MemberState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.MembersHint.inactive') }], + label: t('Hints.MembersHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + // Filter items by type + const filteredItems = allItems.filter((item) => (item?.type || 'user') === memberType); + + const totalCount = filteredItems.length; + + if (totalCount === 0) { + const labelKey = memberType === 'user' ? 'noMembers' : 'noServiceAccounts'; + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t(`Hints.MembersHint.${labelKey}`) || (memberType === 'user' ? 'No members' : 'No service accounts') }], + label: memberType === 'user' ? 'Users' : 'Service Accounts', + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + hasData: false, + }; + } + + // Count the number of roles and their distribution + const roleCounts: Record = {}; + + filteredItems.forEach((item: MemberItem) => { + const role = item?.role || 'unknown'; + roleCounts[role] = (roleCounts[role] || 0) + 1; + }); + + const segments = Object.entries(roleCounts) + .map(([role, count]) => ({ + percentage: Math.round((count / totalCount) * 100), + color: HINT_COLORS.roles, // All roles use the same teal color + label: role.charAt(0).toUpperCase() + role.slice(1), + count, + })) + .filter((segment) => segment.percentage > 0) + .sort((a, b) => b.percentage - a.percentage); + + const labelText = memberType === 'user' ? 'Users' : 'Service Accounts'; + + return { + segments, + label: labelText, + showPercentage: true, + isHealthy: false, // Changed to false to prevent green styling + showOnlyNonZero: true, + totalCount, // Add totalCount to the state for separate display + hasData: true, + }; +}; diff --git a/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.module.css b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.module.css new file mode 100644 index 00000000..6761a53d --- /dev/null +++ b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.module.css @@ -0,0 +1,341 @@ +/* CSS Variables for customization */ +.container { + --animation-duration: 600ms; + --bar-width: 80%; + --bar-max-width: 400px; + --bar-height: 16px; + --gap: 2px; + --border-radius: 6px; + --label-font-size: 0.875rem; + --background-color: transparent; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding-bottom: 4px; + + /* Initial animation */ + animation: fadeInUp var(--animation-duration) ease-out; +} + +/* Respect user's motion preferences */ +@media (prefers-reduced-motion: reduce) { + .container { + animation: fadeIn var(--animation-duration) ease-out; + } + + .segment { + transition: none !important; + } + + .waveOverlay { + animation: none !important; + } + + .percentage { + animation: none !important; + } +} + +/* Label styling */ +.labelContainer { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: left; + width: var(--bar-width); +} + +.labelGroup { + display: flex; + align-items: center; + gap: 6px; +} + +.label, +.percentage, +.count { + font-size: var(--label-font-size); + font-weight: 400; + color: var(--sapTextColor, #374151); + transition: color 0.3s ease, font-weight 0.3s ease; +} + +.label.healthy { + color: var(--healthy-color, #28a745); + font-weight: 700; +} + +.percentage.healthy { + color: var(--healthy-color, #28a745); + font-weight: 400 !important; +} + +.count.healthy { + color: var(--healthy-color, #28a745); + font-weight: 400 !important; +} + +.count { + opacity: 0.7; + font-size: calc(var(--label-font-size) * 0.9); +} + +/* Bar container */ +.barContainer { + display: flex; + gap: var(--gap); + width: var(--bar-width); + max-width: var(--bar-max-width); + background-color: var(--background-color); + border-radius: 0; + padding: 2px; + overflow: hidden; + position: relative; +} + +/* Animated reveal overlay for continuous animation */ +.barContainer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--background-color, transparent); + z-index: 1; + transform: translateX(-100%); + animation: revealBar var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1) forwards; +} +.barContainer > :first-child { + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); +} +.barContainer > :last-child { + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); +} + + +/* Individual segments */ +.segment { + flex: var(--segment-percentage); + min-width: 10px; + background-color: var(--segment-color); + height: var(--bar-height); + position: relative; + overflow: hidden; + + /* Smooth transitions for flex changes */ + transition: flex var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Wave animation overlay */ +.waveOverlay { + position: absolute; + top: 0; + left: -80%; + width: 80%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.15) 25%, + rgba(255, 255, 255, 0.25) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 100%); + border-radius: var(--border-radius); + pointer-events: none; + + /* Continuous wave animation */ + animation: wave 3s ease-in-out infinite; + animation-delay: var(--animation-duration); +} + +/* Keyframe animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes growWidth { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +@keyframes wave { + 0% { + left: -80%; + } + 50% { + left: 100%; + } + 100% { + left: -80%; + } +} + +@keyframes revealBar { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +/* Segment label inside the bar */ +.segmentLabel { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + color: var(--segment-label-color, white); + font-size: var(--segment-label-font-size, 0.75rem); + font-weight: var(--segment-label-font-weight, 500); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + white-space: nowrap; + pointer-events: none; + z-index: 2; + opacity: 0; + transition: opacity 0.3s ease; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 12px); /* Account for left and right padding */ +} + +/* Truncation variants */ +.segmentLabel[data-truncation="left"] { + direction: rtl; + text-align: right; /* Keep right alignment but use RTL for left truncation */ + text-overflow: ellipsis; +} + +.segmentLabel[data-truncation="right"] { + direction: ltr; + text-align: right; + text-overflow: ellipsis; +} + +.segmentLabel[data-truncation="middle"] { + direction: ltr; + text-align: right; + text-overflow: ellipsis; +} + +/* Enhanced truncation support with better fallbacks */ +.segmentLabel { + /* Use CSS text-overflow as a fallback for very small segments */ + min-width: 0; /* Allow shrinking */ + flex-shrink: 1; /* Allow flexible shrinking */ +} + +/* For right-to-left truncation (left truncation), we use a special technique */ +.segmentLabel[data-truncation="left"]::before { + content: ''; + float: right; + width: 0; + white-space: nowrap; +} + +/* Ensure text remains readable on different backgrounds */ +.segmentLabel { + /* Add subtle outline for better readability */ + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.3), + 1px 0 1px rgba(0, 0, 0, 0.2), + -1px 0 1px rgba(0, 0, 0, 0.2); +} + +/* Show segment labels on card hover */ +.showLabelsOnHover .segmentLabel { + opacity: 0; +} + +/* This will be triggered by parent card hover */ +.showLabelsOnHover.showLabels .segmentLabel { + opacity: 1; +} + +/* Direct control for showing labels */ +.showLabels .segmentLabel { + opacity: 1; +} + +/* Default behavior - labels show with animation */ +.container:not(.showLabelsOnHover) .segmentLabel { + animation: fadeInLabel calc(var(--animation-duration) * 1.5) ease-out forwards; + animation-delay: calc(var(--animation-duration) * 0.5); +} + +@keyframes fadeInLabel { + from { + opacity: 0; + transform: translateY(-50%) scale(0.8); + } + to { + opacity: 1; + transform: translateY(-50%) scale(1); + } +} + + + +/* No animation class for disabled animations */ +.noAnimation { + animation: none !important; +} + +.noAnimation .barContainer::before { + animation: none !important; + transform: translateX(100%) !important; +} + +.noAnimation .segment { + animation: none !important; + transform: scaleX(1) !important; +} + +.noAnimation .segmentLabel { + animation: none !important; + opacity: 1 !important; + transform: translateY(-50%) scale(1) !important; +} + +.noAnimation .waveOverlay { + animation: none !important; +} + +/* Theme Support */ +[data-ui5-theme-root*="dark"] .container, +[data-ui5-theme*="dark"] .container { + --background-color: transparent; +} + +[data-ui5-theme-root*="dark"] .label, +[data-ui5-theme*="dark"] .label, +[data-ui5-theme-root*="dark"] .percentage, +[data-ui5-theme*="dark"] .percentage { + color: #ffffff; +} + + diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.spec.tsx similarity index 60% rename from src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx rename to src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.spec.tsx index 31b40dfa..f9d30321 100644 --- a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx +++ b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.spec.tsx @@ -6,6 +6,7 @@ describe('MultiPercentageBar utilities', () => { percentage: number; color: string; label: string; + count?: number; } // Helper function to filter non-zero segments (simulating component logic) @@ -113,4 +114,73 @@ describe('MultiPercentageBar utilities', () => { expect(result.segments).toBe(0); }); }); + + describe('color configuration utilities', () => { + // Helper function to apply color overrides (simulating component logic) + const applyColorOverrides = (segments: PercentageSegment[], overrides: Record) => { + return segments.map((segment) => ({ + ...segment, + color: overrides[segment.label] || segment.color, + })); + }; + + it('applies color overrides correctly', () => { + const segments: PercentageSegment[] = [ + { percentage: 60, color: '#28a745', label: 'Healthy' }, + { percentage: 40, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const overrides = { + Healthy: '#00ff00', + Unhealthy: '#ff0000', + }; + + const result = applyColorOverrides(segments, overrides); + + expect(result[0].color).toBe('#00ff00'); + expect(result[1].color).toBe('#ff0000'); + }); + + it('keeps original colors when no overrides match', () => { + const segments: PercentageSegment[] = [ + { percentage: 60, color: '#28a745', label: 'Healthy' }, + { percentage: 40, color: '#d22020ff', label: 'Unknown' }, + ]; + + const overrides = { + Different: '#00ff00', + }; + + const result = applyColorOverrides(segments, overrides); + + expect(result[0].color).toBe('#28a745'); + expect(result[1].color).toBe('#d22020ff'); + }); + }); + + describe('label configuration utilities', () => { + // Helper function to determine if primary label should be hidden + const shouldHidePrimaryLabel = (segments: PercentageSegment[], hideWhenSingleFull: boolean) => { + return hideWhenSingleFull && segments.length === 1 && segments[0]?.percentage === 100; + }; + + it('hides primary label when single segment is 100%', () => { + const segments: PercentageSegment[] = [{ percentage: 100, color: '#28a745', label: 'Complete' }]; + + expect(shouldHidePrimaryLabel(segments, true)).toBe(true); + expect(shouldHidePrimaryLabel(segments, false)).toBe(false); + }); + + it('shows primary label when multiple segments or not 100%', () => { + const multipleSegments: PercentageSegment[] = [ + { percentage: 50, color: '#28a745', label: 'Healthy' }, + { percentage: 50, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const partialSegment: PercentageSegment[] = [{ percentage: 80, color: '#28a745', label: 'Partial' }]; + + expect(shouldHidePrimaryLabel(multipleSegments, true)).toBe(false); + expect(shouldHidePrimaryLabel(partialSegment, true)).toBe(false); + }); + }); }); diff --git a/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.tsx b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.tsx new file mode 100644 index 00000000..66f851e3 --- /dev/null +++ b/src/components/BentoGrid/MultiPercentageBar/MultiPercentageBar.tsx @@ -0,0 +1,413 @@ +import React, { useMemo } from 'react'; +import styles from './MultiPercentageBar.module.css'; + +/** + * MultiPercentageBar - A configurable progress bar component with segments + */ + +// Utility function to truncate text from the left +const truncateFromLeft = (text: string, maxLength: number, ellipsis: string = '…'): string => { + if (text.length <= maxLength) return text; + return ellipsis + text.slice(-(maxLength - ellipsis.length)); +}; + +// Calculate approximate character width based on font size +const getApproximateCharWidth = (fontSize: string = '0.75rem'): number => { + const numericSize = parseFloat(fontSize); + return numericSize * 0.6; // Rough approximation: 0.6 * font size +}; + +interface PercentageSegment { + percentage: number; + color: string; + label: string; + count?: number; // Optional count for displaying inside segments + segmentLabel?: string; // Optional label to show inside the segment + segmentLabelColor?: string; // Optional color for the segment label +} + +type LabelPosition = 'above' | 'inside' | 'none'; +type LabelDisplayMode = 'all' | 'primary' | 'custom'; + +interface LabelConfig { + position: LabelPosition; + displayMode: LabelDisplayMode; + showPercentage?: boolean; + showSegmentPercentage?: boolean; // Control segment label percentages independently + showCount?: boolean; + customLabels?: string[]; // Custom labels to show when displayMode is 'custom' + primaryLabelText?: string; // Override primary label text + primaryLabelValue?: string | number; // Arbitrary number/percentage to show after primary label + fontSize?: string; + fontWeight?: 'normal' | 'bold' | number; + textColor?: string; + healthyTextColor?: string; // Arbitrary color for healthy state (replaces hardcoded green) + hideWhenSingleFull?: boolean; // Hide primary label when single segment is 100% + segmentLabelFontSize?: string; // Font size for segment labels + segmentLabelFontWeight?: 'normal' | 'bold' | number; // Font weight for segment labels + enableSegmentTruncation?: boolean; // Enable automatic truncation of segment labels + segmentTruncationEllipsis?: string; // Custom ellipsis for truncation (default: '…') + segmentTruncationDirection?: 'left' | 'right' | 'middle'; // Direction of truncation +} + +interface ColorConfig { + overrides?: Record; // Override colors by segment label + healthyThreshold?: number; // Percentage threshold for healthy state + useGradients?: boolean; // Whether to use gradient effects + opacity?: number; // Overall opacity for segments +} + +interface AnimationConfig { + duration?: number; + enableWave?: boolean; + staggerDelay?: number; // Delay between segment animations + enableTransitions?: boolean; +} + +interface MultiPercentageBarProps { + segments: PercentageSegment[]; + showOnlyNonZero?: boolean; + isHealthy?: boolean; // Override for healthy state from parent component + + barWidth?: string; + barMaxWidth?: string; + barHeight?: string; + gap?: string; + borderRadius?: string; + backgroundColor?: string; + className?: string; + style?: React.CSSProperties; + + labelConfig?: LabelConfig; + colorConfig?: ColorConfig; + animationConfig?: AnimationConfig; + + showSegmentLabels?: boolean; + showSegmentLabelsOnHover?: boolean; + showLabels?: boolean; + minSegmentWidthForLabel?: number; +} + +export const MultiPercentageBar: React.FC = ({ + segments, + showOnlyNonZero = true, + isHealthy = false, + barWidth = '80%', + barMaxWidth = '400px', + barHeight = '8px', + gap = '2px', + borderRadius = '6px', + backgroundColor, + className, + style, + labelConfig, + colorConfig, + animationConfig, + showSegmentLabels = false, + showSegmentLabelsOnHover = false, + showLabels = false, + minSegmentWidthForLabel = 15, +}) => { + const mergedLabelConfig: LabelConfig = useMemo( + () => ({ + position: 'above', // Always show above labels, segment labels are controlled separately + displayMode: 'primary', + showPercentage: false, + showSegmentPercentage: true, // Default to showing percentages in segments + showCount: false, + fontSize: '0.875rem', + fontWeight: 'normal', + hideWhenSingleFull: false, + healthyTextColor: '#28a745', // Default green, but now customizable + segmentLabelFontSize: '0.75rem', + segmentLabelFontWeight: 'normal', + enableSegmentTruncation: true, // Enable truncation by default + segmentTruncationEllipsis: '…', + segmentTruncationDirection: 'left', + ...labelConfig, + }), + [labelConfig], + ); + + const mergedColorConfig: ColorConfig = useMemo( + () => ({ + healthyThreshold: 100, + useGradients: false, + opacity: 1, + ...colorConfig, + }), + [colorConfig], + ); + + const mergedAnimationConfig: AnimationConfig = useMemo( + () => ({ + duration: 400, + enableWave: true, + staggerDelay: 100, + enableTransitions: true, + ...animationConfig, + }), + [animationConfig], + ); + + // Memoize filtered segments with color overrides + const processedSegments = useMemo(() => { + const filtered = showOnlyNonZero ? segments.filter((segment) => segment.percentage > 0) : segments; + + return filtered.map((segment) => ({ + ...segment, + color: mergedColorConfig.overrides?.[segment.label] || segment.color, + })); + }, [segments, showOnlyNonZero, mergedColorConfig.overrides]); + + if (processedSegments.length === 0) { + return null; + } + + const primarySegment = processedSegments[0]; + const primaryPercentage = primarySegment?.percentage || 0; + + const shouldHidePrimaryLabel = + mergedLabelConfig.hideWhenSingleFull && processedSegments.length === 1 && primaryPercentage === 100; + + // Function to calculate truncated segment label + const getTruncatedSegmentLabel = (segment: PercentageSegment, segmentWidth: number): string => { + if (!mergedLabelConfig.enableSegmentTruncation) { + return segment.segmentLabel || + (mergedLabelConfig.showCount && segment.count + ? `${segment.label} ${segment.count}` + : segment.label); + } + + const baseLabel = segment.segmentLabel || segment.label; + const percentageText = mergedLabelConfig.showSegmentPercentage ? ` ${segment.percentage}%` : ''; + const countText = mergedLabelConfig.showCount && segment.count ? ` ${segment.count}` : ''; + const fullText = `${baseLabel}${countText}${percentageText}`; + + // Calculate available space (rough estimation) + const charWidth = getApproximateCharWidth(mergedLabelConfig.segmentLabelFontSize); + const availableChars = Math.floor((segmentWidth - 12) / charWidth); // 12px for padding + + if (availableChars <= 0 || fullText.length <= availableChars) { + return fullText; + } + + // Reserve space for percentage and count if they exist + const reservedText = `${countText}${percentageText}`; + const availableForLabel = availableChars - reservedText.length; + + if (availableForLabel <= 1) { + return mergedLabelConfig.segmentTruncationEllipsis || '…'; + } + + let truncatedLabel = baseLabel; + if (mergedLabelConfig.segmentTruncationDirection === 'left') { + truncatedLabel = truncateFromLeft(baseLabel, availableForLabel, mergedLabelConfig.segmentTruncationEllipsis); + } else if (mergedLabelConfig.segmentTruncationDirection === 'right') { + const ellipsis = mergedLabelConfig.segmentTruncationEllipsis || '…'; + truncatedLabel = baseLabel.length > availableForLabel + ? baseLabel.slice(0, availableForLabel - ellipsis.length) + ellipsis + : baseLabel; + } else if (mergedLabelConfig.segmentTruncationDirection === 'middle') { + const ellipsis = mergedLabelConfig.segmentTruncationEllipsis || '…'; + if (baseLabel.length > availableForLabel) { + const sideLength = Math.floor((availableForLabel - ellipsis.length) / 2); + truncatedLabel = baseLabel.slice(0, sideLength) + ellipsis + baseLabel.slice(-sideLength); + } + } + + return `${truncatedLabel}${reservedText}`; + }; + + // Helper function to render labels above the bar + const renderAboveLabels = () => { + if (mergedLabelConfig.position !== 'above') return null; + + const labelsToShow = []; + + switch (mergedLabelConfig.displayMode) { + case 'primary': + if (!shouldHidePrimaryLabel) { + const displayText = mergedLabelConfig.primaryLabelText || processedSegments[0]?.label || 'Primary'; + const isRolesLabel = displayText.toLowerCase().includes('roles'); + labelsToShow.push({ + text: displayText, + percentage: mergedLabelConfig.showPercentage ? primaryPercentage : undefined, + count: mergedLabelConfig.showCount ? processedSegments[0]?.count : undefined, + customValue: mergedLabelConfig.primaryLabelValue, + isHealthy: isHealthy && !isRolesLabel, + }); + } + break; + case 'all': + processedSegments.forEach((segment) => { + labelsToShow.push({ + text: segment.label, + percentage: mergedLabelConfig.showPercentage ? segment.percentage : undefined, + count: mergedLabelConfig.showCount ? segment.count : undefined, + isHealthy: false, // Only primary should be styled as healthy + }); + }); + break; + case 'custom': + if (mergedLabelConfig.customLabels) { + mergedLabelConfig.customLabels.forEach((customLabel, index) => { + const segment = processedSegments[index]; + if (segment) { + labelsToShow.push({ + text: customLabel, + percentage: mergedLabelConfig.showPercentage ? segment.percentage : undefined, + count: mergedLabelConfig.showCount ? segment.count : undefined, + isHealthy: index === 0 ? isHealthy : false, + }); + } + }); + } + break; + } + + if (labelsToShow.length === 0) return null; + + return ( +
+ {labelsToShow.map((labelItem, index) => ( +
+ + {labelItem.text} + + {labelItem.percentage !== undefined && ( + + {labelItem.percentage}% + + )} + {labelItem.count !== undefined && ( + + ({labelItem.count}) + + )} + {labelItem.customValue !== undefined && ( + + {labelItem.customValue} + + )} +
+ ))} +
+ ); + }; + + return ( +
+ {/* Labels above the bar */} + {renderAboveLabels()} + + {/* Progress bar */} +
+ {processedSegments.map((segment, index) => ( +
+ {/* Wave animation overlay */} + {mergedAnimationConfig.enableWave &&
} + + {/* Segment label inside the bar */} + {(showSegmentLabels || (segment.segmentLabel && (!showSegmentLabelsOnHover || showLabels))) && segment.percentage >= (minSegmentWidthForLabel || 15) && ( + + {(() => { + // Calculate segment width in pixels (approximate) + const barMaxWidthPx = parseFloat(barMaxWidth.replace('px', '')) || 400; + const segmentWidthPx = (segment.percentage / 100) * barMaxWidthPx; + return getTruncatedSegmentLabel(segment, segmentWidthPx); + })()} + + )} +
+ ))} +
+
+ ); +}; + +export type { + PercentageSegment, + MultiPercentageBarProps, + LabelConfig, + ColorConfig, + AnimationConfig, + LabelPosition, + LabelDisplayMode, +}; diff --git a/src/components/ControlPlane/FluxList.tsx b/src/components/ControlPlane/FluxList.tsx index ccbf5f18..f7371bf1 100644 --- a/src/components/ControlPlane/FluxList.tsx +++ b/src/components/ControlPlane/FluxList.tsx @@ -172,7 +172,7 @@ export default function FluxList() { <>
- {t('FluxList.gitOpsTitle')} + {t('FluxList.repositoriesTitle') || 'Repositories'} diff --git a/src/components/Core/ShellBar.tsx b/src/components/Core/ShellBar.tsx index c08b8edf..4dd9c753 100644 --- a/src/components/Core/ShellBar.tsx +++ b/src/components/Core/ShellBar.tsx @@ -44,8 +44,8 @@ export function ShellBarComponent() { startButton={
- MCP - MCP + ManagedControlPlane + ManagedControlPlane UI
} diff --git a/src/components/Graphs/Graph.module.css b/src/components/Graphs/Graph.module.css index 15a3d0e9..89bbe087 100644 --- a/src/components/Graphs/Graph.module.css +++ b/src/components/Graphs/Graph.module.css @@ -1,17 +1,61 @@ .graphContainer { display: flex; - height: 600px; + flex-direction: column; + height: 500px; border: 1px solid var(--sapList_BorderColor, #ddd); border-radius: 16px; overflow: hidden; - background-color: var(--sapBackgroundColor, #fafafa); + background-color: #ffffff; font-family: var(--sapFontFamily); + position: relative; + touch-action: none; + overflow: hidden; } .graphColumn { flex: 1; display: flex; flex-direction: column; + background-color: #ffffff; + position: relative; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; + overflow: hidden; +} + +.bottomLegendContainer { + position: absolute; + bottom: 1rem; + right: 1rem; + display: flex; + align-items: center; + gap: 1rem; + z-index: 10; +} + +.topRightContainer { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 10; +} + +.filterIcon { + display: flex; + align-items: center; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; + background-color: rgba(255, 255, 255, 0.9); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-left: 0.5rem; +} + +.filterIcon:hover { + background-color: var(--sapButton_Hover_Background, #f0f0f0); } .graphHeader { @@ -71,4 +115,59 @@ :global([data-theme='dark'] .react-flow__controls-button:hover) { background: rgba(255, 255, 255, 0.08); +} + +/* Animated edge styles */ +:global(.react-flow__edge-path) { + stroke-dasharray: 5 5; + animation: flowAnimation 3s linear infinite; + stroke: #888; + opacity: 0.8; +} + +:global(.react-flow__edge.react-flow__edge-step .react-flow__edge-path) { + stroke-dasharray: 8 4; + animation: flowAnimation 2s linear infinite; + stroke: #888; + opacity: 0.8; +} + +@keyframes flowAnimation { + 0% { + stroke-dashoffset: 0; + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + stroke-dashoffset: 12; + opacity: 0.6; + } +} + +/* Blocky edge styling */ +:global(.react-flow__edge.react-flow__edge-step) { + stroke-width: 2px; +} + +:global(.react-flow__background) { + background-color: #ffffff !important; +} + +:global(.react-flow__pane) { + cursor: grab; + overflow: hidden; +} + +:global(.react-flow__pane.dragging) { + cursor: grabbing; +} + +:global(.react-flow) { + overflow: hidden; +} + +:global(.react-flow__renderer) { + overflow: hidden; } \ No newline at end of file diff --git a/src/components/Graphs/Graph.tsx b/src/components/Graphs/Graph.tsx index f4974b54..fda6d56f 100644 --- a/src/components/Graphs/Graph.tsx +++ b/src/components/Graphs/Graph.tsx @@ -1,7 +1,7 @@ -import React, { useState, useCallback, useMemo } from 'react'; -import { ReactFlow, Background, Controls, MarkerType, Node, Panel } from '@xyflow/react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { ReactFlow, Background, Controls, Node, BackgroundVariant, SelectionMode } from '@xyflow/react'; import type { NodeProps } from '@xyflow/react'; -import { RadioButton, FlexBox, FlexBoxAlignItems } from '@ui5/webcomponents-react'; +import { Button, Popover } from '@ui5/webcomponents-react'; import styles from './Graph.module.css'; import '@xyflow/react/dist/style.css'; import { NodeData, ColorBy } from './types'; @@ -15,7 +15,6 @@ import { useTranslation } from 'react-i18next'; import { useGraph } from './useGraph'; import { ManagedResourceItem } from '../../lib/shared/types'; import { useTheme } from '../../hooks/useTheme'; - const nodeTypes = { custom: (props: NodeProps>) => ( { +interface GraphProps { + colorBy?: ColorBy; +} + +const Graph: React.FC = ({ colorBy: initialColorBy = 'source' }) => { const { t } = useTranslation(); const { isDarkTheme } = useTheme(); - const [colorBy, setColorBy] = useState('provider'); + const [colorBy, setColorBy] = useState(initialColorBy); const [yamlDialogOpen, setYamlDialogOpen] = useState(false); const [yamlResource, setYamlResource] = useState(null); + const [filterPopoverOpen, setFilterPopoverOpen] = useState(false); + + // Update colorBy when prop changes + useEffect(() => { + setColorBy(initialColorBy); + }, [initialColorBy]); const handleYamlClick = useCallback((item: ManagedResourceItem) => { setYamlResource(item); @@ -83,53 +92,75 @@ const Graph: React.FC = () => { nodes={nodes} edges={edges} nodeTypes={nodeTypes} - defaultEdgeOptions={{ - style: { stroke: '#888', strokeWidth: 1.5 }, - markerEnd: { type: MarkerType.ArrowClosed }, - }} - fitView + defaultViewport={{ x: 40, y: 40, zoom: 0.8 }} + minZoom={0.2} + maxZoom={4.0} proOptions={{ hideAttribution: true, }} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} - zoomOnScroll={true} + zoomOnScroll={false} + panOnScroll={false} panOnDrag={true} + selectionOnDrag={false} + selectionMode={SelectionMode.Partial} + preventScrolling={true} > + - - - -
-
- {t('Graphs.colorizedTitle')} - setColorBy('provider')} - /> - setColorBy('source')} - /> - setColorBy('flux')} - /> -
-
-
-
- - - + + {/* Legend and filter in bottom-right */} +
+ + setFilterPopoverOpen(false)} + > +
+ + + +
+
+
+
+
= ({ legendItems }) => { +export const Legend: React.FC = ({ legendItems, horizontal = false }) => { return ( -
+
{legendItems.map(({ name, color }) => ( -
+
{name}
diff --git a/src/components/Graphs/useGraph.ts b/src/components/Graphs/useGraph.ts index 92e72db6..942479cf 100644 --- a/src/components/Graphs/useGraph.ts +++ b/src/components/Graphs/useGraph.ts @@ -2,7 +2,7 @@ import { useMemo, useEffect, useState } from 'react'; import { useApiResource, useProvidersConfigResource } from '../../lib/api/useApiResource'; import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listManagedResources'; import { resourcesInterval } from '../../lib/shared/constants'; -import { Node, Edge, Position, MarkerType } from '@xyflow/react'; +import { Node, Edge, Position } from '@xyflow/react'; import dagre from 'dagre'; import { NodeData, ColorBy } from './types'; import { extractRefs, generateColorMap, getStatusCondition, resolveProviderType } from './graphUtils'; @@ -24,14 +24,20 @@ function buildGraph( treeData.forEach((n) => { const colorKey: string = colorBy === 'source' ? n.providerType : colorBy === 'flux' ? (n.fluxName ?? 'default') : n.providerConfigName; + + // Use provider/filter color for background tinting + const borderColor = colorMap[colorKey] || '#ccc'; + // Convert the border color to a very light tint for background + const backgroundColor = `${borderColor}08`; // Add 08 for ~3% opacity + const node: Node = { id: n.id, type: 'custom', data: { ...n }, style: { - border: `2px solid ${colorMap[colorKey] || '#ccc'}`, + border: `2px solid ${borderColor}`, borderRadius: 8, - backgroundColor: 'var(--sapTile_Background, #fff)', + backgroundColor, width: nodeWidth, height: nodeHeight, }, @@ -53,7 +59,9 @@ function buildGraph( id: `e-${n.parentId}-${n.id}`, source: n.parentId, target: n.id, - markerEnd: { type: MarkerType.ArrowClosed }, + type: 'step', + style: { strokeWidth: 2, stroke: '#888' }, + animated: true, }); } n.extraRefs?.forEach((refId) => { @@ -63,7 +71,9 @@ function buildGraph( id: `e-${refId}-${n.id}`, source: refId, target: n.id, - markerEnd: { type: MarkerType.ArrowClosed }, + type: 'step', + style: { strokeWidth: 2, stroke: '#888' }, + animated: true, }); } }); diff --git a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css deleted file mode 100644 index 59a63c81..00000000 --- a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Card Hover Content Styles */ -.hoverContent { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - margin: 1rem 0; - overflow: visible; -} - -.chartContainer { - width: 100%; - height: 300px; - display: flex; - justify-content: center; - align-items: center; -} diff --git a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx deleted file mode 100644 index ad0bd314..00000000 --- a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { useMemo } from 'react'; -import { RadarChart } from '@ui5/webcomponents-react-charts'; -import { LegendSection } from '../LegendSection/LegendSection'; -import { styles } from '../HintsCardsRow'; -import styles2 from './CardHoverContent.module.css'; -import cx from 'clsx'; - -export interface LegendItem { - label: string; - count: number; - color: string; -} - -export interface RadarDataPoint { - [key: string]: string | number; -} - -export interface RadarMeasure { - accessor: string; - color: string; - hideDataLabel?: boolean; - label: string; -} - -export interface RadarDimension { - accessor: string; -} - -export interface HoverContentProps { - enabled: boolean; - totalCount: number; - totalLabel: string; - legendItems: LegendItem[]; - radarDataset: RadarDataPoint[]; - radarDimensions: RadarDimension[]; - radarMeasures: RadarMeasure[]; - isLoading?: boolean; -} - -// Helper function to truncate labels to max 13 characters -const truncateLabel = (label: string, maxLength: number = 13): string => { - if (label.length <= maxLength) { - return label; - } - return label.substring(0, maxLength) + '...'; -}; - -export const HoverContent: React.FC = ({ - enabled, - totalCount, - totalLabel, - legendItems, - radarDataset, - radarDimensions, - radarMeasures, - isLoading = false, -}) => { - // Process the dataset to truncate labels - const processedDataset = useMemo(() => { - return radarDataset.map((dataPoint) => { - const processedDataPoint = { ...dataPoint }; - - // Truncate labels for each dimension accessor - radarDimensions.forEach((dimension) => { - const value = dataPoint[dimension.accessor]; - if (typeof value === 'string') { - processedDataPoint[dimension.accessor] = truncateLabel(value); - } - }); - - return processedDataPoint; - }); - }, [radarDataset, radarDimensions]); - - if (!enabled) { - return null; - } - - return ( -
- -
- {isLoading || radarDataset.length === 0 ? ( -
- String(value || ''), - }, - ]} - measures={[ - { - accessor: 'users', - formatter: (value: string | number) => String(value || ''), - label: 'Users', - }, - { - accessor: 'sessions', - formatter: (value: string | number) => String(value || ''), - hideDataLabel: true, - label: 'Active Sessions', - }, - { - accessor: 'volume', - label: 'Vol.', - }, - ]} - style={{ width: '100%', height: '100%', minWidth: 280, minHeight: 280 }} - noLegend={true} - onClick={() => {}} - onDataPointClick={() => {}} - onLegendClick={() => {}} - /> -
- ) : ( - - )} -
-
- ); -}; diff --git a/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts b/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts deleted file mode 100644 index e77d18fd..00000000 --- a/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ManagedResourceItem, Condition } from '../../../lib/shared/types'; - -export interface ResourceTypeStats { - type: string; - total: number; - healthy: number; - creating: number; - unhealthy: number; - healthyPercentage: number; - creatingPercentage: number; - unhealthyPercentage: number; -} - -export interface OverallStats { - total: number; - healthy: number; - creating: number; - unhealthy: number; -} - -export interface CrossplaneHoverData { - resourceTypeStats: ResourceTypeStats[]; - overallStats: OverallStats; -} - -/** - * Calculate comprehensive statistics for Crossplane hover content - */ -export const calculateCrossplaneHoverData = (allItems: ManagedResourceItem[]): CrossplaneHoverData => { - const typeStats: Record = {}; - let totalHealthy = 0; - let totalCreating = 0; - let totalUnhealthy = 0; - - allItems.forEach((item: ManagedResourceItem) => { - const type = item.kind || 'Unknown'; - - if (!typeStats[type]) { - typeStats[type] = { total: 0, healthy: 0, creating: 0, unhealthy: 0 }; - } - - typeStats[type].total++; - - const conditions = item.status?.conditions || []; - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); - - if (ready && synced) { - typeStats[type].healthy++; - totalHealthy++; - } else if (synced && !ready) { - // Resource is synced but not ready - it's creating - typeStats[type].creating++; - totalCreating++; - } else { - // Resource has issues or is not synced - typeStats[type].unhealthy++; - totalUnhealthy++; - } - }); - - const resourceTypeStats: ResourceTypeStats[] = Object.keys(typeStats).map((type) => { - const stats = typeStats[type]; - return { - type, - total: stats.total, - healthy: stats.healthy, - creating: stats.creating, - unhealthy: stats.unhealthy, - healthyPercentage: Math.round((stats.healthy / stats.total) * 100), - creatingPercentage: Math.round((stats.creating / stats.total) * 100), - unhealthyPercentage: Math.round((stats.unhealthy / stats.total) * 100), - }; - }); - - return { - resourceTypeStats, - overallStats: { - total: allItems.length, - healthy: totalHealthy, - creating: totalCreating, - unhealthy: totalUnhealthy, - }, - }; -}; diff --git a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css deleted file mode 100644 index 2625616c..00000000 --- a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.container { - position: relative; - width: 100%; -} - -.avatar { - width: 50px; - height: 50px; - border-radius: 50%; - background: transparent; - object-fit: cover; -} - -.contentContainer { - display: flex; - flex-direction: column; - align-items: center; - padding: 0.5rem 0; -} - -.progressBarContainer { - display: flex; - gap: 8px; - width: 100%; - max-width: 500px; - padding: 0 0.5rem; -} - -.progressBar { - width: 100%; -} - -.activateButton { - position: absolute; - top: 16px; - right: 16px; - z-index: 2; - pointer-events: auto; -} - -.activateButtonClickable { - cursor: pointer; -} - -.activateButtonDefault { - cursor: default; -} diff --git a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx deleted file mode 100644 index dc6123e0..00000000 --- a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState } from 'react'; -import { Card, CardHeader } from '@ui5/webcomponents-react'; -import { useTranslation } from 'react-i18next'; -import cx from 'clsx'; -import { MultiPercentageBar } from '../MultiPercentageBar/MultiPercentageBar'; -import { styles } from '../HintsCardsRow'; -import { HoverContent } from '../CardHoverContent/CardHoverContent'; -import styles2 from './GenericHintCard.module.css'; -import { GenericHintProps } from '../../../types/types'; - -export const GenericHintCard: React.FC = ({ - enabled = false, - version, - allItems = [], - isLoading, - error, - config, -}) => { - const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); - - // Calculate segments and state using the provided calculator - const hintState = config.calculateSegments(allItems, isLoading || false, error, enabled, t); - - // Handle click navigation if scroll target is provided - const handleClick = - enabled && config.scrollTarget - ? () => { - const el = document.querySelector(config.scrollTarget!); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - : undefined; - - return ( -
- - } - titleText={config.title} - subtitleText={config.subtitle} - interactive={enabled} - /> - } - className={cx({ - [styles['disabled']]: !enabled, - })} - onClick={handleClick} - onMouseEnter={enabled ? () => setHovered(true) : undefined} - onMouseLeave={enabled ? () => setHovered(false) : undefined} - > - {/* Disabled overlay */} - {!enabled &&
} - -
-
- -
-
- - {(() => { - const shouldShowHoverContent = enabled && hovered && config.calculateHoverData; - if (!shouldShowHoverContent) return null; - - const hoverData = config.calculateHoverData!(allItems, enabled, t); - const hasValidHoverData = !!hoverData; - - return hasValidHoverData ? : null; - })()} - -
- ); -}; diff --git a/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts b/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts deleted file mode 100644 index b44eebc9..00000000 --- a/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { GenericHintConfig } from '../../../types/types'; -import { - calculateCrossplaneSegments, - calculateGitOpsSegments, - calculateVaultSegments, - calculateCrossplaneHoverDataGeneric, - calculateGitOpsHoverDataGeneric, -} from '../../../utils/hintsCardsRowCalculations'; - -export const useCrossplaneHintConfig = (): GenericHintConfig => { - const { t } = useTranslation(); - - return { - title: t('Hints.CrossplaneHint.title'), - subtitle: t('Hints.CrossplaneHint.subtitle'), - iconSrc: '/crossplane-icon.png', - iconAlt: 'Crossplane', - scrollTarget: '.crossplane-table-element', - calculateSegments: (allItems, isLoading, error, enabled) => - calculateCrossplaneSegments(allItems, isLoading, error, enabled, t), - calculateHoverData: (allItems, enabled) => calculateCrossplaneHoverDataGeneric(allItems, enabled, t), - }; -}; - -export const useGitOpsHintConfig = (): GenericHintConfig => { - const { t } = useTranslation(); - - return { - title: t('Hints.GitOpsHint.title'), - subtitle: t('Hints.GitOpsHint.subtitle'), - iconSrc: '/flux.png', - iconAlt: 'Flux', - scrollTarget: '.cp-page-section-gitops', - calculateSegments: (allItems, isLoading, error, enabled) => - calculateGitOpsSegments(allItems, isLoading, error, enabled, t), - calculateHoverData: (allItems, enabled) => calculateGitOpsHoverDataGeneric(allItems, enabled, t), - }; -}; - -export const useVaultHintConfig = (): GenericHintConfig => { - const { t } = useTranslation(); - - return { - title: t('Hints.VaultHint.title'), - subtitle: t('Hints.VaultHint.subtitle'), - iconSrc: '/vault.png', - iconAlt: 'Vault', - iconStyle: { borderRadius: '0' }, // Vault icon should not be rounded - calculateSegments: (allItems, isLoading, error, enabled) => - calculateVaultSegments(allItems, isLoading, error, enabled, t), - }; -}; diff --git a/src/components/HintsCardsRow/HintsCardsRow.module.css b/src/components/HintsCardsRow/HintsCardsRow.module.css deleted file mode 100644 index 9fc831c9..00000000 --- a/src/components/HintsCardsRow/HintsCardsRow.module.css +++ /dev/null @@ -1,66 +0,0 @@ -.disabled { - position: relative; -} - -.disabledOverlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(255, 255, 255, 0.6); - backdrop-filter: grayscale(0.9) blur(0.5px); - border-radius: inherit; - z-index: 1; - pointer-events: none; -} - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .disabledOverlay { - background: rgba(0, 0, 0, 0.4); - } - - .chartBackground { - background-color: #2a2a2a; - } - - .chartLabel { - color: #ffffff; - } -} - -/* Also check for UI5 theme variables for dark themes */ -[data-ui5-theme-root*="dark"] .disabledOverlay, -[data-ui5-theme*="dark"] .disabledOverlay { - background: rgba(0, 0, 0, 0.4); -} - -/* Hover Content Animation */ -.hoverContent { - overflow: hidden; - transition: all 0.3s ease-in-out; - animation: expandIn 0.3s ease-in-out; -} - -@keyframes expandIn { - from { - max-height: 0; - opacity: 0; - transform: scaleY(0); - transform-origin: top; - } - to { - max-height: 500px; - opacity: 1; - transform: scaleY(1); - transform-origin: top; - } -} - -.hoverContentLoading { - display: flex; - justify-content: center; - align-items: center; - height: 300px; -} \ No newline at end of file diff --git a/src/components/HintsCardsRow/HintsCardsRow.tsx b/src/components/HintsCardsRow/HintsCardsRow.tsx deleted file mode 100644 index de68d23c..00000000 --- a/src/components/HintsCardsRow/HintsCardsRow.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { FlexBox, FlexBoxDirection } from '@ui5/webcomponents-react'; -import { GenericHintCard } from './GenericHintCard/GenericHintCard'; -import { useCrossplaneHintConfig, useGitOpsHintConfig, useVaultHintConfig } from './GenericHintCard/genericHintConfigs'; - -import { ControlPlaneType } from '../../lib/api/types/crate/controlPlanes'; -import { ManagedResourcesRequest, ManagedResourcesResponse } from '../../lib/api/types/crossplane/listManagedResources'; -import { resourcesInterval } from '../../lib/shared/constants'; -import { useApiResource } from '../../lib/api/useApiResource'; -import { ManagedResourceItem } from '../../lib/shared/types'; -import React, { useMemo } from 'react'; - -interface HintsProps { - mcp: ControlPlaneType; -} - -// Export styles for use by hint components -export { default as styles } from './HintsCardsRow.module.css'; - -// Utility function to flatten managed resources -export const flattenManagedResources = (managedResources: ManagedResourcesResponse): ManagedResourceItem[] => { - if (!managedResources || !Array.isArray(managedResources)) return []; - - return managedResources - .filter((managedResource) => managedResource?.items) - .flatMap((managedResource) => managedResource.items || []); -}; - -const HintsCardsRow: React.FC = ({ mcp }) => { - const { - data: managedResources, - isLoading: managedResourcesLoading, - error: managedResourcesError, - } = useApiResource(ManagedResourcesRequest, { - refreshInterval: resourcesInterval, - }); - - // Flatten all managed resources once and pass to components - const allItems = useMemo( - () => flattenManagedResources(managedResources ?? ([] as unknown as ManagedResourcesResponse)), - [managedResources], - ); - - // Get hint configurations - const crossplaneConfig = useCrossplaneHintConfig(); - const gitOpsConfig = useGitOpsHintConfig(); - const vaultConfig = useVaultHintConfig(); - - return ( - - - - - - ); -}; - -export default HintsCardsRow; diff --git a/src/components/HintsCardsRow/LegendSection/LegendSection.module.css b/src/components/HintsCardsRow/LegendSection/LegendSection.module.css deleted file mode 100644 index 63e40530..00000000 --- a/src/components/HintsCardsRow/LegendSection/LegendSection.module.css +++ /dev/null @@ -1,71 +0,0 @@ -/* Legend Section Styles */ -.legendSection { - color: var(--sapTextColor, #374151); - margin-bottom: 1rem; - width: 80%; - max-width: 400px; - align-self: center; -} - -.legendTitle { - color: var(--sapTitleColor, var(--sapTextColor, #374151)); - font-size: 0.95rem; - font-weight: 600; - margin-bottom: 0.5rem; - text-align: left; -} - -.legendItem { - color: var(--sapContent_LabelColor, #6b7280); - font-size: 0.85rem; -} - -.legendItemsContainer { - display: flex; - gap: 0.75rem; - align-items: center; - justify-content: flex-start; -} - -.legendItemWrapper { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.legendDot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .legendSection { - color: #ffffff; - } - - .legendTitle { - color: #ffffff; - } - - .legendItem { - color: #cccccc; - } -} - -/* Also check for UI5 theme variables for dark themes */ -[data-ui5-theme-root*="dark"] .legendSection, -[data-ui5-theme*="dark"] .legendSection { - color: #ffffff; -} - -[data-ui5-theme-root*="dark"] .legendTitle, -[data-ui5-theme*="dark"] .legendTitle { - color: #ffffff; -} - -[data-ui5-theme-root*="dark"] .legendItem, -[data-ui5-theme*="dark"] .legendItem { - color: #cccccc; -} diff --git a/src/components/HintsCardsRow/LegendSection/LegendSection.tsx b/src/components/HintsCardsRow/LegendSection/LegendSection.tsx deleted file mode 100644 index 8c308234..00000000 --- a/src/components/HintsCardsRow/LegendSection/LegendSection.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import styles from './LegendSection.module.css'; - -interface LegendItem { - label: string; - count: number; - color: string; -} - -interface LegendSectionProps { - title: string; - items: LegendItem[]; - style?: React.CSSProperties; -} - -export const LegendSection: React.FC = ({ title, items, style }) => { - return ( -
-
{title}
-
- {items.map((item, index) => ( -
-
- - {item.count} {item.label} - -
- ))} -
-
- ); -}; diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css deleted file mode 100644 index 153b8ca7..00000000 --- a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css +++ /dev/null @@ -1,183 +0,0 @@ -/* CSS Variables for customization */ -.container { - --animation-duration: 600ms; - --bar-width: 80%; - --bar-max-width: 400px; - --bar-height: 8px; - --gap: 2px; - --border-radius: 6px; - --label-font-size: 0.875rem; - --background-color: var(--sapBackgroundColor, #fafafa); - - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - width: 100%; - padding-bottom: 8px; - - /* Initial animation */ - animation: fadeInUp var(--animation-duration) ease-out; -} - -/* Respect user's motion preferences */ -@media (prefers-reduced-motion: reduce) { - .container { - animation: fadeIn var(--animation-duration) ease-out; - } - - .segment { - transition: none !important; - } - - .waveOverlay { - animation: none !important; - } - - .percentage { - animation: none !important; - } -} - -/* Label styling */ -.labelContainer { - display: flex; - gap: 6px; - flex-wrap: wrap; - justify-content: left; - width: var(--bar-width); -} - -.labelGroup { - display: flex; - align-items: center; - gap: 6px; -} - -.label, -.percentage { - font-size: var(--label-font-size); - font-weight: 400; - color: var(--sapTextColor, #374151); - transition: color 0.3s ease, font-weight 0.3s ease; -} - -.label.healthy, -.percentage.healthy { - color: green; - font-weight: 700; -} - -/* Bar container */ -.barContainer { - display: flex; - gap: var(--gap); - width: var(--bar-width); - max-width: var(--bar-max-width); - background-color: var(--background-color); - border-radius: var(--border-radius); - padding: 2px; - overflow: hidden; -} - -/* Individual segments */ -.segment { - flex: var(--segment-percentage); - min-width: 10px; - background-color: var(--segment-color); - border-radius: var(--border-radius); - height: var(--bar-height); - position: relative; - overflow: hidden; - - /* Smooth transitions for flex changes */ - transition: flex var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1); - - /* Initial state for animation */ - transform: scaleX(0); - transform-origin: left; - animation: growWidth var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1) forwards; -} - -/* Stagger the segment animations */ -.segment:nth-child(1) { animation-delay: 0ms; } -.segment:nth-child(2) { animation-delay: 100ms; } -.segment:nth-child(3) { animation-delay: 200ms; } -.segment:nth-child(4) { animation-delay: 300ms; } -.segment:nth-child(5) { animation-delay: 400ms; } - -/* Wave animation overlay */ -.waveOverlay { - position: absolute; - top: 0; - left: -80%; - width: 80%; - height: 100%; - background: linear-gradient(90deg, - transparent 0%, - rgba(255, 255, 255, 0.15) 25%, - rgba(255, 255, 255, 0.25) 50%, - rgba(255, 255, 255, 0.15) 75%, - transparent 100%); - border-radius: var(--border-radius); - pointer-events: none; - - /* Continuous wave animation */ - animation: wave 3s ease-in-out infinite; - animation-delay: var(--animation-duration); -} - -/* Keyframe animations */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes growWidth { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } -} - -@keyframes wave { - 0% { - left: -80%; - } - 50% { - left: 100%; - } - 100% { - left: -80%; - } -} - -/* Theme Support */ -[data-ui5-theme-root*="dark"] .container, -[data-ui5-theme*="dark"] .container { - --background-color: #2a2a2a; -} - -[data-ui5-theme-root*="dark"] .label, -[data-ui5-theme*="dark"] .label, -[data-ui5-theme-root*="dark"] .percentage, -[data-ui5-theme*="dark"] .percentage { - color: #ffffff; -} diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx deleted file mode 100644 index e1824653..00000000 --- a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useMemo } from 'react'; -import styles from './MultiPercentageBar.module.css'; - -interface PercentageSegment { - percentage: number; - color: string; - label: string; -} - -interface MultiPercentageBarProps { - segments: PercentageSegment[]; - label?: string; - showOnlyNonZero?: boolean; - barWidth?: string; - barMaxWidth?: string; - barHeight?: string; - showLabels?: boolean; - showPercentage?: boolean; // Control whether to show percentage number - isHealthy?: boolean; // Control whether to style the text as healthy (green) - labelFontSize?: string; - gap?: string; - borderRadius?: string; - backgroundColor?: string; - className?: string; - style?: React.CSSProperties; - animationDuration?: number; // Animation duration in ms (for CSS custom property) -} - -export const MultiPercentageBar: React.FC = ({ - segments, - label, - showOnlyNonZero = true, - barWidth = '80%', - barMaxWidth = '400px', - barHeight = '8px', - showLabels = true, - showPercentage = true, - isHealthy, - labelFontSize = '0.875rem', - gap = '2px', - borderRadius = '6px', - backgroundColor, - className, - style, - animationDuration = 400, // Match CSS default -}) => { - // Memoize filtered segments - const filteredSegments = useMemo(() => { - return showOnlyNonZero ? segments.filter((segment) => segment.percentage > 0) : segments; - }, [segments, showOnlyNonZero]); - - if (filteredSegments.length === 0) { - return null; - } - - const primaryPercentage = filteredSegments[0]?.percentage || 0; - const displayLabel = label || 'Healthy'; - const allHealthy = isHealthy !== undefined ? isHealthy : primaryPercentage === 100; - - return ( -
- {/* Label */} - {showLabels && ( -
-
- {showPercentage && ( - {primaryPercentage}% - )} - {displayLabel} -
-
- )} - - {/* Progress bar */} -
- {filteredSegments.map((segment, index) => ( -
- {/* Wave animation overlay */} -
-
- ))} -
-
- ); -}; - -export type { PercentageSegment, MultiPercentageBarProps }; diff --git a/src/lib/api/types/crate/controlPlanes.ts b/src/lib/api/types/crate/controlPlanes.ts index 0df87bcf..8f00ffec 100644 --- a/src/lib/api/types/crate/controlPlanes.ts +++ b/src/lib/api/types/crate/controlPlanes.ts @@ -79,6 +79,6 @@ export const ControlPlane = ( ): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}--ws-${workspaceName}/managedcontrolplanes/${controlPlaneName}`, - jq: '{ spec: .spec | {components}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}', + jq: '{ spec: .spec | {components, authorization}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}', }; }; diff --git a/src/spaces/mcp/pages/McpPage.module.css b/src/spaces/mcp/pages/McpPage.module.css index 4d46900a..a987f812 100644 --- a/src/spaces/mcp/pages/McpPage.module.css +++ b/src/spaces/mcp/pages/McpPage.module.css @@ -3,3 +3,118 @@ margin: 0.1em auto -8px auto; width: 100%; } + +.expandedGrid { + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.expandedMembersGrid { + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + height: 200px !important; /* Reduced height for members view without graph */ +} + +.expandedCard { + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid var(--sapObjectHeader_BorderColor) !important; +} + +.expandedCard:hover { + transform: none !important; + box-shadow: var(--sapContent_Shadow1) !important; +} + +.expandedCardNonInteractive { + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.expandedCardNonInteractive:hover { + transform: none !important; + box-shadow: var(--sapContent_Shadow1) !important; +} + +.disabledCard { + pointer-events: none; + opacity: 0.6; +} + +.disabledCard:hover { + transform: none !important; + box-shadow: var(--sapContent_Shadow1) !important; +} + +.nonInteractiveCard { + cursor: default; +} + +.nonInteractiveCard:hover { + transform: none !important; + box-shadow: var(--sapContent_Shadow1) !important; +} + +.hidingCard { + opacity: 0; + transform: scale(0.95) translateX(20px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +/* Smooth grid column transitions */ +.expandedGrid .bentoCard { + transition: grid-column 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Action bar styles */ +.actionsBar { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 0.5rem; +} + +/* Main container styles */ +.mainContainer { + max-width: 1280px; + margin: 0 auto; + width: 100%; + padding-top: 16px; + padding-bottom: 12px; +} + +/* Card content container */ +.cardContentContainer { + position: relative; + height: 100%; +} + +/* Details panel bottom spacing */ +.detailsPanelBottom { + height: 12px; + background-color: #f5f5f5; + margin-bottom: 32px; + border-radius: 0 0 8px 8px; +} + +/* Table sections spacing */ +.tableSection { + margin-top: 24px; +} + +.crossplaneTableElement { + margin-top: 16px; +} + +/* First crossplane table element (no top margin) */ +.crossplaneTableElementFirst { + margin-top: 24px; +} diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index de04cac0..7d279e99 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -1,32 +1,30 @@ -import { BusyIndicator, ObjectPage, ObjectPageSection, ObjectPageTitle, Panel, Title } from '@ui5/webcomponents-react'; +import { BusyIndicator, ObjectPage, ObjectPageSection, ObjectPageTitle } from '@ui5/webcomponents-react'; import { useParams } from 'react-router-dom'; import CopyKubeconfigButton from '../../../components/ControlPlanes/CopyKubeconfigButton.tsx'; import styles from './McpPage.module.css'; import '@ui5/webcomponents-fiori/dist/illustrations/SimpleBalloon'; -import '@ui5/webcomponents-fiori/dist/illustrations/SimpleError'; // thorws error sometimes if not imported import '@ui5/webcomponents-fiori/dist/illustrations/BeforeSearch'; import IllustratedError from '../../../components/Shared/IllustratedError.tsx'; import { BreadCrumbFeedbackHeader } from '../../../components/Core/IntelligentBreadcrumbs.tsx'; -import FluxList from '../../../components/ControlPlane/FluxList.tsx'; import { ControlPlane as ControlPlaneResource } from '../../../lib/api/types/crate/controlPlanes.ts'; import { useTranslation } from 'react-i18next'; import { McpContextProvider, WithinManagedControlPlane } from '../../../lib/shared/McpContext.tsx'; -import { ManagedResources } from '../../../components/ControlPlane/ManagedResources.tsx'; -import { ProvidersConfig } from '../../../components/ControlPlane/ProvidersConfig.tsx'; -import { Providers } from '../../../components/ControlPlane/Providers.tsx'; -import ComponentList from '../../../components/ControlPlane/ComponentList.tsx'; import MCPHealthPopoverButton from '../../../components/ControlPlane/MCPHealthPopoverButton.tsx'; import { useApiResource } from '../../../lib/api/useApiResource.ts'; import { YamlViewButtonWithLoader } from '../../../components/Yaml/YamlViewButtonWithLoader.tsx'; -import { Landscapers } from '../../../components/ControlPlane/Landscapers.tsx'; import { AuthProviderMcp } from '../auth/AuthContextMcp.tsx'; import { isNotFoundError } from '../../../lib/api/error.ts'; import { NotFoundBanner } from '../../../components/Ui/NotFoundBanner/NotFoundBanner.tsx'; -import Graph from '../../../components/Graphs/Graph.tsx'; -import HintsCardsRow from '../../../components/HintsCardsRow/HintsCardsRow.tsx'; +import { ManagedResourcesRequest } from '../../../lib/api/types/crossplane/listManagedResources'; +import { resourcesInterval } from '../../../lib/shared/constants'; +import { useMemo } from 'react'; +import { useMcpBentoLayout } from '../views/DefaultBento.tsx'; +import { CrossplaneDetailsTable } from '../views/CrossplaneDetailsTable'; +import { GitOpsDetailsTable } from '../views/FluxDetailsTable.tsx'; +import { MembersDetailsTable } from '../views/MembersDetailsTable'; export default function McpPage() { const { projectName, workspaceName, controlPlaneName } = useParams(); @@ -60,130 +58,93 @@ export default function McpPage() { > - } - //TODO: actionBar should use Toolbar and ToolbarButton for consistent design - actionsBar={ -
- - - -
- } - /> - } - > - - - - - - - - {t('McpPage.componentsTitle')}} - noAnimation - > - - - - - {t('McpPage.crossplaneTitle')}} - noAnimation - > -
- -
-
- -
-
- -
-
-
- - {t('McpPage.landscapersTitle')}} - noAnimation - > - - - - - {t('McpPage.gitOpsTitle')}} - noAnimation - > - - - -
+
); } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function McpPageContent({ mcp, controlPlaneName }: { mcp: any; controlPlaneName: string }) { + const { t } = useTranslation(); + const { projectName, workspaceName } = useParams(); + + // Add managed resources API call within the MCP context + const { + data: managedResources, + isLoading: managedResourcesLoading, + error: managedResourcesError, + } = useApiResource(ManagedResourcesRequest, { + refreshInterval: resourcesInterval, + }); + + // Flatten managed resources + const allItems = useMemo(() => { + if (!managedResources || !Array.isArray(managedResources)) return []; + return managedResources + .filter((managedResource) => managedResource?.items) + .flatMap((managedResource) => managedResource.items || []); + }, [managedResources]); + + // Prepare member items from role bindings + const memberItems = useMemo( + () => (mcp?.spec?.authorization?.roleBindings || []).map((rb: any) => ({ role: rb.role })), + [mcp?.spec?.authorization?.roleBindings], + ); + + // Use the Bento layout hook which manages expansion state internally + const { expandedCard, bentoGrid } = useMcpBentoLayout({ + mcp, + allItems, + memberItems, + isLoading: managedResourcesLoading, + error: managedResourcesError, + }); + + return ( + } + //TODO: actionBar should use Toolbar and ToolbarButton for consistent design + actionsBar={ +
+ + + +
+ } + /> + } + > + +
+ {/* Unified Bento Layout - Graph stays persistent */} + {bentoGrid} + + {/* Render details tables based on expanded state */} + {expandedCard === 'crossplane' && } + {expandedCard === 'gitops' && } + {expandedCard === 'members' && } +
+
+
+ ); +} diff --git a/src/spaces/mcp/views/CrossplaneDetailsTable.tsx b/src/spaces/mcp/views/CrossplaneDetailsTable.tsx new file mode 100644 index 00000000..46b4bbc5 --- /dev/null +++ b/src/spaces/mcp/views/CrossplaneDetailsTable.tsx @@ -0,0 +1,26 @@ +import { Panel } from '@ui5/webcomponents-react'; +import { ManagedResources } from '../../../components/ControlPlane/ManagedResources'; +import { Providers } from '../../../components/ControlPlane/Providers'; +import { ProvidersConfig } from '../../../components/ControlPlane/ProvidersConfig'; +import styles from '../pages/McpPage.module.css'; + +export function CrossplaneDetailsTable() { + return ( +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/spaces/mcp/views/DefaultBento.tsx b/src/spaces/mcp/views/DefaultBento.tsx new file mode 100644 index 00000000..248422b3 --- /dev/null +++ b/src/spaces/mcp/views/DefaultBento.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import { BentoGrid, BentoCard } from '../../../components/BentoGrid/BentoGrid'; +import { GraphCard } from '../../../components/BentoGrid/GraphCard/GraphCard'; +import { CrossplaneCard } from '../../../components/BentoGrid/ComponentCard/CrossplaneCard/CrossplaneCard'; +import { FluxCard } from '../../../components/BentoGrid/ComponentCard/FluxCard/FluxCard'; +import { ESOCard } from '../../../components/BentoGrid/ComponentCard/ESOCard/ESOCard'; +import { KyvernoCard } from '../../../components/BentoGrid/ComponentCard/KyvernoCard/KyvernoCard'; +import { MembersCard } from '../../../components/BentoGrid/MembersCard/MembersCard'; +import { ManagedResourceItem } from '../../../lib/shared/types'; +import styles from '../pages/McpPage.module.css'; + +export type ExpandedCardType = 'crossplane' | 'gitops' | 'members' | null; + +interface McpBentoLayoutProps { + mcp: any; + allItems: ManagedResourceItem[]; + memberItems: any[]; + isLoading: boolean; + error: any; + onExpandedCardChange?: (expandedCard: ExpandedCardType) => void; +} + +export function useMcpBentoLayout({ + mcp, + allItems, + memberItems, + isLoading, + error, + onExpandedCardChange, +}: McpBentoLayoutProps) { + // Card expansion state management + const [expandedCard, setExpandedCard] = useState(null); + const [isExpanding, setIsExpanding] = useState(false); + + const createExpandHandler = (cardType: ExpandedCardType) => () => { + setIsExpanding(true); + setTimeout(() => { + setExpandedCard(cardType); + setIsExpanding(false); + onExpandedCardChange?.(cardType); + }, 50); + }; + + const handleCollapseExpanded = () => { + setIsExpanding(true); + setTimeout(() => { + setExpandedCard(null); + setIsExpanding(false); + onExpandedCardChange?.(null); + }, 300); + }; + + const handleCrossplaneExpand = createExpandHandler('crossplane'); + const handleGitOpsExpand = createExpandHandler('gitops'); + const handleMembersExpand = createExpandHandler('members'); + + const bentoGrid = ( + + {/* Crossplane Card - always rendered but changes size/position */} + {(!expandedCard || expandedCard === 'crossplane') && ( + +
+ +
+
+ )} + + {/* GitOps Card - shows when expanded */} + {expandedCard === 'gitops' && ( + +
+ +
+
+ )} + + {/* Members Card - shows when expanded */} + {expandedCard === 'members' && ( + +
+ +
+
+ )} + + {/* Graph Card - persistent, only hidden for members view */} + {expandedCard !== 'members' && ( + + + + )} + + {/* Right side cards - only show when collapsed */} + {!expandedCard && ( + <> + {/* Flux Card */} + + + + + {/* Members Card */} + + + + + {/* Kyverno Card */} + + + + + {/* ESO Card */} + + + + + )} +
+ ); + + return { expandedCard, bentoGrid }; +} diff --git a/src/spaces/mcp/views/FluxDetailsTable.tsx b/src/spaces/mcp/views/FluxDetailsTable.tsx new file mode 100644 index 00000000..758d44dc --- /dev/null +++ b/src/spaces/mcp/views/FluxDetailsTable.tsx @@ -0,0 +1,14 @@ +import { Panel } from '@ui5/webcomponents-react'; +import FluxList from '../../../components/ControlPlane/FluxList'; +import styles from '../pages/McpPage.module.css'; + +export function GitOpsDetailsTable() { + return ( +
+ + + +
+
+ ); +} diff --git a/src/spaces/mcp/views/MembersDetailsTable.tsx b/src/spaces/mcp/views/MembersDetailsTable.tsx new file mode 100644 index 00000000..b582cdb5 --- /dev/null +++ b/src/spaces/mcp/views/MembersDetailsTable.tsx @@ -0,0 +1,37 @@ +import { Panel } from '@ui5/webcomponents-react'; +import { MemberTable } from '../../../components/Members/MemberTable'; +import styles from '../pages/McpPage.module.css'; + +interface RoleBinding { + role: string; + subjects: { + kind: string; + name: string; + }[]; + namespace?: string; +} + +interface MembersDetailsTableProps { + mcp: any; +} + +export function MembersDetailsTable({ mcp }: MembersDetailsTableProps) { + return ( +
+ + ({ + name: (binding.subjects?.[0]?.name || 'Unknown').replace(/^openmcp:/, ''), + kind: binding.subjects?.[0]?.kind || 'Unknown', + roles: binding.role ? [binding.role] : [], + namespace: binding.namespace || '', + })) || [] + } + requireAtLeastOneMember={false} + /> + +
+
+ ); +} diff --git a/src/types/types.ts b/src/types/types.ts index 342e89aa..921b5bda 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,12 +1,10 @@ import { ReactNode } from 'react'; import { APIError } from '../lib/api/error'; -import { ManagedResourceItem } from '../lib/shared/types'; -import { PercentageSegment } from '../components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar'; -import { HoverContentProps } from '../components/HintsCardsRow/CardHoverContent/CardHoverContent'; +import { PercentageSegment } from '../components/BentoGrid/MultiPercentageBar/MultiPercentageBar'; -export interface GenericHintSegmentCalculator { +export interface GenericHintSegmentCalculator { ( - allItems: ManagedResourceItem[], + allItems: T[], isLoading: boolean, error: APIError | undefined, enabled: boolean, @@ -14,14 +12,6 @@ export interface GenericHintSegmentCalculator { ): GenericHintState; } -export interface HoverDataCalculator { - ( - allItems: ManagedResourceItem[], - enabled: boolean, - t: (key: string) => string, - ): Omit | null; -} - export interface GenericHintState { segments: PercentageSegment[]; label: string; @@ -30,24 +20,24 @@ export interface GenericHintState { showOnlyNonZero?: boolean; } -export interface GenericHintConfig { +export interface GenericHintConfig { title: string; subtitle: string; iconSrc: string; iconAlt: string; iconStyle?: React.CSSProperties; scrollTarget?: string; - calculateSegments: GenericHintSegmentCalculator; - calculateHoverData?: HoverDataCalculator; - renderHoverContent?: (allItems: ManagedResourceItem[], enabled: boolean) => ReactNode; + calculateSegments: GenericHintSegmentCalculator; + renderHoverContent?: (allItems: T[], enabled: boolean) => ReactNode; } -export interface GenericHintProps { +export interface GenericHintProps { enabled?: boolean; version?: string; onActivate?: () => void; - allItems?: ManagedResourceItem[]; + allItems?: T[]; isLoading?: boolean; error?: APIError; - config: GenericHintConfig; + config: GenericHintConfig; + height?: string | number; } diff --git a/src/utils/hintsCardsRowCalculations.spec.ts b/src/utils/hintsCardsRowCalculations.spec.ts deleted file mode 100644 index d5c1df9f..00000000 --- a/src/utils/hintsCardsRowCalculations.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { - calculateCrossplaneSegments, - calculateGitOpsSegments, - calculateVaultSegments, - calculateCrossplaneHoverData, - HINT_COLORS, -} from './hintsCardsRowCalculations'; -import { ManagedResourceItem, Condition } from '../lib/shared/types'; -import { APIError } from '../lib/api/error'; - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe('calculations', () => { - // Mock translation function - const mockT = (key: string) => key; - - const createManagedResourceItem = ( - kind: string = 'TestResource', - conditions: Condition[] = [], - ): ManagedResourceItem => ({ - kind, - metadata: { - name: 'test-resource', - creationTimestamp: '2023-01-01T00:00:00Z', - labels: [], - }, - status: { - conditions, - }, - }); - - const createCondition = (type: string, status: 'True' | 'False'): Condition => ({ - type, - status, - lastTransitionTime: '2023-01-01T00:00:00Z', - }); - - describe('calculateCrossplaneSegments', () => { - it('returns loading state when isLoading is true', () => { - const result = calculateCrossplaneSegments([], true, undefined, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].color).toBe(HINT_COLORS.inactive); - expect(result.showPercentage).toBe(false); - expect(result.isHealthy).toBe(false); - }); - - it('returns error state when error is provided', () => { - const error = new APIError('Test error', 500); - const result = calculateCrossplaneSegments([], false, error, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].color).toBe(HINT_COLORS.unhealthy); - expect(result.showPercentage).toBe(false); - expect(result.isHealthy).toBe(false); - }); - - it('returns inactive state when enabled is false', () => { - const result = calculateCrossplaneSegments([], false, undefined, false, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].color).toBe(HINT_COLORS.inactive); - expect(result.showPercentage).toBe(false); - expect(result.isHealthy).toBe(false); - }); - - it('returns no resources state when items array is empty', () => { - const result = calculateCrossplaneSegments([], false, undefined, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].color).toBe(HINT_COLORS.inactive); - expect(result.showPercentage).toBe(false); - expect(result.isHealthy).toBe(false); - }); - - it('correctly calculates segments for healthy resources', () => { - const healthyItems = [ - createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - createManagedResourceItem('Service', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - ]; - - const result = calculateCrossplaneSegments(healthyItems, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(3); - expect(result.segments[0].percentage).toBe(100); // healthy - expect(result.segments[1].percentage).toBe(0); // creating - expect(result.segments[2].percentage).toBe(0); // unhealthy - expect(result.isHealthy).toBe(true); - expect(result.showPercentage).toBe(true); - }); - - it('correctly calculates segments for creating resources', () => { - const creatingItems = [ - createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), - ]; - - const result = calculateCrossplaneSegments(creatingItems, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(3); - expect(result.segments[0].percentage).toBe(0); // healthy - expect(result.segments[1].percentage).toBe(100); // creating - expect(result.segments[2].percentage).toBe(0); // unhealthy - expect(result.isHealthy).toBe(false); - }); - - it('correctly calculates segments for unhealthy resources', () => { - const unhealthyItems = [ - createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), - ]; - - const result = calculateCrossplaneSegments(unhealthyItems, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(3); - expect(result.segments[0].percentage).toBe(0); // healthy - expect(result.segments[1].percentage).toBe(0); // creating - expect(result.segments[2].percentage).toBe(100); // unhealthy - expect(result.isHealthy).toBe(false); - }); - - it('correctly calculates mixed resource states', () => { - const mixedItems = [ - createManagedResourceItem('Pod1', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - createManagedResourceItem('Pod2', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), - createManagedResourceItem('Pod3', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), - createManagedResourceItem('Pod4', []), // No conditions = unhealthy - ]; - - const result = calculateCrossplaneSegments(mixedItems, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(3); - expect(result.segments[0].percentage).toBe(25); // 1/4 healthy - expect(result.segments[1].percentage).toBe(25); // 1/4 creating - expect(result.segments[2].percentage).toBe(50); // 2/4 unhealthy - expect(result.isHealthy).toBe(false); - }); - }); - - describe('calculateGitOpsSegments', () => { - it('returns loading state when isLoading is true', () => { - const result = calculateGitOpsSegments([], true, undefined, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].color).toBe(HINT_COLORS.inactive); - expect(result.showPercentage).toBe(false); - expect(result.isHealthy).toBe(false); - }); - - it('correctly calculates progress for flux-labeled resources', () => { - const itemWithFluxLabel = createManagedResourceItem('Pod'); - itemWithFluxLabel.metadata.labels = { - 'kustomize.toolkit.fluxcd.io/name': 'test-app', - } as any; - - const itemWithoutFluxLabel = createManagedResourceItem('Service'); - - const items = [itemWithFluxLabel, itemWithoutFluxLabel]; - const result = calculateGitOpsSegments(items, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(2); - expect(result.segments[0].percentage).toBe(50); // 1/2 with flux label - expect(result.segments[1].percentage).toBe(50); // 1/2 remaining - expect(result.isHealthy).toBe(false); // < 70% - }); - - it('marks as healthy when progress >= 70%', () => { - const items = Array.from({ length: 10 }, (_, i) => { - const item = createManagedResourceItem(`Pod${i}`); - if (i < 8) { - // 8/10 = 80% - item.metadata.labels = { - 'kustomize.toolkit.fluxcd.io/name': 'test-app', - } as any; - } - return item; - }); - - const result = calculateGitOpsSegments(items, false, undefined, true, mockT); - - expect(result.segments[0].percentage).toBe(80); - expect(result.segments[0].color).toBe(HINT_COLORS.healthy); - expect(result.isHealthy).toBe(true); - }); - - it('uses progress color when progress < 70%', () => { - const items = Array.from({ length: 10 }, (_, i) => { - const item = createManagedResourceItem(`Pod${i}`); - if (i < 5) { - // 5/10 = 50% - item.metadata.labels = { - 'kustomize.toolkit.fluxcd.io/name': 'test-app', - } as any; - } - return item; - }); - - const result = calculateGitOpsSegments(items, false, undefined, true, mockT); - - expect(result.segments[0].percentage).toBe(50); - expect(result.segments[0].color).toBe(HINT_COLORS.progress); - expect(result.isHealthy).toBe(false); - }); - }); - - describe('calculateVaultSegments', () => { - it('returns active state when resources exist', () => { - const items = [createManagedResourceItem('Secret')]; - const result = calculateVaultSegments(items, false, undefined, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].percentage).toBe(100); - expect(result.segments[0].color).toBe(HINT_COLORS.healthy); - expect(result.isHealthy).toBe(true); - expect(result.showPercentage).toBe(true); - }); - - it('returns inactive state when no resources exist', () => { - const result = calculateVaultSegments([], false, undefined, true, mockT); - - expect(result.segments).toHaveLength(1); - expect(result.segments[0].percentage).toBe(100); - expect(result.segments[0].color).toBe(HINT_COLORS.inactive); - expect(result.isHealthy).toBe(false); - }); - }); - - describe('calculateCrossplaneHoverData', () => { - it('calculates statistics by resource type', () => { - const items = [ - createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), - createManagedResourceItem('Service', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - createManagedResourceItem('Service', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), - ]; - - const result = calculateCrossplaneHoverData(items); - - expect(result.resourceTypeStats).toHaveLength(2); - - const podStats = result.resourceTypeStats.find((s) => s.type === 'Pod'); - expect(podStats).toBeDefined(); - expect(podStats!.total).toBe(2); - expect(podStats!.healthy).toBe(1); - expect(podStats!.creating).toBe(1); - expect(podStats!.unhealthy).toBe(0); - expect(podStats!.healthyPercentage).toBe(50); - - const serviceStats = result.resourceTypeStats.find((s) => s.type === 'Service'); - expect(serviceStats).toBeDefined(); - expect(serviceStats!.total).toBe(2); - expect(serviceStats!.healthy).toBe(1); - expect(serviceStats!.creating).toBe(0); - expect(serviceStats!.unhealthy).toBe(1); - expect(serviceStats!.unhealthyPercentage).toBe(50); - }); - - it('calculates overall statistics correctly', () => { - const items = [ - createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), - createManagedResourceItem('Service', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), - createManagedResourceItem('ConfigMap', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), - ]; - - const result = calculateCrossplaneHoverData(items); - - expect(result.overallStats.total).toBe(3); - expect(result.overallStats.healthy).toBe(1); - expect(result.overallStats.creating).toBe(1); - expect(result.overallStats.unhealthy).toBe(1); - }); - - it('handles resources without kind', () => { - const itemWithoutKind = createManagedResourceItem('', [ - createCondition('Ready', 'True'), - createCondition('Synced', 'True'), - ]); - // @ts-ignore - testing edge case - itemWithoutKind.kind = undefined; - - const result = calculateCrossplaneHoverData([itemWithoutKind]); - - expect(result.resourceTypeStats).toHaveLength(1); - expect(result.resourceTypeStats[0].type).toBe('Unknown'); - expect(result.resourceTypeStats[0].healthy).toBe(1); - }); - - it('handles resources without conditions', () => { - const itemWithoutConditions = createManagedResourceItem('Pod'); - delete itemWithoutConditions.status; - - const result = calculateCrossplaneHoverData([itemWithoutConditions]); - - expect(result.resourceTypeStats).toHaveLength(1); - expect(result.resourceTypeStats[0].type).toBe('Pod'); - expect(result.resourceTypeStats[0].unhealthy).toBe(1); - expect(result.overallStats.unhealthy).toBe(1); - }); - - it('returns empty arrays for no items', () => { - const result = calculateCrossplaneHoverData([]); - - expect(result.resourceTypeStats).toHaveLength(0); - expect(result.overallStats.total).toBe(0); - expect(result.overallStats.healthy).toBe(0); - expect(result.overallStats.creating).toBe(0); - expect(result.overallStats.unhealthy).toBe(0); - }); - }); -}); diff --git a/src/utils/hintsCardsRowCalculations.ts b/src/utils/hintsCardsRowCalculations.ts deleted file mode 100644 index 2e9f9a89..00000000 --- a/src/utils/hintsCardsRowCalculations.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { ManagedResourceItem, Condition } from '../lib/shared/types'; -import { APIError } from '../lib/api/error'; -import { GenericHintSegmentCalculator, GenericHintState, HoverDataCalculator } from '../types/types'; - -import { HoverContentProps } from '../components/HintsCardsRow/CardHoverContent/CardHoverContent'; - -/** - * Common colors used across all hints - */ -export const HINT_COLORS = { - healthy: '#28a745', - creating: '#0874f4', - unhealthy: '#d22020ff', - inactive: '#e9e9e9ff', - managed: '#28a745', - progress: '#fd7e14', -} as const; - -/** - * Crossplane-specific segment calculation - */ -export const calculateCrossplaneSegments: GenericHintSegmentCalculator = ( - allItems: ManagedResourceItem[], - isLoading: boolean, - error: APIError | undefined, - enabled: boolean, - t: (key: string) => string, -): GenericHintState => { - if (isLoading) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], - label: t('Hints.common.loading'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (error) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], - label: t('Hints.common.errorLoadingResources'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (!enabled) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.inactive') }], - label: t('Hints.CrossplaneHint.inactive'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - const totalCount = allItems.length; - - if (totalCount === 0) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.noResources') }], - label: t('Hints.CrossplaneHint.noResources'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - // Calculate health statistics - const healthyCount = allItems.filter((item: ManagedResourceItem) => { - const conditions = item.status?.conditions || []; - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); - return !!ready && !!synced; - }).length; - - const creatingCount = allItems.filter((item: ManagedResourceItem) => { - const conditions = item.status?.conditions || []; - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); - return !!synced && !ready; - }).length; - - const unhealthyCount = totalCount - healthyCount - creatingCount; - const healthyPercentage = Math.round((healthyCount / totalCount) * 100); - const creatingPercentage = Math.round((creatingCount / totalCount) * 100); - const unhealthyPercentage = Math.round((unhealthyCount / totalCount) * 100); - - return { - segments: [ - { percentage: healthyPercentage, color: HINT_COLORS.healthy, label: t('common.healthy') }, - { percentage: creatingPercentage, color: HINT_COLORS.creating, label: t('common.creating') }, - { percentage: unhealthyPercentage, color: HINT_COLORS.unhealthy, label: t('common.unhealthy') }, - ], - label: t('Hints.CrossplaneHint.healthy'), - showPercentage: true, - isHealthy: healthyPercentage === 100 && totalCount > 0, - showOnlyNonZero: true, - }; -}; - -/** - * GitOps-specific segment calculation - */ -export const calculateGitOpsSegments: GenericHintSegmentCalculator = ( - allItems: ManagedResourceItem[], - isLoading: boolean, - error: APIError | undefined, - enabled: boolean, - t: (key: string) => string, -): GenericHintState => { - if (isLoading) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], - label: t('Hints.common.loading'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (error) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], - label: t('Hints.common.errorLoadingResources'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (!enabled) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.inactive') }], - label: t('Hints.GitOpsHint.inactive'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - const totalCount = allItems.length; - - if (totalCount === 0) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.noResources') }], - label: t('Hints.GitOpsHint.noResources'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - // Count the number of items with the flux label - const fluxLabelCount = allItems.filter( - (item: ManagedResourceItem) => - item?.metadata?.labels && - Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name'), - ).length; - - const progressValue = totalCount > 0 ? Math.round((fluxLabelCount / totalCount) * 100) : 0; - const restPercentage = 100 - progressValue; - const progressColor = progressValue >= 70 ? HINT_COLORS.healthy : HINT_COLORS.progress; - - return { - segments: [ - { percentage: progressValue, color: progressColor, label: t('common.progress') }, - { percentage: restPercentage, color: HINT_COLORS.inactive, label: t('common.remaining') }, - ], - label: t('Hints.GitOpsHint.managed'), - showPercentage: true, - isHealthy: progressValue >= 70, - showOnlyNonZero: true, - }; -}; - -/** - * Vault-specific segment calculation - */ -export const calculateVaultSegments: GenericHintSegmentCalculator = ( - allItems: ManagedResourceItem[], - isLoading: boolean, - error: APIError | undefined, - enabled: boolean, - t: (key: string) => string, -): GenericHintState => { - if (isLoading) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], - label: t('Hints.common.loading'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (error) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], - label: t('Hints.common.errorLoadingResources'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - if (!enabled) { - return { - segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.VaultHint.inactive') }], - label: t('Hints.VaultHint.inactive'), - showPercentage: false, - isHealthy: false, - showOnlyNonZero: true, - }; - } - - const hasResources = allItems.length > 0; - const label = hasResources ? `100${t('Hints.VaultHint.progressAvailable')}` : t('Hints.VaultHint.noResources'); - const color = hasResources ? HINT_COLORS.healthy : HINT_COLORS.inactive; - - return { - segments: [{ percentage: 100, color, label: t('common.active') }], - label, - showPercentage: true, - isHealthy: hasResources, - showOnlyNonZero: true, - }; -}; - -/** - * Types for hover content calculations - */ -export interface ResourceTypeStats { - type: string; - total: number; - healthy: number; - creating: number; - unhealthy: number; - healthyPercentage: number; - creatingPercentage: number; - unhealthyPercentage: number; -} - -export interface OverallStats { - total: number; - healthy: number; - creating: number; - unhealthy: number; -} - -export interface CrossplaneHoverData { - resourceTypeStats: ResourceTypeStats[]; - overallStats: OverallStats; -} - -/** - * Calculate comprehensive statistics for Crossplane hover content - */ -export const calculateCrossplaneHoverData = (allItems: ManagedResourceItem[]): CrossplaneHoverData => { - const typeStats: Record = {}; - let totalHealthy = 0; - let totalCreating = 0; - let totalUnhealthy = 0; - - allItems.forEach((item: ManagedResourceItem) => { - const type = item.kind || 'Unknown'; - - if (!typeStats[type]) { - typeStats[type] = { total: 0, healthy: 0, creating: 0, unhealthy: 0 }; - } - - typeStats[type].total++; - - const conditions = item.status?.conditions || []; - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); - - if (ready && synced) { - typeStats[type].healthy++; - totalHealthy++; - } else if (synced && !ready) { - // Resource is synced but not ready - it's creating - typeStats[type].creating++; - totalCreating++; - } else { - // Resource has issues or is not synced - typeStats[type].unhealthy++; - totalUnhealthy++; - } - }); - - const resourceTypeStats: ResourceTypeStats[] = Object.keys(typeStats).map((type) => { - const stats = typeStats[type]; - return { - type, - total: stats.total, - healthy: stats.healthy, - creating: stats.creating, - unhealthy: stats.unhealthy, - healthyPercentage: Math.round((stats.healthy / stats.total) * 100), - creatingPercentage: Math.round((stats.creating / stats.total) * 100), - unhealthyPercentage: Math.round((stats.unhealthy / stats.total) * 100), - }; - }); - - return { - resourceTypeStats, - overallStats: { - total: allItems.length, - healthy: totalHealthy, - creating: totalCreating, - unhealthy: totalUnhealthy, - }, - }; -}; - -/** - * Calculate hover data for Crossplane using the generic HoverContent structure - * Shows healthy resources (the positive segment) - */ -export const calculateCrossplaneHoverDataGeneric: HoverDataCalculator = ( - allItems: ManagedResourceItem[], - enabled: boolean, - t: (key: string) => string, -): Omit | null => { - if (!enabled || allItems.length === 0) { - return null; - } - - const { resourceTypeStats, overallStats } = calculateCrossplaneHoverData(allItems); - - // Get the segments from the bar chart calculation to ensure color consistency - const segmentData = calculateCrossplaneSegments(allItems, false, undefined, enabled, t); - - const legendItems = segmentData.segments.map((segment) => ({ - label: segment.label, - count: - segment.label === t('common.healthy') - ? overallStats.healthy - : segment.label === t('common.creating') - ? overallStats.creating - : overallStats.unhealthy, - color: segment.color, - })); - - // Focus on healthy percentage in radar chart (the positive aspect) - const radarDataset = resourceTypeStats.map((stats) => ({ - type: stats.type, - healthy: stats.healthyPercentage, - })); - - // Use the color of the healthy segment (first segment in the bar chart) - const healthyColor = segmentData.segments.find((s) => s.label === t('common.healthy'))?.color || HINT_COLORS.healthy; - - return { - totalCount: overallStats.total, - totalLabel: t('Hints.CrossplaneHint.hoverContent.totalResources'), - legendItems, - radarDataset, - radarDimensions: [{ accessor: 'type' }], - radarMeasures: [ - { - accessor: 'healthy', - color: healthyColor, - hideDataLabel: true, - label: t('Hints.CrossplaneHint.hoverContent.healthy') + ' (%)', - }, - ], - }; -}; - -/** - * Calculate hover data for GitOps showing resource type management coverage - * Shows managed resources (the positive segment) - */ -export const calculateGitOpsHoverDataGeneric: HoverDataCalculator = ( - allItems: ManagedResourceItem[], - enabled: boolean, - t: (key: string) => string, -): Omit | null => { - if (!enabled || allItems.length === 0) { - return null; - } - - // Group by resource type and calculate flux management coverage - const typeStats: Record = {}; - let totalManaged = 0; - - allItems.forEach((item: ManagedResourceItem) => { - const type = item.kind || 'Unknown'; - - if (!typeStats[type]) { - typeStats[type] = { total: 0, managed: 0 }; - } - - typeStats[type].total++; - - // Check if the resource is managed by Flux - if ( - item?.metadata?.labels && - Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name') - ) { - typeStats[type].managed++; - totalManaged++; - } - }); - - const totalUnmanaged = allItems.length - totalManaged; - - // Get the segments from the bar chart calculation to ensure color consistency - const segmentData = calculateGitOpsSegments(allItems, false, undefined, enabled, t); - - const legendItems = segmentData.segments.map((segment) => ({ - label: segment.label, - count: segment.label === t('common.progress') ? totalManaged : totalUnmanaged, - color: segment.color, - })); - - // Focus on managed percentage in radar chart (the positive aspect) - const radarDataset = Object.keys(typeStats).map((type) => { - const stats = typeStats[type]; - const managedPercentage = Math.round((stats.managed / stats.total) * 100); - return { - type, - managed: managedPercentage, - }; - }); - - // Use the color of the progress/managed segment (first segment in the bar chart) - const managedColor = segmentData.segments.find((s) => s.label === t('common.progress'))?.color || HINT_COLORS.managed; - - return { - totalCount: allItems.length, - totalLabel: t('Hints.GitOpsHint.hoverContent.totalResources'), - legendItems, - radarDataset, - radarDimensions: [{ accessor: 'type' }], - radarMeasures: [ - { - accessor: 'managed', - color: managedColor, - hideDataLabel: true, - label: t('Hints.GitOpsHint.hoverContent.managed') + ' (%)', - }, - ], - }; -}; diff --git a/src/views/Login.tsx b/src/views/Login.tsx index ca935f9d..4e7a3643 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -29,7 +29,11 @@ export default function LoginView() { >
- Logo + + + + Logo +
{t('Login.welcomeMessage')}
{t('Login.description')}