|
1 | | -import { Card, CardHeader, Button } from '@ui5/webcomponents-react'; |
2 | | -import { RadarChart } from '@ui5/webcomponents-react-charts'; |
3 | | -import { useTranslation } from 'react-i18next'; |
4 | | -import cx from 'clsx'; |
5 | | -import { APIError } from '../../lib/api/error'; |
6 | | -import { styles } from './Hints'; |
7 | | -import { ManagedResourceItem, Condition } from '../../lib/shared/types'; |
8 | | -import { MultiPercentageBar } from '../Shared/MultiPercentageBar'; |
9 | | -import React, { useMemo } from 'react'; |
10 | | - |
11 | | -interface CrossplaneHintProps { |
12 | | - enabled?: boolean; |
13 | | - version?: string; |
14 | | - onActivate?: () => void; |
15 | | - allItems?: ManagedResourceItem[]; |
16 | | - isLoading?: boolean; |
17 | | - error?: APIError; |
18 | | -} |
19 | | - |
20 | | -export const CrossplaneHint: React.FC<CrossplaneHintProps> = ({ |
21 | | - enabled = false, |
22 | | - version, |
23 | | - onActivate, |
24 | | - allItems = [], |
25 | | - isLoading, |
26 | | - error, |
27 | | -}) => { |
28 | | - const { t } = useTranslation(); |
29 | | - const [hovered, setHovered] = React.useState(false); |
30 | | - |
31 | | - // Memoize resource type health calculations |
32 | | - const { resourceTypeHealth, resourceTypeTotal } = useMemo(() => { |
33 | | - const typeHealth: Record<string, number> = {}; |
34 | | - const typeTotal: Record<string, number> = {}; |
35 | | - |
36 | | - allItems.forEach((item: ManagedResourceItem) => { |
37 | | - const type = item.kind || 'Unknown'; |
38 | | - typeTotal[type] = (typeTotal[type] || 0) + 1; |
39 | | - const conditions = item.status?.conditions || []; |
40 | | - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); |
41 | | - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); |
42 | | - if (ready && synced) { |
43 | | - typeHealth[type] = (typeHealth[type] || 0) + 1; |
44 | | - } |
45 | | - }); |
46 | | - |
47 | | - return { resourceTypeHealth: typeHealth, resourceTypeTotal: typeTotal }; |
48 | | - }, [allItems]); |
49 | | - |
50 | | - // Memoize radar chart dataset |
51 | | - const radarDataset = useMemo(() => { |
52 | | - return Object.keys(resourceTypeTotal).map(type => { |
53 | | - const total = resourceTypeTotal[type]; |
54 | | - const healthy = resourceTypeHealth[type] || 0; |
55 | | - |
56 | | - // Count creating resources (no conditions yet or unknown status) |
57 | | - const creating = allItems.filter((item: ManagedResourceItem) => { |
58 | | - if (item.kind !== type) return false; |
59 | | - const conditions = item.status?.conditions || []; |
60 | | - const hasReadyCondition = conditions.some((c: Condition) => c.type === 'Ready'); |
61 | | - const hasSyncedCondition = conditions.some((c: Condition) => c.type === 'Synced'); |
62 | | - return !hasReadyCondition || !hasSyncedCondition; |
63 | | - }).length; |
64 | | - |
65 | | - return { |
66 | | - type, |
67 | | - healthy: Math.round((healthy / total) * 100), |
68 | | - creating: Math.round((creating / total) * 100) |
69 | | - }; |
70 | | - }); |
71 | | - }, [allItems, resourceTypeHealth, resourceTypeTotal]); |
72 | | - |
73 | | - // Memoize health status calculations |
74 | | - const healthStats = useMemo(() => { |
75 | | - const totalCount = allItems.length; |
76 | | - |
77 | | - if (totalCount === 0) { |
78 | | - return { |
79 | | - totalCount: 0, |
80 | | - healthyCount: 0, |
81 | | - creatingCount: 0, |
82 | | - unhealthyCount: 0, |
83 | | - healthyPercentage: 0, |
84 | | - creatingPercentage: 0, |
85 | | - unhealthyPercentage: 0, |
86 | | - isCurrentlyHealthy: false |
87 | | - }; |
88 | | - } |
89 | | - |
90 | | - const healthyCount = allItems.filter((item: ManagedResourceItem) => { |
91 | | - const conditions = item.status?.conditions || []; |
92 | | - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); |
93 | | - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); |
94 | | - return !!ready && !!synced; |
95 | | - }).length; |
96 | | - |
97 | | - const creatingCount = allItems.filter((item: ManagedResourceItem) => { |
98 | | - const conditions = item.status?.conditions || []; |
99 | | - const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); |
100 | | - const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); |
101 | | - return !!synced && !ready; |
102 | | - }).length; |
103 | | - |
104 | | - const unhealthyCount = totalCount - healthyCount - creatingCount; |
105 | | - const healthyPercentage = Math.round((healthyCount / totalCount) * 100); |
106 | | - const creatingPercentage = Math.round((creatingCount / totalCount) * 100); |
107 | | - const unhealthyPercentage = Math.round((unhealthyCount / totalCount) * 100); |
108 | | - const isCurrentlyHealthy = healthyPercentage === 100 && totalCount > 0; |
109 | | - |
110 | | - return { |
111 | | - totalCount, |
112 | | - healthyCount, |
113 | | - creatingCount, |
114 | | - unhealthyCount, |
115 | | - healthyPercentage, |
116 | | - creatingPercentage, |
117 | | - unhealthyPercentage, |
118 | | - isCurrentlyHealthy |
119 | | - }; |
120 | | - }, [allItems]); |
121 | | - |
122 | | - // Memoize segments for the percentage bar |
123 | | - const segments = useMemo(() => { |
124 | | - return [ |
125 | | - { |
126 | | - percentage: healthStats.healthyPercentage, |
127 | | - color: '#28a745', |
128 | | - label: 'Healthy' |
129 | | - }, |
130 | | - { |
131 | | - percentage: healthStats.creatingPercentage, |
132 | | - color: '#e9730c', |
133 | | - label: 'Creating' |
134 | | - }, |
135 | | - { |
136 | | - percentage: healthStats.unhealthyPercentage, |
137 | | - color: '#d22020ff', |
138 | | - label: 'Unhealthy' |
139 | | - } |
140 | | - ]; |
141 | | - }, [healthStats]); |
142 | | - |
143 | | - const totalCount = allItems.length; |
144 | | - |
145 | | - return ( |
146 | | - <div style={{ position: 'relative', width: '100%' }}> |
147 | | - <Card |
148 | | - header={ |
149 | | - <CardHeader |
150 | | - additionalText={enabled ? `v${version ?? ''}` : undefined} |
151 | | - avatar={ |
152 | | - <img |
153 | | - src="/crossplane-icon.png" |
154 | | - alt="Crossplane" |
155 | | - style={{ width: 50, height: 50, borderRadius: '50%', background: 'transparent', objectFit: 'cover' }} |
156 | | - /> |
157 | | - } |
158 | | - titleText={t('Hints.CrossplaneHint.title')} |
159 | | - subtitleText={t('Hints.CrossplaneHint.subtitle')} |
160 | | - interactive={enabled} |
161 | | - /> |
162 | | - } |
163 | | - className={cx({ |
164 | | - [styles['disabled']]: !enabled, |
165 | | - })} |
166 | | - onClick={enabled ? () => { |
167 | | - const el = document.querySelector('.crossplane-table-element'); |
168 | | - if (el) { |
169 | | - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
170 | | - } |
171 | | - } : undefined} |
172 | | - onMouseEnter={enabled ? () => setHovered(true) : undefined} |
173 | | - onMouseLeave={enabled ? () => setHovered(false) : undefined} |
174 | | - > |
175 | | - {/* Disabled overlay */} |
176 | | - {!enabled && <div className={styles.disabledOverlay} />} |
177 | | - |
178 | | - <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '1rem 0' }}> |
179 | | - <div style={{ |
180 | | - display: 'flex', |
181 | | - gap: '8px', |
182 | | - width: '100%', |
183 | | - maxWidth: 500, |
184 | | - padding: '0 1rem' |
185 | | - }}> |
186 | | - {(() => { |
187 | | - if (isLoading) { |
188 | | - return ( |
189 | | - <MultiPercentageBar |
190 | | - segments={[{ |
191 | | - percentage: 100, |
192 | | - color: '#e9e9e9ff', |
193 | | - label: 'Loading' |
194 | | - }]} |
195 | | - style={{ width: '100%' }} |
196 | | - label={t('Hints.common.loading')} |
197 | | - showPercentage={false} |
198 | | - isHealthy={false} |
199 | | - /> |
200 | | - ); |
201 | | - } |
202 | | - |
203 | | - if (error) { |
204 | | - return ( |
205 | | - <MultiPercentageBar |
206 | | - segments={[{ |
207 | | - percentage: 100, |
208 | | - color: '#d22020ff', |
209 | | - label: 'Error' |
210 | | - }]} |
211 | | - style={{ width: '100%' }} |
212 | | - label={t('Hints.common.errorLoadingResources')} |
213 | | - showPercentage={false} |
214 | | - isHealthy={false} |
215 | | - /> |
216 | | - ); |
217 | | - } |
218 | | - |
219 | | - if (!enabled) { |
220 | | - return ( |
221 | | - <MultiPercentageBar |
222 | | - segments={[{ |
223 | | - percentage: 100, |
224 | | - color: '#e9e9e9ff', |
225 | | - label: 'Inactive' |
226 | | - }]} |
227 | | - style={{ width: '100%' }} |
228 | | - label={t('Hints.CrossplaneHint.inactive')} |
229 | | - showPercentage={false} |
230 | | - isHealthy={false} |
231 | | - /> |
232 | | - ); |
233 | | - } |
234 | | - |
235 | | - if (totalCount === 0) { |
236 | | - return ( |
237 | | - <MultiPercentageBar |
238 | | - segments={[{ |
239 | | - percentage: 100, |
240 | | - color: '#e9e9e9ff', |
241 | | - label: 'No Resources' |
242 | | - }]} |
243 | | - style={{ width: '100%' }} |
244 | | - label={t('Hints.CrossplaneHint.noResources')} |
245 | | - showPercentage={false} |
246 | | - isHealthy={false} |
247 | | - /> |
248 | | - ); |
249 | | - } |
250 | | - |
251 | | - return ( |
252 | | - <MultiPercentageBar |
253 | | - segments={segments} |
254 | | - style={{ width: '100%' }} |
255 | | - label="Resources" |
256 | | - showPercentage={true} |
257 | | - isHealthy={healthStats.isCurrentlyHealthy} |
258 | | - /> |
259 | | - ); |
260 | | - })()} |
261 | | - </div> |
262 | | - </div> |
263 | | - {/* RadarChart for resource healthiness, only show on hover when enabled */} |
264 | | - {enabled && hovered && !isLoading && !error && radarDataset.length > 0 && ( |
265 | | - <div style={{ |
266 | | - width: '100%', |
267 | | - height: 300, |
268 | | - display: 'flex', |
269 | | - justifyContent: 'center', |
270 | | - alignItems: 'center', |
271 | | - margin: '1rem 0', |
272 | | - overflow: 'visible' |
273 | | - }}> |
274 | | - <RadarChart |
275 | | - dataset={radarDataset} |
276 | | - dimensions={[{ accessor: 'type' }]} |
277 | | - measures={[ |
278 | | - { |
279 | | - accessor: 'healthy', |
280 | | - color: '#28a745', |
281 | | - hideDataLabel: true, |
282 | | - label: 'Healthy (%)' |
283 | | - }, |
284 | | - { |
285 | | - accessor: 'creating', |
286 | | - color: '#fd7e14', |
287 | | - hideDataLabel: true, |
288 | | - label: 'Creating (%)' |
289 | | - } |
290 | | - ]} |
291 | | - style={{ width: '100%', height: '100%', minWidth: 280, minHeight: 280 }} |
292 | | - noLegend={false} |
293 | | - /> |
294 | | - </div> |
295 | | - )} |
296 | | - {!enabled && ( |
297 | | - <div |
298 | | - style={{ |
299 | | - position: 'absolute', |
300 | | - top: '16px', |
301 | | - right: '16px', |
302 | | - zIndex: 2, |
303 | | - pointerEvents: 'auto', |
304 | | - }} |
305 | | - > |
306 | | - <Button design="Emphasized" onClick={onActivate}> |
307 | | - {t('Hints.CrossplaneHint.activate')} |
308 | | - </Button> |
309 | | - </div> |
310 | | - )} |
311 | | - </Card> |
312 | | - </div> |
313 | | - ); |
| 1 | +import React from 'react'; |
| 2 | +import { GenericHint } from './GenericHint'; |
| 3 | +import { useCrossplaneHintConfig } from './hintConfigs'; |
| 4 | +import { GenericHintProps } from './types'; |
| 5 | + |
| 6 | +// New modular CrossplaneHint using the generic component |
| 7 | +export const CrossplaneHint: React.FC<Omit<GenericHintProps, 'config'>> = (props) => { |
| 8 | + const config = useCrossplaneHintConfig(); |
| 9 | + return <GenericHint {...props} config={config} />; |
314 | 10 | }; |
0 commit comments