diff --git a/.claude/skills/openshift-console-plugin-advanced/SKILL.md b/.claude/skills/openshift-console-plugin-advanced/SKILL.md new file mode 100644 index 00000000..b2960a3f --- /dev/null +++ b/.claude/skills/openshift-console-plugin-advanced/SKILL.md @@ -0,0 +1,963 @@ +--- +name: openshift-console-plugin-advanced +description: Advanced patterns, performance optimization, security, and complex plugin development for OpenShift Console +--- + +# OpenShift Console Plugin Advanced Development + +This skill covers advanced patterns, performance optimization, security best practices, and complex plugin development techniques for experienced OpenShift Console plugin developers. + +## Performance Optimization + +### Code Splitting and Lazy Loading + +```typescript +// Dynamic imports for large components +import React, { Suspense } from 'react'; +import { Spinner, Bullseye } from '@patternfly/react-core'; + +// Lazy load heavy components +const MyLargeComponent = React.lazy(() => import('./MyLargeComponent')); +const MyChartComponent = React.lazy(() => import('./MyChartComponent')); + +const MyPage: React.FC = () => { + const [activeTab, setActiveTab] = useState('overview'); + + return ( + + + +
Overview content (loaded immediately)
+
+ + }> + + + + + }> + + + +
+
+ ); +}; +``` + +### Webpack Bundle Optimization + +```typescript +// webpack.config.ts +import * as webpack from 'webpack'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; + +const config: webpack.Configuration = { + optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + priority: 10, + }, + patternfly: { + test: /[\\/]node_modules[\\/]@patternfly[\\/]/, + name: 'patternfly', + chunks: 'all', + priority: 20, + }, + common: { + minChunks: 2, + chunks: 'all', + name: 'common', + priority: 5, + }, + }, + }, + minimizer: [ + new TerserPlugin({ + terserOptions: { + compress: { + drop_console: process.env.NODE_ENV === 'production', + drop_debugger: process.env.NODE_ENV === 'production', + }, + mangle: true, + }, + }), + ], + }, + plugins: [ + // Analyze bundle in development + process.env.ANALYZE && new BundleAnalyzerPlugin(), + ].filter(Boolean), +}; +``` + +### Memoization and Performance Hooks + +```typescript +import React, { useMemo, useCallback, useState } from 'react'; + +// Expensive computation memoization +export const useExpensiveCalculation = (data: MyResource[], filter: string) => { + return useMemo(() => { + // Only recalculate when data or filter changes + return data + .filter(item => item.metadata?.name?.includes(filter)) + .sort((a, b) => (a.metadata?.name || '').localeCompare(b.metadata?.name || '')) + .map(item => ({ + ...item, + // Expensive transformation + computed: performExpensiveOperation(item), + })); + }, [data, filter]); +}; + +// Stable callback references +const MyExpensiveComponent = React.memo(({ data, onUpdate }) => { + const [localState, setLocalState] = useState(''); + + // Stable callback reference + const handleUpdate = useCallback((id: string, updates: Partial) => { + onUpdate(id, updates); + }, [onUpdate]); + + // Memoized derived state + const processedData = useMemo(() => { + return data.map(item => ({ + ...item, + displayName: item.metadata?.name || 'Unknown', + isHealthy: item.status?.phase === 'Ready', + })); + }, [data]); + + return ( +
+ {processedData.map(item => ( + + ))} +
+ ); +}); + +MyExpensiveComponent.displayName = 'MyExpensiveComponent'; +``` + +### Virtual Scrolling for Large Lists + +```typescript +import React from 'react'; +import { FixedSizeList } from 'react-window'; +import { Card, CardBody } from '@patternfly/react-core'; + +interface VirtualizedListProps { + items: MyResource[]; + height: number; + itemHeight: number; +} + +const VirtualizedList: React.FC = ({ items, height, itemHeight }) => { + const Row = React.memo(({ index, style }: { index: number; style: React.CSSProperties }) => { + const item = items[index]; + + return ( +
+ + +

{item.metadata?.name}

+

Status: {item.status?.phase}

+
+
+
+ ); + }); + + Row.displayName = 'VirtualizedRow'; + + return ( + + {Row} + + ); +}; +``` + +### Resource Caching Strategies + +```typescript +import { useRef, useEffect } from 'react'; + +// Simple cache implementation +class ResourceCache { + private cache = new Map(); + + set(key: string, data: T, ttl = 300000) { // 5 minutes default + this.cache.set(key, { + data, + timestamp: Date.now(), + ttl, + }); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + + if (Date.now() - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + return entry.data; + } + + clear() { + this.cache.clear(); + } +} + +// Global cache instance +const resourceCache = new ResourceCache(); + +export const useCachedResource = (key: string, fetcher: () => Promise) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + const cached = resourceCache.get(key); + if (cached) { + setData(cached); + setLoading(false); + return; + } + + try { + setLoading(true); + const result = await fetcher(); + resourceCache.set(key, result); + setData(result); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [key, fetcher]); + + return { data, loading, error }; +}; +``` + +## Security Best Practices + +### Content Security Policy (CSP) + +```nginx +# nginx.conf security headers +add_header Content-Security-Policy " + default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: https:; + font-src 'self' data:; + connect-src 'self' https://*.openshift.com wss://*.openshift.com; + frame-ancestors 'none'; +" always; + +add_header X-Frame-Options "DENY" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +``` + +### Secure API Communication + +```typescript +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; + +// Secure API wrapper +class SecureApiClient { + private baseUrl: string; + private timeout: number; + + constructor(baseUrl: string, timeout = 30000) { + this.baseUrl = baseUrl; + this.timeout = timeout; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + + // Add security headers + const headers = { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...options.headers, + }; + + // Create request with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await consoleFetch(url, { + ...options, + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Validate response content type + const contentType = response.headers.get('content-type'); + if (!contentType?.includes('application/json')) { + throw new Error('Invalid response content type'); + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } + + async get(endpoint: string): Promise { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, data: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data), + }); + } +} +``` + +### Input Validation and Sanitization + +```typescript +import DOMPurify from 'dompurify'; + +// Input validation utilities +export const ValidationUtils = { + // Kubernetes name validation + isValidKubernetesName: (name: string): boolean => { + const pattern = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; + return pattern.test(name) && name.length <= 253; + }, + + // Label validation + isValidLabel: (key: string, value: string): boolean => { + const keyPattern = /^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?[a-z0-9A-Z]([-a-z0-9A-Z._]*[a-z0-9A-Z])?$/; + const valuePattern = /^[a-z0-9A-Z]([-a-z0-9A-Z._]*[a-z0-9A-Z])?$/; + return keyPattern.test(key) && valuePattern.test(value); + }, + + // Sanitize HTML content + sanitizeHtml: (html: string): string => { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code'], + ALLOWED_ATTR: [], + }); + }, + + // Escape shell commands + escapeShellArg: (arg: string): string => { + return "'" + arg.replace(/'/g, "'\"'\"'") + "'"; + }, +}; + +// Secure form component +const SecureResourceForm: React.FC = () => { + const [name, setName] = useState(''); + const [nameError, setNameError] = useState(''); + + const handleNameChange = (value: string) => { + setName(value); + + if (!ValidationUtils.isValidKubernetesName(value)) { + setNameError('Name must be a valid Kubernetes resource name'); + } else { + setNameError(''); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + // Final validation before submission + if (!ValidationUtils.isValidKubernetesName(name)) { + alert('Invalid resource name'); + return; + } + + // Submit safely validated data + await createResource({ name }); + }; + + return ( +
+ + + +
+ ); +}; +``` + +### Secrets and Sensitive Data Handling + +```typescript +// Never store secrets in component state or props +// Use refs for temporary sensitive data + +const SecurePasswordComponent: React.FC = () => { + const passwordRef = useRef(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + const password = passwordRef.current?.value; + if (!password) return; + + try { + setIsSubmitting(true); + + // Use password immediately, don't store + await authenticateUser(password); + + // Clear the input immediately + if (passwordRef.current) { + passwordRef.current.value = ''; + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + + + +
+ ); +}; +``` + +## Advanced Extension Patterns + +### Dynamic Extension Registration + +```typescript +// Dynamic extension loading based on feature flags +import { Extension } from '@openshift-console/dynamic-plugin-sdk'; + +interface FeatureFlags { + experimentalFeatures: boolean; + betaFeatures: boolean; + adminFeatures: boolean; +} + +export const getDynamicExtensions = (flags: FeatureFlags): Extension[] => { + const extensions: Extension[] = [ + // Base extensions always loaded + { + type: 'console.navigation/href', + properties: { + id: 'my-plugin-main', + name: '%plugin__my-console-plugin~Main%', + href: '/my-plugin', + }, + }, + ]; + + // Conditional extensions + if (flags.experimentalFeatures) { + extensions.push({ + type: 'console.page/route', + properties: { + path: '/my-plugin/experimental', + component: { $codeRef: 'ExperimentalPage' }, + }, + }); + } + + if (flags.betaFeatures) { + extensions.push({ + type: 'console.tab', + properties: { + model: { kind: 'Pod' }, + component: { $codeRef: 'BetaTab' }, + name: '%plugin__my-console-plugin~Beta Features%', + }, + }); + } + + if (flags.adminFeatures) { + extensions.push({ + type: 'console.action/resource-provider', + properties: { + model: { kind: 'Node' }, + provider: { $codeRef: 'adminActions' }, + }, + }); + } + + return extensions; +}; +``` + +### Custom Hook Factories + +```typescript +// Advanced hook factory pattern +export const createResourceHook = ( + groupVersionKind: GroupVersionKind, + options: { + caching?: boolean; + polling?: number; + transform?: (data: T[]) => T[]; + } = {} +) => { + return (namespace?: string) => { + const [resources, loaded, loadError] = useK8sWatchResource({ + groupVersionKind, + isList: true, + namespace, + }); + + const processedResources = useMemo(() => { + if (!resources) return []; + + let processed = resources; + + if (options.transform) { + processed = options.transform(processed); + } + + return processed; + }, [resources, options.transform]); + + // Add polling if requested + useEffect(() => { + if (!options.polling || !loaded) return; + + const interval = setInterval(() => { + // Trigger refresh + console.log('Polling for updates...'); + }, options.polling); + + return () => clearInterval(interval); + }, [loaded, options.polling]); + + return { + resources: processedResources, + loaded, + loadError, + total: processedResources.length, + }; + }; +}; + +// Usage +const useMyResources = createResourceHook( + { group: 'my-group.io', version: 'v1', kind: 'MyResource' }, + { + caching: true, + polling: 30000, // 30 seconds + transform: (data) => data.filter(item => item.status?.phase !== 'Terminating'), + } +); +``` + +### Advanced State Management + +```typescript +// Complex state management with reducers +interface PluginState { + resources: MyResource[]; + filters: { + namespace?: string; + status?: string; + search?: string; + }; + pagination: { + page: number; + perPage: number; + total: number; + }; + selectedItems: Set; + loading: boolean; + error: string | null; +} + +type PluginAction = + | { type: 'SET_RESOURCES'; payload: MyResource[] } + | { type: 'SET_FILTER'; payload: { key: keyof PluginState['filters']; value: string } } + | { type: 'SET_PAGINATION'; payload: Partial } + | { type: 'TOGGLE_SELECTION'; payload: string } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'SET_ERROR'; payload: string | null }; + +const pluginReducer = (state: PluginState, action: PluginAction): PluginState => { + switch (action.type) { + case 'SET_RESOURCES': + return { + ...state, + resources: action.payload, + pagination: { + ...state.pagination, + total: action.payload.length, + }, + }; + + case 'SET_FILTER': + return { + ...state, + filters: { + ...state.filters, + [action.payload.key]: action.payload.value, + }, + pagination: { + ...state.pagination, + page: 1, // Reset to first page when filtering + }, + }; + + case 'SET_PAGINATION': + return { + ...state, + pagination: { + ...state.pagination, + ...action.payload, + }, + }; + + case 'TOGGLE_SELECTION': + const newSelection = new Set(state.selectedItems); + if (newSelection.has(action.payload)) { + newSelection.delete(action.payload); + } else { + newSelection.add(action.payload); + } + return { + ...state, + selectedItems: newSelection, + }; + + case 'SET_LOADING': + return { ...state, loading: action.payload }; + + case 'SET_ERROR': + return { ...state, error: action.payload }; + + default: + return state; + } +}; + +export const usePluginState = () => { + const [state, dispatch] = useReducer(pluginReducer, { + resources: [], + filters: {}, + pagination: { page: 1, perPage: 20, total: 0 }, + selectedItems: new Set(), + loading: false, + error: null, + }); + + const filteredResources = useMemo(() => { + return state.resources.filter(resource => { + if (state.filters.namespace && resource.metadata?.namespace !== state.filters.namespace) { + return false; + } + + if (state.filters.status && resource.status?.phase !== state.filters.status) { + return false; + } + + if (state.filters.search) { + const searchLower = state.filters.search.toLowerCase(); + const name = resource.metadata?.name?.toLowerCase() || ''; + if (!name.includes(searchLower)) { + return false; + } + } + + return true; + }); + }, [state.resources, state.filters]); + + const paginatedResources = useMemo(() => { + const start = (state.pagination.page - 1) * state.pagination.perPage; + const end = start + state.pagination.perPage; + return filteredResources.slice(start, end); + }, [filteredResources, state.pagination.page, state.pagination.perPage]); + + return { + state, + dispatch, + filteredResources, + paginatedResources, + }; +}; +``` + +## Error Handling and Resilience + +### Circuit Breaker Pattern + +```typescript +class CircuitBreaker { + private failureCount = 0; + private lastFailureTime: number | null = null; + private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; + + constructor( + private threshold: number = 5, + private timeout: number = 60000 + ) {} + + async execute(operation: () => Promise): Promise { + if (this.state === 'OPEN') { + if (this.shouldAttemptReset()) { + this.state = 'HALF_OPEN'; + } else { + throw new Error('Circuit breaker is OPEN'); + } + } + + try { + const result = await operation(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess() { + this.failureCount = 0; + this.state = 'CLOSED'; + } + + private onFailure() { + this.failureCount++; + this.lastFailureTime = Date.now(); + + if (this.failureCount >= this.threshold) { + this.state = 'OPEN'; + } + } + + private shouldAttemptReset(): boolean { + return this.lastFailureTime !== null && + Date.now() - this.lastFailureTime >= this.timeout; + } +} + +// Usage in API calls +const apiCircuitBreaker = new CircuitBreaker(3, 30000); + +const useResilientApi = () => { + const callApi = useCallback(async (operation: () => Promise) => { + try { + return await apiCircuitBreaker.execute(operation); + } catch (error) { + console.error('API call failed:', error); + throw error; + } + }, []); + + return { callApi }; +}; +``` + +### Graceful Degradation + +```typescript +const ResilientComponent: React.FC = () => { + const [resources, setResources] = useState([]); + const [fallbackMode, setFallbackMode] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const loadResources = async () => { + try { + // Primary data source + const data = await fetchResourcesFromApi(); + setResources(data); + setFallbackMode(false); + setError(null); + } catch (primaryError) { + console.warn('Primary API failed, trying fallback:', primaryError); + + try { + // Fallback to cached data or alternative source + const fallbackData = await fetchFromCache() || []; + setResources(fallbackData); + setFallbackMode(true); + setError('Using cached data - some information may be outdated'); + } catch (fallbackError) { + console.error('Both primary and fallback failed:', fallbackError); + setFallbackMode(true); + setError('Unable to load resources'); + } + } + }; + + loadResources(); + }, []); + + if (error && resources.length === 0) { + return ( + + + Unable to load resources + {error} + + + ); + } + + return ( +
+ {fallbackMode && ( + + Some features may be unavailable. {error} + + )} + + +
+ ); +}; +``` + +## Testing Advanced Patterns + +### Integration Testing for Complex Workflows + +```typescript +// integration-tests/advanced-workflows.spec.ts +describe('Advanced Plugin Workflows', () => { + beforeEach(() => { + cy.login(); + cy.intercept('GET', '/api/kubernetes/apis/my-group.io/v1/myresources', + { fixture: 'resources.json' }); + }); + + it('should handle bulk operations correctly', () => { + cy.visit('/my-plugin/resources'); + + // Select multiple resources + cy.get('[data-test="resource-checkbox"]').first().click(); + cy.get('[data-test="resource-checkbox"]').eq(1).click(); + + // Perform bulk action + cy.get('[data-test="bulk-actions"]').click(); + cy.get('[data-test="bulk-delete"]').click(); + + // Confirm bulk operation + cy.get('[data-test="confirm-bulk-delete"]').click(); + + // Verify operation completed + cy.get('[data-test="success-alert"]').should('be.visible'); + }); + + it('should handle error scenarios gracefully', () => { + // Simulate API error + cy.intercept('GET', '/api/kubernetes/apis/my-group.io/v1/myresources', + { statusCode: 500, body: { message: 'Server error' } }); + + cy.visit('/my-plugin/resources'); + + // Should show error state + cy.get('[data-test="error-state"]').should('be.visible'); + cy.get('[data-test="retry-button"]').should('be.visible'); + + // Test retry functionality + cy.intercept('GET', '/api/kubernetes/apis/my-group.io/v1/myresources', + { fixture: 'resources.json' }); + cy.get('[data-test="retry-button"]').click(); + + // Should recover and show data + cy.get('[data-test="resource-list"]').should('be.visible'); + }); +}); +``` + +## Related Skills + +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup for advanced configurations +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - Advanced component patterns +- [openshift-console-plugin-data](../openshift-console-plugin-data/SKILL.md) - Advanced data management +- [openshift-console-plugin-deployment](../openshift-console-plugin-deployment/SKILL.md) - Production deployment considerations +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Advanced testing strategies + +## Advanced Development Checklist + +- [ ] Implement code splitting for large components +- [ ] Optimize webpack bundle configuration +- [ ] Add performance monitoring and profiling +- [ ] Implement proper error boundaries and fallback UI +- [ ] Set up comprehensive security headers +- [ ] Validate and sanitize all user inputs +- [ ] Implement circuit breaker patterns for API calls +- [ ] Add caching strategies for improved performance +- [ ] Use virtual scrolling for large data sets +- [ ] Implement graceful degradation for offline scenarios +- [ ] Add comprehensive integration tests +- [ ] Monitor bundle size and performance metrics +- [ ] Implement advanced state management patterns +- [ ] Set up proper logging and error reporting +- [ ] Add feature flag support for experimental features \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-components/SKILL.md b/.claude/skills/openshift-console-plugin-components/SKILL.md new file mode 100644 index 00000000..bb6f46ca --- /dev/null +++ b/.claude/skills/openshift-console-plugin-components/SKILL.md @@ -0,0 +1,721 @@ +--- +name: openshift-console-plugin-components +description: React component development patterns and best practices for OpenShift Console plugins +--- + +# OpenShift Console Plugin Components + +This skill covers React component development patterns and best practices for building user interfaces in OpenShift Console plugins. Learn how to create maintainable, accessible, and performant components. + +## Component Development Patterns + +### Base Page Component Template + +```typescript +import React from 'react'; +import { + Page, + PageSection, + Title, + Card, + CardBody +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +interface MyPageProps { + // Define props interface +} + +const MyPage: React.FC = (props) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + return ( + + + {t('My Page Title')} + + + + + {t('Page content goes here')} + + + + + ); +}; + +export default MyPage; +``` + +### Resource List Component Pattern + +```typescript +import React from 'react'; +import { + Page, + PageSection, + Title, + Alert +} from '@patternfly/react-core'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableComposable +} from '@patternfly/react-table'; +import { + useK8sWatchResource, + ListPageHeader, + ListPageBody, + VirtualizedTable, + TableColumn, + RowFunction +} from '@openshift-console/dynamic-plugin-sdk'; +import { useTranslation } from 'react-i18next'; +import { MyResource } from '../types'; + +const MyResourceList: React.FC = () => { + const { t } = useTranslation('plugin__my-console-plugin'); + + const [resources, loaded, loadError] = useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + isList: true, + }); + + const columns: TableColumn[] = [ + { + title: t('Name'), + id: 'name', + transforms: [], + props: { className: 'pf-m-width-30' }, + }, + { + title: t('Namespace'), + id: 'namespace', + transforms: [], + props: { className: 'pf-m-width-20' }, + }, + { + title: t('Status'), + id: 'status', + transforms: [], + props: { className: 'pf-m-width-15' }, + }, + { + title: t('Created'), + id: 'created', + transforms: [], + props: { className: 'pf-m-width-15' }, + }, + ]; + + const Row: RowFunction = ({ obj, activeColumnIDs }) => ( + <> + + + + + {obj.metadata?.namespace && ( + + )} + + + + + + + + + ); + + if (loadError) { + return ; + } + + return ( + + + + + + + ); +}; + +export default MyResourceList; +``` + +### Resource Details Component Pattern + +```typescript +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { + Page, + PageSection, + Card, + CardBody, + Alert +} from '@patternfly/react-core'; +import { + useK8sWatchResource, + DetailsPage, + navFactory, + viewYamlComponent +} from '@openshift-console/dynamic-plugin-sdk'; +import { useTranslation } from 'react-i18next'; +import { MyResource } from '../types'; + +interface RouteParams { + ns: string; + name: string; +} + +const MyResourceDetailsPage: React.FC = () => { + const { t } = useTranslation('plugin__my-console-plugin'); + const { ns, name } = useParams(); + + const [resource, loaded, loadError] = useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + name, + namespace: ns, + }); + + const MyResourceDetails: React.FC<{ obj: MyResource }> = ({ obj }) => ( + + + + + {t('Status')} + + + + + + {t('Message')} + + {obj.status?.message || t('No message')} + + + + + + ); + + const pages = [ + { + href: '', + name: t('Details'), + component: MyResourceDetails, + }, + { + href: 'yaml', + name: t('YAML'), + component: viewYamlComponent, + }, + ]; + + return ( + + ); +}; + +export default MyResourceDetailsPage; +``` + +### Modal Component Pattern + +```typescript +import React, { useState } from 'react'; +import { + Modal, + ModalVariant, + Form, + FormGroup, + TextInput, + Button, + Alert +} from '@patternfly/react-core'; +import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; +import { useTranslation } from 'react-i18next'; +import { MyResource } from '../types'; + +interface CreateResourceModalProps { + isOpen: boolean; + onClose: () => void; + namespace: string; +} + +const CreateResourceModal: React.FC = ({ + isOpen, + onClose, + namespace +}) => { + const { t } = useTranslation('plugin__my-console-plugin'); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + setLoading(true); + setError(''); + + try { + const resource: MyResource = { + apiVersion: 'my-group.io/v1', + kind: 'MyResource', + metadata: { + name, + namespace, + }, + spec: { + // Add spec properties + }, + }; + + await k8sCreate({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + }, + data: resource, + }); + + onClose(); + setName(''); + } catch (err) { + setError(err.message || t('Failed to create resource')); + } finally { + setLoading(false); + } + }; + + return ( + + {t('Create')} + , + , + ]} + > + {error && } +
+ + + +
+
+ ); +}; + +export default CreateResourceModal; +``` + +## Component Patterns and Best Practices + +### Error Handling Pattern + +```typescript +import React from 'react'; +import { Alert, Button } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class ErrorBoundary extends React.Component, ErrorBoundaryState> { + constructor(props: React.PropsWithChildren<{}>) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Component error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( + window.location.reload()}> + Reload page + + } + > + {this.state.error?.message} + + ); + } + + return this.props.children; + } +} +``` + +### Loading States Pattern + +```typescript +import React from 'react'; +import { Spinner, Bullseye, EmptyState, EmptyStateIcon, Title } from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; +import { useTranslation } from 'react-i18next'; + +interface LoadingStateProps { + loaded: boolean; + data: any[]; + error?: Error; + children: React.ReactNode; +} + +const LoadingState: React.FC = ({ loaded, data, error, children }) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + if (error) { + return ( + + + {t('Error loading data')} +
{error.message}
+
+ ); + } + + if (!loaded) { + return ( + + + + ); + } + + if (loaded && data.length === 0) { + return ( + + + {t('No data found')} + + ); + } + + return <>{children}; +}; +``` + +### Custom Hooks Pattern + +```typescript +import { useState, useEffect } from 'react'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { MyResource } from '../types'; + +// Custom hook for resource management +export const useMyResources = (namespace?: string) => { + const [resources, loaded, loadError] = useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + isList: true, + namespace, + }); + + return { resources, loaded, loadError }; +}; + +// Custom hook for resource filtering +export const useFilteredResources = (resources: MyResource[], filter: string) => { + const [filteredResources, setFilteredResources] = useState([]); + + useEffect(() => { + if (!filter) { + setFilteredResources(resources); + return; + } + + const filtered = resources.filter(resource => + resource.metadata?.name?.toLowerCase().includes(filter.toLowerCase()) + ); + setFilteredResources(filtered); + }, [resources, filter]); + + return filteredResources; +}; + +// Custom hook for resource status +export const useResourceStatus = (resource: MyResource) => { + const isReady = resource.status?.phase === 'Ready'; + const hasError = resource.status?.phase === 'Error'; + const isPending = resource.status?.phase === 'Pending'; + + return { isReady, hasError, isPending }; +}; +``` + +### TypeScript Interface Patterns + +```typescript +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; + +// Base resource interface +export interface MyResourceSpec { + replicas?: number; + selector?: { + matchLabels?: Record; + }; + template?: { + metadata?: { + labels?: Record; + }; + spec?: { + containers?: Array<{ + name: string; + image: string; + ports?: Array<{ + containerPort: number; + protocol?: string; + }>; + }>; + }; + }; +} + +export interface MyResourceStatus { + phase?: 'Pending' | 'Ready' | 'Error'; + message?: string; + readyReplicas?: number; + replicas?: number; + conditions?: Array<{ + type: string; + status: string; + lastTransitionTime?: string; + message?: string; + }>; +} + +export interface MyResource extends K8sResourceCommon { + spec: MyResourceSpec; + status?: MyResourceStatus; +} + +// Component props interfaces +export interface MyResourceRowProps { + obj: MyResource; + index: number; + isScrolling: boolean; + style: React.CSSProperties; +} + +export interface MyResourceDetailsProps { + resource: MyResource; + loaded: boolean; + loadError?: Error; +} +``` + +## Performance Optimization + +### React.memo for Component Optimization + +```typescript +import React from 'react'; +import { MyResource } from '../types'; + +interface MyResourceCardProps { + resource: MyResource; + onSelect?: (resource: MyResource) => void; +} + +const MyResourceCard: React.FC = React.memo(({ resource, onSelect }) => { + const handleClick = React.useCallback(() => { + onSelect?.(resource); + }, [resource, onSelect]); + + return ( + + {resource.metadata?.name} + {resource.status?.phase} + + ); +}); + +MyResourceCard.displayName = 'MyResourceCard'; + +export default MyResourceCard; +``` + +### Lazy Loading Components + +```typescript +import React, { Suspense } from 'react'; +import { Spinner, Bullseye } from '@patternfly/react-core'; + +// Lazy load heavy components +const MyLargeComponent = React.lazy(() => import('./MyLargeComponent')); + +const MyPage: React.FC = () => { + const [showLargeComponent, setShowLargeComponent] = useState(false); + + return ( + + + + {showLargeComponent && ( + }> + + + )} + + + ); +}; +``` + +## Accessibility Best Practices + +### ARIA Labels and Descriptions + +```typescript +import React from 'react'; +import { Button, Card, CardTitle, CardBody } from '@patternfly/react-core'; + +const AccessibleComponent: React.FC = () => { + return ( + + + Resource Status + + + +
+ This will restart the resource and may cause temporary downtime +
+
+
+ ); +}; +``` + +### Keyboard Navigation + +```typescript +import React, { useState } from 'react'; +import { Card, CardBody } from '@patternfly/react-core'; + +const KeyboardNavigableCard: React.FC = () => { + const [focused, setFocused] = useState(false); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + // Handle selection + } + }; + + return ( + setFocused(true)} + onBlur={() => setFocused(false)} + onKeyDown={handleKeyDown} + className={focused ? 'pf-m-focus' : ''} + role="button" + aria-label="Selectable resource card" + > + Resource content + + ); +}; +``` + +## Related Skills + +- [openshift-console-plugin-styling](../openshift-console-plugin-styling/SKILL.md) - UI design and PatternFly usage +- [openshift-console-plugin-data](../openshift-console-plugin-data/SKILL.md) - Data fetching and state management +- [openshift-console-plugin-i18n](../openshift-console-plugin-i18n/SKILL.md) - Internationalization in components +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Testing component patterns + +## Component Checklist + +- [ ] Use TypeScript interfaces for all props +- [ ] Implement proper error handling +- [ ] Add loading states for async operations +- [ ] Include accessibility attributes (ARIA labels) +- [ ] Use i18n for all user-facing strings +- [ ] Follow PatternFly design patterns +- [ ] Implement keyboard navigation where needed +- [ ] Use React.memo for performance optimization +- [ ] Add proper error boundaries +- [ ] Include comprehensive prop validation \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-data/SKILL.md b/.claude/skills/openshift-console-plugin-data/SKILL.md new file mode 100644 index 00000000..5d9cfa14 --- /dev/null +++ b/.claude/skills/openshift-console-plugin-data/SKILL.md @@ -0,0 +1,644 @@ +--- +name: openshift-console-plugin-data +description: K8s data fetching, SDK helpers, and state management for OpenShift Console plugins +--- + +# OpenShift Console Plugin Data Management + +This skill covers K8s data fetching, SDK helpers, and state management patterns for OpenShift Console plugins. Learn how to efficiently work with Kubernetes resources and manage application state. + +## K8s SDK Helpers for Resource Operations + +**⚠️ CRITICAL: Always use SDK helpers for Kubernetes resource operations** + +**DO NOT construct API paths manually**. The console SDK provides helper functions that handle authentication, proper URL construction, error handling, and caching. Always use these instead of raw fetch calls or manual path construction. + +### Core SDK Helper Functions +```typescript +import { + useK8sWatchResource, + k8sGet, + k8sCreate, + k8sUpdate, + k8sDelete, + k8sList, + consoleFetch +} from '@openshift-console/dynamic-plugin-sdk'; +``` + +## API Groups and Versions - Critical Configuration + +**IMPORTANT**: API groups and versions must be specified as separate properties, not as combined strings. + +```typescript +// ✅ CORRECT - Separate group and version properties +const resourceModel = { + groupVersionKind: { + group: 'apps', // Separate property + version: 'v1', // Separate property + kind: 'Deployment' + } +}; + +// ❌ WRONG - Do not combine group/version as single string +const badModel = { + groupVersionKind: { + group: 'apps/v1', // WRONG - don't combine + version: '', + kind: 'Deployment' + } +}; +``` + +### Mapping YAML apiVersion to SDK Properties +When you see a YAML resource with `apiVersion: "apps/v1"`, map it to SDK properties: + +```yaml +# YAML resource shows: +apiVersion: apps/v1 +kind: Deployment +``` + +```typescript +// Maps to SDK configuration: +const deploymentModel = { + groupVersionKind: { + group: 'apps', // Everything before the '/' + version: 'v1', // Everything after the '/' + kind: 'Deployment' + } +}; + +// Special case: Core resources (no group in YAML) +# YAML: apiVersion: v1 +# Maps to: group: '', version: 'v1' +``` + +## Watching Resources with useK8sWatchResource + +### Basic Resource Watching +```typescript +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { MyResource } from '../types'; + +const useMyResources = (namespace?: string) => { + return useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', // Correct separate properties + version: 'v1', + kind: 'MyResource', + }, + isList: true, + namespace, // Optional namespace filter + optional: true, // Won't fail if CRD doesn't exist + }); +}; + +// Usage in component +const MyComponent: React.FC = () => { + const [resources, loaded, loadError] = useMyResources('my-namespace'); + + if (loadError) { + return ; + } + + if (!loaded) { + return ; + } + + return
{resources.length} resources found
; +}; +``` + +### Single Resource Watching +```typescript +const useSingleResource = (name: string, namespace: string) => { + return useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + name, + namespace, + }); +}; +``` + +### Cluster-scoped Resources +```typescript +const useClusterResources = () => { + return useK8sWatchResource({ + groupVersionKind: { + group: 'config.openshift.io', + version: 'v1', + kind: 'ClusterVersion', + }, + isList: true, + // No namespace for cluster-scoped resources + }); +}; +``` + +## One-time Resource Fetching with k8sGet + +### Fetch Single Resource +```typescript +import { k8sGet } from '@openshift-console/dynamic-plugin-sdk'; + +const fetchSpecificResource = async (name: string, namespace: string) => { + try { + const resource = await k8sGet({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + name, + ns: namespace + }); + return resource; + } catch (error) { + console.error('Failed to fetch resource:', error); + throw error; + } +}; +``` + +### Fetch Multiple Resources +```typescript +import { k8sList } from '@openshift-console/dynamic-plugin-sdk'; + +const fetchAllResources = async (namespace?: string) => { + try { + const response = await k8sList({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + queryParams: namespace ? { ns: namespace } : undefined + }); + return response; + } catch (error) { + console.error('Failed to fetch resources:', error); + throw error; + } +}; +``` + +## Resource Mutations + +### Creating Resources +```typescript +import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; + +const createResource = async (resourceData: MyResource) => { + try { + const created = await k8sCreate({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + data: resourceData + }); + return created; + } catch (error) { + console.error('Failed to create resource:', error); + throw error; + } +}; +``` + +### Updating Resources +```typescript +import { k8sUpdate } from '@openshift-console/dynamic-plugin-sdk'; + +const updateResource = async (resource: MyResource, updates: Partial) => { + try { + const updatedResource = { + ...resource, + ...updates, + metadata: { + ...resource.metadata, + ...updates.metadata, + }, + }; + + const result = await k8sUpdate({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + data: updatedResource, + name: resource.metadata?.name, + ns: resource.metadata?.namespace + }); + return result; + } catch (error) { + console.error('Failed to update resource:', error); + throw error; + } +}; +``` + +### Deleting Resources +```typescript +import { k8sDelete } from '@openshift-console/dynamic-plugin-sdk'; + +const deleteResource = async (resource: MyResource) => { + try { + await k8sDelete({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + resource + }); + } catch (error) { + console.error('Failed to delete resource:', error); + throw error; + } +}; +``` + +## Common API Group Examples + +```typescript +// Core Kubernetes resources (no group) +const podModel = { + groupVersionKind: { + group: '', // Empty string for core resources + version: 'v1', + kind: 'Pod' + } +}; + +// Apps group +const deploymentModel = { + groupVersionKind: { + group: 'apps', + version: 'v1', + kind: 'Deployment' + } +}; + +// OpenShift specific +const routeModel = { + groupVersionKind: { + group: 'route.openshift.io', + version: 'v1', + kind: 'Route' + } +}; + +// Custom Resource +const myResourceModel = { + groupVersionKind: { + group: 'example.com', + version: 'v1alpha1', + kind: 'MyCustomResource' + } +}; +``` + +## Custom Hooks for Complex Logic + +### Resource Management Hook +```typescript +import { useState, useCallback } from 'react'; +import { useK8sWatchResource, k8sCreate, k8sUpdate, k8sDelete } from '@openshift-console/dynamic-plugin-sdk'; + +export const useMyResourceManager = (namespace: string) => { + const [resources, loaded, loadError] = useK8sWatchResource({ + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + }, + isList: true, + namespace, + }); + + const [operationLoading, setOperationLoading] = useState(false); + const [operationError, setOperationError] = useState(''); + + const createResource = useCallback(async (data: Partial) => { + setOperationLoading(true); + setOperationError(''); + try { + const resource: MyResource = { + apiVersion: 'my-group.io/v1', + kind: 'MyResource', + metadata: { + namespace, + ...data.metadata, + }, + spec: data.spec || {}, + }; + + await k8sCreate({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + data: resource + }); + } catch (error) { + setOperationError(error.message); + throw error; + } finally { + setOperationLoading(false); + } + }, [namespace]); + + const updateResource = useCallback(async (resource: MyResource, updates: Partial) => { + setOperationLoading(true); + setOperationError(''); + try { + await k8sUpdate({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + data: { ...resource, ...updates }, + name: resource.metadata?.name, + ns: resource.metadata?.namespace + }); + } catch (error) { + setOperationError(error.message); + throw error; + } finally { + setOperationLoading(false); + } + }, []); + + const deleteResource = useCallback(async (resource: MyResource) => { + setOperationLoading(true); + setOperationError(''); + try { + await k8sDelete({ + model: { + groupVersionKind: { + group: 'my-group.io', + version: 'v1', + kind: 'MyResource', + } + }, + resource + }); + } catch (error) { + setOperationError(error.message); + throw error; + } finally { + setOperationLoading(false); + } + }, []); + + return { + resources, + loaded, + loadError, + createResource, + updateResource, + deleteResource, + operationLoading, + operationError, + }; +}; +``` + +### Metrics and Observability Hook +```typescript +import { useState, useEffect } from 'react'; +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; + +interface MetricData { + timestamp: number; + value: number; +} + +export const useMetrics = (resource: MyResource, metricName: string) => { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchMetrics = async () => { + setLoading(true); + setError(''); + + try { + const query = `my_metric{resource="${resource.metadata?.name}",namespace="${resource.metadata?.namespace}"}[5m]`; + const response = await consoleFetch(`/api/prometheus/query_range?query=${encodeURIComponent(query)}`); + const data = await response.json(); + + if (data.status === 'success') { + const values = data.data.result[0]?.values || []; + const metricData = values.map(([timestamp, value]: [number, string]) => ({ + timestamp: timestamp * 1000, + value: parseFloat(value), + })); + setMetrics(metricData); + } + } catch (err) { + setError(err.message || 'Failed to fetch metrics'); + } finally { + setLoading(false); + } + }; + + if (resource.metadata?.name && metricName) { + fetchMetrics(); + } + }, [resource.metadata?.name, resource.metadata?.namespace, metricName]); + + return { metrics, loading, error }; +}; +``` + +## State Management Patterns + +### Resource Status Hook +```typescript +import { useMemo } from 'react'; + +export const useResourceStatus = (resource: MyResource) => { + return useMemo(() => { + const phase = resource.status?.phase; + const conditions = resource.status?.conditions || []; + + const isReady = phase === 'Ready'; + const isError = phase === 'Error'; + const isPending = phase === 'Pending'; + + const lastCondition = conditions[conditions.length - 1]; + const statusMessage = resource.status?.message || lastCondition?.message || ''; + + const readyCondition = conditions.find(c => c.type === 'Ready'); + const isHealthy = readyCondition?.status === 'True'; + + return { + phase, + isReady, + isError, + isPending, + isHealthy, + statusMessage, + conditions, + }; + }, [resource.status]); +}; +``` + +### Resource Filter Hook +```typescript +import { useMemo } from 'react'; + +export const useResourceFilter = } }>( + resources: T[], + filters: { + nameFilter?: string; + labelSelector?: Record; + statusFilter?: (resource: T) => boolean; + } +) => { + return useMemo(() => { + let filtered = resources; + + if (filters.nameFilter) { + const nameFilter = filters.nameFilter.toLowerCase(); + filtered = filtered.filter(resource => + resource.metadata?.name?.toLowerCase().includes(nameFilter) + ); + } + + if (filters.labelSelector) { + filtered = filtered.filter(resource => { + const labels = resource.metadata?.labels || {}; + return Object.entries(filters.labelSelector).every(([key, value]) => + labels[key] === value + ); + }); + } + + if (filters.statusFilter) { + filtered = filtered.filter(filters.statusFilter); + } + + return filtered; + }, [resources, filters.nameFilter, filters.labelSelector, filters.statusFilter]); +}; +``` + +## Error Handling Patterns + +### Resource Operation with Retry +```typescript +import { useState, useCallback } from 'react'; + +export const useResourceOperationWithRetry = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [retryCount, setRetryCount] = useState(0); + + const executeOperation = useCallback(async ( + operation: () => Promise, + maxRetries = 3, + retryDelay = 1000 + ) => { + setLoading(true); + setError(''); + setRetryCount(0); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const result = await operation(); + setLoading(false); + return result; + } catch (err) { + setRetryCount(attempt + 1); + + if (attempt === maxRetries) { + setError(err.message || 'Operation failed'); + setLoading(false); + throw err; + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1))); + } + } + }, []); + + return { + executeOperation, + loading, + error, + retryCount, + }; +}; +``` + +## Performance Optimization + +### Stable References +```typescript +import { useMemo } from 'react'; + +// Stable empty arrays to prevent infinite re-renders +const EMPTY_RESOURCES_ARRAY = [] as MyResource[]; +const EMPTY_STRINGS_ARRAY = [] as string[]; + +export const useStableResourceData = (resources: MyResource[] | undefined) => { + return useMemo(() => { + return resources || EMPTY_RESOURCES_ARRAY; + }, [resources]); +}; + +export const useResourceNames = (resources: MyResource[]) => { + return useMemo(() => { + if (!resources.length) return EMPTY_STRINGS_ARRAY; + return resources.map(r => r.metadata?.name).filter(Boolean) as string[]; + }, [resources]); +}; +``` + +## Related Skills + +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - React component patterns using data +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - TypeScript configuration for data types +- [openshift-console-plugin-advanced](../openshift-console-plugin-advanced/SKILL.md) - Performance optimization techniques +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Testing data hooks and operations + +## Data Management Checklist + +- [ ] Use SDK helpers for all K8s operations (never construct API paths) +- [ ] Specify API group and version as separate properties +- [ ] Implement proper error handling for all resource operations +- [ ] Use stable array references to prevent infinite re-renders +- [ ] Add TypeScript interfaces for all resource types +- [ ] Implement loading states for async operations +- [ ] Use custom hooks for complex data logic +- [ ] Add retry logic for critical operations +- [ ] Optimize with useMemo for expensive computations +- [ ] Handle edge cases (empty data, errors, missing resources) \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-deployment/SKILL.md b/.claude/skills/openshift-console-plugin-deployment/SKILL.md new file mode 100644 index 00000000..377e73fe --- /dev/null +++ b/.claude/skills/openshift-console-plugin-deployment/SKILL.md @@ -0,0 +1,823 @@ +--- +name: openshift-console-plugin-deployment +description: Build, packaging, containerization, and deployment strategies for OpenShift Console plugins +--- + +# OpenShift Console Plugin Deployment + +This skill covers building, packaging, containerizing, and deploying OpenShift Console plugins to OpenShift clusters, including Helm charts, CI/CD integration, and production deployment best practices. + +## Build and Packaging + +### Webpack Production Build + +```typescript +// webpack.config.ts +import * as webpack from 'webpack'; +import { ConsoleRemotePlugin } from '@openshift-console/dynamic-plugin-sdk-webpack'; +import * as path from 'path'; + +const config: webpack.Configuration = { + mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', + entry: './src/index.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].[contenthash].js', + chunkFilename: '[name].[contenthash].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx'], + }, + module: { + rules: [ + { + test: /\.(tsx?|jsx?)$/, + exclude: /node_modules/, + use: [ + { + loader: 'ts-loader', + options: { + configFile: path.resolve(__dirname, 'tsconfig.json'), + }, + }, + ], + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + { + test: /\.(png|jpg|jpeg|gif|svg|woff2?|ttf|eot)$/, + type: 'asset/resource', + }, + ], + }, + plugins: [ + new ConsoleRemotePlugin(), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + }), + ], + optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, + }, + }, + }, + devtool: process.env.NODE_ENV === 'production' ? 'source-map' : 'eval-source-map', +}; + +export default config; +``` + +### Build Scripts + +```json +{ + "scripts": { + "build": "npm run clean && NODE_ENV=production webpack --mode=production", + "build:dev": "npm run clean && webpack --mode=development", + "clean": "rm -rf dist", + "analyze": "npm run build && npx webpack-bundle-analyzer dist/", + "prebuild": "npm run lint && npm run test", + "postbuild": "npm run verify-build" + } +} +``` + +### Build Verification + +```bash +#!/bin/bash +# scripts/verify-build.sh + +echo "Verifying build artifacts..." + +# Check required files exist +required_files=( + "dist/plugin-entry.js" + "dist/plugin-manifest.json" +) + +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "Error: Required file $file not found" + exit 1 + fi +done + +# Check bundle size +max_size=5000000 # 5MB +actual_size=$(stat -f%z dist/plugin-entry.js) + +if [[ $actual_size -gt $max_size ]]; then + echo "Warning: Bundle size ${actual_size} bytes exceeds recommended ${max_size} bytes" + exit 1 +fi + +echo "Build verification passed" +``` + +## Containerization + +### Multi-stage Dockerfile + +```dockerfile +# Dockerfile +FROM registry.access.redhat.com/ubi8/nodejs-18:latest AS builder + +# Copy package files +COPY package*.json ./ +COPY yarn.lock ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the plugin +RUN npm run build + +# Production image +FROM registry.access.redhat.com/ubi8/nginx-120:latest + +# Copy build artifacts +COPY --from=builder /opt/app-root/src/dist /opt/app-root/src + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Expose port +EXPOSE 8080 + +# Set user +USER 1001 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] +``` + +### Nginx Configuration + +```nginx +# nginx.conf +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + gzip on; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + server { + listen 8080; + server_name _; + root /opt/app-root/src; + index index.html; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + + # Plugin entry point + location /plugin-entry.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri =404; + } + + # Static assets with caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Default location + location / { + try_files $uri $uri/ =404; + } + } +} +``` + +### Container Build Process + +```bash +#!/bin/bash +# scripts/build-container.sh + +REGISTRY=${REGISTRY:-"quay.io/my-org"} +IMAGE_NAME=${IMAGE_NAME:-"my-console-plugin"} +VERSION=${VERSION:-"latest"} +PLATFORM=${PLATFORM:-"linux/amd64,linux/arm64"} + +echo "Building container image..." + +# Build and push multi-architecture image +docker buildx build \ + --platform ${PLATFORM} \ + --tag ${REGISTRY}/${IMAGE_NAME}:${VERSION} \ + --tag ${REGISTRY}/${IMAGE_NAME}:latest \ + --push \ + . + +echo "Container image built and pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" +``` + +## Helm Chart Deployment + +### Chart Structure + +``` +charts/my-console-plugin/ +├── Chart.yaml +├── values.yaml +├── templates/ +│ ├── deployment.yaml +│ ├── service.yaml +│ ├── configmap.yaml +│ ├── console-plugin.yaml +│ └── NOTES.txt +└── .helmignore +``` + +### Chart.yaml + +```yaml +apiVersion: v2 +name: my-console-plugin +description: A Helm chart for My Console Plugin +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - openshift + - console + - plugin +maintainers: + - name: My Team + email: team@example.com +sources: + - https://github.com/my-org/my-console-plugin +``` + +### values.yaml + +```yaml +# Default values for my-console-plugin +replicaCount: 2 + +image: + registry: quay.io + repository: my-org/my-console-plugin + tag: "latest" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + +nodeSelector: {} +tolerations: [] +affinity: {} + +plugin: + name: my-console-plugin + displayName: "My Console Plugin" + description: "Extends OpenShift Console with custom functionality" + proxy: + - type: Service + alias: my-api + authorize: true + service: + name: my-api-service + namespace: my-namespace + port: 8080 + +securityContext: + enabled: true + runAsNonRoot: true + runAsUser: 1001 + +podSecurityContext: + fsGroup: 2000 + +networkPolicy: + enabled: false + +monitoring: + enabled: false + serviceMonitor: + enabled: false +``` + +### Deployment Template + +```yaml +# templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "my-console-plugin.fullname" . }} + labels: + {{- include "my-console-plugin.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "my-console-plugin.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "my-console-plugin.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.securityContext.enabled }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.securityContext.enabled }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: {{ .Values.securityContext.runAsNonRoot }} + runAsUser: {{ .Values.securityContext.runAsUser }} + seccompProfile: + type: RuntimeDefault + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +``` + +### ConsolePlugin Resource + +```yaml +# templates/console-plugin.yaml +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: {{ .Values.plugin.name }} + labels: + {{- include "my-console-plugin.labels" . | nindent 4 }} +spec: + displayName: {{ .Values.plugin.displayName }} + description: {{ .Values.plugin.description }} + service: + name: {{ include "my-console-plugin.fullname" . }} + namespace: {{ .Release.Namespace }} + port: {{ .Values.service.port }} + basePath: "/" + {{- if .Values.plugin.proxy }} + proxy: + {{- toYaml .Values.plugin.proxy | nindent 4 }} + {{- end }} +``` + +## CI/CD Integration + +### GitHub Actions Workflow + +```yaml +# .github/workflows/ci.yml +name: CI/CD Pipeline + +on: + push: + branches: [main] + pull_request: + branches: [main] + release: + types: [published] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm run test + + - name: Build plugin + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: plugin-build + path: dist/ + + build-container: + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'release' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: quay.io + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: quay.io/my-org/my-console-plugin + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: [test, build-container] + runs-on: ubuntu-latest + if: github.event_name == 'release' + + steps: + - uses: actions/checkout@v4 + + - name: Install OpenShift CLI + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: latest + helm: latest + + - name: Log in to OpenShift + run: | + oc login --token=${{ secrets.OPENSHIFT_TOKEN }} --server=${{ secrets.OPENSHIFT_SERVER }} + + - name: Deploy with Helm + run: | + helm upgrade --install my-console-plugin ./charts/my-console-plugin \ + --namespace my-plugin-namespace \ + --create-namespace \ + --set image.tag=${{ github.event.release.tag_name }} \ + --wait +``` + +## Production Deployment + +### Environment-specific Values + +```yaml +# environments/production/values.yaml +replicaCount: 3 + +image: + tag: "v1.0.0" + pullPolicy: Always + +resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + +securityContext: + enabled: true + runAsNonRoot: true + runAsUser: 1001 + +networkPolicy: + enabled: true + +monitoring: + enabled: true + serviceMonitor: + enabled: true +``` + +### Deployment Commands + +```bash +#!/bin/bash +# scripts/deploy-production.sh + +NAMESPACE="my-plugin-production" +RELEASE_NAME="my-console-plugin" +CHART_PATH="./charts/my-console-plugin" +VALUES_FILE="./environments/production/values.yaml" + +echo "Deploying to production..." + +# Create namespace if it doesn't exist +oc create namespace ${NAMESPACE} --dry-run=client -o yaml | oc apply -f - + +# Deploy with Helm +helm upgrade --install ${RELEASE_NAME} ${CHART_PATH} \ + --namespace ${NAMESPACE} \ + --values ${VALUES_FILE} \ + --wait \ + --timeout 10m + +# Enable the plugin in console +oc patch consoles.operator.openshift.io cluster \ + --type merge \ + --patch '{"spec":{"plugins":["'${RELEASE_NAME}'"]}}' + +echo "Deployment completed successfully" +echo "Plugin URL: https://$(oc get route console -n openshift-console -o jsonpath='{.spec.host}')/my-plugin" +``` + +## Plugin Registration + +### Manual Plugin Enablement + +```bash +# Enable plugin in OpenShift Console +oc patch consoles.operator.openshift.io cluster \ + --type merge \ + --patch '{"spec":{"plugins":["my-console-plugin"]}}' + +# Verify plugin is enabled +oc get consoles.operator.openshift.io cluster -o jsonpath='{.spec.plugins}' +``` + +### Automatic Plugin Registration + +```yaml +# templates/console-plugin-patch.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "my-console-plugin.fullname" . }}-register + labels: + {{- include "my-console-plugin.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "1" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + template: + spec: + serviceAccountName: {{ include "my-console-plugin.fullname" . }} + restartPolicy: OnFailure + containers: + - name: plugin-register + image: quay.io/openshift/cli:latest + command: + - /bin/bash + - -c + - | + echo "Registering plugin..." + oc patch consoles.operator.openshift.io cluster \ + --type merge \ + --patch '{"spec":{"plugins":["{{ .Values.plugin.name }}"]}}' + echo "Plugin registered successfully" +``` + +## Monitoring and Observability + +### ServiceMonitor for Prometheus + +```yaml +# templates/servicemonitor.yaml +{{- if and .Values.monitoring.enabled .Values.monitoring.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "my-console-plugin.fullname" . }} + labels: + {{- include "my-console-plugin.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "my-console-plugin.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: 30s +{{- end }} +``` + +### Health Checks + +```bash +#!/bin/bash +# scripts/health-check.sh + +PLUGIN_URL="https://my-console-plugin.example.com" + +echo "Performing health checks..." + +# Check plugin service +response=$(curl -s -o /dev/null -w "%{http_code}" ${PLUGIN_URL}/health) +if [[ $response -eq 200 ]]; then + echo "✅ Plugin service is healthy" +else + echo "❌ Plugin service is unhealthy (HTTP $response)" + exit 1 +fi + +# Check plugin loading +response=$(curl -s -o /dev/null -w "%{http_code}" ${PLUGIN_URL}/plugin-entry.js) +if [[ $response -eq 200 ]]; then + echo "✅ Plugin entry file is accessible" +else + echo "❌ Plugin entry file is not accessible (HTTP $response)" + exit 1 +fi + +echo "All health checks passed" +``` + +## Rollback and Recovery + +### Helm Rollback + +```bash +# List releases +helm list -n my-plugin-namespace + +# Check release history +helm history my-console-plugin -n my-plugin-namespace + +# Rollback to previous version +helm rollback my-console-plugin -n my-plugin-namespace + +# Rollback to specific revision +helm rollback my-console-plugin 2 -n my-plugin-namespace +``` + +### Emergency Plugin Disable + +```bash +#!/bin/bash +# scripts/emergency-disable.sh + +echo "Disabling plugin in emergency..." + +# Get current plugins +current_plugins=$(oc get consoles.operator.openshift.io cluster -o jsonpath='{.spec.plugins}') + +# Remove our plugin from the list +new_plugins=$(echo $current_plugins | jq '. - ["my-console-plugin"]') + +# Apply the change +oc patch consoles.operator.openshift.io cluster \ + --type merge \ + --patch "{\"spec\":{\"plugins\":$new_plugins}}" + +echo "Plugin disabled successfully" +``` + +## Related Skills + +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup and dependencies +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Build and testing workflow +- [openshift-console-plugin-advanced](../openshift-console-plugin-advanced/SKILL.md) - Security and performance considerations +- [openshift-console-plugin-styling](../openshift-console-plugin-styling/SKILL.md) - Build optimization for assets + +## Deployment Checklist + +- [ ] Configure webpack for production builds +- [ ] Create multi-stage Dockerfile with security best practices +- [ ] Set up Helm chart with proper templates +- [ ] Configure CI/CD pipeline with automated testing +- [ ] Implement health checks and monitoring +- [ ] Set up environment-specific configurations +- [ ] Configure automatic plugin registration +- [ ] Test rollback procedures +- [ ] Set up container registry and image scanning +- [ ] Configure resource limits and autoscaling +- [ ] Implement network policies if required +- [ ] Set up monitoring and alerting \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-development/SKILL.md b/.claude/skills/openshift-console-plugin-development/SKILL.md new file mode 100644 index 00000000..caf27f63 --- /dev/null +++ b/.claude/skills/openshift-console-plugin-development/SKILL.md @@ -0,0 +1,510 @@ +--- +name: openshift-console-plugin-development +description: Development workflow, testing, linting, and debugging for OpenShift Console plugins +--- + +# OpenShift Console Plugin Development + +This skill covers the complete development workflow for OpenShift Console plugins, including local development setup, testing strategies, code quality tools, and debugging techniques. + +## Development Workflow + +### Plugin Testing Requirements + +**⚠️ IMPORTANT: Plugin Testing Requirements** + +To test your console plugin, you MUST run both the development server AND the OpenShift Console container. Running only the development server (`npm run start`) is insufficient for testing because: + +1. **Plugin Loading**: The console must load your plugin via module federation +2. **Authentication**: Console APIs require proper authentication context +3. **Extension Points**: Navigation items, routes, and other extensions only work within the full console +4. **K8s API Access**: Resource operations require the console's proxy to the cluster APIs + +### Complete Development Setup +```bash +# 1. Install dependencies +npm install + +# 2. Login to OpenShift cluster (REQUIRED) +oc login https://your-cluster-api:6443 + +# 3. Start plugin development server (serves plugin assets) +npm run start +# This starts webpack dev server on http://localhost:9001 + +# 4. Start OpenShift Console with plugin enabled (REQUIRED FOR TESTING) +npm run start-console +# This starts console container on http://localhost:9000 +# The console will load your plugin from the dev server + +# 5. Navigate to http://localhost:9000 to test your plugin +``` + +### Testing Workflow +```bash +# After making changes to your plugin: +# 1. Webpack dev server automatically rebuilds (from step 3) +# 2. Refresh browser at http://localhost:9000 to see changes +# 3. Check browser console for any plugin loading errors + +# Run automated tests +npm run test # Unit tests +npm run test-cypress-headless # E2E tests + +# Code quality checks (REQUIRED before committing) +npm run lint # ESLint + Stylelint with auto-fix +``` + +## Code Quality and Linting + +### Pre-Commit Checklist + +**⚠️ ALWAYS run the linter before committing changes** + +**Code Quality First**: Always run `yarn lint` before testing and committing. This catches style issues, potential bugs, and accessibility violations before they reach testing or production. + +```bash +# Essential pre-commit workflow: +# 1. Run linter (fixes most issues automatically) +yarn lint + +# 2. Review any remaining linter errors that couldn't be auto-fixed +# 3. Fix any TypeScript compilation errors +yarn tsc --noEmit + +# 4. Test your changes work in the browser +# 5. Stage and commit your changes +git add . +git commit -m "Your commit message" +``` + +### Why Lint Before Committing? +- **Consistency**: Maintains consistent code style across the project +- **Quality**: Catches potential bugs and code issues early +- **CI/CD**: Prevents build failures in continuous integration +- **Collaboration**: Makes code reviews easier and more focused +- **Accessibility**: ESLint rules help catch accessibility issues +- **Performance**: Identifies potential performance anti-patterns + +### What the Linter Checks +- Code formatting (Prettier) +- JavaScript/TypeScript best practices (ESLint) +- CSS style consistency (Stylelint) +- Accessibility violations +- Potential security issues +- Import/export consistency + +## Testing Strategies + +### Unit Testing with Jest + +```typescript +// src/components/__tests__/MyPage.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../utils/i18n-test'; +import MyPage from '../MyPage'; + +describe('MyPage', () => { + const renderWithI18n = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + + it('renders page title correctly', () => { + renderWithI18n(); + expect(screen.getByRole('heading', { name: /my page/i })).toBeInTheDocument(); + }); + + it('displays content when loaded', () => { + renderWithI18n(); + expect(screen.getByText(/page content/i)).toBeInTheDocument(); + }); +}); +``` + +### Testing Data Hooks +```typescript +// src/hooks/__tests__/useMyResources.test.tsx +import { renderHook } from '@testing-library/react'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { useMyResources } from '../useMyResources'; + +// Mock the SDK hook +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + useK8sWatchResource: jest.fn(), +})); + +const mockUseK8sWatchResource = useK8sWatchResource as jest.MockedFunction; + +describe('useMyResources', () => { + it('returns resources when loaded', () => { + const mockResources = [ + { metadata: { name: 'test-resource' } } + ]; + + mockUseK8sWatchResource.mockReturnValue([mockResources, true, undefined]); + + const { result } = renderHook(() => useMyResources('test-namespace')); + + expect(result.current.resources).toEqual(mockResources); + expect(result.current.loaded).toBe(true); + expect(result.current.loadError).toBeUndefined(); + }); + + it('handles loading error', () => { + const mockError = new Error('Failed to load'); + mockUseK8sWatchResource.mockReturnValue([[], false, mockError]); + + const { result } = renderHook(() => useMyResources('test-namespace')); + + expect(result.current.resources).toEqual([]); + expect(result.current.loaded).toBe(false); + expect(result.current.loadError).toBe(mockError); + }); +}); +``` + +### Integration Testing with Cypress + +```typescript +// integration-tests/my-plugin.spec.ts +describe('My Console Plugin', () => { + beforeEach(() => { + cy.login(); + cy.visit('/my-plugin'); + }); + + it('should display plugin navigation', () => { + cy.get('[data-test="nav-item-my-plugin"]').should('be.visible'); + }); + + it('should load plugin page', () => { + cy.get('[data-test="nav-item-my-plugin"]').click(); + cy.get('[data-test="my-plugin-page"]').should('be.visible'); + cy.get('h1').should('contain', 'My Plugin'); + }); + + it('should create a new resource', () => { + cy.get('[data-test="create-resource-button"]').click(); + cy.get('[data-test="resource-name-input"]').type('test-resource'); + cy.get('[data-test="create-button"]').click(); + + cy.get('[data-test="resource-list"]').should('contain', 'test-resource'); + }); + + it('should handle error states', () => { + cy.intercept('GET', '/api/kubernetes/apis/my-group.io/v1/namespaces/*/myresources', { + statusCode: 500, + body: { message: 'Server error' } + }); + + cy.visit('/my-plugin/resources'); + cy.get('[data-test="error-alert"]').should('be.visible'); + cy.get('[data-test="error-alert"]').should('contain', 'Error loading resources'); + }); +}); +``` + +## Debugging Techniques + +### Browser DevTools Debugging + +```typescript +// Add debugging helpers in development +const MyComponent: React.FC = () => { + const [resources, loaded, loadError] = useMyResources(); + + // Debug logging in development + React.useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.log('MyComponent render:', { resources, loaded, loadError }); + } + }, [resources, loaded, loadError]); + + // Debug breakpoint + if (process.env.NODE_ENV === 'development' && resources.length > 0) { + debugger; // Will pause in browser devtools + } + + return
Component content
; +}; +``` + +### Console Extension Debugging +```typescript +// Debug extension loading +const MyPage: React.FC = () => { + React.useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.log('Plugin extension loaded:', { + pathname: window.location.pathname, + extensions: window.SERVER_FLAGS?.consolePlugins + }); + } + }, []); + + return ...; +}; +``` + +### Network Request Debugging +```typescript +// Debug API calls +const debugApiCall = (url: string, options?: RequestInit) => { + if (process.env.NODE_ENV === 'development') { + console.log('API Call:', { url, options }); + } + return consoleFetch(url, options); +}; + +// Use in API operations +const createResource = async (data: MyResource) => { + try { + const response = await debugApiCall('/api/kubernetes/apis/my-group.io/v1/namespaces/default/myresources', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } catch (error) { + console.error('Create resource failed:', error); + throw error; + } +}; +``` + +## Troubleshooting Plugin Loading + +### Common Issues and Solutions + +#### Plugin Not Loading +```bash +# Check webpack dev server is running +curl http://localhost:9001/plugin-entry.js + +# Check console container logs +docker logs $(docker ps --filter "ancestor=quay.io/openshift/console" --format "{{.ID}}") + +# Verify plugin registration +oc get consolePlugin my-plugin -o yaml +``` + +#### Extension Points Not Working +1. **Check console-extensions.json matches exposedModules**: +```json +// console-extensions.json +"component": { "$codeRef": "MyPage" } + +// package.json +"exposedModules": { + "MyPage": "./components/MyPage" // Must match exactly +} +``` + +2. **Verify component exports**: +```typescript +// Component must be default export +const MyPage: React.FC = () => { ... }; +export default MyPage; // Required for $codeRef +``` + +#### Module Federation Issues +```typescript +// Check webpack.config.ts +const config: webpack.Configuration = { + plugins: [ + new ModuleFederationPlugin({ + name: 'my-plugin', + filename: 'plugin-entry.js', + exposes: { + './plugin': './src/plugin.ts', // Entry point + './MyPage': './src/components/MyPage', // Components + }, + }), + ], +}; +``` + +### Development Scripts + +Common development commands: +```json +{ + "scripts": { + "start": "webpack serve --config webpack.config.ts", + "start-console": "./scripts/start-console.sh", + "build": "webpack --mode production", + "test": "jest", + "test:watch": "jest --watch", + "test-cypress": "cypress open", + "test-cypress-headless": "cypress run", + "lint": "eslint src --ext .ts,.tsx --fix && stylelint 'src/**/*.css' --fix", + "lint:check": "eslint src --ext .ts,.tsx && stylelint 'src/**/*.css'", + "tsc": "tsc --noEmit", + "i18n": "i18next-scanner --config i18next-scanner.config.js" + } +} +``` + +## Performance Monitoring + +### Bundle Analysis +```bash +# Analyze bundle size +npm run build +npx webpack-bundle-analyzer dist/ + +# Check for large dependencies +npm install -g webpack-bundle-analyzer +webpack-bundle-analyzer dist/main.js +``` + +### Runtime Performance +```typescript +// Performance monitoring in development +const withPerformanceMonitoring =

( + WrappedComponent: React.ComponentType

+) => { + return (props: P) => { + React.useEffect(() => { + if (process.env.NODE_ENV === 'development') { + const startTime = performance.now(); + return () => { + const endTime = performance.now(); + console.log(`${WrappedComponent.name} render time: ${endTime - startTime}ms`); + }; + } + }); + + return ; + }; +}; + +// Usage +export default withPerformanceMonitoring(MyExpensiveComponent); +``` + +## Error Handling and Monitoring + +### Error Boundaries +```typescript +// Global error boundary for plugin +class PluginErrorBoundary extends React.Component< + React.PropsWithChildren<{}>, + { hasError: boolean; error?: Error } +> { + constructor(props: React.PropsWithChildren<{}>) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Plugin error:', error, errorInfo); + + // Send error to monitoring service in production + if (process.env.NODE_ENV === 'production') { + // sendErrorToMonitoring(error, errorInfo); + } + } + + render() { + if (this.state.hasError) { + return ( + window.location.reload()}> + Reload Page + + } + > + Something went wrong in the plugin. Please try reloading the page. + + ); + } + + return this.props.children; + } +} +``` + +### Development vs Production Configurations + +```typescript +// Environment-specific configurations +const isDevelopment = process.env.NODE_ENV === 'development'; + +export const config = { + // API endpoints + apiBaseUrl: isDevelopment + ? 'http://localhost:8080/api' + : '/api', + + // Logging level + logLevel: isDevelopment ? 'debug' : 'error', + + // Debug features + enableDebugPanel: isDevelopment, + + // Performance monitoring + enablePerformanceMonitoring: isDevelopment, + + // Error reporting + enableErrorReporting: !isDevelopment, +}; +``` + +## Related Skills + +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup and dependencies +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - Component testing patterns +- [openshift-console-plugin-data](../openshift-console-plugin-data/SKILL.md) - Testing data hooks and operations +- [openshift-console-plugin-deployment](../openshift-console-plugin-deployment/SKILL.md) - Build and deployment workflow + +## Development Checklist + +- [ ] Set up local development with both dev server and console container +- [ ] Configure linting and run before all commits +- [ ] Write unit tests for components and hooks +- [ ] Add integration tests for critical user flows +- [ ] Set up error boundaries for error handling +- [ ] Configure debugging tools and logging +- [ ] Monitor bundle size and performance +- [ ] Test plugin loading and extension points +- [ ] Verify authentication and API access +- [ ] Test in different browser environments +- [ ] Check accessibility with screen readers +- [ ] Validate i18n translations work correctly + +## Common Development Commands + +```bash +# Start development environment +npm run start # Plugin dev server +npm run start-console # Console container +npm test # Run unit tests +npm run test:watch # Watch mode for tests +npm run test-cypress # Integration tests +npm run lint # Code quality checks +npm run build # Production build +npm run i18n # Update translations + +# Debugging +npm run lint:check # Check without fixes +npm run tsc # TypeScript check +npx webpack-bundle-analyzer dist/ # Analyze bundle + +# Cleanup +rm -rf node_modules dist # Clean install +npm ci # Fresh install +``` \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-extensions/SKILL.md b/.claude/skills/openshift-console-plugin-extensions/SKILL.md new file mode 100644 index 00000000..0cd01c1b --- /dev/null +++ b/.claude/skills/openshift-console-plugin-extensions/SKILL.md @@ -0,0 +1,450 @@ +--- +name: openshift-console-plugin-extensions +description: Console extension points, navigation, routes, and integration patterns for OpenShift Console plugins +--- + +# OpenShift Console Plugin Extensions + +This skill covers console extension points and integration patterns for extending the OpenShift Console with custom functionality. Learn how to add navigation, routes, tabs, and actions to the console. + +## Console Extensions Overview + +Console extensions are declared in `console-extensions.json` and define how your plugin integrates with the OpenShift Console. Each extension must have a corresponding module in your `package.json` `exposedModules` section. + +### Critical Requirements +- **Extension-Module Mapping**: Every `$codeRef` in extensions must match an entry in `exposedModules` +- **Type Safety**: Use proper TypeScript types for all extension properties +- **i18n Support**: Use translation keys for user-facing strings + +## Navigation Extensions + +### Navigation Sections +Create logical groupings for your plugin's navigation items: + +```json +{ + "type": "console.navigation/section", + "properties": { + "id": "my-plugin-section", + "perspective": "admin", + "name": "%plugin__my-console-plugin~My Section%" + } +} +``` + +### Navigation Links +Add navigation items that link to your plugin pages: + +```json +{ + "type": "console.navigation/href", + "properties": { + "id": "my-plugin-nav", + "name": "%plugin__my-console-plugin~My Feature%", + "href": "/my-feature", + "perspective": "admin", + "section": "my-plugin-section", + "insertAfter": "workloads" + } +} +``` + +### Navigation with Resources +Link navigation to specific Kubernetes resources: + +```json +{ + "type": "console.navigation/resource-ns", + "properties": { + "id": "my-resources-nav", + "name": "%plugin__my-console-plugin~My Resources%", + "section": "my-plugin-section", + "model": { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource" + } + } +} +``` + +## Page Routes + +### Basic Page Routes +Define routes for your plugin pages: + +```json +{ + "type": "console.page/route", + "properties": { + "path": "/my-feature", + "component": { "$codeRef": "MyPage" } + } +} +``` + +### Parameterized Routes +Create routes with URL parameters: + +```json +{ + "type": "console.page/route", + "properties": { + "path": "/my-feature/:id", + "component": { "$codeRef": "MyDetailsPage" } + } +} +``` + +### Namespace-scoped Routes +Routes that work within namespace contexts: + +```json +{ + "type": "console.page/route", + "properties": { + "path": "/ns/:ns/my-feature", + "component": { "$codeRef": "MyNamespacedPage" } + } +} +``` + +## Resource Pages + +### Resource List Pages +Override or create list pages for Kubernetes resources: + +```json +{ + "type": "console.page/resource/list", + "properties": { + "model": { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource" + }, + "component": { "$codeRef": "MyResourceList" } + } +} +``` + +### Resource Detail Pages +Create detailed views for individual resources: + +```json +{ + "type": "console.page/resource/details", + "properties": { + "model": { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource" + }, + "component": { "$codeRef": "MyResourceDetails" } + } +} +``` + +## Tab Extensions + +### Resource Detail Tabs +Add tabs to existing resource detail pages: + +```json +{ + "type": "console.tab", + "properties": { + "model": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "component": { "$codeRef": "MyResourceMonitoringTab" }, + "name": "%plugin__my-console-plugin~Monitoring%" + } +} +``` + +### Conditional Tabs +Show tabs only when certain conditions are met: + +```json +{ + "type": "console.tab", + "properties": { + "model": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "component": { "$codeRef": "MyConditionalTab" }, + "name": "%plugin__my-console-plugin~Advanced%" + }, + "flags": { + "required": ["MY_FEATURE_FLAG"] + } +} +``` + +## Action Extensions + +### Resource Actions +Add actions to resource pages (kebab menus, buttons): + +```json +{ + "type": "console.action/resource-provider", + "properties": { + "model": { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource" + }, + "provider": { "$codeRef": "myResourceActions" } + } +} +``` + +### Global Actions +Add actions to global areas like the utility menu: + +```json +{ + "type": "console.action/provider", + "properties": { + "contextId": "topnav-utility-menu", + "provider": { "$codeRef": "myGlobalActions" } + } +} +``` + +## Feature Flags + +### Declaring Feature Flags +Enable conditional functionality: + +```json +{ + "type": "console.flag", + "properties": { + "flag": "MY_FEATURE_FLAG", + "handler": { "$codeRef": "myFeatureFlag" } + } +} +``` + +### Using Flags in Extensions +Control extension visibility with flags: + +```json +{ + "type": "console.page/route", + "properties": { + "path": "/experimental-feature", + "component": { "$codeRef": "ExperimentalPage" } + }, + "flags": { + "required": ["EXPERIMENTAL_FEATURES"] + } +} +``` + +## Dashboard Extensions + +### Dashboard Cards +Add cards to overview dashboards: + +```json +{ + "type": "console.dashboards/overview/health/url", + "properties": { + "title": "%plugin__my-console-plugin~My Service Health%", + "url": "/api/my-plugin/health", + "healthHandler": { "$codeRef": "myHealthHandler" } + } +} +``` + +### Activity Cards +Show activities in the Home page: + +```json +{ + "type": "console.dashboards/overview/activity/resource", + "properties": { + "k8sResource": { + "prop": "myResources", + "isList": true, + "kind": "MyResource" + }, + "component": { "$codeRef": "MyActivityCard" } + } +} +``` + +## Model Registration + +### Custom Resource Models +Register your custom resources with the console: + +```json +{ + "type": "console.model", + "properties": { + "models": [ + { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource", + "plural": "myresources", + "namespaced": true, + "crd": true + } + ] + } +} +``` + +## YAML Template Extensions + +### YAML Templates +Provide templates for creating resources: + +```json +{ + "type": "console.yaml-template", + "properties": { + "model": { + "group": "my-group.io", + "version": "v1", + "kind": "MyResource" + }, + "template": { "$codeRef": "myResourceTemplate" }, + "name": "My Resource Template" + } +} +``` + +## Extension Best Practices + +### 1. Consistent Naming +```json +{ + "id": "my-plugin-feature-name", // Use plugin prefix + "name": "%plugin__my-console-plugin~Feature Name%" // Always use i18n +} +``` + +### 2. Proper Model References +```json +{ + "model": { + "group": "apps", // Separate group property + "version": "v1", // Separate version property + "kind": "Deployment" // Exact kind name + } +} +``` + +### 3. Code Reference Mapping +```json +// In console-extensions.json +"component": { "$codeRef": "MyPage" } + +// In package.json +"exposedModules": { + "MyPage": "./components/MyPage" // Must match exactly +} +``` + +### 4. Perspective Targeting +```json +{ + "perspective": "admin", // Target specific perspective + "perspective": "dev", // Or developer perspective + "perspective": "*" // Or all perspectives +} +``` + +## Common Extension Patterns + +### Multi-tab Resource Page +```json +[ + { + "type": "console.page/resource/details", + "properties": { + "model": { "group": "my-group.io", "version": "v1", "kind": "MyResource" }, + "component": { "$codeRef": "MyResourceDetails" } + } + }, + { + "type": "console.tab", + "properties": { + "model": { "group": "my-group.io", "version": "v1", "kind": "MyResource" }, + "component": { "$codeRef": "MyResourceEventsTab" }, + "name": "%plugin__my-console-plugin~Events%" + } + }, + { + "type": "console.tab", + "properties": { + "model": { "group": "my-group.io", "version": "v1", "kind": "MyResource" }, + "component": { "$codeRef": "MyResourceLogsTab" }, + "name": "%plugin__my-console-plugin~Logs%" + } + } +] +``` + +### Navigation with Sub-items +```json +[ + { + "type": "console.navigation/section", + "properties": { + "id": "my-plugin-section", + "perspective": "admin", + "name": "%plugin__my-console-plugin~My Plugin%" + } + }, + { + "type": "console.navigation/href", + "properties": { + "id": "my-plugin-overview", + "name": "%plugin__my-console-plugin~Overview%", + "href": "/my-plugin", + "section": "my-plugin-section" + } + }, + { + "type": "console.navigation/resource-ns", + "properties": { + "id": "my-resources", + "section": "my-plugin-section", + "model": { "group": "my-group.io", "version": "v1", "kind": "MyResource" } + } + } +] +``` + +## Related Skills + +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup and dependencies +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - React component development +- [openshift-console-plugin-i18n](../openshift-console-plugin-i18n/SKILL.md) - Internationalization for extensions +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Development and testing workflow + +## Extension Reference + +### Most Common Extension Types +- `console.navigation/section` - Navigation sections +- `console.navigation/href` - Navigation links +- `console.navigation/resource-ns` - Resource navigation +- `console.page/route` - Custom routes +- `console.page/resource/list` - Resource list pages +- `console.page/resource/details` - Resource detail pages +- `console.tab` - Resource detail tabs +- `console.action/resource-provider` - Resource actions +- `console.flag` - Feature flags +- `console.model` - Custom resource models \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-i18n/SKILL.md b/.claude/skills/openshift-console-plugin-i18n/SKILL.md new file mode 100644 index 00000000..f3b02931 --- /dev/null +++ b/.claude/skills/openshift-console-plugin-i18n/SKILL.md @@ -0,0 +1,565 @@ +--- +name: openshift-console-plugin-i18n +description: Internationalization and localization for OpenShift Console plugins +--- + +# OpenShift Console Plugin Internationalization (i18n) + +This skill covers internationalization and localization for OpenShift Console plugins, including translation setup, namespace conventions, and best practices for multi-language support. + +## i18n Overview + +Internationalization enables your console plugin to support multiple languages and regions. The OpenShift Console uses react-i18next for translation management, and plugins must follow specific namespace conventions. + +### Core Requirements +- **Namespace Convention**: `plugin__` (matches ConsolePlugin resource name) +- **Translation Files**: Located in `/locales` directory +- **Default Language**: English (en) is required +- **Key Format**: Use descriptive, hierarchical keys + +## Namespace Convention + +**⚠️ CRITICAL: Namespace must match your ConsolePlugin resource name** + +```typescript +// If your ConsolePlugin resource is named "my-console-plugin" +const PLUGIN_NAMESPACE = 'plugin__my-console-plugin'; + +// Use this namespace in all translation calls +const { t } = useTranslation('plugin__my-console-plugin'); +``` + +### ConsolePlugin Resource Name Matching +```yaml +# In your Helm chart or YAML manifest +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: my-console-plugin # This determines your i18n namespace +spec: + displayName: "My Console Plugin" +``` + +```typescript +// i18n namespace MUST match the resource name above +const namespace = 'plugin__my-console-plugin'; +``` + +## Translation Setup + +### Directory Structure +``` +my-console-plugin/ +├── locales/ +│ ├── en/ +│ │ └── plugin__my-console-plugin.json +│ ├── es/ +│ │ └── plugin__my-console-plugin.json +│ ├── fr/ +│ │ └── plugin__my-console-plugin.json +│ └── zh/ +│ └── plugin__my-console-plugin.json +├── src/ +│ └── components/ +└── package.json +``` + +### English Translation File (Required) +```json +{ + "My Plugin": "My Plugin", + "Dashboard": "Dashboard", + "Resources": "Resources", + "Create Resource": "Create Resource", + "Edit Resource": "Edit Resource", + "Delete Resource": "Delete Resource", + "Name": "Name", + "Namespace": "Namespace", + "Status": "Status", + "Created": "Created", + "Actions": "Actions", + "Loading": "Loading...", + "No resources found": "No resources found", + "Error loading resources": "Error loading resources", + "Resource created successfully": "Resource created successfully", + "Failed to create resource": "Failed to create resource", + "Are you sure you want to delete {{name}}?": "Are you sure you want to delete {{name}}?", + "This action cannot be undone": "This action cannot be undone", + "resource": "resource", + "resources": "resources", + "{{count}} resource": "{{count}} resource", + "{{count}} resource_plural": "{{count}} resources", + "Welcome": "Welcome", + "Getting Started": "Getting Started", + "Documentation": "Documentation", + "Support": "Support", + "Settings": "Settings", + "Configuration": "Configuration", + "Advanced": "Advanced", + "Overview": "Overview", + "Details": "Details", + "YAML": "YAML", + "Events": "Events", + "Logs": "Logs", + "Metrics": "Metrics", + "Ready": "Ready", + "Pending": "Pending", + "Error": "Error", + "Unknown": "Unknown", + "Healthy": "Healthy", + "Unhealthy": "Unhealthy", + "Running": "Running", + "Stopped": "Stopped", + "Success": "Success", + "Warning": "Warning", + "Info": "Information" +} +``` + +## Using Translations in Components + +### Basic Translation Hook +```typescript +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Page, PageSection, Title } from '@patternfly/react-core'; + +const MyPage: React.FC = () => { + const { t } = useTranslation('plugin__my-console-plugin'); + + return ( + + + {t('My Plugin')} + + +

{t('Welcome to the plugin dashboard')}

+ + + ); +}; +``` + +### Translations with Variables +```typescript +const ResourceCard: React.FC<{ resource: MyResource }> = ({ resource }) => { + const { t } = useTranslation('plugin__my-console-plugin'); + const name = resource.metadata?.name; + + return ( + + {t('Resource Details for {{name}}', { name })} + +

{t('Status: {{status}}', { status: resource.status?.phase })}

+

{t('Created: {{date}}', { + date: new Date(resource.metadata?.creationTimestamp).toLocaleDateString() + })}

+
+
+ ); +}; +``` + +### Pluralization +```typescript +const ResourceList: React.FC<{ resources: MyResource[] }> = ({ resources }) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + return ( +
+ + {t('{{count}} resource', { count: resources.length })} + + {resources.length === 0 && ( + + {t('No resources found')} + + )} +
+ ); +}; +``` + +### Complex Formatting +```typescript +const DeleteConfirmation: React.FC<{ resourceName: string }> = ({ resourceName }) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + return ( + + + +

{t('Are you sure you want to delete {{name}}?', { name: resourceName })}

+
+ + + +
+
+ ); +}; +``` + +## Console Extensions i18n + +### Navigation Extensions +```json +{ + "type": "console.navigation/section", + "properties": { + "id": "my-plugin-section", + "perspective": "admin", + "name": "%plugin__my-console-plugin~My Plugin%" + } +} +``` + +### Page Routes with i18n +```json +{ + "type": "console.navigation/href", + "properties": { + "id": "my-plugin-dashboard", + "name": "%plugin__my-console-plugin~Dashboard%", + "href": "/my-plugin/dashboard", + "section": "my-plugin-section" + } +} +``` + +### Tab Extensions +```json +{ + "type": "console.tab", + "properties": { + "model": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "component": { "$codeRef": "MyResourceTab" }, + "name": "%plugin__my-console-plugin~Monitoring%" + } +} +``` + +## Advanced Translation Patterns + +### Conditional Translations +```typescript +const StatusBadge: React.FC<{ status: string; isError: boolean }> = ({ status, isError }) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + const getStatusText = () => { + if (isError) return t('Error'); + + switch (status) { + case 'Ready': return t('Ready'); + case 'Pending': return t('Pending'); + case 'Running': return t('Running'); + default: return t('Unknown'); + } + }; + + return ( + + ); +}; +``` + +### Date and Time Formatting +```typescript +const ResourceTimestamp: React.FC<{ timestamp: string }> = ({ timestamp }) => { + const { t, i18n } = useTranslation('plugin__my-console-plugin'); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return new Intl.DateTimeFormat(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + }; + + return ( + + {formatDate(timestamp)} + + ); +}; +``` + +### Number Formatting +```typescript +const ResourceMetrics: React.FC<{ cpuUsage: number; memoryUsage: number }> = ({ + cpuUsage, + memoryUsage +}) => { + const { t, i18n } = useTranslation('plugin__my-console-plugin'); + + const formatNumber = (value: number) => { + return new Intl.NumberFormat(i18n.language, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(value); + }; + + return ( + + + {t('CPU Usage')} + + {t('{{value}}%', { value: formatNumber(cpuUsage) })} + + + + {t('Memory Usage')} + + {t('{{value}} MB', { value: formatNumber(memoryUsage) })} + + + + ); +}; +``` + +## Translation Workflow + +### Adding New Translations +```bash +# 1. Add new keys to English translation file +# locales/en/plugin__my-console-plugin.json + +# 2. Use the translation in your component +const { t } = useTranslation('plugin__my-console-plugin'); +const text = t('New feature description'); + +# 3. Update all translation files +npm run i18n + +# 4. Send translation files to translators +# 5. Update translation files with translated content +``` + +### Translation Build Process +```json +{ + "scripts": { + "i18n": "i18next-scanner --config i18next-scanner.config.js", + "i18n:extract": "i18next-scanner", + "i18n:build": "node scripts/build-i18n.js" + } +} +``` + +### i18n Scanner Configuration +```javascript +// i18next-scanner.config.js +module.exports = { + input: [ + 'src/**/*.{js,jsx,ts,tsx}', + 'console-extensions.json' + ], + output: './locales', + options: { + debug: false, + func: { + list: ['t', 'i18next.t', 'i18n.t'], + extensions: ['.js', '.jsx', '.ts', '.tsx'] + }, + trans: { + component: 'Trans', + i18nKey: 'i18nKey', + defaultsKey: 'defaults', + extensions: ['.js', '.jsx', '.ts', '.tsx'] + }, + lngs: ['en'], + ns: [`plugin__${require('./package.json').consolePlugin.name}`], + defaultLng: 'en', + defaultNs: `plugin__${require('./package.json').consolePlugin.name}`, + resource: { + loadPath: '{{lng}}/{{ns}}.json', + savePath: '{{lng}}/{{ns}}.json', + jsonIndent: 2 + }, + nsSeparator: '~', + keySeparator: false + } +}; +``` + +## Multi-language Support + +### Spanish Translation Example +```json +{ + "My Plugin": "Mi Plugin", + "Dashboard": "Panel de Control", + "Resources": "Recursos", + "Create Resource": "Crear Recurso", + "Edit Resource": "Editar Recurso", + "Delete Resource": "Eliminar Recurso", + "Name": "Nombre", + "Namespace": "Espacio de Nombres", + "Status": "Estado", + "Created": "Creado", + "Actions": "Acciones", + "Loading": "Cargando...", + "No resources found": "No se encontraron recursos", + "Error loading resources": "Error al cargar recursos", + "Resource created successfully": "Recurso creado exitosamente", + "Failed to create resource": "Error al crear recurso", + "Are you sure you want to delete {{name}}?": "¿Está seguro de que desea eliminar {{name}}?", + "This action cannot be undone": "Esta acción no se puede deshacer", + "{{count}} resource": "{{count}} recurso", + "{{count}} resource_plural": "{{count}} recursos" +} +``` + +### French Translation Example +```json +{ + "My Plugin": "Mon Plugin", + "Dashboard": "Tableau de Bord", + "Resources": "Ressources", + "Create Resource": "Créer une Ressource", + "Edit Resource": "Modifier la Ressource", + "Delete Resource": "Supprimer la Ressource", + "Name": "Nom", + "Namespace": "Espace de Noms", + "Status": "État", + "Created": "Créé", + "Actions": "Actions", + "Loading": "Chargement...", + "No resources found": "Aucune ressource trouvée", + "Error loading resources": "Erreur lors du chargement des ressources", + "{{count}} resource": "{{count}} ressource", + "{{count}} resource_plural": "{{count}} ressources" +} +``` + +## Testing Translations + +### Translation Testing +```typescript +// src/utils/i18n-test.ts +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +// Test translations +const testResources = { + en: { + 'plugin__my-console-plugin': { + 'My Plugin': 'My Plugin', + 'Dashboard': 'Dashboard', + 'Loading': 'Loading...', + 'No resources found': 'No resources found' + } + } +}; + +i18n + .use(initReactI18next) + .init({ + lng: 'en', + fallbackLng: 'en', + debug: false, + resources: testResources, + ns: ['plugin__my-console-plugin'], + defaultNS: 'plugin__my-console-plugin' + }); + +export default i18n; +``` + +### Component Testing with i18n +```typescript +// src/components/__tests__/MyPage.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../utils/i18n-test'; +import MyPage from '../MyPage'; + +describe('MyPage with i18n', () => { + const renderWithI18n = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + + it('renders translated content', () => { + renderWithI18n(); + expect(screen.getByText('My Plugin')).toBeInTheDocument(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); +}); +``` + +## Best Practices + +### Translation Key Naming +```json +{ + "page.dashboard.title": "Dashboard", + "page.dashboard.description": "View system overview", + "action.create": "Create", + "action.edit": "Edit", + "action.delete": "Delete", + "status.ready": "Ready", + "status.pending": "Pending", + "status.error": "Error", + "message.success.create": "Resource created successfully", + "message.error.create": "Failed to create resource", + "modal.delete.title": "Delete Resource", + "modal.delete.confirm": "Are you sure you want to delete {{name}}?" +} +``` + +### Context-Aware Translations +```typescript +const ActionButton: React.FC<{ action: 'create' | 'edit' | 'delete'; resource: string }> = ({ + action, + resource +}) => { + const { t } = useTranslation('plugin__my-console-plugin'); + + const getActionText = () => { + switch (action) { + case 'create': return t('action.create.resource', { resource }); + case 'edit': return t('action.edit.resource', { resource }); + case 'delete': return t('action.delete.resource', { resource }); + } + }; + + return ; +}; +``` + +## Related Skills + +- [openshift-console-plugin-extensions](../openshift-console-plugin-extensions/SKILL.md) - Using i18n in console extensions +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - Component translation patterns +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup with i18n +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Testing translations + +## i18n Checklist + +- [ ] Namespace matches ConsolePlugin resource name +- [ ] English translation file exists and is complete +- [ ] All user-facing strings use translation keys +- [ ] Console extensions use i18n format (%namespace~key%) +- [ ] Pluralization rules implemented for count-based content +- [ ] Date and number formatting uses locale-aware methods +- [ ] Translation extraction process configured +- [ ] Tests include i18n provider +- [ ] Variable interpolation used for dynamic content +- [ ] Context-aware translations for ambiguous terms +- [ ] All supported languages have translation files +- [ ] Translation build process integrated into CI/CD \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-setup/SKILL.md b/.claude/skills/openshift-console-plugin-setup/SKILL.md new file mode 100644 index 00000000..0ce7e5d3 --- /dev/null +++ b/.claude/skills/openshift-console-plugin-setup/SKILL.md @@ -0,0 +1,221 @@ +--- +name: openshift-console-plugin-setup +description: Project setup, dependencies, and version compatibility for OpenShift Console plugins +--- + +# OpenShift Console Plugin Setup + +This skill provides guidance for setting up OpenShift Console plugin projects, managing dependencies, and ensuring version compatibility. This is the foundation for all console plugin development. + +## Project Structure and Setup + +### Essential Files and Directories +``` +my-console-plugin/ +├── src/ +│ ├── components/ # React components +│ ├── types/ # TypeScript type definitions +│ └── index.ts # Entry point +├── console-extensions.json # Plugin extension declarations +├── package.json # Dependencies and plugin metadata +├── webpack.config.ts # Module federation configuration +├── tsconfig.json # TypeScript configuration +├── locales/ # i18n translation files +├── charts/ # Helm chart for deployment +└── integration-tests/ # Cypress e2e tests +``` + +### Plugin Metadata in package.json + +```json +{ + "name": "@my-org/my-console-plugin", + "consolePlugin": { + "name": "my-console-plugin", + "version": "1.0.0", + "displayName": "My Console Plugin", + "description": "Extends OpenShift Console with custom functionality", + "exposedModules": { + "MyPage": "./components/MyPage", + "MyListPage": "./components/MyListPage", + "MyDetailsPage": "./components/MyDetailsPage" + }, + "dependencies": { + "@console/pluginAPI": "^4.21.0" + } + } +} +``` + +## OpenShift Version Compatibility + +**⚠️ CRITICAL: Version compatibility is essential for plugin stability** + +Plugin development requires careful attention to OpenShift Console and shared library versions. Always reference the [official SDK documentation](https://github.com/openshift/console/blob/main/frontend/packages/console-dynamic-plugin-sdk/README.md) for the latest compatibility matrix. + +### Console SDK Version Mapping +```json +{ + "devDependencies": { + "@openshift-console/dynamic-plugin-sdk": "4.21-latest", + "@openshift-console/dynamic-plugin-sdk-webpack": "4.21-latest" + } +} +``` + +**SDK Version Scheme:** +- SDK packages follow semver where major/minor version indicates supported OpenShift Console version +- Example: `4.21.x` supports OpenShift Console 4.21.x +- Prerelease versions: `"4.19.0-prerelease.1"` (development builds) +- Full releases: `"4.19.0"` (published after Console GA) + +### PatternFly Version Compatibility Matrix + +| OpenShift Console Version | Supported PatternFly Versions | Recommended | +|---------------------------|-------------------------------|-------------| +| 4.22.x | PatternFly 6.x | ✅ PF6 | +| 4.19.x - 4.22.x | PatternFly 6.x, 5.x | ✅ PF6 | +| 4.15.x - 4.18.x | PatternFly 5.x, 4.x | ✅ PF5 | +| 4.12.x - 4.14.x | PatternFly 4.x | ⚠️ PF4 (legacy) | + +```json +{ + "devDependencies": { + "@patternfly/react-core": "^6.0.0", + "@patternfly/react-icons": "^6.0.0", + "@patternfly/react-table": "^6.0.0" + } +} +``` + +### Shared Libraries Provided by Console + +The OpenShift Console provides these shared modules to avoid duplication: + +**Core Libraries:** +- `@openshift/dynamic-plugin-sdk` +- `@openshift-console/dynamic-plugin-sdk` +- `react` (version managed by console) +- `react-dom` +- `react-redux` +- `redux` + +**UI Libraries:** +- `@patternfly/react-core` +- `@patternfly/react-icons` +- `@patternfly/react-table` + +**Additional Libraries:** +- Various utility libraries (check the [official SDK documentation](https://github.com/openshift/console/blob/main/frontend/packages/console-dynamic-plugin-sdk/README.md) for current list) + +## Version Selection Best Practices + +1. **Target Specific Console Version**: Use exact SDK version matching your target OpenShift release +```json +{ + "@openshift-console/dynamic-plugin-sdk": "4.21.0" // Exact version +} +``` + +2. **Use Version Ranges for Broader Compatibility**: +```json +{ + "@openshift-console/dynamic-plugin-sdk": "^4.21.0" // Compatible versions +} +``` + +3. **Pin PatternFly Major Version**: +```json +{ + "@patternfly/react-core": "^6.0.0" // Pin to PF6 for console 4.22+ +} +``` + +4. **Check Compatibility Before Upgrading**: +```bash +# Always check latest compatibility matrix +curl -s https://raw.githubusercontent.com/openshift/console/main/frontend/packages/console-dynamic-plugin-sdk/README.md | grep -A 20 "Version compatibility" +``` + +## TypeScript Configuration + +### Basic TypeScript Setup +```json +{ + "compilerOptions": { + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "node", + "target": "es2021", + "sourceMap": true, + "jsx": "react-jsx", + "allowJs": true, + "strict": false, + "noUnusedLocals": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} +``` + +### Strict TypeScript Setup (Recommended) +```json +{ + "compilerOptions": { + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "node", + "target": "es2021", + "sourceMap": true, + "jsx": "react-jsx", + "allowJs": false, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +## Version Troubleshooting + +### Common Version-Related Issues +- **Runtime errors**: Version mismatch between plugin and console shared modules +- **Styling issues**: PatternFly version incompatibility +- **Build failures**: SDK version doesn't support target OpenShift version +- **Type errors**: TypeScript definitions mismatch + +### Resolution Steps +1. Verify OpenShift cluster version: `oc version` +2. Check console version in cluster +3. Update SDK versions to match console version +4. Update PatternFly to compatible version +5. Clear node_modules and reinstall: `rm -rf node_modules && npm install` + +## Related Skills + +- [openshift-console-plugin-extensions](../openshift-console-plugin-extensions/SKILL.md) - Console extension points and integration +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - React component development patterns +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Development workflow and testing +- [openshift-console-plugin-deployment](../openshift-console-plugin-deployment/SKILL.md) - Build and deployment strategies + +## Quick Setup Checklist + +- [ ] Create project structure with essential directories +- [ ] Configure package.json with plugin metadata +- [ ] Set up TypeScript configuration +- [ ] Install compatible SDK and PatternFly versions +- [ ] Verify version compatibility with target OpenShift release +- [ ] Configure webpack for module federation +- [ ] Set up development scripts +- [ ] Initialize i18n support +- [ ] Configure linting and testing \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin-styling/SKILL.md b/.claude/skills/openshift-console-plugin-styling/SKILL.md new file mode 100644 index 00000000..bc60c24c --- /dev/null +++ b/.claude/skills/openshift-console-plugin-styling/SKILL.md @@ -0,0 +1,618 @@ +--- +name: openshift-console-plugin-styling +description: UI design, PatternFly usage, and CSS best practices for OpenShift Console plugins +--- + +# OpenShift Console Plugin Styling + +This skill covers UI design, PatternFly component usage, and CSS best practices for creating consistent, accessible, and theme-compatible OpenShift Console plugins. + +## PatternFly First Approach + +**⚠️ CRITICAL: Always prefer PatternFly components over custom implementations** + +The OpenShift Console uses PatternFly as its design system. Using PatternFly components ensures consistency, accessibility, theming support, and reduces maintenance burden. Avoid creating custom components when PatternFly alternatives exist. + +### Why Use PatternFly Components + +1. **Consistency**: Matches OpenShift Console's look and feel +2. **Accessibility**: Built-in ARIA attributes and keyboard navigation +3. **Theming**: Automatic dark/light mode support +4. **Responsive**: Mobile and desktop optimized +5. **Maintenance**: Updates handled by PatternFly team +6. **Performance**: Optimized and tested components + +## Core PatternFly Components for Console Plugins + +### Dashboard and Layout Components +```typescript +import { + Page, // Main page wrapper + PageSection, // Content sections + Card, // Content cards + CardTitle, // Card headers + CardBody, // Card content + Gallery, // Responsive grid layout + GalleryItem, // Grid items + Grid, // Manual grid system + GridItem, // Grid cells + Flex, // Flexbox layout + FlexItem, // Flex children + Stack, // Vertical stacking + StackItem // Stack children +} from '@patternfly/react-core'; + +// Example: Dashboard with cards +const MyDashboard: React.FC = () => ( + + + + + + Cluster Status + Content here + + + + + Resource Usage + More content + + + + + +); +``` + +### Data Display Components +```typescript +import { + Table, // Data tables + Thead, // Table header + Tbody, // Table body + Tr, // Table rows + Th, // Header cells + Td, // Data cells + DataList, // Alternative to tables + DataListItem, // List items + DescriptionList, // Key-value pairs + Label, // Status labels + Badge, // Count indicators + Progress, // Progress bars + Spinner // Loading indicators +} from '@patternfly/react-core'; + +// Example: Resource status display +const ResourceStatus: React.FC<{ resource }> = ({ resource }) => ( + + + + + Status + + + + + + Progress + + + + + + + +); +``` + +### Navigation and Actions +```typescript +import { + Tabs, // Tab navigation + Tab, // Individual tabs + TabTitleText, // Tab labels + Breadcrumb, // Navigation breadcrumbs + BreadcrumbItem, // Breadcrumb links + Button, // Action buttons + Dropdown, // Action menus + DropdownItem, // Menu items + KebabToggle, // Three-dot menu + Toolbar, // Action toolbars + ToolbarContent, // Toolbar sections + ToolbarGroup, // Toolbar groups + ToolbarItem // Individual tools +} from '@patternfly/react-core'; + +// Example: Resource actions toolbar +const ResourceActions: React.FC = () => ( + + + + + + + + } + dropdownItems={[ + Edit, + Delete + ]} + /> + + + + +); +``` + +### Forms and Input Components +```typescript +import { + Form, // Form wrapper + FormGroup, // Form sections + TextInput, // Text fields + Select, // Dropdowns + SelectOption, // Dropdown options + Checkbox, // Checkboxes + Radio, // Radio buttons + Switch, // Toggle switches + FormHelperText, // Help text + Alert // Validation messages +} from '@patternfly/react-core'; + +// Example: Configuration form +const ConfigForm: React.FC = () => ( +
+ + + Must be unique within namespace + + + + +
+); +``` + +### Status and Feedback Components +```typescript +import { + Alert, // Notifications + AlertGroup, // Alert containers + Banner, // Page banners + EmptyState, // No data states + EmptyStateBody, // Empty state content + EmptyStateIcon, // Empty state icons + Modal, // Dialog modals + ModalVariant, // Modal types + NotificationDrawer, // Notification panel + Tooltip // Help tooltips +} from '@patternfly/react-core'; + +// Example: Empty state for resource lists +const NoResourcesFound: React.FC = () => ( + + + No resources found + + Create your first resource to get started. + + + +); +``` + +## PatternFly vs Custom Components Decision Guide + +| Use Case | Prefer PatternFly | Consider Custom | +|----------|-------------------|-----------------| +| Data tables | ✅ Table component | ❌ | +| Status displays | ✅ Label, Badge | ❌ | +| Forms | ✅ Form components | ❌ | +| Navigation | ✅ Tabs, Breadcrumb | ❌ | +| Cards/panels | ✅ Card component | ❌ | +| Buttons/actions | ✅ Button, Dropdown | ❌ | +| Loading states | ✅ Spinner, Progress | ❌ | +| Empty states | ✅ EmptyState | ❌ | +| Modals/dialogs | ✅ Modal | ❌ | +| Unique visualizations | Consider first | ✅ Charts, diagrams | +| Domain-specific widgets | Consider first | ✅ If no PF equivalent | + +## Styling Best Practices + +**⚠️ NEVER use inline styles - Always use CSS classes or PatternFly props** + +Inline styles should be avoided in OpenShift Console plugins for several critical reasons: + +### Why Avoid Inline Styles? +1. **Theming Breaks**: Inline styles override CSS custom properties, breaking dark/light theme switching +2. **Responsiveness**: Cannot use media queries or responsive design patterns +3. **Accessibility**: Harder to implement focus states, high contrast modes, and screen reader optimizations +4. **Maintenance**: Difficult to update styling across components +5. **Performance**: Inline styles prevent CSS caching and optimization +6. **Consistency**: Prevents using PatternFly design tokens and variables +7. **CSP Violations**: May violate Content Security Policy rules + +### ✅ CORRECT Styling Approaches + +#### CSS Classes with Plugin Prefixing +```css +/* Use plugin prefix for all custom classes */ +.my-console-plugin__container { + padding: var(--pf-v6-global-spacer-md); +} + +.my-console-plugin__card { + background: var(--pf-v6-global-palette--grey-100); + border: 1px solid var(--pf-v6-global-BorderColor-300); +} + +.my-console-plugin__status-running { + color: var(--pf-v6-global-palette--green-500); +} + +.my-console-plugin__status-failed { + color: var(--pf-v6-global-palette--red-500); +} + +/* Never use hex colors - use CSS variables */ +.my-console-plugin__highlight { + background-color: var(--pf-v6-global-palette--blue-50); + color: var(--pf-v6-global-palette--blue-700); +} +``` + +### ❌ WRONG - Avoid These Patterns +```typescript +// DON'T DO THIS - Inline styles break theming +const BadComponent: React.FC = () => ( +
+ Content +
+); + +// DON'T DO THIS - Conditional inline styles +const AnotherBadComponent: React.FC = ({ isError }) => ( + + Status + +); +``` + +## Component Styling - Correct Approaches + +### Method 1: CSS Classes with Conditional Styling +```typescript +import React from 'react'; +import { + Card, + CardTitle, + CardBody, + Label, + Flex, + FlexItem +} from '@patternfly/react-core'; +import './MyComponent.css'; + +interface MyComponentProps { + status: 'running' | 'failed' | 'pending'; + isHighlighted?: boolean; +} + +const MyComponent: React.FC = ({ status, isHighlighted }) => { + // Use conditional className instead of inline styles + const containerClassName = [ + 'my-console-plugin__status-card', + isHighlighted && 'my-console-plugin__status-card--highlighted' + ].filter(Boolean).join(' '); + + return ( + + Status Overview + + + + + {status} + + + + {/* Use PatternFly color props when available */} + + + + + + ); +}; + +export default MyComponent; +``` + +### Method 2: PatternFly Component Props +```typescript +import React from 'react'; +import { + Card, + CardTitle, + CardBody, + Alert, + Button, + Flex, + FlexItem +} from '@patternfly/react-core'; + +const MyAlertComponent: React.FC<{ hasError: boolean }> = ({ hasError }) => ( + + + {/* Use PatternFly variant props instead of inline styles */} + + + + {/* Use PatternFly size and variant props */} + + + + + +); +``` + +### Method 3: CSS-in-JS Alternative (Use Sparingly) +```typescript +import React from 'react'; +import { Card } from '@patternfly/react-core'; + +// If CSS-in-JS is absolutely necessary, use CSS custom properties +const MyDynamicComponent: React.FC<{ progress: number }> = ({ progress }) => { + // Use CSS custom properties, not direct style values + const cardStyle = { + '--my-progress-width': `${progress}%` + } as React.CSSProperties; + + return ( + + {/* Progress bar uses CSS custom property in stylesheet */} +
+ + ); +}; +``` + +## CSS File Organization + +### Component CSS Structure +```css +/* MyComponent.css */ + +/* Main component styles */ +.my-console-plugin__status-card { + margin-bottom: var(--pf-v6-global-spacer-md); + border-radius: var(--pf-v6-global-BorderRadius-md); +} + +/* Modifier classes for state variations */ +.my-console-plugin__status-card--highlighted { + border: 2px solid var(--pf-v6-global-palette--blue-300); + box-shadow: var(--pf-v6-global-box-shadow-md); +} + +/* Status indicator styles */ +.my-console-plugin__status { + font-weight: var(--pf-v6-global-FontWeight-bold); + padding: var(--pf-v6-global-spacer-xs); + border-radius: var(--pf-v6-global-BorderRadius-sm); +} + +.my-console-plugin__status--running { + background-color: var(--pf-v6-global-palette--green-50); + color: var(--pf-v6-global-palette--green-700); +} + +.my-console-plugin__status--failed { + background-color: var(--pf-v6-global-palette--red-50); + color: var(--pf-v6-global-palette--red-700); +} + +.my-console-plugin__status--pending { + background-color: var(--pf-v6-global-palette--orange-50); + color: var(--pf-v6-global-palette--orange-700); +} + +/* CSS custom property approach for dynamic values */ +.my-console-plugin__progress-card { + position: relative; + overflow: hidden; +} + +.my-console-plugin__progress-bar { + width: var(--my-progress-width); + height: 4px; + background-color: var(--pf-v6-global-palette--blue-300); + transition: width 0.3s ease; +} + +/* Responsive design using media queries */ +@media (max-width: 768px) { + .my-console-plugin__status-card { + margin-bottom: var(--pf-v6-global-spacer-sm); + } + + .my-console-plugin__status { + font-size: var(--pf-v6-global-FontSize-sm); + } +} + +/* Dark theme considerations */ +@media (prefers-color-scheme: dark) { + .my-console-plugin__status-card--highlighted { + border-color: var(--pf-v6-global-palette--blue-200); + } +} +``` + +## Theme Compatibility + +### Using CSS Custom Properties +```css +/* Always use PatternFly CSS variables for colors */ +.my-plugin__primary-text { + color: var(--pf-v6-global-Color-100); +} + +.my-plugin__secondary-text { + color: var(--pf-v6-global-Color-200); +} + +.my-plugin__background { + background-color: var(--pf-v6-global-BackgroundColor-100); +} + +.my-plugin__border { + border: 1px solid var(--pf-v6-global-BorderColor-100); +} + +/* Status colors */ +.my-plugin__success { + color: var(--pf-v6-global-success-color-100); +} + +.my-plugin__warning { + color: var(--pf-v6-global-warning-color-100); +} + +.my-plugin__danger { + color: var(--pf-v6-global-danger-color-100); +} +``` + +### Responsive Design Patterns +```css +/* Mobile-first approach */ +.my-plugin__card-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--pf-v6-global-spacer-md); +} + +/* Tablet */ +@media (min-width: 768px) { + .my-plugin__card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Desktop */ +@media (min-width: 1200px) { + .my-plugin__card-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +/* Large screens */ +@media (min-width: 1600px) { + .my-plugin__card-grid { + grid-template-columns: repeat(4, 1fr); + } +} +``` + +## Accessibility in Styling + +### Focus States +```css +/* Ensure visible focus indicators */ +.my-plugin__interactive-element { + border-radius: var(--pf-v6-global-BorderRadius-sm); + transition: all 0.2s ease; +} + +.my-plugin__interactive-element:focus { + outline: 2px solid var(--pf-v6-global-palette--blue-300); + outline-offset: 2px; +} + +.my-plugin__interactive-element:focus:not(:focus-visible) { + outline: none; +} + +.my-plugin__interactive-element:focus-visible { + outline: 2px solid var(--pf-v6-global-palette--blue-300); + outline-offset: 2px; +} +``` + +### High Contrast Mode +```css +/* High contrast media query support */ +@media (prefers-contrast: high) { + .my-plugin__card { + border: 2px solid; + } + + .my-plugin__status--running { + background-color: transparent; + border: 2px solid var(--pf-v6-global-palette--green-500); + } +} +``` + +## Performance Considerations + +### CSS Loading Strategy +```typescript +// Lazy load CSS for large components +const LazyStyledComponent = React.lazy(async () => { + // Import CSS + await import('./LazyComponent.css'); + // Import component + return import('./LazyComponent'); +}); +``` + +### CSS Optimization Tips +1. **Use CSS Custom Properties**: Better performance than CSS-in-JS +2. **Minimize CSS Specificity**: Use single classes when possible +3. **Avoid Deep Nesting**: Keep CSS selectors shallow +4. **Group Related Styles**: Organize CSS logically +5. **Use PatternFly Utilities**: Leverage existing utility classes + +## Related Skills + +- [openshift-console-plugin-components](../openshift-console-plugin-components/SKILL.md) - React component development patterns +- [openshift-console-plugin-setup](../openshift-console-plugin-setup/SKILL.md) - Project setup and PatternFly versions +- [openshift-console-plugin-development](../openshift-console-plugin-development/SKILL.md) - Linting CSS and styling workflow +- [openshift-console-plugin-advanced](../openshift-console-plugin-advanced/SKILL.md) - Performance optimization for styles + +## Styling Checklist + +- [ ] Use PatternFly components instead of custom implementations +- [ ] Never use inline styles - use CSS classes or PatternFly props +- [ ] Prefix all custom CSS classes with plugin name +- [ ] Use PatternFly CSS variables instead of hex colors +- [ ] Implement responsive design with media queries +- [ ] Add proper focus states for accessibility +- [ ] Test in both light and dark themes +- [ ] Use semantic color variables (success, warning, danger) +- [ ] Optimize CSS for performance +- [ ] Follow mobile-first design approach \ No newline at end of file diff --git a/.claude/skills/openshift-console-plugin/SKILL.md b/.claude/skills/openshift-console-plugin/SKILL.md new file mode 100644 index 00000000..b00d40bc --- /dev/null +++ b/.claude/skills/openshift-console-plugin/SKILL.md @@ -0,0 +1,206 @@ +--- +name: openshift-console-plugin +description: Comprehensive guide for developing OpenShift Console dynamic plugins - overview and navigation to specialized skills +--- + +# OpenShift Console Plugin Development + +This is the main skill for OpenShift Console plugin development. For comprehensive guidance, this skill has been organized into specialized skills covering different aspects of plugin development. Use this overview to navigate to the specific area you need. + +## Skill Overview + +The OpenShift Console plugin development workflow is broken down into focused, specialized skills: + +### 🏗️ [Project Setup](../openshift-console-plugin-setup/SKILL.md) +**Essential first step** - Project initialization, dependencies, and version compatibility + +**When to use:** Starting a new plugin project, updating dependencies, or troubleshooting version compatibility issues. + +**Key topics:** +- Project structure and essential files +- OpenShift version compatibility matrix +- PatternFly version mapping +- TypeScript configuration +- Package.json plugin metadata + +--- + +### 🧩 [Console Extensions](../openshift-console-plugin-extensions/SKILL.md) +**Core integration** - Console extension points, navigation, routes, and integration patterns + +**When to use:** Adding navigation items, creating custom pages, adding tabs to existing resources, or integrating with console features. + +**Key topics:** +- Navigation extensions and sections +- Page routes and resource pages +- Tab extensions and action providers +- Feature flags and conditional extensions +- Extension best practices + +--- + +### ⚛️ [Component Development](../openshift-console-plugin-components/SKILL.md) +**UI building blocks** - React component patterns and development best practices + +**When to use:** Creating React components, building user interfaces, implementing component patterns, or optimizing component performance. + +**Key topics:** +- Component development patterns +- Resource list and detail components +- Modal and form patterns +- Error handling and loading states +- TypeScript interfaces and accessibility + +--- + +### 📊 [Data Management](../openshift-console-plugin-data/SKILL.md) +**K8s integration** - Data fetching, SDK helpers, and state management + +**When to use:** Working with Kubernetes resources, implementing data fetching, managing application state, or optimizing API calls. + +**Key topics:** +- K8s SDK helpers (useK8sWatchResource, k8sGet) +- API group/version configuration +- Resource mutations (create, update, delete) +- Custom hooks and state management +- Error handling and performance optimization + +--- + +### 🎨 [UI Design & Styling](../openshift-console-plugin-styling/SKILL.md) +**Visual consistency** - PatternFly usage, CSS best practices, and theming + +**When to use:** Styling components, ensuring design consistency, implementing responsive design, or troubleshooting theme compatibility. + +**Key topics:** +- PatternFly component usage +- CSS best practices and avoiding inline styles +- Component styling approaches +- Theme compatibility and responsive design +- Performance optimization for styles + +--- + +### 🔄 [Development Workflow](../openshift-console-plugin-development/SKILL.md) +**Development process** - Local development, testing, linting, and debugging + +**When to use:** Setting up local development, running tests, debugging issues, or establishing development workflows. + +**Key topics:** +- Local development setup (dev server + console container) +- Testing strategies (Jest, Cypress) +- Code quality and linting +- Debugging techniques +- Pre-commit workflows and best practices + +--- + +### 🌍 [Internationalization](../openshift-console-plugin-i18n/SKILL.md) +**Multi-language support** - Translation setup, namespace conventions, and localization + +**When to use:** Adding multi-language support, setting up translations, or implementing i18n in components and extensions. + +**Key topics:** +- i18n namespace conventions +- Translation file structure +- Using translations in React components +- Console extension i18n +- Translation workflow and testing + +--- + +### 🚀 [Deployment](../openshift-console-plugin-deployment/SKILL.md) +**Production delivery** - Build, containerization, Helm charts, and CI/CD + +**When to use:** Building production releases, creating container images, setting up deployments, or configuring CI/CD pipelines. + +**Key topics:** +- Webpack production builds +- Containerization with Docker/Podman +- Helm chart development +- CI/CD integration (GitHub Actions) +- Production deployment strategies + +--- + +### 🏆 [Advanced Patterns](../openshift-console-plugin-advanced/SKILL.md) +**Expert techniques** - Performance optimization, security, and complex patterns + +**When to use:** Optimizing performance, implementing security best practices, handling complex state management, or building advanced plugin features. + +**Key topics:** +- Code splitting and lazy loading +- Security best practices and CSP +- Advanced state management patterns +- Error handling and resilience +- Performance optimization techniques + +--- + +## Quick Start Guide + +### For New Plugin Development: +1. **Start with [Setup](../openshift-console-plugin-setup/SKILL.md)** - Initialize project and configure dependencies +2. **Define [Extensions](../openshift-console-plugin-extensions/SKILL.md)** - Plan your console integration points +3. **Build [Components](../openshift-console-plugin-components/SKILL.md)** - Create your React components +4. **Implement [Data](../openshift-console-plugin-data/SKILL.md)** - Add K8s resource integration +5. **Apply [Styling](../openshift-console-plugin-styling/SKILL.md)** - Use PatternFly for consistent UI +6. **Setup [Development](../openshift-console-plugin-development/SKILL.md)** - Configure testing and workflows + +### For Existing Plugin Enhancement: +- **Adding features** → [Extensions](../openshift-console-plugin-extensions/SKILL.md) + [Components](../openshift-console-plugin-components/SKILL.md) +- **Performance issues** → [Advanced Patterns](../openshift-console-plugin-advanced/SKILL.md) +- **Multi-language** → [Internationalization](../openshift-console-plugin-i18n/SKILL.md) +- **Production deployment** → [Deployment](../openshift-console-plugin-deployment/SKILL.md) + +### For Troubleshooting: +- **Plugin not loading** → [Development](../openshift-console-plugin-development/SKILL.md) +- **Version conflicts** → [Setup](../openshift-console-plugin-setup/SKILL.md) +- **UI/styling issues** → [Styling](../openshift-console-plugin-styling/SKILL.md) +- **Data/API problems** → [Data Management](../openshift-console-plugin-data/SKILL.md) + +## Development Principles + +### Core Principles Across All Skills: + +1. **Use SDK Helpers**: Always use OpenShift Console SDK helpers for K8s operations +2. **PatternFly First**: Prefer PatternFly components over custom implementations +3. **TypeScript**: Use TypeScript for type safety and better development experience +4. **Accessibility**: Follow WCAG guidelines and use proper ARIA attributes +5. **Internationalization**: Support multiple languages from the start +6. **Testing**: Write tests for components, hooks, and critical user flows +7. **Security**: Validate inputs, sanitize outputs, and follow security best practices +8. **Performance**: Optimize for bundle size and runtime performance + +### Quality Standards: + +- ✅ **Linting**: Run `yarn lint` before every commit +- ✅ **Testing**: Maintain comprehensive test coverage +- ✅ **Type Safety**: Use TypeScript interfaces for all data structures +- ✅ **Accessibility**: Test with screen readers and keyboard navigation +- ✅ **Documentation**: Keep documentation updated with code changes +- ✅ **Consistency**: Follow established patterns across the codebase + +## Common Tasks Quick Reference + +| Task | Primary Skill | Supporting Skills | +|------|---------------|------------------| +| Project initialization | [Setup](../openshift-console-plugin-setup/SKILL.md) | [Development](../openshift-console-plugin-development/SKILL.md) | +| Add navigation menu | [Extensions](../openshift-console-plugin-extensions/SKILL.md) | [i18n](../openshift-console-plugin-i18n/SKILL.md) | +| Create custom page | [Components](../openshift-console-plugin-components/SKILL.md) | [Extensions](../openshift-console-plugin-extensions/SKILL.md) | +| Fetch K8s resources | [Data Management](../openshift-console-plugin-data/SKILL.md) | [Components](../openshift-console-plugin-components/SKILL.md) | +| Style components | [Styling](../openshift-console-plugin-styling/SKILL.md) | [Components](../openshift-console-plugin-components/SKILL.md) | +| Add translations | [i18n](../openshift-console-plugin-i18n/SKILL.md) | [Components](../openshift-console-plugin-components/SKILL.md) | +| Deploy to production | [Deployment](../openshift-console-plugin-deployment/SKILL.md) | [Advanced](../openshift-console-plugin-advanced/SKILL.md) | +| Optimize performance | [Advanced](../openshift-console-plugin-advanced/SKILL.md) | [Styling](../openshift-console-plugin-styling/SKILL.md) | + +## Getting Help + +Each specialized skill contains: +- **Comprehensive examples** with copy-paste code +- **Best practices** and common patterns +- **Troubleshooting guides** for common issues +- **Cross-references** to related skills +- **Checklists** to ensure completeness + +Start with the skill most relevant to your current task, and follow the cross-references to related skills as needed. \ No newline at end of file