diff --git a/public/locales/en.json b/public/locales/en.json index da3857bc..c4bc875f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -290,14 +290,12 @@ "Graphs": { "colorsProvider": "Provider", "colorsProviderConfig": "Provider Config", - "colorizedTitle": "Visualize: ", + "colorizedTitle": "Group by: ", + "colorsFlux": "Flux", "loadingError": "Error loading graph data", "loadingGraph": "Loading graph data...", "noResources": "No resources to display" }, - "GraphsLegend": { - "title": "Legend" - }, "validationErrors": { "required": "This field is required!", "properFormatting": "Use A-Z, a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", @@ -328,7 +326,8 @@ "ready": "Ready", "synced": "Synced", "healthy": "Healthy", - "installed": "Installed" + "installed": "Installed", + "none": "None" }, "errors": { "installError": "Install error", diff --git a/src/components/Graphs/CustomNode.module.css b/src/components/Graphs/CustomNode.module.css index b314605d..dffcf4b4 100644 --- a/src/components/Graphs/CustomNode.module.css +++ b/src/components/Graphs/CustomNode.module.css @@ -9,6 +9,7 @@ position: relative; font-family: var(--sapFontFamily); pointer-events: auto; + color: var(--sapTextColor, #222); } .nodeContent { @@ -32,7 +33,7 @@ .nodeType { font-size: 12px; - color: #888; + color: var(--sapContent_LabelColor, #888); margin-top: 2px; } @@ -48,4 +49,12 @@ .handleHidden { visibility: hidden; +} + +:global([data-theme='dark']) .nodeContainer { + color: #fff; +} + +:global([data-theme='dark']) .nodeType { + color: rgba(255, 255, 255, 0.75); } \ No newline at end of file diff --git a/src/components/Graphs/CustomNode.tsx b/src/components/Graphs/CustomNode.tsx index 0470ecab..de2296b5 100644 --- a/src/components/Graphs/CustomNode.tsx +++ b/src/components/Graphs/CustomNode.tsx @@ -1,37 +1,49 @@ import React from 'react'; import { Button, Icon } from '@ui5/webcomponents-react'; -import StatusIcon from './StatusIcon'; +import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import styles from './CustomNode.module.css'; import { Handle, Position } from '@xyflow/react'; +import { useTranslation } from 'react-i18next'; export interface CustomNodeProps { label: string; type?: string; status: string; + transitionTime?: string; + statusMessage?: string; onYamlClick: () => void; } -const CustomNode: React.FC = ({ label, type, status, onYamlClick }) => ( -
- - -
-
- -
-
-
- {label} +const CustomNode: React.FC = ({ label, type, status, transitionTime, statusMessage, onYamlClick }) => { + const { t } = useTranslation(); + return ( +
+ + +
+
+ +
+
+
+ {label} +
+ {type &&
{type}
}
- {type &&
{type}
} +
+
+
-
- -
-
-); + ); +}; export default CustomNode; diff --git a/src/components/Graphs/Graph.module.css b/src/components/Graphs/Graph.module.css index 57ab3501..bab70a86 100644 --- a/src/components/Graphs/Graph.module.css +++ b/src/components/Graphs/Graph.module.css @@ -1,10 +1,10 @@ .graphContainer { display: flex; height: 600px; - border: 1px solid #ddd; + border: 1px solid var(--sapList_BorderColor, #ddd); border-radius: 8px; overflow: hidden; - background-color: #fafafa; + background-color: var(--sapBackgroundColor, #fafafa); font-family: var(--sapFontFamily); } @@ -19,6 +19,7 @@ display: flex; gap: 1rem; align-items: center; + color: var(--sapTextColor, #222); } .graphToolbar { @@ -43,4 +44,30 @@ .colorizedTitle { font-weight: 500; + color: var(--sapTextColor, #222); +} + +/* Remove the default fieldset frame when used for grouping only */ +.fieldsetReset { + border: 0; + margin: 0; + padding: 0; + min-inline-size: 0; +} + +/* React Flow Controls dark mode */ +:global([data-theme='dark'] .react-flow__controls) { + background-color: rgba(28, 28, 28, 0.9); + border: 1px solid rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +:global([data-theme='dark'] .react-flow__controls-button) { + background: transparent; + color: #fff; + border-color: rgba(255, 255, 255, 0.25); +} + +:global([data-theme='dark'] .react-flow__controls-button:hover) { + background: rgba(255, 255, 255, 0.08); } \ No newline at end of file diff --git a/src/components/Graphs/Graph.tsx b/src/components/Graphs/Graph.tsx index 44029035..f4974b54 100644 --- a/src/components/Graphs/Graph.tsx +++ b/src/components/Graphs/Graph.tsx @@ -1,10 +1,10 @@ import React, { useState, useCallback, useMemo } from 'react'; -import { ReactFlow, Background, Controls, MarkerType, Node } from '@xyflow/react'; +import { ReactFlow, Background, Controls, MarkerType, Node, Panel } from '@xyflow/react'; import type { NodeProps } from '@xyflow/react'; import { RadioButton, FlexBox, FlexBoxAlignItems } from '@ui5/webcomponents-react'; import styles from './Graph.module.css'; import '@xyflow/react/dist/style.css'; -import { ManagedResourceItem, NodeData, ColorBy } from './types'; +import { NodeData, ColorBy } from './types'; import CustomNode from './CustomNode'; import { Legend, LegendItem } from './Legend'; import { YamlViewDialog } from '../Yaml/YamlViewDialog'; @@ -13,6 +13,8 @@ import { stringify } from 'yaml'; import { removeManagedFieldsProperty } from '../../utils/removeManagedFieldsProperty'; 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>) => ( @@ -20,6 +22,8 @@ const nodeTypes = { label={props.data.label} type={props.data.type} status={props.data.status} + transitionTime={props.data.transitionTime} + statusMessage={props.data.statusMessage} onYamlClick={() => props.data.onYamlClick(props.data.item)} /> ), @@ -27,6 +31,7 @@ const nodeTypes = { const Graph: React.FC = () => { const { t } = useTranslation(); + const { isDarkTheme } = useTheme(); const [colorBy, setColorBy] = useState('provider'); const [yamlDialogOpen, setYamlDialogOpen] = useState(false); const [yamlResource, setYamlResource] = useState(null); @@ -51,11 +56,11 @@ const Graph: React.FC = () => { const legendItems: LegendItem[] = useMemo( () => - Object.entries(colorMap).map(([name, color]) => ({ - name: name === 'default' ? 'default' : name, - color, - })), - [colorMap], + Object.entries(colorMap).map(([name, color]) => { + const displayName = colorBy === 'flux' && (name === 'default' || !name) ? t('common.none') : name; + return { name: displayName, color }; + }), + [colorMap, colorBy, t], ); if (error) { @@ -71,26 +76,10 @@ const Graph: React.FC = () => { } return ( -
+
-
- - {t('Graphs.colorizedTitle')} - setColorBy('provider')} - /> - setColorBy('source')} - /> - -
{ zoomOnScroll={true} panOnDrag={true} > - + + + +
+
+ {t('Graphs.colorizedTitle')} + setColorBy('provider')} + /> + setColorBy('source')} + /> + setColorBy('flux')} + /> +
+
+
+
+ + +
{ setIsOpen={setYamlDialogOpen} dialogContent={} /> -
); }; diff --git a/src/components/Graphs/Legend.module.css b/src/components/Graphs/Legend.module.css index 54759829..73efd11a 100644 --- a/src/components/Graphs/Legend.module.css +++ b/src/components/Graphs/Legend.module.css @@ -3,23 +3,26 @@ min-width: 240px; max-width: 300px; max-height: 280px; - border: 1px solid #ccc; + border: 1px solid var(--sapList_BorderColor, #ccc); border-radius: 8px; - background-color: #fff; + background-color: var(--sapTile_Background, #fff); margin: 1rem; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); overflow: auto; align-self: flex-start; + color: var(--sapTextColor, #222); } .legendTitle { margin-bottom: 10px; + color: var(--sapTitleColor, var(--sapTextColor, #222)); } .legendRow { display: flex; align-items: center; margin-bottom: 8px; + color: var(--sapTextColor, #222); } .legendColorBox { @@ -27,5 +30,5 @@ height: 16px; margin-right: 8px; border-radius: 3px; - border: 1px solid #999; + border: 1px solid var(--sapList_BorderColor, #999); } \ No newline at end of file diff --git a/src/components/Graphs/Legend.tsx b/src/components/Graphs/Legend.tsx index 0cf8a960..8c4f5cc1 100644 --- a/src/components/Graphs/Legend.tsx +++ b/src/components/Graphs/Legend.tsx @@ -1,7 +1,5 @@ import React from 'react'; import styles from './Legend.module.css'; -import { useTranslation } from 'react-i18next'; - export interface LegendItem { name: string; color: string; @@ -12,11 +10,8 @@ interface LegendProps { } export const Legend: React.FC = ({ legendItems }) => { - const { t } = useTranslation(); - return (
-

{t('GraphsLegend.title')}

{legendItems.map(({ name, color }) => (
diff --git a/src/components/Graphs/graphUtils.spec.ts b/src/components/Graphs/graphUtils.spec.ts index b807f307..f4340194 100644 --- a/src/components/Graphs/graphUtils.spec.ts +++ b/src/components/Graphs/graphUtils.spec.ts @@ -1,68 +1,65 @@ import { describe, it, expect } from 'vitest'; -import { getStatusFromConditions, resolveProviderType, generateColorMap } from './graphUtils'; +import { getStatusCondition, resolveProviderType, generateColorMap } from './graphUtils'; +import { ProviderConfigs } from '../../lib/shared/types'; -describe('getStatusFromConditions', () => { - it('returns OK if Ready is True', () => { - expect(getStatusFromConditions([{ type: 'Ready', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe('OK'); +describe('getStatusCondition', () => { + it('returns the Ready condition when present', () => { + const ready = { type: 'Ready', status: 'True', lastTransitionTime: '2024-01-01' } as const; + const result = getStatusCondition([ready, { type: 'Healthy', status: 'False', lastTransitionTime: '2024-01-02' }]); + expect(result).toEqual(ready); }); - it('returns OK if Healthy is True', () => { - expect(getStatusFromConditions([{ type: 'Healthy', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe('OK'); + it('returns the Healthy condition when Ready is absent', () => { + const healthy = { type: 'Healthy', status: 'True', lastTransitionTime: '2024-01-01' } as const; + const result = getStatusCondition([healthy, { type: 'Other', status: 'True', lastTransitionTime: '2024-01-02' }]); + expect(result).toEqual(healthy); }); - it('returns ERROR if Ready is False', () => { - expect(getStatusFromConditions([{ type: 'Ready', status: 'False', lastTransitionTime: '2024-01-01' }])).toBe( - 'ERROR', - ); + it('returns undefined if no relevant condition exists', () => { + const result = getStatusCondition([{ type: 'Other', status: 'True', lastTransitionTime: '2024-01-01' }] as any); + expect(result).toBeUndefined(); }); - it('returns ERROR if Healthy is False', () => { - expect(getStatusFromConditions([{ type: 'Healthy', status: 'False', lastTransitionTime: '2024-01-01' }])).toBe( - 'ERROR', - ); - }); - - it('returns ERROR if no relevant condition exists', () => { - expect(getStatusFromConditions([{ type: 'Other', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe( - 'ERROR', - ); - }); - - it('returns ERROR for undefined or empty input', () => { - expect(getStatusFromConditions(undefined)).toBe('ERROR'); - expect(getStatusFromConditions([])).toBe('ERROR'); + it('returns undefined for undefined or empty input', () => { + expect(getStatusCondition(undefined)).toBeUndefined(); + expect(getStatusCondition([])).toBeUndefined(); }); }); describe('resolveProviderType', () => { it('returns correct providerType if found', () => { - const configs = [ + const configs: ProviderConfigs[] = [ { + provider: 'provider-a', items: [ { metadata: { name: 'foo' }, apiVersion: 'btp/v1' }, { metadata: { name: 'bar' }, apiVersion: 'cloudfoundry/v1' }, ], }, { + provider: 'provider-b', items: [{ metadata: { name: 'baz' }, apiVersion: 'gardener/v1' }], }, - ]; + ] as any; expect(resolveProviderType('foo', configs)).toBe('provider-btp'); expect(resolveProviderType('bar', configs)).toBe('provider-cf'); expect(resolveProviderType('baz', configs)).toBe('provider-gardener'); }); it('returns apiVersion or configName if no match for known providers', () => { - const configs = [ + const configs: ProviderConfigs[] = [ { + provider: 'provider-a', items: [{ metadata: { name: 'other' }, apiVersion: 'custom/v1' }], }, - ]; + ] as any; expect(resolveProviderType('other', configs)).toBe('custom/v1'); }); it('returns configName if not found', () => { - const configs = [{ items: [{ metadata: { name: 'foo' }, apiVersion: 'btp/v1' }] }]; + const configs: ProviderConfigs[] = [ + { provider: 'provider-a', items: [{ metadata: { name: 'foo' }, apiVersion: 'btp/v1' }] }, + ] as any; expect(resolveProviderType('notfound', configs)).toBe('notfound'); }); }); diff --git a/src/components/Graphs/graphUtils.ts b/src/components/Graphs/graphUtils.ts index 3eeda676..3c19b005 100644 --- a/src/components/Graphs/graphUtils.ts +++ b/src/components/Graphs/graphUtils.ts @@ -1,14 +1,14 @@ -import { Condition, ManagedResourceItem, NodeData, ProviderConfig } from './types'; +import { Condition, ManagedResourceItem, ProviderConfigs } from '../../lib/shared/types'; +import { NodeData } from './types'; export type StatusType = 'ERROR' | 'OK'; -export const getStatusFromConditions = (conditions?: Condition[]): StatusType => { - if (!conditions || !Array.isArray(conditions)) return 'ERROR'; - const relevant = conditions.find((c) => c.type === 'Ready' || c.type === 'Healthy'); - return relevant?.status === 'True' ? 'OK' : 'ERROR'; +export const getStatusCondition = (conditions?: Condition[]): Condition | undefined => { + if (!conditions || !Array.isArray(conditions)) return undefined; + return conditions.find((c) => c.type === 'Ready' || c.type === 'Healthy'); }; -export const resolveProviderType = (configName: string, providerConfigsList: ProviderConfig[]): string => { +export const resolveProviderType = (configName: string, providerConfigsList: ProviderConfigs[]): string => { for (const configList of providerConfigsList || []) { const match = configList.items?.find((item) => item.metadata?.name === configName); @@ -26,31 +26,23 @@ export const resolveProviderType = (configName: string, providerConfigsList: Pro }; export const generateColorMap = (items: NodeData[], colorBy: string): Record => { - const colors = [ - '#1abc9c', - '#9b59b6', - '#2ecc71', - '#2980b9', - '#3498db', - '#e67e22', - '#e74c3c', - '#16a085', - '#f39c12', - '#d35400', - '#8e44ad', - '#c0392b', - ]; + const colors = ['#E09D00', '#E6600D', '#AB218E', '#678BC7', '#1A9898', '#759421', '#925ACE', '#647987']; - const keys = - colorBy === 'source' - ? Array.from(new Set(items.map((i) => i.providerType).filter(Boolean))) - : Array.from(new Set(items.map((i) => i.providerConfigName).filter(Boolean))); + const keys = (() => { + if (colorBy === 'source') return Array.from(new Set(items.map((i) => i.providerType).filter(Boolean))); + if (colorBy === 'flux') return Array.from(new Set(items.map((i) => i.fluxName ?? 'default'))); + return Array.from(new Set(items.map((i) => i.providerConfigName).filter(Boolean))); + })(); const map = new Map(); keys.forEach((key, i) => { map.set(key, colors[i % colors.length]); }); + if (colorBy === 'flux' && keys.includes('default')) { + map.set('default', '#BFBFBF'); + } + return Object.fromEntries(map); }; diff --git a/src/components/Graphs/types.ts b/src/components/Graphs/types.ts index 632bb3a8..427cf66f 100644 --- a/src/components/Graphs/types.ts +++ b/src/components/Graphs/types.ts @@ -1,56 +1,6 @@ -export type ColorBy = 'provider' | 'source'; +import { ManagedResourceItem } from '../../lib/shared/types'; -export interface Condition { - type: 'Ready' | 'Synced' | unknown; - status: 'True' | 'False'; - lastTransitionTime: string; -} - -export interface ManagedResourceItem { - kind: string; - metadata: { - name: string; - creationTimestamp: string; - }; - apiVersion?: string; - spec?: { - providerConfigRef?: { name: string }; - forProvider?: { - subaccountRef?: { name?: string }; - serviceManagerRef?: { name?: string }; - spaceRef?: { name?: string }; - orgRef?: { name?: string }; - directoryRef?: { name?: string }; - entitlementRef?: { name?: string }; - globalAccountRef?: { name?: string }; - orgRoleRef?: { name?: string }; - spaceMembersRef?: { name?: string }; - cloudFoundryEnvironmentRef?: { name?: string }; - kymaEnvironmentRef?: { name?: string }; - roleCollectionRef?: { name?: string }; - roleCollectionAssignmentRef?: { name?: string }; - subaccountTrustConfigurationRef?: { name?: string }; - globalaccountTrustConfigurationRef?: { name?: string }; - }; - cloudManagementRef?: { name: string }; - }; - status?: { - conditions?: Condition[]; - }; -} - -export interface ManagedResourceGroup { - items: ManagedResourceItem[]; -} - -export interface ProviderConfigItem { - metadata?: { name: string }; - apiVersion?: string; -} - -export interface ProviderConfig { - items?: ProviderConfigItem[]; -} +export type ColorBy = 'provider' | 'source' | 'flux'; export interface NodeData { [key: string]: unknown; @@ -60,6 +10,9 @@ export interface NodeData { providerConfigName: string; providerType: string; status: string; + transitionTime?: string; + statusMessage?: string; + fluxName?: string; parentId?: string; extraRefs: string[]; item: ManagedResourceItem; diff --git a/src/components/Graphs/useGraph.ts b/src/components/Graphs/useGraph.ts index 68593f22..63542e40 100644 --- a/src/components/Graphs/useGraph.ts +++ b/src/components/Graphs/useGraph.ts @@ -4,8 +4,9 @@ import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listMana import { resourcesInterval } from '../../lib/shared/constants'; import { Node, Edge, Position, MarkerType } from '@xyflow/react'; import dagre from 'dagre'; -import { NodeData, ManagedResourceGroup, ManagedResourceItem, ColorBy } from './types'; -import { extractRefs, generateColorMap, getStatusFromConditions, resolveProviderType } from './graphUtils'; +import { NodeData, ColorBy } from './types'; +import { extractRefs, generateColorMap, getStatusCondition, resolveProviderType } from './graphUtils'; +import { ManagedResourceGroup, ManagedResourceItem } from '../../lib/shared/types'; const nodeWidth = 250; const nodeHeight = 60; @@ -21,7 +22,8 @@ function buildGraph( const nodeMap = new Map>(); treeData.forEach((n) => { - const colorKey = colorBy === 'source' ? n.providerType : n.providerConfigName; + const colorKey: string = + colorBy === 'source' ? n.providerType : colorBy === 'flux' ? (n.fluxName ?? 'default') : n.providerConfigName; const node: Node = { id: n.id, type: 'custom', @@ -29,7 +31,7 @@ function buildGraph( style: { border: `2px solid ${colorMap[colorKey] || '#ccc'}`, borderRadius: 8, - backgroundColor: '#fff', + backgroundColor: 'var(--sapTile_Background, #fff)', width: nodeWidth, height: nodeHeight, }, @@ -104,7 +106,15 @@ export function useGraph(colorBy: ColorBy, onYamlClick: (item: ManagedResourceIt const kind = item?.kind; const providerConfigName = item?.spec?.providerConfigRef?.name ?? 'unknown'; const providerType = resolveProviderType(providerConfigName, providerConfigsList); - const status = getStatusFromConditions(item?.status?.conditions); + const statusCond = getStatusCondition(item?.status?.conditions); + const status = statusCond?.status === 'True' ? 'OK' : 'ERROR'; + + let fluxName: string | undefined; + const labelsMap = (item.metadata as { labels?: Record }).labels; + if (labelsMap) { + const key = Object.keys(labelsMap).find((k) => k.endsWith('/name')); + if (key) fluxName = labelsMap[key]; + } const { subaccountRef, @@ -151,6 +161,9 @@ export function useGraph(colorBy: ColorBy, onYamlClick: (item: ManagedResourceIt providerConfigName, providerType, status, + transitionTime: statusCond?.lastTransitionTime ?? '', + statusMessage: statusCond?.reason ?? statusCond?.message ?? '', + fluxName, parentId, extraRefs, item, diff --git a/src/lib/api/types/crossplane/listManagedResources.ts b/src/lib/api/types/crossplane/listManagedResources.ts index 2ebd01e9..91745649 100644 --- a/src/lib/api/types/crossplane/listManagedResources.ts +++ b/src/lib/api/types/crossplane/listManagedResources.ts @@ -1,27 +1,9 @@ +import { ManagedResourceItem } from '../../../shared/types'; import { Resource } from '../resource'; export type ManagedResourcesResponse = [ { - items: [ - { - kind: string; - metadata: { - name: string; - creationTimestamp: string; - }; - status?: { - conditions?: [ - { - type: 'Ready' | 'Synced' | unknown; - status: 'True' | 'False'; - lastTransitionTime: string; - message?: string; - reason?: string; - }, - ]; - }; - }, - ]; + items: [ManagedResourceItem]; }, ]; diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index f6d3f170..e8fe763e 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -16,19 +16,65 @@ export type ProviderConfigsDataForRequest = { export type ProviderConfigs = { provider: string; - items: [ - { - kind: string; - metadata: { - provider: string; - name: string; - usage: string; - creationTimestamp: string; - }; - status: { - count: string; - users: string; - }; - }, - ]; + items: [ProviderConfigItem]; +}; + +export interface ProviderConfigItem { + kind: string; + metadata: { + provider: string; + name: string; + usage: string; + creationTimestamp: string; + }; + status: { + count: string; + users: string; + }; + apiVersion?: string; +} + +export type Condition = { + type: 'Ready' | 'Synced' | unknown; + status: 'True' | 'False'; + lastTransitionTime: string; + reason?: string; + message?: string; +}; + +export type ManagedResourceGroup = { + items: ManagedResourceItem[]; +}; + +export type ManagedResourceItem = { + kind: string; + metadata: { + name: string; + creationTimestamp: string; + }; + apiVersion?: string; + spec?: { + providerConfigRef?: { name: string }; + forProvider?: { + subaccountRef?: { name?: string }; + serviceManagerRef?: { name?: string }; + spaceRef?: { name?: string }; + orgRef?: { name?: string }; + directoryRef?: { name?: string }; + entitlementRef?: { name?: string }; + globalAccountRef?: { name?: string }; + orgRoleRef?: { name?: string }; + spaceMembersRef?: { name?: string }; + cloudFoundryEnvironmentRef?: { name?: string }; + kymaEnvironmentRef?: { name?: string }; + roleCollectionRef?: { name?: string }; + roleCollectionAssignmentRef?: { name?: string }; + subaccountTrustConfigurationRef?: { name?: string }; + globalaccountTrustConfigurationRef?: { name?: string }; + }; + cloudManagementRef?: { name: string }; + }; + status?: { + conditions?: Condition[]; + }; };